diff --git a/.github/scripts/smoke.sh b/.github/scripts/smoke.sh index 3780ed3..c751f5b 100755 --- a/.github/scripts/smoke.sh +++ b/.github/scripts/smoke.sh @@ -14,7 +14,7 @@ trap 'rm -f "$probe"' EXIT cat > "$probe" <<'GO' package tables -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" var smoke = sqlshape.Query[int64, struct{}](`SELECT no_such_column FROM customers`) GO diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b4dd84..2917d25 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,19 @@ -# Release: push a tag v* and this cross-compiles the sqlshape binary for every platform, -# packs it, and publishes a GitHub Release with checksums. +# Release: push the root tag vX.Y.Z and this cross-compiles the sqlshape binary for every +# platform, packs it, and publishes a GitHub Release with checksums. # -# git tag v0.1.0 && git push origin v0.1.0 +# scripts/release.sh v1.3.0 --push +# +# The repository's modules (the root, postgres, mysql, mysqltest, pgtest, check/postgres, +# check/mysql, cmd/sqlshape) release in lockstep: scripts/release.sh sets the inter-module requires to +# the version, commits, and tags vX.Y.Z and /vX.Y.Z on that commit. Only the root +# tag triggers this workflow (the others do not start with "v"). # # The binary is a plain Go build (libpg_query runs as WebAssembly, no cgo), so one runner # builds every target; the smoke test runs the runner's own (linux/amd64) binary as a # vettool over the examples, the only one it can execute. # -# `go install github.com/kr9ly/sqlshape/cmd/sqlshape@` needs nothing from this -# workflow: it reads the tag straight from the module proxy. +# `go install github.com/kr9ly/sqlshape/cmd/sqlshape/v2@` needs nothing from this +# workflow: it reads the tag cmd/sqlshape/ straight from the module proxy. name: release on: @@ -27,6 +32,9 @@ jobs: with: go-version-file: go.mod + - name: Version + run: echo "VERSION=${GITHUB_REF_NAME}" >> "$GITHUB_ENV" + - name: Build env: CGO_ENABLED: "0" @@ -36,11 +44,11 @@ jobs: goos=${target%/*}; goarch=${target#*/} bin=sqlshape; [ "$goos" = windows ] && bin=sqlshape.exe GOOS=$goos GOARCH=$goarch go build -trimpath \ - -ldflags "-s -w -X github.com/kr9ly/sqlshape/internal/cli.version=${GITHUB_REF_NAME}" \ + -ldflags "-s -w -X github.com/kr9ly/sqlshape/cmd/sqlshape/v2/internal/cli.version=${VERSION}" \ -o "build/$goos-$goarch/$bin" ./cmd/sqlshape - name="sqlshape_${GITHUB_REF_NAME}_${goos}_${goarch}" + name="sqlshape_${VERSION}_${goos}_${goarch}" mkdir "$name" - cp "build/$goos-$goarch/$bin" LICENSE NOTICE README.md "$name"/ + cp "build/$goos-$goarch/$bin" cmd/sqlshape/LICENSE check/postgres/NOTICE README.md "$name"/ if [ "$goos" = windows ]; then zip -qr "dist/$name.zip" "$name" else @@ -51,7 +59,7 @@ jobs: ls -la dist - name: Smoke test - run: .github/scripts/smoke.sh ./build/linux-amd64/sqlshape "$GITHUB_REF_NAME" + run: .github/scripts/smoke.sh ./build/linux-amd64/sqlshape "$VERSION" - name: Checksums run: cd dist && sha256sum sqlshape_* > checksums.txt @@ -61,14 +69,14 @@ jobs: GH_TOKEN: ${{ github.token }} run: | pre="" - case "$GITHUB_REF_NAME" in *-*) pre="--prerelease" ;; esac # v1.0.0-rc.1 - # the release notes are the tag's section of CHANGELOG.md (a candidate v1.1.0-rc.1 reads - # the 1.1.0 section); generated notes when it has none - v="${GITHUB_REF_NAME#v}"; v="${v%%-*}" + case "$VERSION" in *-*) pre="--prerelease" ;; esac # v1.3.0-rc.1 + # the release notes are the version's section of CHANGELOG.md (a candidate v1.3.0-rc.1 + # reads the 1.3.0 section); generated notes when it has none + v="${VERSION#v}"; v="${v%%-*}" awk -v v="$v" '/^## \[/ { on = index($0, "[" v "]") > 0; next } on' CHANGELOG.md > notes.md notes="--generate-notes" if grep -q . notes.md; then notes="--notes-file notes.md"; fi gh release create "$GITHUB_REF_NAME" dist/* \ --repo "$GITHUB_REPOSITORY" \ - --title "$GITHUB_REF_NAME" \ + --title "sqlshape $VERSION" \ $notes $pre diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 231de79..ef4a728 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,9 +11,18 @@ permissions: jobs: test: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 45 steps: - uses: actions/checkout@v4 + + # the run boots real PostgreSQL and MySQL servers and initializes databases by the + # hundred; the stock runner's free disk fills up mid-run (TestMigrateProbe died on + # "No space left on device"), so the preinstalled toolchains this job never uses go + - name: Free disk space + run: | + sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup /opt/hostedtoolcache/CodeQL + df -h / + - uses: actions/setup-go@v5 with: go-version-file: go.mod @@ -32,33 +41,69 @@ jobs: /usr/lib/postgresql/17/bin/pg_dump --version # the embedded PostgreSQL archive, its extracted binaries + initialized template data - # directory (internal/oracle), and PostgreSQL's regression corpus (TestRegress) + # directory (check/postgres/oracle), and PostgreSQL's regression corpus (TestRegress) - name: Cache PostgreSQL binaries and regress corpus uses: actions/cache@v4 with: path: | ~/.embedded-postgres-go ~/.cache/sqlshape - key: sqlshape-pg-${{ hashFiles('internal/oracle/oracle.go', 'internal/analyze/testdata/tools/fetch-regress.sh') }} + key: sqlshape-pg-${{ hashFiles('check/postgres/oracle/oracle.go', 'check/postgres/analyze/testdata/tools/fetch-regress.sh') }} - name: Fetch regress corpus - run: internal/analyze/testdata/tools/fetch-regress.sh + run: check/postgres/analyze/testdata/tools/fetch-regress.sh + + # the MySQL tests boot the mysqld on PATH, and it must be 8.4 (the analyzer's measured + # version; the runner's own 8.0 is also AppArmor-confined and cannot write a data + # directory under $HOME). The official minimal tarball runs unconfined from $HOME. + - name: Cache MySQL 8.4 + uses: actions/cache@v4 + with: + path: ~/mysql-8.4.11 + key: sqlshape-mysql-8.4.11-minimal + + - name: Install MySQL 8.4 + run: | + sudo apt-get install -y -q libaio1t64 libnuma1 + # the tarball's mysqld links libaio.so.1; Ubuntu 24.04 ships it as libaio.so.1t64 + sudo ln -sf /usr/lib/x86_64-linux-gnu/libaio.so.1t64 /usr/lib/x86_64-linux-gnu/libaio.so.1 + if [ ! -x "$HOME/mysql-8.4.11/bin/mysqld" ]; then + curl -fsSL -o /tmp/mysql.tar.xz https://cdn.mysql.com/Downloads/MySQL-8.4/mysql-8.4.11-linux-glibc2.28-x86_64-minimal.tar.xz + tar -xJf /tmp/mysql.tar.xz -C "$HOME" + mv "$HOME/mysql-8.4.11-linux-glibc2.28-x86_64-minimal" "$HOME/mysql-8.4.11" + rm /tmp/mysql.tar.xz + fi + echo "$HOME/mysql-8.4.11/bin" >> "$GITHUB_PATH" + "$HOME/mysql-8.4.11/bin/mysqld" --version # the binary as users run it: as a vettool over the examples - name: Smoke test run: go build -o sqlshape ./cmd/sqlshape && .github/scripts/smoke.sh ./sqlshape - # coverage counts every package except the catalog generator (a manual tool run - # against a PostgreSQL) and the 5-statement main; the generated protobuf node types - # (internal/pgparse/pg_query.pb.go, 40k lines of accessors) are dropped from the profile. + # coverage counts every package except the generators, which are manual tools run + # against a server or its source (check/postgres/catalog/gen, check/mysql's parsegen) + # and the 5-statement main; generated data tables are dropped from the profile the way + # the protobuf node types (check/postgres/pgparse/pg_query.pb.go, 40k lines of + # accessors) are: mysqlast/views.go, mysqlparse's shapes.go / kinds.go and the + # catalog's functions.go / sysvars.go, all written by parsegen from the server source. # -count=1: a cached test result replays the profile it recorded, with block positions # from the sources of that run; after an edit to a package the cached package does not # depend on, the stale blocks no longer line up and the merged total drops by ten points - name: Test + # the test servers put their data directories on /dev/shm by default; the runner's + # tmpfs (RAM/2) is too small for the parallel suite ("No space left on device"), so + # both escape hatches point at the disk, which the free-space step cleared + env: + SQLSHAPE_ORACLE_DIR: ${{ runner.temp }} + SQLSHAPE_MYSQLTEST_DIR: ${{ runner.temp }} run: | - pkgs=$(go list ./... | grep -v -e /internal/catalog/gen -e /cmd/sqlshape | paste -sd,) - go test ./... -count=1 -coverpkg="$pkgs" -coverprofile=cover.out -timeout 20m - grep -v '/pg_query\.pb\.go:' cover.out > cover.filtered && mv cover.filtered cover.out + # ./... is the root module; the other modules of the workspace are named one by one + # the exclusions name path segments, not full paths, so the /v2/ in the module + # paths cannot silently defeat them (it did: /check/postgres/catalog/gen matched + # nothing once the paths carried /v2/) + pkgs=$(go list ./... ./postgres/... ./mysql/... ./mysqltest/... ./pgtest/... ./cmd/sqlshape/... ./check/postgres/... ./check/mysql/... | grep -v -e /catalog/gen -e /internal/parsegen | paste -sd,) + go test ./... ./postgres/... ./mysql/... ./mysqltest/... ./pgtest/... ./examples/... ./cmd/sqlshape/... ./check/postgres/... ./check/mysql/... -count=1 -coverpkg="$pkgs" -coverprofile=cover.out -timeout 40m + grep -v -e '/pg_query\.pb\.go:' -e '/mysqlast/views\.go:' -e '/mysqlparse/shapes\.go:' -e '/mysqlparse/kinds\.go:' -e '/internal/catalog/functions\.go:' -e '/internal/catalog/sysvars\.go:' cover.out > cover.filtered && mv cover.filtered cover.out # coverage badge: statement coverage of the whole module, committed as an SVG so the # README can show it without a third-party service; --min makes it a regression gate @@ -91,14 +136,14 @@ jobs: uses: actions/cache@v4 with: path: ~/.cache/sqlshape - key: sqlshape-pg18-${{ hashFiles('internal/oracle/oracle.go', 'internal/analyze/testdata/tools/fetch-regress.sh') }} + key: sqlshape-pg18-${{ hashFiles('check/postgres/oracle/oracle.go', 'check/postgres/analyze/testdata/tools/fetch-regress.sh') }} - name: Fetch regress corpus - run: internal/analyze/testdata/tools/fetch-regress.sh REL_18_3 + run: check/postgres/analyze/testdata/tools/fetch-regress.sh REL_18_3 - name: Test env: SQLSHAPE_PG: "18" run: | - go test ./internal/catalog ./internal/oracle -count=1 - go test ./internal/analyze -run 'TestRegress$' -count=1 -v -timeout 25m 2>&1 | grep -E 'statements:|hits|PASS|FAIL|ok ' + go test ./check/postgres/catalog ./check/postgres/oracle -count=1 + go test ./check/postgres/analyze -run 'TestRegress$' -count=1 -v -timeout 25m 2>&1 | grep -E 'statements:|hits|PASS|FAIL|ok ' diff --git a/CHANGELOG.md b/CHANGELOG.md index e899e00..cbf78b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,85 +8,226 @@ release it is a candidate for. ## [Unreleased] +## [2.0.0] - 2026-09-14 + +### Added + +- MySQL 8.4. A `schema.sql` that declares `-- sqlshape: mysql 8.4` is checked by a MySQL + analyzer: the parser and lexer are the server's own, the function catalog is read from the + server's source, and the verdicts (types, nullability, errors, the `ONLY_FULL_GROUP_BY` check) + are measured against a running `mysqld`. `One`, the obligations and the failure modes work + through the same contract PostgreSQL uses, with the constraints named as MySQL reports them + (`PRIMARY`, `orders_ibfk_1`, `t_chk_1`) and `INSERT IGNORE` / `ON DUPLICATE KEY UPDATE` / + `REPLACE` judged as the server runs them. What has no MySQL counterpart is listed in + [docs/mysql.md](docs/mysql.md). +- MySQL triggers, stored procedures and functions, and events: their bodies are analyzed + (`NEW` / `OLD`, variables, control flow, cursors, `SIGNAL` / `RESIGNAL` / `HANDLER`, `CALL`), + a statement inherits the failure modes of the triggers it fires and the routines it calls, + and the server's own refusals (a trigger writing its own table, 1442, among them) are + reported. `-- sqlshape: error = ` names a raised error, as on PostgreSQL; + `-- sqlshape: not null` above a `CREATE FUNCTION` declares it never returns NULL. +- On MySQL the checker also reads `LOAD DATA` (an INSERT of the file's rows, with the server's + own 1263 for a NULL field in a `NOT NULL` column), `LOCK TABLES` / `UNLOCK TABLES`, + `SELECT ... INTO OUTFILE` / `DUMPFILE` (no result set) and a `SELECT` with a trailing + `FOR UPDATE` / `FOR SHARE`; the loader applies `ALTER VIEW`. Inside a routine or trigger body + the first three are the server's own 1314. +- MySQL writes through views, analyzed as the server runs them (each rule measured): an + `INSERT` / `REPLACE`, `UPDATE` or `DELETE` through a merged view lands on its base table, + with the base table's own failure modes and the view's own (`WITH CHECK OPTION` as the + violation 1369 -- on `INSERT` and `REPLACE` too -- and a base column with no default the + statement leaves unassigned as the view's 1423, both matched by `Violates` under the view's + name), and the server's refusals are statement errors under the server's own codes: the + view's updatable / insertable flags are computed over its `FROM` leaves the way + `sql_resolver.cc` computes them when it merges, a join view follows the server's rules for + each write kind, `REPLACE` and `DELETE` never reach one, and a subquery reading the written + view itself is 1093. The rules are [docs/mysql.md](docs/mysql.md#views)'s. +- The run-time failures MySQL decides by a constant's value are judged (each measured against + mysqld; the rules are [docs/mysql.md](docs/mysql.md)'s): a literal a column can never store + (1264 / 1265 / 1292 / 1366 / 1406 / 1416, in strict mode outside `IGNORE`), a temporal + literal the server cannot read as its type (1525) or a `TIMESTAMP` column cannot hold, the + spatial rules (1210 / 3037 / 3516 / 1416), constant arithmetic the server cannot compute + (1690), the constant conversions a strict write escalates (1292; a temporal column compared + with a constant string that is no datetime is 1525 whatever the statement), and the + functions that judge a constant argument's value: `INET_ATON` / `INET6_ATON` / `UNHEX` / + `STR_TO_DATE` fail a strict write outside `IGNORE` (1411; `STR_TO_DATE` under a port of the + server's own `extract_date_time`), `UUID_TO_BIN` / `BIN_TO_UUID` (1411) and `PERIOD_ADD` / + `PERIOD_DIFF` (1210) fail every statement whatever the mode, and `NAME_CONST`, `ESCAPE`, + `NTILE`, `NTH_VALUE` and `MATCH ... AGAINST` are refused at resolution, rows or none. Each + is the statement's own error where the server evaluates it before reading rows, and a + failure mode `Violates` matches by number (`"1416"`, `"1690"`, `"1292"`, `"1411"`) where it + runs per row. A prefix key (`UNIQUE (c(10))`) or an expression key (`UNIQUE ((n * 2))`) is + a violable key (1062) for the writes that assign its columns. +- A MySQL function call argument carrying an alias (`f(x AS a)`, the loadable function + syntax) is refused in the server's own order (1582 / 1583 / 1584, a data dictionary + function 3566), and an explicitly scoped system variable read must match the variable's + scope: `@@session.x` of a GLOBAL-only variable and `@@global.x` of a SESSION-only one are + the statement's 1238 wherever the read sits, over a scope table generated from the server + source (`sql/sys_vars.cc`) and pinned against mysqld entry by entry; a plugin's or + component's variable is not judged. +- The statement facts the `One` proof and the obligations are judged on are tested against a + running server the way the checker's types are: `x/stmtprobe` generates schemas with rows + and statements over them (joins, views, derived tables, CTEs, `EXISTS` / `IN` subqueries, + `GROUP BY`, `UNION ALL`, writes), and refutes every claim of the facts -- a predicate holding + on every row, a fixed column, an at-most-one-row proof, the value a write stores -- with the + rows the server returns, on PostgreSQL 17 and MySQL 8.4. The same probe judges the predicted + failure modes: writes against a schema carrying every constraint kind (named and unnamed + keys, foreign keys with each `ON DELETE` action, checks, `NOT NULL`; `IGNORE` / `REPLACE` / + `ON DUPLICATE KEY UPDATE` and `ON CONFLICT`), with every constraint error the server raises + required to be a predicted violation under a key `Violates` matches. +- MySQL trigger, procedure and function bodies are tested against the server's own + CREATE-time verdict the same way (the body probe in `check/mysql/internal/analyze`): every + construct the server refuses when the body is created, mixed into generated bodies, must + be refused by the checker too, and nothing the server accepts may be. +- MySQL's own test corpus (mysql-test/t: 1,281 files, 137,000 statements) replays against a + running mysqld and the analyzer side by side (`check/mysql/internal/analyze`'s corpus probe, + the counterpart of PostgreSQL's regress probe): each file on a server of its own, the + analyzer's schema rebuilt from `SHOW CREATE` after every DDL under the session's `sql_mode`, + a SELECT's columns compared by name, type family and nullability, an error by number; the + remaining disagreements (2,525 statements) are pinned one by one as a baseline, and a new + one fails the build. +- `mysqltest.StartOwn` boots a server of its own even under `mysqltest.Main`, for a test that + changes accounts, global variables or other databases. +- `postgres.WrapError(err)` and `mysql.WrapError(err)` give an error from a statement run + outside `Run` / `Exec` the wrapping `Violates` judges. +- `sqlshape.Error(code)` declares a schema's named error in Go (`var OrderTooLarge = + sqlshape.Error("30001")`); `go vet` checks the declaration against the schema both ways, an + expect line may use the Name, and `Violates` accepts the value. +- `-- sqlshape: server = ` declares the server settings the checker's judgments + depend on (MySQL: `sql_mode`, `lower_case_table_names`, `max_sp_recursion_depth`); the + checker follows them, and `mysqltest.Start` starts the server with them. +- The MySQL runtime, `github.com/kr9ly/sqlshape/mysql/v2` (`Run`, `Collect`, `First`, `Exec`, + `Get` / `Find` / `ExecOne`, `ConstraintError`, `Violates`) over `database/sql`, and + `github.com/kr9ly/sqlshape/mysqltest/v2`, which boots the `mysqld` on `PATH` with the + application's schema; a package whose `TestMain` runs through `mysqltest.Main` boots one + server per set of declared settings and hands it from test to test instead of booting one + per `Start`. `examples/5-mysql` shows the whole path. +- `sqlshape diff`, `apply` and `verify-schema` on MySQL: both sides are read as the server's own + `SHOW CREATE` output, the target canonicalized in a scratch database on the same server, and + the plan written in MySQL's own DDL, statement by statement. Tables with their options, + columns with their position, keys, foreign keys, checks, views, triggers, routines, events + and partitions are compared; seeded rows are not. +- Partitions are migrated on both dialects: a partitioned table is created, a partition added, + dropped (under a `-- @migrate drop` declaration, since it takes its rows), attached, detached, + its bound moved with the rows that no longer fit moved along, and a populated table can be + partitioned after the fact, unpartitioned, or repartitioned, the rows redistributed by the + server. MySQL covers `RANGE`, `LIST`, `COLUMNS`, `HASH`, `KEY`, `LINEAR` and subpartitioning + (a partition's explicit `SUBPARTITION` name list included), + with `-- @migrate drop partition .` for a partition that goes. +- The migration planner is tested against a real server the way the checker is: generated + schema pairs, every kind of change the diff can report, applied with rows in the tables on + PostgreSQL 17 and 18 and on MySQL 8.4, with the plan required to land on the declared schema + and leave nothing to plan afterwards ([docs/migrations.md](docs/migrations.md#how-the-plan-is-tested)). + The orderings and omissions this found are fixed and pinned as regression tests. +- Row-level security, composite types, rules, extensions, schema moves, standalone sequences, + procedures, aggregates, `EXCLUDE`, partial and expression indexes, `NULLS NOT DISTINCT` and + `DEFERRABLE` are handled by the PostgreSQL migration plan; `FULLTEXT` / `SPATIAL` / functional + / `INVISIBLE` keys, `ROW_FORMAT`, table charset changes and column comments by MySQL's. +- `x/dialect.Type.ElemNotNull`: an array whose elements are provably not NULL + (`array_agg((a, b)::t)`, an inner-joined `NOT NULL` column) binds to `[]T` without the "may + contain a NULL element" note; `col:",notnull"` on an array field asserts the elements too. +- `-query=pkg.Func,pkg.Other:one` registers a program's own marker functions, read like + `Query` / `One`. +- Every example in `docs/` runs as a test: the checker must produce exactly the diagnostics the + docs claim. + +### Changed + +- The checker is the product; the runtime is a library per database. The root module + `github.com/kr9ly/sqlshape/v2` holds only the declarations and has no dependencies. Running + a statement on pgx is `github.com/kr9ly/sqlshape/postgres/v2` (`postgres.Run(ctx, db, stmt, + p)`; the methods `stmt.Run(ctx, db, p)` are gone), on MySQL `.../mysql/v2`. The analyzers are + `check/postgres` (Apache 2.0) and `check/mysql` (GPLv2, MySQL's own parser), the binary + `cmd/sqlshape` (GPLv2, `go install github.com/kr9ly/sqlshape/cmd/sqlshape/v2@latest`); + nothing under them is linked into your program. Every module path carries `/v2`, and all + modules release together under one version. +- The `One` proof is written once, over the statement's facts, for every dialect. Two verdicts + change on PostgreSQL: `GROUP BY 1` (an ordinal or an alias) is no longer a pinned constant, so + such a statement is not proved single; a `LIMIT 1` over a `UNION` and an aggregate over a + `FULL JOIN` are proved. +- A template's `if` / `with` / `range` over a path an earlier control already decided follows + that decision, so impossible expansions are no longer checked; a pointer parameter inside its + own `{{if .X}}` branch is not NULL there; `-strict` advice about a table or column is reported + only in packages that touch it. +- PostgreSQL verdicts corrected against the server: `TRUNCATE` of a still-referenced table + is certain to fail; `ON CONFLICT` absorbs a unique constraint only when it can be the + arbiter (a `DEFERRABLE` key, or a partial index whose predicate is not repeated, keeps its + 23505); a domain's `NOT NULL` is keyed by the domain's name; `unnest(arr)` in the select list + is nullable; `SET col = DEFAULT` is a transition to the literal default; `-strict` rejects + `[]T` for an array whose Go element cannot be NULL; `interval` into `time.Duration` carries a + Lossy note; `paired` requires the write, not matching values; `RETURNING` a `sensitive` column + is reading it. +- Obligations on both databases: `require single` on an UPDATE or DELETE asks about the + target table's rows alone, so a join that only filters no longer breaks the proof; + `transitions` reads MySQL's constants (both producers now spell a constant the same way); + `pinned` propagates across a composite foreign key only into a + `NOT NULL` column (a NULL in a foreign-key column exempts the row from the constraint, so + such a row joins the parent while agreeing on nothing); `WITH CHECK OPTION` follows every + view of a chain the servers enforce -- an underlying view behind a join, and an underlying + view with its own check option under a `LOCAL` one. +- Migrations refuse what they cannot do losslessly instead of noting it: a change of `INHERITS`, + `OF type`, a domain's base type, a range's subtype, or a composite type's attribute type while + a column uses it stops `apply` as a problem. `pgtest` and `mysqltest` are sqlshape's own test + tooling with no compatibility promise; the docs are reorganized per database + ([docs/postgres.md](docs/postgres.md), [docs/mysql.md](docs/mysql.md)). + +### Fixed + +- A subquery in a join's `ON` clause referencing a joined table's column was recorded as a + value known before the statement runs rather than as the row's own column, so a proof or an + obligation could rest on it (found by `x/stmtprobe` against a running PostgreSQL). + ## [1.2.0] - 2026-09-09 ### Added -- PostgreSQL 18. `schema.sql` declares its version with `-- sqlshape: postgres 18`, and the schema - and every statement are judged with 18's grammar and catalog: `RETURNING old` / `new` (with - `WITH (OLD AS ..., NEW AS ...)`), `WITHOUT OVERLAPS` keys and `PERIOD` foreign keys, `NOT ENFORCED` - constraints (which are no failure mode), named `NOT NULL` constraints, `VIRTUAL` generated columns, - and 18's own checks (a JSON_VALUE DEFAULT whose collation is not the RETURNING type's, a JSON path - that is not a jsonpath, stricter datetime and aclitem input, an outer-level aggregate over a nested - CTE). `pgtest` and the migration commands run the declared version's PostgreSQL; `diff`, `apply` - and `verify-schema` warn when the database runs another major version than the schema declares. -- `One` proves a single row through a temporal key: the scalar columns fixed by equality and the - range column equal to a known value or containing a known point (`valid_at @> {{.Day}}::date`). +- PostgreSQL 18: `schema.sql` declares its version with `-- sqlshape: postgres 18`, and the schema + and every statement are judged with 18's grammar and catalog (`RETURNING old` / `new`, + `WITHOUT OVERLAPS` keys and `PERIOD` foreign keys, `NOT ENFORCED` constraints, named `NOT NULL` + constraints, `VIRTUAL` generated columns). `pgtest` and the migration commands run the declared + version. +- `One` proves a single row through a temporal key (`valid_at @> {{.Day}}::date`). ### Fixed -- `SELECT DISTINCT ON` expressions are matched to the leading `ORDER BY` expressions by expression, - not by where each was written; the same expression in both no longer fails with 42P10. -- An UPDATE that assigns a generated column reports PostgreSQL's message (`column "b" can only be - updated to DEFAULT`) rather than INSERT's. +- `SELECT DISTINCT ON` expressions are matched to the leading `ORDER BY` expressions by + expression, not by position. +- An UPDATE that assigns a generated column reports PostgreSQL's own message. ### Changed -- `schema.sql` must declare its PostgreSQL version (`-- sqlshape: postgres 17`); a schema without - the line is not read. Add the line to move from 1.1. -- The parser (libpg_query) is compiled to WebAssembly and run by wazero instead of being linked - through cgo. `go install` no longer needs a C compiler, and the binary is a plain Go build. - Parse trees, error messages and positions are unchanged. -- Prebuilt binaries for every platform, cross-compiled by one job: Linux, macOS and Windows, amd64 - and arm64 each (Windows and Intel macOS are new). +- `schema.sql` must declare its PostgreSQL version (`-- sqlshape: postgres 17`); add the line to + move from 1.1. +- The parser is compiled to WebAssembly and run by wazero instead of linked through cgo: + `go install` needs no C compiler, and prebuilt binaries cover Linux, macOS and Windows on + amd64 and arm64. ## [1.1.0] - 2026-09-08 ### Added -- Obligations: `schema.sql` declares, above a `CREATE TABLE` or `CREATE VIEW`, what every statement - touching the relation must do, and vet judges each statement against it - ([checks.md, Part 2](docs/checks.md#part-2--rules-the-schema-declares)). The general form is - `-- sqlshape: require [on ]`, where `` is an SQL predicate, `pinned(col)`, - `immutable(col)`, `via view`, `never`, `paired(table)` or `single`. -- `aggregate root (child, ...) [lock version]`: the tables form one aggregate; children are reached - through the root's key, one statement touches one aggregate, and with `lock` every write names - the root's version. -- `transitions col: a -> b, b -> c | d`: a status column is a state machine; an UPDATE setting it - must compare-and-set from a declared predecessor. -- `sensitive label: col, col` and `context name: may read label`: labelled columns are readable - only in a context that allows the label. -- `context name: require ...; waive ...`: obligations that differ per caller, selected by a - package's `// sqlshape: context name` comment, vet's `-context`, or `check -context`. -- `-- sqlshape: waive table [obligation]` in a statement or a view definition: an opt-out, - reported with `-strict`. -- Cross-table predicates: `require EXISTS (SELECT 1 FROM parent p WHERE p.id = parent_id AND ...)` - is discharged by a join or subquery that witnesses it. -- `sqlshape check`: the same judgments for SQL outside Go (files or stdin), every judgment printed - as an audit line, exit code 1 on a failure. -- The `internal/facts` package: what a statement provably does, in a form that does not depend on - the SQL dialect, so that the obligation checker can be shared with other dialects later. +- Obligations: `schema.sql` declares, above a `CREATE TABLE` or `CREATE VIEW`, what every + statement touching the relation must do, and vet judges each statement against it + ([checks.md, Part 2](docs/checks.md#part-2--rules-the-schema-declares)): `require [on ]`, + `aggregate root (children) [lock version]`, `transitions col: a -> b`, `sensitive label: cols` + with `context name: may read label`, per-caller `context name: require ...; waive ...`, and + `-- sqlshape: waive` as the opt-out. +- `sqlshape check`: the same judgments for SQL outside Go, one audit line per judgment. ### Changed -- `visible where`, `-require-columns`, `-no-table-reads` and `-no-tables` are now obligations - (`require on read`, `require pinned(col)`, `require via view`, `require via view on all`) - and are judged by the same checker. Judgment is per occurrence of a table: a self join or a - subquery reading the table again owes the obligation again; `-require-columns` used to accept a - statement that pinned the column anywhere in it. A row-level security policy or a composite - foreign key can discharge a pin. MERGE's ON is judged like a WHERE. -- Writes are counted by what the statement does to the table: each MERGE branch is a write of its - own kind, an `INSERT ... ON CONFLICT DO UPDATE` is an insert and an update, `TRUNCATE` is a - delete, and a write through an automatically updatable view is a write to the base table. -- `pinned` on an UPDATE is satisfied by the WHERE only; assigning the column in SET is not a pin. -- A `-- sqlshape:` directive above a statement that takes none (`ALTER TABLE`, `COMMENT ON`) is a - schema problem instead of being ignored. +- `visible where`, `-require-columns`, `-no-table-reads` and `-no-tables` are obligations now and + are judged per occurrence of a table (a self join owes it again). Writes are counted by what + the statement does: each MERGE branch, both halves of `INSERT ... ON CONFLICT DO UPDATE`, + `TRUNCATE` as a delete, a write through an updatable view as a write to the base table. + `pinned` on an UPDATE is satisfied by the WHERE only. +- A `-- sqlshape:` directive above a statement that takes none is a schema problem. - The docs are split: the README is an overview; [docs/](docs/) has checks, templates, runtime, migrations and flags, in English and Japanese. ### Fixed -- A vet failure shared by every branch of a template is reported once without the branch tag. +- A vet failure shared by every branch of a template is reported once, without the branch tag. - A diagnostic on a column referenced only in GROUP BY had no position. ## [1.0.0] - 2026-09-07 @@ -96,7 +237,8 @@ First release: `sqlshape.Query[R, P]` / `One[R, P]` templates checked by `go vet test oracle), the runtime on pgx, and `sqlshape diff` / `apply` / `verify-schema` for migrations from a declared schema. -[Unreleased]: https://github.com/kr9ly/sqlshape/compare/v1.2.0...HEAD +[Unreleased]: https://github.com/kr9ly/sqlshape/compare/v2.0.0...HEAD +[2.0.0]: https://github.com/kr9ly/sqlshape/compare/v1.2.0...v2.0.0 [1.2.0]: https://github.com/kr9ly/sqlshape/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/kr9ly/sqlshape/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/kr9ly/sqlshape/releases/tag/v1.0.0 diff --git a/NOTICE b/NOTICE index 5f710c1..f91522f 100644 --- a/NOTICE +++ b/NOTICE @@ -3,33 +3,5 @@ Copyright 2026 kr9ly Licensed under the Apache License, Version 2.0. -This product includes data and logic derived from PostgreSQL -(https://www.postgresql.org/), which is distributed under the PostgreSQL -License: - - internal/catalog/data the pg_catalog contents of PostgreSQL 17 and of - its contrib extensions, dumped as TSV - internal/analyze input-function validation and type-conversion - rules ported from the PostgreSQL source - -PostgreSQL Database Management System -(formerly known as Postgres, then as Postgres95) - -Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group -Portions Copyright (c) 1994, The Regents of the University of California - -Permission to use, copy, modify, and distribute this software and its -documentation for any purpose, without fee, and without a written agreement is -hereby granted, provided that the above copyright notice and this paragraph and -the following two paragraphs appear in all copies. - -IN NO EVENT SHALL THE UNIVERSITY OF CALIFORNIA BE LIABLE TO ANY PARTY FOR -DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST -PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF -THE UNIVERSITY OF CALIFORNIA HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -THE UNIVERSITY OF CALIFORNIA SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A -PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, AND -THE UNIVERSITY OF CALIFORNIA HAS NO OBLIGATIONS TO PROVIDE MAINTENANCE, SUPPORT, -UPDATES, ENHANCEMENTS, OR MODIFICATIONS. +The PostgreSQL side of the checker, the module check/postgres, includes data and logic +derived from PostgreSQL; see check/postgres/NOTICE. diff --git a/README.ja.md b/README.ja.md index a796960..9d0dfd9 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,11 +1,11 @@ # sqlshape -[![Go Reference](https://pkg.go.dev/badge/github.com/kr9ly/sqlshape.svg)](https://pkg.go.dev/github.com/kr9ly/sqlshape) +[![Go Reference](https://pkg.go.dev/badge/github.com/kr9ly/sqlshape/v2.svg)](https://pkg.go.dev/github.com/kr9ly/sqlshape/v2) [![release](https://img.shields.io/github/v/release/kr9ly/sqlshape)](https://github.com/kr9ly/sqlshape/releases) [![test](https://github.com/kr9ly/sqlshape/actions/workflows/test.yml/badge.svg)](https://github.com/kr9ly/sqlshape/actions/workflows/test.yml) ![coverage](.github/badges/coverage.svg) -sqlshapeは、Goのコードにそのまま書いたSQLを`go vet`で検査するツールである。ORMやクエリビルダを挟まず、SQLと、その結果やパラメータを受け渡すGoの構造体とが食い違っていないかを、コンパイル時に確かめる。 +sqlshapeは、Goのコードにそのまま書いたSQLを`go vet`で検査するツールである。ORMやクエリビルダを挟まず、SQLと、その結果やパラメータを受け渡すGoの構造体とが食い違っていないかを、コンパイル時に確かめる。PostgreSQLとMySQLに対応する。 [English](README.md) @@ -13,35 +13,38 @@ sqlshapeは、Goのコードにそのまま書いたSQLを`go vet`で検査す var ByEmail = sqlshape.One[User, struct{ Email string }](` SELECT id, email, name, deleted_at FROM users WHERE email = {{.Email}}`) -u, err := ByEmail.Get(ctx, db, struct{ Email string }{Email: email}) +u, err := postgres.Get(ctx, db, ByEmail, struct{ Email string }{Email: email}) // MySQLなら mysql.Get ``` -- 生成ではなく検査。列と構造体のフィールドの対応、パラメータの型、NULLの扱い、`One`と宣言した文が本当に1行しか返さないこと、INSERTやUPDATEが違反しうる制約を宣言していることを、`schema.sql`と照らして確かめる。`{{if}}`や`{{range}}`で分岐するSQLは、分岐の全組み合わせが検査される。違反は`go vet`の診断として、コードを実行する前に出る。 -- SQLインジェクションは起きない。`{{.X}}`は必ず`$n`のプレースホルダになり、値がSQLの文字列に埋め込まれることはない。実行時も、検査済みのSQLのみが実行できる。 +- 生成ではなく検査。列と構造体のフィールドの対応、パラメータの型、NULLの扱い、`One`と宣言した文が本当に1行しか返さないこと、INSERTやUPDATEが違反しうる制約を宣言していることを、`schema.sql`と照らして確かめる。判定するのは、使うデータベース自身のパーサと規則から組み上げたpure Goのアナライザーである。`{{if}}`や`{{range}}`で分岐するSQLは、分岐の全組み合わせが検査される。違反は`go vet`の診断として、コードを実行する前に出る。 +- SQLインジェクションは起きない。`{{.X}}`は必ずプレースホルダになり、値がSQLの文字列に埋め込まれることはない。実行時も、検査済みのSQLのみが実行できる。 - スキーマの定義は`schema.sql`の1ファイルだけ。静的検査もマイグレーションもこのファイルから導かれるので、モデル定義やマイグレーションファイルを別に書く必要はない。ビュー・関数・ドメイン・複合型・行レベルセキュリティ・seed済みのlookupテーブルもテーブルと同じ厳しさで検査されるので、ロジックをデータベース側に置いても検査の抜け穴にはならない。 +- プロジェクトが使うデータベースは1つ。どちらかは`schema.sql`が名乗り(`-- sqlshape: postgres 17`か`-- sqlshape: mysql 8.4`)、文法、型、診断に出る制約の名前、importするランタイムモジュールはすべてその宣言から決まる。片方にしか関係しないことは[docs/postgres.ja.md](docs/postgres.ja.md)と[docs/mysql.ja.md](docs/mysql.ja.md)にまとめてあり、他のドキュメントは両方の読者に向けて書いてある。 ## インストール 検査器とマイグレーションコマンドは1つのバイナリになっている。Go 1.26以上で: ``` -$ go install github.com/kr9ly/sqlshape/cmd/sqlshape@latest +$ go install github.com/kr9ly/sqlshape/cmd/sqlshape/v2@latest $ sqlshape version ``` ビルド済みのバイナリは[releasesページ](https://github.com/kr9ly/sqlshape/releases)にある。Linux・macOS・Windowsそれぞれamd64とarm64で、`sqlshape___.tar.gz`(Windowsは`.zip`)と`checksums.txt`。展開して`sqlshape`を`PATH`に置く。 -検査に他の準備は要らない。PostgreSQLのパーサ(libpg_query、対応するメジャー版ごとに1つ)はWebAssemblyとして埋め込まれwazeroで動くので、Cコンパイラもリンクするライブラリも無い。初回だけモジュールのコンパイルに1秒ほどかかり、結果はユーザーのキャッシュディレクトリ(Linuxでは`~/.cache/sqlshape`)に置かれる。`pgtest`とマイグレーション系コマンドは本物のPostgreSQLでスキーマを動かすので、初回に宣言した版のサーババイナリを同じキャッシュにダウンロードする([migrations.ja.md](docs/migrations.ja.md)の要件を参照)。 +検査に他の準備は要らない。パーサ(PostgreSQLはlibpg_queryを対応するメジャーバージョンごとに、MySQLは8.4自身の文法と字句解析器)はWebAssemblyとして埋め込まれwazeroで動くので、Cコンパイラもリンクするライブラリも無い。初回だけモジュールのコンパイルに1秒ほどかかり、結果はユーザーのキャッシュディレクトリ(Linuxでは`~/.cache/sqlshape`)に置かれる。本物のデータベースを動かすのはマイグレーションコマンドだけである。PostgreSQLでは初回に宣言したバージョンのバイナリを同じキャッシュにダウンロードし、MySQLでは対象サーバ上の一時データベースを使う。 -ランタイムは通常のGoモジュール: +宣言(`sqlshape.Query`、`sqlshape.One`)は依存の無いGoモジュールで、DBごとのランタイムは別のモジュールになっている。アプリケーションが取り込むのは自分のドライバだけである: ``` -$ go get github.com/kr9ly/sqlshape +$ go get github.com/kr9ly/sqlshape/v2 # Query / One: 検査器が読む宣言 +$ go get github.com/kr9ly/sqlshape/postgres/v2 # pgxの上で実行する +$ go get github.com/kr9ly/sqlshape/mysql/v2 # またはMySQLの上で、database/sqlを通して ``` -バージョンはsemantic versioningに従い、`vX.Y.Z`のタグを打つ。同じメジャーバージョンの中では、`sqlshape`と`pgtest`の公開API、テンプレート構文、ディレクティブ、検査器のフラグは互換を保つ。検査器が報告する内容はマイナーバージョンで増えることがある。 +バージョンはsemantic versioningに従い、リポジトリの全モジュールに同じタグを打つ(`vX.Y.Z`、`postgres/vX.Y.Z`、…)。同じメジャーバージョンの中では、これらのモジュールの公開API、テンプレート構文、ディレクティブ、検査器のフラグは互換を保つ。検査器が報告する内容はマイナーバージョンで増えることがある。 -## Quickstart +## Quickstart: PostgreSQL ```sql -- schema.sql @@ -54,7 +57,7 @@ CREATE TABLE users ( ); ``` -`schema.sql`の最初の行が、どの版のPostgreSQL向けのスキーマかを名乗る。すべての文はその版の文法とカタログで判定される(17と18に対応)。 +`schema.sql`の最初の行が、どのバージョンのPostgreSQL向けのスキーマかを名乗る。すべての文はそのバージョンの文法とカタログで判定される(17と18に対応)。 ```go // users.go @@ -82,12 +85,65 @@ users.go:25:46: sqlshape: may violate users_email_key (UNIQUE (email) on users, 1つ目は`deleted_at`がNULLになりうるのに`time.Time`で受けている、2つ目はこのINSERTはemailの一意制約に違反しうるのにそれを宣言していない、という指摘である。`DeletedAt`を`*time.Time`にして、INSERTの1行目に`-- sqlshape: expect users_email_key`を書けば通る。実行時はこう使う: ```go -users, err := Users.Collect(ctx, pool, struct{ Name *string }{}) // []User -id, err := Create.First(ctx, pool, struct{ Email, Name string }{e, n}) // int64 -if sqlshape.Violates(err, "users_email_key") { /* expect行で宣言した失敗 */ } +users, err := postgres.Collect(ctx, pool, Users, struct{ Name *string }{}) // []User +id, err := postgres.First(ctx, pool, Create, struct{ Email, Name string }{e, n}) // int64 +if postgres.Violates(err, "users_email_key") { /* expect行で宣言した失敗 */ } ``` -`db`にはpgxの`*pgxpool.Pool`、`*pgx.Conn`、`pgx.Tx`のどれでも渡せる。 +`pool`にはpgxの`*pgxpool.Pool`、`*pgx.Conn`、`pgx.Tx`のどれでも渡せる([docs/postgres.ja.md](docs/postgres.ja.md))。 + +## Quickstart: MySQL + +```sql +-- schema.sql +-- sqlshape: mysql 8.4 +CREATE TABLE users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL, + name VARCHAR(100) NOT NULL, + deleted_at DATETIME(6), + UNIQUE KEY users_email_key (email) +); +``` + +`schema.sql`の最初の行がMySQLのバージョンを名乗る。MySQL自身の文法がスキーマと全部の文を読み、MySQLの規則が型を付ける(8.4に対応)。既定と違う`sql_mode`や`lower_case_table_names`でサーバを動かすなら、`-- sqlshape: server`の行で宣言する。 + +```go +// users.go +type User struct { + ID uint64 + Email string + Name string + DeletedAt time.Time +} + +var Users = sqlshape.Query[User, struct{ Name *string }](` +SELECT id, email, name, deleted_at FROM users + WHERE true {{if .Name}} AND name = {{.Name}} {{end}}`) + +var Create = sqlshape.Query[struct{}, struct{ Email, Name string }](` +INSERT INTO users (email, name) VALUES ({{.Email}}, {{.Name}})`) +``` + +``` +$ sqlshape ./... +users.go:16:13: sqlshape: field DeletedAt is time.Time but column "deleted_at" may be NULL (use a pointer, or tag it `col:",notnull"` if you know better) +users.go:20:70: sqlshape: may violate users_email_key (UNIQUE users_email_key (email) on users, MySQL error 1062); add `-- sqlshape: expect users_email_key` to the template or make it impossible +``` + +1つ目は`deleted_at`がNULLになりうるのに`time.Time`で受けている、2つ目はこのINSERTは`users_email_key`に違反しうるのにそれを宣言していない、という指摘である。`DeletedAt`を`*time.Time`にして、INSERTの1行目に`-- sqlshape: expect users_email_key`を書けば通る。実行時はこう使う: + +```go +users, err := mysql.Collect(ctx, db, Users, struct{ Name *string }{}) // []User +res, err := mysql.Exec(ctx, db, Create, struct{ Email, Name string }{e, n}) // sql.Result +if mysql.Violates(err, "users_email_key") { /* expect行で宣言した失敗 */ } +``` + +`db`はgo-sql-driver/mysql(`parseTime=true`)で開いた`*sql.DB`、`*sql.Tx`、`*sql.Conn`のどれでもよい([docs/mysql.ja.md](docs/mysql.ja.md))。 + +## 構造体の生成とエディタ連携 + +どちらのデータベースでも同じ。検査器は文がどう実行されるかを見ない。読むのは宣言だけなので、自前のランタイムで実行してもよい。 構造体は自分で書かなくてもよい。`type Row struct{}`と`type Params struct{}`を空のまま宣言してSQLだけ書くと、列やパラメータに対応するフィールドが無いという診断が出て、それぞれにSQLから構造体を書き起こすquick fixが付く。`sqlshape -fix ./...`で一括適用できる。 @@ -107,6 +163,7 @@ $ go vet -vettool="$(which sqlshape)" ./... - [`examples/2-views`](examples/2-views) — 読み取りをビューにまとめる。JOINや列名の決定をビューに閉じ込めて、アプリケーション側のSQLを薄くする段階 - [`examples/3-database-api`](examples/3-database-api) — 書き込みを関数に、値の意味をドメインや複合型に移す。ロジックをデータベース側に置いたとき、検査がどう働くか - [`examples/4-everything`](examples/4-everything) — 全機能を使った例。特定の機能の使い方を探すときの索引 +- [`examples/5-mysql`](examples/5-mysql) — 同じ宣言をMySQLに対して。`schema.sql`が`mysql 8.4`を名乗り、文は`sqlshape/mysql`(database/sql)で実行する ## 何を検査するか @@ -114,7 +171,7 @@ $ go vet -vettool="$(which sqlshape)" ./... - 形 — SQLが返す列とGoの構造体、`{{.X}}`とパラメータの構造体が、名前も型もNULLの扱いも合っていること。ネストした行や複合型も含む - 意味 — enumやlookupテーブルの値、主キー、ドメインに対応するGoの型は、使われた箇所からその意味に結びつけられる。別のテーブルのIDを渡す、単位の違うドメインを足す、定数とラベルがずれている、といった型が同じでも意味の違う誤りが見つかる -- 失敗モード — 書き込みが違反しうる制約はexpect行に宣言しなければならない。宣言漏れも、起こりえない宣言も報告されるので、どの制約違反を処理すべきかがコードに書かれた状態が保たれる。これはORMには無い。ORMのモデルは制約の写しなので、そこから「何が失敗しうるか」は導けず、例外は実行時に名前を持って届き、それを手で突き合わせることになる。sqlshapeでは一覧を`schema.sql`から計算し(トリガや呼び出す関数の中まで)、名前はPostgreSQL自身の命名で、実行時のエラーも同じ名前を運ぶ。宣言・検査・ハンドラが一つの文字列で結ばれる +- 失敗モード — 書き込みが違反しうる制約はexpect行に宣言しなければならない。宣言漏れも、起こりえない宣言も報告されるので、どの制約違反を処理すべきかがコードに書かれた状態が保たれる。これはORMには無い。ORMのモデルは制約の写しなので、そこから「何が失敗しうるか」は導けず、例外は実行時に名前を持って届き、それを手で突き合わせることになる。sqlshapeでは一覧を`schema.sql`から計算し(トリガや呼び出す関数の中まで)、名前はデータベース自身の命名(PostgreSQLの制約名とSQLSTATE、MySQLのキー名とエラー番号)で、実行時のエラーも同じ名前を運ぶ。宣言・検査・ハンドラが一つの文字列で結ばれる - カーディナリティ — `One`と宣言した文は、本当に1行以下しか返さないことがスキーマから証明される - 境界 — 論理削除の条件を必ず付ける、テナント列で必ず絞る、テーブルを直接読まずビューを通す、といったチームの規約を検査器に強制させられる - スキーマ自体 — `schema.sql`の関数(SQLもPL/pgSQLも本体まで)・ビュー・ポリシーも型検査される。`-strict`を付けると、インデックスが使われない条件やlookupテーブルで済むenumなどの助言も出る @@ -148,20 +205,28 @@ $ sqlshape verify-schema -db "$DSN" # ドリフト検出: データベ ## ドキュメント -- [docs/checks.ja.md](docs/checks.ja.md) — 検査器が確かめること全部: 形、意味、失敗モード(PostgreSQLの制約命名規則の表つき)、カーディナリティ、境界 +- [docs/postgres.ja.md](docs/postgres.ja.md) — PostgreSQLに属するもの全部: バージョンの宣言、検査器が埋め込むものとその検証、pgxの上のランタイム(`Batch`、`Copy`、`MatView`、型の登録)、PostgreSQLのマイグレーション +- [docs/mysql.ja.md](docs/mysql.ja.md) — MySQLに属するもの全部: バージョンと`server`の宣言、Go型の表、制約名とエラー番号、`ONLY_FULL_GROUP_BY`の検査、`database/sql`の上のランタイム、MySQLのマイグレーション +- [docs/checks.ja.md](docs/checks.ja.md) — 検査器が確かめること全部(両データベース共通): 形、意味、失敗モード、カーディナリティ、スキーマが宣言する規約(`require`、集約、`sqlshape check`) - [docs/templates.ja.md](docs/templates.ja.md) — テンプレートで使える構文、ディレクティブ、共有フラグメント、危険な書き方、疎検査 -- [docs/runtime.ja.md](docs/runtime.ja.md) — `Run` / `Collect` / `First` / `Exec`、`One`、`Batch`、`Copy`、`MatView`、Go型の表、型の登録、エラー、本物のPostgreSQLでのテスト -- [docs/migrations.ja.md](docs/migrations.ja.md) — `diff` / `apply` / `verify-schema`、`-- @migrate`宣言、seed済みテーブル、必要な環境 +- [docs/runtime.ja.md](docs/runtime.ja.md) — どのランタイムでも同じこと: `Run` / `Collect` / `First` / `Exec`、`One`、行のマッピング、エラー、検査済みのSQLだけが走る保証 +- [docs/migrations.ja.md](docs/migrations.ja.md) — `diff` / `apply` / `verify-schema`、`-- @migrate`宣言、seed済みテーブル、必要な環境、MySQLで違うところ - [docs/flags.ja.md](docs/flags.ja.md) — 全フラグ、`-strict`の助言一覧、エディタ設定 - [docs/design.md](docs/design.md) — 設計上の裁定。何を決めたか、なぜか、何を棄てたか ## 互換性 -PostgreSQL 17と18に対応し、どちらかは`schema.sql`が宣言する。構文はPostgreSQL自身のものである。パーサはその版のlibpg_queryなので、サーバが読める文は検査器も同じように読む。SELECTとDML、MERGE、CTE、ウィンドウ関数、GROUPING SETS、SQL/JSON、範囲型、18の`RETURNING old` / `new`と時制キー、citextやhstoreなどの拡張、ビュー・関数(SQLとPL/pgSQLの本体まで)・トリガー・ポリシーを含むDDL。アナライザーはその版のカタログから組み上げたpure Goの実装で、検査時にPostgreSQLへ接続することはない。 +PostgreSQL 17と18。構文はPostgreSQL自身のもの(宣言したバージョンのlibpg_query)で、アナライザーはそのバージョンのカタログから組み上げてある。判定はPostgreSQL自身の回帰テストで裏付けている。アナライザーと本物のサーバを並走させ、17では22,103文のうち19件、18では23,384文のうち31件だけが一致せず、全件を列挙して理由を付けてある([docs/postgres.ja.md](docs/postgres.ja.md#検査器が埋め込んでいるもの))。マイグレーションも同じやり方で判定している。生成したスキーマの組を、diffが報告し得る変更の全種にわたって、行の入った本物のサーバにDDLとして流す([docs/migrations.ja.md](docs/migrations.ja.md#計画がどう検証されているか))。 -判定の裏付けはPostgreSQL自身の回帰テストである。`src/test/regress`の文を、アナライザーと同じ版の本物のPostgreSQLに並走させ、パラメータの型・結果列・エラーの判定が一致することを確認している。一致しないのは17で22,103文のうち19件、18で23,384文のうち31件。全件を`internal/analyze/testdata/regress_baseline_.txt`に列挙してあり、どれも静的解析では判定できないもの(行レベルセキュリティの再帰、権限、サーバー内部のエラー)か、検査器が正しくサーバのDescribeには見えないもの(INSERTの`RETURNING old`がNULLであること)である。この突き合わせは`go test ./...`の一部なので、新しい不一致が出ればテストが失敗する。 +MySQL 8.4。パーサと字句解析器はサーバのソースから切り出したMySQL自身のもので、関数の表も同じソースから読む。判定は動いている`mysqld`と照合している。組み込み関数全部の結果型、エラーになる文、`ONLY_FULL_GROUP_BY`の検査、`sql_mode`を変えた場合が8.4と一致し、MySQL自身のテストコーパス(`mysql-test/t`、約137,000文)をアナライザーとサーバの両方に流して突き合わせている——一致しないと分かっている2,525文は1件ずつベースラインに固定してあり、新しい不一致が出るとビルドが落ちる([docs/mysql.ja.md](docs/mysql.ja.md#検査器が埋め込んでいるもの))。MySQLに無いもの(`Copy`、`MatView`、PL/pgSQL、ドメイン、複合型と配列、`-schemas`、`// sqlshape: type`、マイグレーションのseed表)はそこに列挙してある。マイグレーションはPostgreSQLと同じやり方で、動いている`mysqld`に対して判定している。 ## License -Apache License 2.0, see [LICENSE](LICENSE). The embedded `pg_catalog` data and the validation -rules ported from PostgreSQL are used under the PostgreSQL License, see [NOTICE](NOTICE). +Everything a checked program links is Apache License 2.0, see [LICENSE](LICENSE): the +declarations (the root module) and the runtimes (`postgres`, `mysql`). The PostgreSQL +side of the checker (`check/postgres`) embeds `pg_catalog` data and validation rules ported from +PostgreSQL under the PostgreSQL License, see [check/postgres/NOTICE](check/postgres/NOTICE). The +`sqlshape` binary (`cmd/sqlshape`) and the MySQL side of the checker (`check/mysql`, which carries +MySQL's own parser) are modules under the GNU General Public License v2, see +[cmd/sqlshape/LICENSE](cmd/sqlshape/LICENSE); the binary is a development tool, and nothing under +it is linked into your program. diff --git a/README.md b/README.md index a104004..e16e613 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # sqlshape -[![Go Reference](https://pkg.go.dev/badge/github.com/kr9ly/sqlshape.svg)](https://pkg.go.dev/github.com/kr9ly/sqlshape) +[![Go Reference](https://pkg.go.dev/badge/github.com/kr9ly/sqlshape/v2.svg)](https://pkg.go.dev/github.com/kr9ly/sqlshape/v2) [![release](https://img.shields.io/github/v/release/kr9ly/sqlshape)](https://github.com/kr9ly/sqlshape/releases) [![test](https://github.com/kr9ly/sqlshape/actions/workflows/test.yml/badge.svg)](https://github.com/kr9ly/sqlshape/actions/workflows/test.yml) ![coverage](.github/badges/coverage.svg) -Write SQL as SQL, and let a `go vet` checker prove the Go code around it fits. +Write SQL as SQL, and let a `go vet` checker prove the Go code around it fits. For PostgreSQL and +MySQL. [日本語](README.ja.md) @@ -13,28 +14,32 @@ Write SQL as SQL, and let a `go vet` checker prove the Go code around it fits. var ByEmail = sqlshape.One[User, struct{ Email string }](` SELECT id, email, name, deleted_at FROM users WHERE email = {{.Email}}`) -u, err := ByEmail.Get(ctx, db, struct{ Email string }{Email: email}) +u, err := postgres.Get(ctx, db, ByEmail, struct{ Email string }{Email: email}) // or mysql.Get ``` -- Checked, not generated. Every statement is analyzed against `schema.sql` by a pure-Go - PostgreSQL analyzer: the result columns must fit the row type, the `{{.X}}` parameters must - fit the parameter type, nullability is enforced, `One` has to be provably single-row, and a - write must declare the constraints it can violate. Every finding is a `go vet` diagnostic, - reported before the code ever runs. +- Checked, not generated. Every statement is analyzed against `schema.sql` by a pure-Go analyzer + built from your database's own parser and rules: the result columns must fit the row type, the + `{{.X}}` parameters must fit the parameter type, nullability is enforced, `One` has to be + provably single-row, and a write must declare the constraints it can violate. Every finding is a + `go vet` diagnostic, reported before the code ever runs. - Plain SQL, no injection. Templates are Go `text/template`: `{{.X}}` always becomes a - `$n` parameter, never text, and every `{{if}}` / `{{range}}` combination is expanded and - checked. The runtime refuses any rendering the checker never saw. -- `schema.sql` is the only definition. The checker reads it, `pgtest` runs your tests against - a real PostgreSQL with it applied, and `sqlshape diff` derives the migration from it. Views, functions, - domains, composite types, row-level security and seeded lookup tables are all part of the - checked surface, so the database can expose a typed API instead of raw tables. + parameter, never text, and every `{{if}}` / `{{range}}` combination is expanded and checked. The + runtime refuses any rendering the checker never saw. +- `schema.sql` is the only definition. The checker reads it, and `sqlshape diff` derives the + migration from it. Views, + functions, domains, composite types, row-level security and seeded lookup tables are all part + of the checked surface, so the database can expose a typed API instead of raw tables. +- One database per project. `schema.sql` declares which (`-- sqlshape: postgres 17` or + `-- sqlshape: mysql 8.4`), and everything follows from the declaration: the grammar, the types, + the constraint names in the diagnostics and the runtime module you import. [docs/postgres.md](docs/postgres.md) and [docs/mysql.md](docs/mysql.md) each + gather what is specific to one; the rest of the documentation is written for both. ## Install The checker and the migration commands are one binary. With Go 1.26 or newer: ``` -$ go install github.com/kr9ly/sqlshape/cmd/sqlshape@latest +$ go install github.com/kr9ly/sqlshape/cmd/sqlshape/v2@latest $ sqlshape version ``` @@ -42,24 +47,29 @@ Or take a prebuilt binary from the [releases page](https://github.com/kr9ly/sqls Linux, macOS and Windows, amd64 and arm64 each, as `sqlshape___.tar.gz` (`.zip` on Windows) with `checksums.txt` beside them. Unpack and put `sqlshape` on your `PATH`. -Nothing else is needed to check code. PostgreSQL's parser (libpg_query, one per supported major -version) is embedded as WebAssembly and runs on wazero, so there is no C compiler to install and no -library to link; the first run compiles the module (about a second) and caches the result under the -user cache directory (`~/.cache/sqlshape` on Linux). `pgtest` and the migration commands, which run -your schema on a real PostgreSQL, download the declared version's server binaries into the same -cache on first use (see [migrations.md](docs/migrations.md#requirements)). +Nothing else is needed to check code. The parsers (PostgreSQL's libpg_query per supported major +version, MySQL 8.4's own grammar and lexer) are embedded as WebAssembly and run on wazero, so +there is no C compiler to install and no library to link; the first run compiles the module +(about a second) and caches the result under the user cache directory (`~/.cache/sqlshape` on +Linux). Only the migration commands run a real database: on PostgreSQL they download the declared +version's binaries into the same cache on first use, on MySQL they use a scratch database on the +server they migrate. -The runtime is an ordinary Go module: +The declarations (`sqlshape.Query`, `sqlshape.One`) are a Go module with no dependencies, and +the runtime for each database is a module of its own, so an application pulls in only its own +driver: ``` -$ go get github.com/kr9ly/sqlshape +$ go get github.com/kr9ly/sqlshape/v2 # Query / One: the declarations the checker reads +$ go get github.com/kr9ly/sqlshape/postgres/v2 # running them on pgx +$ go get github.com/kr9ly/sqlshape/mysql/v2 # or on MySQL, through database/sql ``` -Versions follow semantic versioning and are tagged `vX.Y.Z`. Within a major version, the exported -API of `sqlshape` and `pgtest`, the template syntax, the directives and the checker's flags stay -compatible; what the checker reports may grow with minor versions. +Versions follow semantic versioning; every module of the repository is tagged together +(`vX.Y.Z`, `postgres/vX.Y.Z`, ...). Within a major version, the exported API of these modules, the +template syntax, the directives and the checker's flags stay compatible; what the checker reports may grow with minor versions. -## Quickstart +## Quickstart: PostgreSQL ```sql -- schema.sql @@ -102,12 +112,72 @@ Make `DeletedAt` a `*time.Time`, put `-- sqlshape: expect users_email_key` on th line, and the package is clean. At run time: ```go -users, err := Users.Collect(ctx, pool, struct{ Name *string }{}) // []User -id, err := Create.First(ctx, pool, struct{ Email, Name string }{e, n}) // int64 -if sqlshape.Violates(err, "users_email_key") { /* the declared failure mode */ } +users, err := postgres.Collect(ctx, pool, Users, struct{ Name *string }{}) // []User +id, err := postgres.First(ctx, pool, Create, struct{ Email, Name string }{e, n}) // int64 +if postgres.Violates(err, "users_email_key") { /* the declared failure mode */ } ``` -`db` is anything pgx gives you: a `*pgxpool.Pool`, `*pgx.Conn` or `pgx.Tx`. +`pool` is anything pgx gives you: a `*pgxpool.Pool`, `*pgx.Conn` or `pgx.Tx` +([docs/postgres.md](docs/postgres.md)). + +## Quickstart: MySQL + +```sql +-- schema.sql +-- sqlshape: mysql 8.4 +CREATE TABLE users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL, + name VARCHAR(100) NOT NULL, + deleted_at DATETIME(6), + UNIQUE KEY users_email_key (email) +); +``` + +The first line of `schema.sql` names the MySQL version; MySQL's own grammar parses the schema and +every statement, and MySQL's rules type them (8.4 is supported). A `-- sqlshape: server` line +declares a `sql_mode` or `lower_case_table_names` the server runs with, when it is not the +default. + +```go +// users.go +type User struct { + ID uint64 + Email string + Name string + DeletedAt time.Time +} + +var Users = sqlshape.Query[User, struct{ Name *string }](` +SELECT id, email, name, deleted_at FROM users + WHERE true {{if .Name}} AND name = {{.Name}} {{end}}`) + +var Create = sqlshape.Query[struct{}, struct{ Email, Name string }](` +INSERT INTO users (email, name) VALUES ({{.Email}}, {{.Name}})`) +``` + +``` +$ sqlshape ./... +users.go:16:13: sqlshape: field DeletedAt is time.Time but column "deleted_at" may be NULL (use a pointer, or tag it `col:",notnull"` if you know better) +users.go:20:70: sqlshape: may violate users_email_key (UNIQUE users_email_key (email) on users, MySQL error 1062); add `-- sqlshape: expect users_email_key` to the template or make it impossible +``` + +Make `DeletedAt` a `*time.Time`, put `-- sqlshape: expect users_email_key` on the INSERT's first +line, and the package is clean. At run time: + +```go +users, err := mysql.Collect(ctx, db, Users, struct{ Name *string }{}) // []User +res, err := mysql.Exec(ctx, db, Create, struct{ Email, Name string }{e, n}) // sql.Result +if mysql.Violates(err, "users_email_key") { /* the declared failure mode */ } +``` + +`db` is a `*sql.DB`, `*sql.Tx` or `*sql.Conn` opened with go-sql-driver/mysql (`parseTime=true`) +([docs/mysql.md](docs/mysql.md)). + +## Struct generation and editor setup + +The same on either database. The checker does not care how a statement is run: the declaration is what it reads, and a program +may run its statements through a runtime of its own. You do not have to write the structs. Declare `type Row struct{}` and `type Params struct{}` empty, write the SQL, and every column and parameter without a field is reported with a quick fix @@ -130,6 +200,7 @@ Four stages of the same order book, one per level of trust in the database: - [`examples/2-views`](examples/2-views) — Reading through views: joins and column names decided once in the schema, so the application's SQL gets thinner - [`examples/3-database-api`](examples/3-database-api) — Writes as functions, meaning as domains and composite types: how the checks work once logic lives in the database - [`examples/4-everything`](examples/4-everything) — Every feature in one place; the index to look up how a particular feature is used +- [`examples/5-mysql`](examples/5-mysql) — The same declarations against MySQL: `schema.sql` declares `mysql 8.4`, the statements run through `sqlshape/mysql` on database/sql ## What it checks @@ -147,16 +218,17 @@ The full list is in [docs/checks.md](docs/checks.md). Broadly: it has to handle. No ORM offers this: an ORM's model is a copy of the constraints, so nothing can be derived from it about what will fail, and the exception arrives at run time with a name to match by hand. Here the list is computed from `schema.sql` (triggers and called functions - included), the names are PostgreSQL's own, and the runtime error carries the same name, so the - declaration, the check and the handler are one string. + included), the names are the database's own (PostgreSQL's constraint names and SQLSTATEs, MySQL's + key names and error numbers), and the runtime error carries the same name, so the declaration, + the check and the handler are one string. - Cardinality: a statement declared with `One` is proved from the schema to return at most one row. - Boundaries: team rules that show up in the shape of the SQL, such as always filtering soft-deleted rows, always pinning the tenant column, or reading tables only through views, are enforced by the checker. -- The schema itself: the functions (SQL and PL/pgSQL bodies), views and policies in `schema.sql` are type-checked too, and - `-strict` adds advice such as a predicate no index serves or an enum a lookup table would serve - better. +- The schema itself: the functions (SQL and PL/pgSQL bodies), views and policies in `schema.sql` + are type-checked too, and `-strict` adds advice such as a predicate no index serves or an enum a + lookup table would serve better. ## SQL outside Go @@ -196,32 +268,44 @@ rename or the removal of an enum label, are declared in `schema.sql` with `-- @m ## Documentation -- [docs/checks.md](docs/checks.md) — everything the checker verifies: shapes, meaning, failure modes (with PostgreSQL's constraint naming rules), cardinality, the rules a schema declares (`require`, aggregates, `sqlshape check`) +- [docs/postgres.md](docs/postgres.md) — everything PostgreSQL's: the version declaration, what the checker embeds and how it is verified, the runtime on pgx (`Batch`, `Copy`, `MatView`, type registration), migrations on PostgreSQL +- [docs/mysql.md](docs/mysql.md) — everything MySQL's: the version and `server` declarations, the Go type table, constraint names and error numbers, the `ONLY_FULL_GROUP_BY` check, the runtime on `database/sql`, migrations on MySQL +- [docs/checks.md](docs/checks.md) — everything the checker verifies, for both databases: shapes, meaning, failure modes, cardinality, the rules a schema declares (`require`, aggregates, `sqlshape check`) - [docs/templates.md](docs/templates.md) — the template subset, directives, shared fragments, hazards, sparse checking -- [docs/runtime.md](docs/runtime.md) — `Run` / `Collect` / `First` / `Exec`, `One`, `Batch`, `Copy`, `MatView`, the Go type table, type registration, errors, tests on a real PostgreSQL -- [docs/migrations.md](docs/migrations.md) — `diff` / `apply` / `verify-schema`, `-- @migrate` declarations, seeded tables, requirements +- [docs/runtime.md](docs/runtime.md) — what every runtime does: `Run` / `Collect` / `First` / `Exec`, `One`, row mapping, errors, the guarantee that only checked SQL runs +- [docs/migrations.md](docs/migrations.md) — `diff` / `apply` / `verify-schema`, `-- @migrate` declarations, seeded tables, requirements, what differs on MySQL - [docs/flags.md](docs/flags.md) — every flag, the `-strict` advisories, editor setup - [docs/design.md](docs/design.md) — design decisions: what was decided, why, and what was rejected (Japanese) ## Compatibility -PostgreSQL 17 and 18; `schema.sql` declares which. The syntax is PostgreSQL's own: the parser is -libpg_query of that version, so everything the server parses, the checker parses the same way — -SELECT and DML, MERGE, CTEs, window functions, GROUPING SETS, SQL/JSON, ranges, `RETURNING old` / -`new` and temporal keys on 18, extensions such as citext and hstore, and DDL including views, -functions (SQL and PL/pgSQL bodies), triggers and policies. The analyzer is a pure-Go implementation -built from that version's catalog; checking never connects to a PostgreSQL. - -The judgment is backed by PostgreSQL's own regression suite: the statements of `src/test/regress` -are run through the analyzer and a real PostgreSQL of the same version side by side, and parameter -types, result columns and errors must agree. On 17 they disagree on 19 of 22,103 statements, on 18 -on 31 of 23,384; every one is listed (`internal/analyze/testdata/regress_baseline_.txt`), -and each is either something static analysis cannot decide (row-level security recursion, -permissions, server internals) or a case where the checker is right and the server's Describe -cannot say (the NULLs of `RETURNING old` after an INSERT). The comparison is part of -`go test ./...`, so a new disagreement fails the build. +PostgreSQL 17 and 18. The syntax is PostgreSQL's own (libpg_query of the declared version) and +the analyzer is built from that version's catalog. The verdicts are checked against PostgreSQL's +own regression suite, run through the analyzer and a real server side by side: on 17 they +disagree on 19 of 22,103 statements, on 18 on 31 of 23,384, every one listed and explained +([docs/postgres.md](docs/postgres.md#what-the-checker-embeds)). The migrations are judged the same +way: generated schema pairs, every kind of change the diff can report, run as DDL against a real +server with rows in its tables +([docs/migrations.md](docs/migrations.md#how-the-plan-is-tested)). + +MySQL 8.4. The parser and lexer are MySQL's own, extracted from the server source, and the +function catalog comes from the same source. The verdicts are checked against a running `mysqld`: +the result types of every built-in function, the error statements, the `ONLY_FULL_GROUP_BY` check +and the `sql_mode` variants agree with 8.4, and MySQL's own test corpus (`mysql-test/t`, some +137,000 statements) replays against the analyzer and the server side by side -- the 2,525 +statements they knowingly disagree on are pinned one by one as a baseline, and a new +disagreement fails the build ([docs/mysql.md](docs/mysql.md#what-the-checker-embeds)). +What has no MySQL counterpart (`Copy`, `MatView`, PL/pgSQL, domains, composite types and arrays, +`-schemas`, `// sqlshape: type`, seeded tables in migrations) is listed there. Its migrations are +judged against a running `mysqld` the same way as PostgreSQL's. ## License -Apache License 2.0, see [LICENSE](LICENSE). The embedded `pg_catalog` data and the validation -rules ported from PostgreSQL are used under the PostgreSQL License, see [NOTICE](NOTICE). +Everything a checked program links is Apache License 2.0, see [LICENSE](LICENSE): the +declarations (the root module) and the runtimes (`postgres`, `mysql`). The PostgreSQL +side of the checker (`check/postgres`) embeds `pg_catalog` data and validation rules ported from +PostgreSQL under the PostgreSQL License, see [check/postgres/NOTICE](check/postgres/NOTICE). The +`sqlshape` binary (`cmd/sqlshape`) and the MySQL side of the checker (`check/mysql`, which carries +MySQL's own parser) are modules under the GNU General Public License v2, see +[cmd/sqlshape/LICENSE](cmd/sqlshape/LICENSE); the binary is a development tool, and nothing under +it is linked into your program. diff --git a/check/mysql/LICENSE b/check/mysql/LICENSE new file mode 100644 index 0000000..d8cf7d4 --- /dev/null +++ b/check/mysql/LICENSE @@ -0,0 +1,280 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS diff --git a/check/mysql/dialect/adv2_obligation_test.go b/check/mysql/dialect/adv2_obligation_test.go new file mode 100644 index 0000000..eb3b128 --- /dev/null +++ b/check/mysql/dialect/adv2_obligation_test.go @@ -0,0 +1,402 @@ +package dialect + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kr9ly/sqlshape/mysqltest/v2" + "github.com/kr9ly/sqlshape/v2/x/obligation" +) + +// Second adversarial round (2026-09, since commit a2875e5): the MySQL adapter's facts for +// the obligation checker (require pinned / immutable / never / single, visible where), +// through the surface that grew after round 1 -- CALL, triggers, stored routines, and the +// MySQL-only statement shapes (INSERT ... ON DUPLICATE KEY UPDATE, REPLACE) round 1 never +// exercised. Every finding here is a case where the fact the MySQL producer hands to +// x/obligation does not match what mysqld 8.4.11 actually does. + +// adv2Load is loadContract's twin for a schema local to this file (the shared testSchema in +// dialect_test.go has no ON DUPLICATE KEY UPDATE target, no non-numeric primary key and no +// trigger, which the findings below all need). +func adv2Load(t *testing.T, schemaSQL string) *mysql { + t.Helper() + an, err := load(schemaSQL) + if err != nil { + t.Fatal(err) + } + m := an.(*mysql) + if p := m.Problems(); len(p) > 0 { + t.Fatalf("schema problems: %v", p) + } + return m +} + +const adv2DupKeySchema = `-- sqlshape: mysql 8.4 +CREATE TABLE tenants (id BIGINT UNSIGNED NOT NULL PRIMARY KEY); +-- sqlshape: require pinned(tenant_id) +CREATE TABLE orders ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + status VARCHAR(10) NOT NULL, + CONSTRAINT fk_orders_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) +); +` + +// TestAdv2OnDuplicateKeyUpdateBypassesPinned: `require pinned(tenant_id)` exists so that +// every statement on orders is trusted to touch exactly one tenant's rows. x/obligation +// already has a case built for exactly this shape -- an INSERT whose conflict-handling +// branch reassigns the pinned column needs the branch's own WHERE-less write checked on its +// own (x/obligation/check.go's writes(): `case o.Body.Pinned != "" && w.Kind == facts.Update +// && c.f.Kind != facts.Update && assigns(w, o.Body.Pinned)`, wired up for PostgreSQL's +// `INSERT ... ON CONFLICT DO UPDATE`, which the obligations.md 1.1.0 log calls out by name: +// "ON CONFLICT DO UPDATE / MERGEの枝がpinned列に代入するなら、文が同じ列を固定していることも +// 求める"). That case only fires when the producer emits a second facts.Write{Kind: Update} +// for the conflict branch. +// +// check/mysql/internal/analyze/analyze.go's insert() never does this for MySQL's own +// equivalent, ON DUPLICATE KEY UPDATE: the ON DUPLICATE columns are typed and kept as +// w.onDuplicate (analyze.go:596-611), which violations.go reads for the 1048/1451/1642 +// failure modes (violations.go:144,207,232-233), but a.facts.Writes is set once, at +// analyze.go:592, to a single facts.Write{Kind: Insert, ...} built before the ON DUPLICATE +// clause is even parsed. The UPDATE branch's assignment to tenant_id never becomes a +// facts.Write at all, so x/obligation never even reaches the case above: it has no Update +// write to iterate over (x/obligation/check.go's writes(): `for _, w := range c.f.Writes`). +// +// The result: `INSERT ... ON DUPLICATE KEY UPDATE tenant_id = ?` is reported exactly like a +// plain INSERT (pinned is satisfied because INSERT assigns tenant_id -- x/obligation's +// pinned(), the `c.f.Kind == facts.Insert && leaf.Role == facts.Target` branch), with no +// hint that, on a duplicate key, the statement can silently move an existing row to a +// different tenant_id with no equality fixing the row it moves in the first place -- the +// exact hazard the Postgres-side case exists to catch. +func TestAdv2OnDuplicateKeyUpdateBypassesPinned(t *testing.T) { + m := adv2Load(t, adv2DupKeySchema) + decls, problems := obligation.Declarations(m.Contract()) + if len(problems) > 0 { + t.Fatalf("problems: %+v", problems) + } + + const sql = `INSERT INTO orders (id, tenant_id, status) VALUES ($1, $2, $3) ON DUPLICATE KEY UPDATE tenant_id = $4, status = $5` + r, err := m.Analyze(sql) + if err != nil { + t.Fatalf("%s: %v", sql, err) + } + flagged := false + for _, d := range obligation.Check(m.Contract(), decls, r.Facts, m) { + if d.Obligation.Source == "require pinned(tenant_id)" && d.Message != "" { + flagged = true + } + } + if !flagged { + t.Errorf("%s: sqlshape reports require pinned(tenant_id) as satisfied, but the ON DUPLICATE KEY UPDATE branch reassigns tenant_id with nothing fixing the row it moves -- the statement is not, in fact, pinned to one tenant on a duplicate key", sql) + } + + // Confirm against a real server: a duplicate key silently moves an existing row's + // tenant_id, exactly the hazard require pinned(tenant_id) exists to rule out. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, adv2DupKeySchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + for _, tenant := range []int{1, 2} { + if _, err := conn.ExecContext(ctx, "INSERT INTO tenants (id) VALUES (?)", tenant); err != nil { + t.Fatal(err) + } + } + if _, err := conn.ExecContext(ctx, "INSERT INTO orders (id, tenant_id, status) VALUES (1, 1, 'open')"); err != nil { + t.Fatal(err) + } + // the same INSERT ... ON DUPLICATE KEY UPDATE sqlshape analyzed above, now executed with + // id=1 colliding: the server takes the UPDATE branch and moves the row to tenant 2 + if _, err := conn.ExecContext(ctx, "INSERT INTO orders (id, tenant_id, status) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE tenant_id = ?, status = ?", 1, 2, "closed", 2, "closed"); err != nil { + t.Fatalf("server rejects the duplicate-key branch: %v", err) + } + var tenantID int64 + if err := conn.QueryRowContext(ctx, "SELECT tenant_id FROM orders WHERE id = 1").Scan(&tenantID); err != nil { + t.Fatal(err) + } + if tenantID != 2 { + t.Fatalf("expected the ON DUPLICATE KEY UPDATE branch to move order 1 to tenant 2 (demonstrating the hazard), got tenant %d", tenantID) + } +} + +const adv2CoercedPKSchema = `-- sqlshape: mysql 8.4 +-- sqlshape: require single on delete +CREATE TABLE orders ( + id VARCHAR(10) NOT NULL PRIMARY KEY, + amount INT NOT NULL +); +` + +// TestAdv2StringNumberCoercionBreaksSingleRowProof: `require single on delete` promises the +// One proof (x/cardinality) has established that the statement provably touches at most one +// row. The proof's premise, over a primary key: an equality to a known value fixes exactly +// one row because the key is unique. That premise silently assumes the equality compares +// like with like; MySQL's own comparison rules do not. +// +// DELETE FROM orders WHERE id = 5 compares a VARCHAR primary key with the *unquoted* integer +// literal 5. check/mysql/internal/analyze's termFacts/literalClass records Item_int as a +// plain Const term (facts.go's literalClass, analyze/facts.go:642-649) with no regard for +// the column's declared type, and eqFacts (facts.go:393-427) fixes the column to it exactly +// as it would fix a matching-typed column -- so closeFixed marks id Fixed, the PRIMARY key +// is enforced (leafFacts, facts.go:150-165), and cardinality.AtMostOne (and so `require +// single on delete`) reports the DELETE as provably single-row. +// +// mysqld disagrees: comparing a non-numeric string column to a number converts the *string* +// to a number for the comparison (Type Conversion in Expression Evaluation, measured below). +// '5' and '05' are both distinct VARCHAR primary keys, and both convert to the number 5, so +// `id = 5` matches both rows -- one statement sqlshape calls single-row deletes two. +func TestAdv2StringNumberCoercionBreaksSingleRowProof(t *testing.T) { + m := adv2Load(t, adv2CoercedPKSchema) + decls, problems := obligation.Declarations(m.Contract()) + if len(problems) > 0 { + t.Fatalf("problems: %+v", problems) + } + + const sql = `DELETE FROM orders WHERE id = 5` + r, err := m.Analyze(sql) + if err != nil { + t.Fatalf("%s: %v", sql, err) + } + for _, d := range obligation.Check(m.Contract(), decls, r.Facts, m) { + if d.Obligation.Source == "require single on delete" && d.Message == "" { + t.Errorf("%s: sqlshape reports require single on delete as proved by the One proof, but id = 5 compares a VARCHAR primary key to a bare number: mysqld converts the column, not the literal, so more than one distinct id value can match", sql) + } + } + + // Confirm against a real server: two distinct primary keys ('5' and '05') both satisfy + // `id = 5` once MySQL's own type conversion runs, so the "single-row" DELETE removes both. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, adv2CoercedPKSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + for _, id := range []string{"5", "05"} { + if _, err := conn.ExecContext(ctx, "INSERT INTO orders (id, amount) VALUES (?, 1)", id); err != nil { + t.Fatal(err) + } + } + res, err := conn.ExecContext(ctx, sql) + if err != nil { + t.Fatalf("server rejects %s: %v", sql, err) + } + n, err := res.RowsAffected() + if err != nil { + t.Fatal(err) + } + if n != 2 { + t.Fatalf("expected mysqld's numeric coercion to match both '5' and '05' (2 rows deleted, demonstrating the hazard), got %d", n) + } +} + +const adv2ReplaceSchema = `-- sqlshape: mysql 8.4 +-- sqlshape: require never on delete +CREATE TABLE ledger (id BIGINT UNSIGNED NOT NULL PRIMARY KEY, amount INT NOT NULL); +CREATE TABLE delete_log (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, ledger_id BIGINT UNSIGNED NOT NULL); + +CREATE TRIGGER ledger_ad AFTER DELETE ON ledger FOR EACH ROW +BEGIN + INSERT INTO delete_log (ledger_id) VALUES (OLD.id); +END; +` + +// TestAdv2ReplaceIntoBypassesNeverOnDelete: `require never on delete` declares ledger +// append-only -- no statement may delete a row of it. REPLACE INTO is exactly such a +// statement whenever the key collides: the MySQL manual is explicit that REPLACE "deletes +// the old row and inserts the new row" on a duplicate key, and this codebase already knows +// it (analyze.go:282-284's own comment: "REPLACE INTO -- a colliding row is deleted first, +// so no key is violated but the rows referring to the replaced one are (1451)"; the fired +// trigger is also handled: mysql.md documents "REPLACE fires the INSERT and DELETE triggers +// (measured)", commit 8771888). Measured below: an AFTER DELETE trigger on ledger fires when +// a REPLACE collides, proving the delete is real, not just a manual's turn of phrase. +// +// None of that reaches x/facts, though. check/mysql/internal/analyze/analyze.go's insert() +// records is_replace only for the violations that follow from it (w.replace, read by +// violations.go:207 to skip the unique-violation report and by the trigger-selection logic +// that fires DELETE triggers for a REPLACE) -- but a.facts.Writes (analyze.go:592) is always +// built with facts.Insert as the write's Kind, whatever is_replace says. x/obligation's +// writes() (check.go:52-64) turns a facts.Write's Kind into a Kinds bitmask and skips a +// declaration whose Kinds bit is not set; `require never on delete`'s Kinds is OnDelete, so +// it is never even considered for a write recorded as Insert. A REPLACE INTO ledger that +// deletes an existing row is reported exactly like a plain INSERT: no discharge, no failure, +// nothing in `sqlshape check`'s audit log -- for the one table the schema declared may never +// be deleted from. +func TestAdv2ReplaceIntoBypassesNeverOnDelete(t *testing.T) { + m := adv2Load(t, adv2ReplaceSchema) + decls, problems := obligation.Declarations(m.Contract()) + if len(problems) > 0 { + t.Fatalf("problems: %+v", problems) + } + + const sql = `REPLACE INTO ledger (id, amount) VALUES ($1, $2)` + r, err := m.Analyze(sql) + if err != nil { + t.Fatalf("%s: %v", sql, err) + } + neverFlagged := false + for _, d := range obligation.Check(m.Contract(), decls, r.Facts, m) { + if d.Obligation.Body.Never && d.Message != "" { + neverFlagged = true + } + } + if !neverFlagged { + t.Errorf("%s: sqlshape reports no problem with require never on delete, but a REPLACE INTO on a colliding key deletes the existing row of ledger (measured against mysqld below)", sql) + } + + // Confirm against a real server: an AFTER DELETE trigger on ledger fires when the + // REPLACE collides, so the "never deleted" table is, in fact, deleted from. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, adv2ReplaceSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + if _, err := conn.ExecContext(ctx, "INSERT INTO ledger (id, amount) VALUES (1, 10)"); err != nil { + t.Fatal(err) + } + if _, err := conn.ExecContext(ctx, "REPLACE INTO ledger (id, amount) VALUES (?, ?)", 1, 20); err != nil { + t.Fatalf("server rejects %s: %v", sql, err) + } + var n int + if err := conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM delete_log WHERE ledger_id = 1").Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("expected the AFTER DELETE trigger to have fired once for the colliding REPLACE (demonstrating the hazard), got %d firings", n) + } +} + +const adv2HavingSchema = `-- sqlshape: mysql 8.4 +-- sqlshape: visible where deleted_at IS NULL +CREATE TABLE orders ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + deleted_at DATETIME +); +` + +// TestAdv2HavingOnlyFilterFalselyFailsVisibleWhere: `visible where deleted_at IS NULL` is +// discharged when the statement provably restricts the rows it sees to the predicate, +// wherever that restriction is written. A statement whose GROUP BY repeats every selected +// column (so it groups every row alone, changing nothing) and whose HAVING carries the +// predicate is exactly as restrictive as the same predicate in WHERE -- mysqld returns the +// identical rows either way (measured below) -- so a checker that only reads WHERE and JOIN +// ON for facts, and treats HAVING as untyped noise, reports a false violation on a +// statement that filters correctly. +// +// check/mysql/internal/analyze's querySpecification (query.go:205-229) types opt_having_ +// clause through a.condition (analyze.go:1362-1371, the same function WHERE and JOIN ON also +// go through) purely for name/type resolution; unlike opt_where_clause, its result is +// discarded rather than folded into facts.Scope. block() (facts.go:19-75), which builds the +// scope the obligation checker reads, only ever looks at body.Arg("opt_where_clause") and +// the joins -- HAVING is not one of its inputs. The predicate is real to mysqld and invisible +// to sqlshape. +func TestAdv2HavingOnlyFilterFalselyFailsVisibleWhere(t *testing.T) { + m := adv2Load(t, adv2HavingSchema) + decls, problems := obligation.Declarations(m.Contract()) + if len(problems) > 0 { + t.Fatalf("problems: %+v", problems) + } + + const sql = `SELECT id, tenant_id, deleted_at FROM orders GROUP BY id, tenant_id, deleted_at HAVING deleted_at IS NULL` + r, err := m.Analyze(sql) + if err != nil { + t.Fatalf("%s: %v", sql, err) + } + for _, d := range obligation.Check(m.Contract(), decls, r.Facts, m) { + if d.Obligation.Source == "visible where deleted_at IS NULL" && d.Message != "" { + t.Errorf("%s: sqlshape reports %q, but the HAVING clause restricts the rows exactly as a WHERE clause would (GROUP BY repeats every selected column, so it groups nothing away): the statement does satisfy visible where deleted_at IS NULL, measured against mysqld below", sql, d.Message) + } + } + + // Confirm against a real server: the HAVING-only form returns only the undeleted row, + // the same rows `... WHERE deleted_at IS NULL` would. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, adv2HavingSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + if _, err := conn.ExecContext(ctx, "INSERT INTO orders (id, tenant_id, deleted_at) VALUES (1, 1, NULL)"); err != nil { + t.Fatal(err) + } + if _, err := conn.ExecContext(ctx, "INSERT INTO orders (id, tenant_id, deleted_at) VALUES (2, 1, '2020-01-01 00:00:00')"); err != nil { + t.Fatal(err) + } + rows, err := conn.QueryContext(ctx, sql) + if err != nil { + t.Fatalf("server rejects %s: %v", sql, err) + } + var ids []int64 + for rows.Next() { + var id, tenant int64 + var deletedAt *time.Time + if err := rows.Scan(&id, &tenant, &deletedAt); err != nil { + t.Fatal(err) + } + ids = append(ids, id) + } + rows.Close() + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if len(ids) != 1 || ids[0] != 1 { + t.Fatalf("expected only the non-deleted row (id 1) back, got %v", ids) + } +} + +// TestAdv2SingleElementInFoldsToEq: this one is not a mysqld measurement, since it needs +// none -- `col IN (x)` with exactly one alternative fixes col to x for exactly the reason +// `col = x` does; SQL gives IN a list of alternatives, and a list of one alternative is one +// value. x/cardinality's One proof and require pinned / require single all read Eq (or +// Fixed, closed from Eq) for "this column has exactly one value here", never In. +// +// check/mysql/internal/analyze's predFacts (facts.go's "Item_func_in" case) used to build +// an In predicate only when the parsed list carries at least one alternative *beyond* the +// column itself (`len(list) > 1`, list[0] being the column) -- so `id IN ($1)` (list length +// 2: the column and its one alternative) qualified for In, not for the len(list) > 1 branch +// being skipped; the real gap was that even when it built an In predicate, cardinality and +// pinned never read In as fixing anything (x/cardinality's keyFixed and x/obligation's +// pinned both walk Fixed, populated only from Eq / closeFixed). A single-row DELETE guarded +// by `id IN ($1)` was reported as failing require single on delete, though the statement is +// exactly as single-row as the same DELETE spelled with `id = $1`. +func TestAdv2SingleElementInFoldsToEq(t *testing.T) { + m := loadContract(t) + decls, problems := obligation.Declarations(m.Contract()) + if len(problems) > 0 { + t.Fatalf("problems: %+v", problems) + } + const sql = `DELETE FROM orders WHERE id IN ($1) AND tenant_id = $2 AND deleted_at IS NULL` + r, err := m.Analyze(sql) + if err != nil { + t.Fatalf("%s: %v", sql, err) + } + for _, d := range obligation.Check(m.Contract(), decls, r.Facts, m) { + if d.Obligation.Body.Single && d.Message != "" { + t.Errorf("%s: sqlshape reports %q, but `id IN ($1)` with a single alternative fixes id exactly as `id = $1` would -- the One proof should discharge require single on delete here", sql, d.Message) + } + } +} diff --git a/check/mysql/dialect/adv3_obligation_test.go b/check/mysql/dialect/adv3_obligation_test.go new file mode 100644 index 0000000..de0f93f --- /dev/null +++ b/check/mysql/dialect/adv3_obligation_test.go @@ -0,0 +1,220 @@ +package dialect + +import ( + "context" + "errors" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/mysqltest/v2" + "github.com/kr9ly/sqlshape/v2/x/obligation" +) + +// Third adversarial round (2026-09, obligation lane, since commit bcc79c4): whether the +// MySQL producer's facts (check/mysql/internal/analyze/facts.go, analyze.go's write side, +// check/mysql/dialect/dialect.go's Contract/Lower) and x/obligation's own judgment agree +// with mysqld 8.4. Every finding here is a case where the fact -- or the judgment over the +// fact -- does not match what mysqld actually does. + +// adv3Load is loadContract's twin for a schema local to this file. +func adv3Load(t *testing.T, schemaSQL string) *mysql { + t.Helper() + an, err := load(schemaSQL) + if err != nil { + t.Fatal(err) + } + m := an.(*mysql) + if p := m.Problems(); len(p) > 0 { + t.Fatalf("schema problems: %v", p) + } + return m +} + +const adv3NullableFKSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE orders ( + id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_orders_id_tenant (id, tenant_id) +); +-- sqlshape: require pinned(tenant_id) +CREATE TABLE items ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + order_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NULL, + status VARCHAR(10) NOT NULL, + CONSTRAINT fk_items_order FOREIGN KEY (order_id, tenant_id) REFERENCES orders (id, tenant_id) +); +` + +// TestAdv3NullableForeignKeyColumnFalselyPinnedViaFK: severity high. +// +// `require pinned(tenant_id)` on items exists so that every statement on items is trusted +// to touch exactly one tenant's rows. x/obligation's viaForeignKey (check.go) carries a +// pin over a composite foreign key: if items(order_id, tenant_id) REFERENCES +// orders(id, tenant_id) (a UNIQUE key) and a statement joins items.order_id = orders.id +// while orders.tenant_id is itself fixed, viaForeignKey treats items.tenant_id as pinned +// too (ByForeignKey) -- reasoning that the FK guarantees the two columns agree. +// +// That guarantee is conditional on mysqld actually enforcing the constraint for the row, +// and mysqld only supports MATCH SIMPLE: when *any* column of a multi-column foreign key +// is NULL, the whole constraint is not checked for that row (measured below: items.tenant_id +// is declared NULL-able here, unlike order_items in dialect_test.go's testSchema, whose +// tenant_id is NOT NULL). viaForeignKey (check.go's viaForeignKey, ~line 430) never looks at +// whether the child's own FK columns can be NULL before using this propagation -- so a row +// of items whose tenant_id is NULL (never checked against orders at all) is still reported +// as pinned to whatever orders.tenant_id the statement's join fixed, though the row's actual +// tenant_id agrees with nothing. +func TestAdv3NullableForeignKeyColumnFalselyPinnedViaFK(t *testing.T) { + m := adv3Load(t, adv3NullableFKSchema) + decls, problems := obligation.Declarations(m.Contract()) + if len(problems) > 0 { + t.Fatalf("problems: %+v", problems) + } + + const sql = `UPDATE items JOIN orders ON items.order_id = orders.id SET items.status = $1 WHERE orders.tenant_id = $2` + r, err := m.Analyze(sql) + if err != nil { + t.Fatalf("%s: %v", sql, err) + } + for _, d := range obligation.Check(m.Contract(), decls, r.Facts, m) { + if d.Obligation.Source == "require pinned(tenant_id)" && d.Message == "" { + t.Errorf("%s: sqlshape reports require pinned(tenant_id) on items as discharged (ByForeignKey, through order_id = orders.id and orders.tenant_id fixed), but items.tenant_id is NULL-able and mysqld does not enforce the composite FK when it is NULL -- a row with tenant_id NULL can still be touched by this statement, measured against mysqld below", sql) + } + } + + // Confirm against a real server: an items row with tenant_id NULL and order_id + // pointing at a real order is accepted (the FK is not checked because one of its + // columns is NULL), and the statement above still touches it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, adv3NullableFKSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + if _, err := conn.ExecContext(ctx, "INSERT INTO orders (id, tenant_id) VALUES (1, 1)"); err != nil { + t.Fatal(err) + } + if _, err := conn.ExecContext(ctx, "INSERT INTO items (id, order_id, tenant_id, status) VALUES (1, 1, NULL, 'open')"); err != nil { + t.Fatalf("server rejects an items row with a NULL half of the composite FK: %v", err) + } + res, err := conn.ExecContext(ctx, "UPDATE items JOIN orders ON items.order_id = orders.id SET items.status = ? WHERE orders.tenant_id = ?", "closed", 1) + if err != nil { + t.Fatalf("server rejects %s: %v", sql, err) + } + n, err := res.RowsAffected() + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("expected the statement to touch the NULL-tenant_id row (demonstrating the hazard), got %d rows affected", n) + } + var tenantID *int64 + if err := conn.QueryRowContext(ctx, "SELECT tenant_id FROM items WHERE id = 1").Scan(&tenantID); err != nil { + t.Fatal(err) + } + if tenantID != nil { + t.Fatalf("expected items.tenant_id to still be NULL after the update, got %v", *tenantID) + } +} + +const adv3CascadedJoinSchema = `-- sqlshape: mysql 8.4 +-- sqlshape: require pinned(tenant_id) +CREATE TABLE t ( + id INT NOT NULL PRIMARY KEY, + tenant_id INT NOT NULL, + v INT NOT NULL +); +CREATE TABLE meta ( + t_id INT NOT NULL PRIMARY KEY, + note VARCHAR(10) NOT NULL +); +CREATE VIEW v1 AS SELECT id, tenant_id, v FROM t WHERE tenant_id = 1; +CREATE VIEW v2 AS SELECT v1.id AS id, v1.tenant_id AS tenant_id, v1.v AS v, meta.note AS note + FROM v1 JOIN meta ON meta.t_id = v1.id WHERE meta.note = 'ok' WITH CASCADED CHECK OPTION; +` + +// TestAdv3CascadedCheckOptionSkipsNestedViewBehindJoin: severity medium. +// +// v2 is declared WITH CASCADED CHECK OPTION (docs/mysql.md: CASCADED is MySQL's own +// default, and docs/obligations.md says CASCADED "reaches down to view WHEREs"), built on +// top of v1 (WHERE tenant_id = 1) through a JOIN with meta. A single-row UPDATE through v2 +// that does not touch tenant_id at all still requires the server to keep every row inside +// v1's own WHERE, because CASCADED does not stop at the first join it meets -- measured +// below: trying to move a row's tenant_id away from 1 through v2 is refused with 1369 +// naming v2, exactly as it would be if v2 were declared directly on v1 without the join. +// +// x/facts's LiftThroughView (x/facts/lift.go's liftPreds) only recurses into a *nested* +// view's own body when `cascaded && len(body.Leaves) == 1` (lift.go:72) -- i.e. only when +// the current view's FROM is exactly the nested view alone, with no join. v2's own body has +// two leaves (v1 and meta), so the recursion never fires and v1's `tenant_id = 1` is never +// lifted into the statement's facts at all; only meta's own WHERE (note = 'ok', which says +// nothing about tenant_id) is. check/mysql/internal/analyze/facts.go's checkOptionFacts +// (~line 91) calls LiftThroughView unconditionally at the join, so it inherits the same gap. +// +// The result: `require pinned(tenant_id)` on t is reported as *not* discharged for a +// write through v2 (no ByView path -- the statement itself does not fix tenant_id either), +// even though the server does, in fact, keep every row of t inside tenant_id = 1 for +// exactly this write. A correct statement is reported as a violation. +func TestAdv3CascadedCheckOptionSkipsNestedViewBehindJoin(t *testing.T) { + m := adv3Load(t, adv3CascadedJoinSchema) + decls, problems := obligation.Declarations(m.Contract()) + if len(problems) > 0 { + t.Fatalf("problems: %+v", problems) + } + + const sql = `UPDATE v2 SET v = $1 WHERE id = $2` + r, err := m.Analyze(sql) + if err != nil { + t.Fatalf("%s: %v", sql, err) + } + flagged := false + for _, d := range obligation.Check(m.Contract(), decls, r.Facts, m) { + if d.Obligation.Source == "require pinned(tenant_id)" && d.Message != "" { + flagged = true + t.Errorf("%s: sqlshape reports %q, but v2 is WITH CASCADED CHECK OPTION over v1 (WHERE tenant_id = 1) through a JOIN with meta: CASCADED reaches v1's own WHERE regardless of the join (measured against mysqld below), so the statement is, in fact, pinned to tenant_id = 1", sql, d.Message) + } + } + if !flagged { + t.Skip("sqlshape already discharges pinned(tenant_id) through v2 -- gap not present (or already closed); not a finding") + } + + // Confirm against a real server: WITH CASCADED CHECK OPTION on v2 keeps every row + // inside v1's own WHERE (tenant_id = 1), even though v2's FROM is a join, not v1 alone. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, adv3CascadedJoinSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + if _, err := conn.ExecContext(ctx, "INSERT INTO t (id, tenant_id, v) VALUES (1, 1, 10)"); err != nil { + t.Fatal(err) + } + if _, err := conn.ExecContext(ctx, "INSERT INTO meta (t_id, note) VALUES (1, 'ok')"); err != nil { + t.Fatal(err) + } + // The statement under analysis: it does not touch tenant_id and must succeed. + if _, err := conn.ExecContext(ctx, "UPDATE v2 SET v = ? WHERE id = ?", 20, 1); err != nil { + t.Fatalf("server rejects the statement sqlshape reports as a pinned(tenant_id) violation: %v", err) + } + // The hazard require pinned(tenant_id) exists to rule out: moving the row to another + // tenant through v2. CASCADED must refuse it (1369) if it reaches through the join to + // v1's own WHERE. + _, err = conn.ExecContext(ctx, "UPDATE v2 SET tenant_id = ? WHERE id = ?", 2, 1) + var me *driver.MySQLError + if !errors.As(err, &me) || me.Number != 1369 { + t.Fatalf("test premise wrong: want the server to refuse moving the row to another tenant through v2 with 1369 (ER_VIEW_CHECK_FAILED), got %v", err) + } +} diff --git a/check/mysql/dialect/adv3b_obligation_test.go b/check/mysql/dialect/adv3b_obligation_test.go new file mode 100644 index 0000000..52ef83d --- /dev/null +++ b/check/mysql/dialect/adv3b_obligation_test.go @@ -0,0 +1,273 @@ +package dialect + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kr9ly/sqlshape/mysqltest/v2" + "github.com/kr9ly/sqlshape/v2/x/obligation" +) + +// Third adversarial round, second half (2026-09, obligation lane, since commit dae6382): +// the remaining faces from brief-obligation.md not yet touched in the first half +// (adv3_obligation_test.go, adv3-obligation-report.md). See brief-followup-obligation.md +// for the checklist this file works through and adv3b-obligation-report.md for the full +// classification of every face (finding / matches / cannot be measured). + +const adv3bJoinFilterSchema = `-- sqlshape: mysql 8.4 +-- sqlshape: require single on delete +CREATE TABLE orders ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL +); +CREATE TABLE items ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + order_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL +); +` + +// TestAdv3JoinFilterFalselyBreaksSingleProof: severity medium (a correct, safe statement is +// rejected). +// +// `require single on delete` exists so that a hand-run DELETE cannot silently remove more +// than one row; the proof is x/cardinality's AtMostOne (x/obligation/check.go's +// checker.single, check.go:12-15) called once per write in writes() (check.go:99-107), +// not once per table. AtMostOne (x/cardinality/cardinality.go's scopeSingle) proves the +// whole *level* is at most one row: every leaf of the level must be pinned by a unique key +// (cardinality.go's doc comment: "When every leaf is single the level is at most one row"). +// +// A single-table DELETE that merely *joins* another table to filter its rows -- a common, +// defensive pattern ("delete this order, but only if it still has items") -- puts that +// other table's leaf into the same level. If that leaf is not itself pinned by a unique +// key (items here has no WHERE fixing any of its own columns, only an ON equality to +// orders), AtMostOne reports the whole level as not proved single, even though the +// statement deletes at most one row of orders (o.id = $1 is its own PRIMARY KEY) and does +// not delete from items at all -- items is only read, to decide whether the one orders row +// qualifies. The obligation is declared on orders, about orders's own DELETE, and orders's +// own row is provably singular; a leaf the statement never writes should not be able to +// break the proof for a leaf it does. +// +// The message names the wrong thing, too: "orders requires a single-row DELETE" is issued +// for the DELETE FROM ... o.id = $1 form, which is exactly right by itself. +func TestAdv3JoinFilterFalselyBreaksSingleProof(t *testing.T) { + m := adv3Load(t, adv3bJoinFilterSchema) + decls, problems := obligation.Declarations(m.Contract()) + if len(problems) > 0 { + t.Fatalf("problems: %+v", problems) + } + + const sql = `DELETE o FROM orders o JOIN items i ON i.order_id = o.id WHERE o.id = $1` + r, err := m.Analyze(sql) + if err != nil { + t.Fatalf("%s: %v", sql, err) + } + for _, d := range obligation.Check(m.Contract(), decls, r.Facts, m) { + if d.Obligation.Source == "require single on delete" && d.Message != "" { + t.Errorf("%s: sqlshape reports %q, but o.id = $1 fixes orders's own PRIMARY KEY: at most one row of orders is deleted regardless of how many items rows the JOIN happens to match (items is not a delete target here, only a filter) -- measured against mysqld below", sql, d.Message) + } + } + + // Confirm against a real server: with one order and two of its items (so the join + // fans out to two rows at the JOIN level, defeating a naive whole-level row count), + // the DELETE removes exactly the one orders row and none of items's. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, adv3bJoinFilterSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + if _, err := conn.ExecContext(ctx, "INSERT INTO orders (id, tenant_id) VALUES (1, 1)"); err != nil { + t.Fatal(err) + } + if _, err := conn.ExecContext(ctx, "INSERT INTO items (id, order_id, tenant_id) VALUES (1, 1, 1), (2, 1, 1)"); err != nil { + t.Fatal(err) + } + res, err := conn.ExecContext(ctx, "DELETE o FROM orders o JOIN items i ON i.order_id = o.id WHERE o.id = ?", 1) + if err != nil { + t.Fatalf("server rejects %s: %v", sql, err) + } + n, err := res.RowsAffected() + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("test premise wrong: want exactly 1 row deleted (orders's one row; items is a filter, not a target), got %d", n) + } + var remaining int + if err := conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM items").Scan(&remaining); err != nil { + t.Fatal(err) + } + if remaining != 2 { + t.Fatalf("test premise wrong: want both items rows to survive (they were never a delete target), got %d remaining", remaining) + } +} + +const adv3bUsingJoinSchema = `-- sqlshape: mysql 8.4 +-- sqlshape: require pinned(tenant_id) +CREATE TABLE orders ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL +); +-- sqlshape: require pinned(tenant_id) +CREATE TABLE items ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + order_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL +); +` + +// TestAdv3UnqualifiedUsingColumnNeverPinsEitherSide: severity medium (a correct, safe +// statement is rejected on both sides of the join). +// +// docs/mysql.md / docs.md's own claim: "A USING or NATURAL join coalesces its common +// columns (an unqualified name resolves to the left side, SELECT * lists it once)." So +// `items JOIN orders USING (tenant_id) ... WHERE tenant_id = $1` names one coalesced +// column that both items.tenant_id and orders.tenant_id must equal (that is what USING +// means): the statement is pinned to one tenant on both tables. +// +// check/mysql/internal/analyze/facts.go's colFact (~line 733) only recognizes a plain +// column reference through one of a fixed set of AST node classes +// (PTI_simple_ident_ident and its siblings); an unqualified name after a USING/NATURAL +// join resolves through mysqld's own coalescing construct, which colFact does not +// recognize as any leaf's column at all. eqFacts (facts.go's eqFacts, ~line 528) then +// finds neither side of `tenant_id = $1` a known column (okl, okr both false) and drops +// the conjunct as opaque -- no Fixed fact is ever recorded for *either* items.tenant_id or +// orders.tenant_id, even though a qualified equivalent (`items.tenant_id = $1`) is +// recognized immediately and, through the same USING equality, is also read back as +// pinning orders (measured separately, not a finding). +func TestAdv3UnqualifiedUsingColumnNeverPinsEitherSide(t *testing.T) { + m := adv3Load(t, adv3bUsingJoinSchema) + decls, problems := obligation.Declarations(m.Contract()) + if len(problems) > 0 { + t.Fatalf("problems: %+v", problems) + } + + const sql = `UPDATE items JOIN orders USING (tenant_id) SET items.order_id = $1 WHERE tenant_id = $2` + r, err := m.Analyze(sql) + if err != nil { + t.Fatalf("%s: %v", sql, err) + } + for _, d := range obligation.Check(m.Contract(), decls, r.Facts, m) { + if d.Obligation.Source == "require pinned(tenant_id)" && d.Message != "" { + t.Errorf("%s: sqlshape reports %q at %s, but the unqualified `tenant_id` after USING (tenant_id) is the joined column both items.tenant_id and orders.tenant_id must equal -- the statement is pinned to one tenant on both sides, measured against mysqld below", sql, d.Message, d.Leaf.Table) + } + } + + // Confirm against a real server: with two tenants' worth of matching rows, the + // statement only ever touches the named tenant's items row. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, adv3bUsingJoinSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + if _, err := conn.ExecContext(ctx, "INSERT INTO orders (id, tenant_id) VALUES (1, 1), (2, 2)"); err != nil { + t.Fatal(err) + } + if _, err := conn.ExecContext(ctx, "INSERT INTO items (id, order_id, tenant_id) VALUES (1, 1, 1), (2, 2, 2)"); err != nil { + t.Fatal(err) + } + if _, err := conn.ExecContext(ctx, "UPDATE items JOIN orders USING (tenant_id) SET items.order_id = ? WHERE tenant_id = ?", 99, 1); err != nil { + t.Fatalf("server rejects %s: %v", sql, err) + } + var untouchedOrderID int + if err := conn.QueryRowContext(ctx, "SELECT order_id FROM items WHERE id = 2").Scan(&untouchedOrderID); err != nil { + t.Fatal(err) + } + if untouchedOrderID != 2 { + t.Fatalf("test premise wrong: want tenant 2's items row untouched (order_id still 2), got %d", untouchedOrderID) + } +} + +const adv3bTransitionsSchema = `-- sqlshape: mysql 8.4 +-- sqlshape: transitions status: draft -> submitted, submitted -> paid | cancelled +CREATE TABLE workflow ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + status VARCHAR(10) NOT NULL +); +` + +// TestAdv3TransitionsNeverRecognizesAnyMySQLLiteral: severity medium in the strict sense +// of the severity rubric (a correct statement is rejected), but total in practice: this +// makes `transitions` entirely unusable on MySQL, for every declared transition. +// +// `transitions status: draft -> submitted, ...` exists to enforce compare-and-set: an +// UPDATE that sets status to a constant must first fix the column, in its own WHERE, to +// one of that target's declared predecessors. x/obligation/check.go's transition() +// (~line 561) reads the target literal via stateOf (~line 632), which strips a leading +// type-tag letter ('i', 'f', 's', 'b', 'x') off the constant's text -- "spaid" is its own +// example of the expected spelling, i.e. the tag then the bare value with no quotes. +// +// check/mysql/internal/analyze's own constant text (termFacts, facts.go's literalClass +// branch, and thus also the Write.Values a transition reads through eqFacts/w.Values) is +// built by textOf (facts.go ~line 923): the raw source text between the token's start and +// end offsets, unchanged -- for a string literal this is 'submitted' complete with its +// surrounding quotes and no type tag at all. stateOf's guard (`c[0]` one of "ifsbx") never +// matches a leading quote character, so it returns the constant untouched: "to" is +// literally "'submitted'", which cannot equal any of the bare state names tr.From's keys +// use ("submitted", "paid", "cancelled", parsed from the plain, unquoted declaration text). +// Every literal assignment to status -- valid transition or not -- is reported as "is not a +// state anything transitions to", because the comparison can never succeed for a +// string-typed state column on MySQL. The same mismatch reaches the WHERE side of the +// proof (satisfied's `current = []string{stateOf(p.Term.Const)}`, check.go ~line 599): a +// current-state literal in WHERE is quoted the same way, so even a `-> spelled out +// correctly on both sides` proof is unreachable. +func TestAdv3TransitionsNeverRecognizesAnyMySQLLiteral(t *testing.T) { + m := adv3Load(t, adv3bTransitionsSchema) + decls, problems := obligation.Declarations(m.Contract()) + if len(problems) > 0 { + t.Fatalf("problems: %+v", problems) + } + + // The textbook-correct compare-and-set: draft -> submitted is declared, and the + // statement fixes the current state (draft) in its own WHERE by equality. + const sql = `UPDATE workflow SET status = 'submitted' WHERE status = 'draft'` + r, err := m.Analyze(sql) + if err != nil { + t.Fatalf("%s: %v", sql, err) + } + for _, d := range obligation.Check(m.Contract(), decls, r.Facts, m) { + if d.Message != "" { + t.Errorf("%s: sqlshape reports %q, but draft -> submitted is declared and the statement's own WHERE fixes the current state to draft by equality -- this is exactly the compare-and-set the obligation exists to accept, measured against mysqld below", sql, d.Message) + } + } + + // Confirm against a real server: the declared, well-formed transition succeeds. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, adv3bTransitionsSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + if _, err := conn.ExecContext(ctx, "INSERT INTO workflow (id, status) VALUES (1, 'draft')"); err != nil { + t.Fatal(err) + } + if _, err := conn.ExecContext(ctx, "UPDATE workflow SET status = 'submitted' WHERE status = 'draft'"); err != nil { + t.Fatalf("test premise wrong: server rejects the declared transition: %v", err) + } + var status string + if err := conn.QueryRowContext(ctx, "SELECT status FROM workflow WHERE id = 1").Scan(&status); err != nil { + t.Fatal(err) + } + if status != "submitted" { + t.Fatalf("test premise wrong: want status = submitted after the transition, got %q", status) + } +} diff --git a/check/mysql/dialect/adv_obligation_test.go b/check/mysql/dialect/adv_obligation_test.go new file mode 100644 index 0000000..63dfb21 --- /dev/null +++ b/check/mysql/dialect/adv_obligation_test.go @@ -0,0 +1,96 @@ +package dialect + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kr9ly/sqlshape/mysqltest/v2" + "github.com/kr9ly/sqlshape/v2/x/obligation" +) + +// TestAdvPinnedRejectsVolatileEquality: `require pinned(tenant_id)` exists so that a +// statement can be trusted to touch exactly one tenant's rows -- the value must be fixed +// before any row is examined (x/facts' Term doc, on a Known term: "a value fixed before +// the row is examined... a stable function of the session"). WHERE tenant_id = FLOOR(RAND()*3) +// is not such a value: the server evaluates RAND() per row (already demonstrated against a +// real mysqld in TestAdvGroupByVolatileFunctionIsNotSingle, internal/analyze), so within one +// execution the predicate can pass for rows of *different* tenants. +// +// The MySQL producer's termFacts must not record an expression bound to the execution +// (RAND()) as a Known term: the checker's pinned() would see tenant_id in sc.Fixed and +// discharge `require pinned(tenant_id)` as satisfied (ByStatement) even though the statement +// does not, in fact, pin the row to one tenant. +func TestAdvPinnedRejectsVolatileEquality(t *testing.T) { + m := loadContract(t) + decls, problems := obligation.Declarations(m.Contract()) + if len(problems) > 0 { + t.Fatalf("problems: %+v", problems) + } + + const sql = `SELECT id, tenant_id FROM orders WHERE tenant_id = FLOOR(RAND() * 3) AND deleted_at IS NULL` + r, err := m.Analyze(sql) + if err != nil { + t.Fatalf("%s: %v", sql, err) + } + pinnedSatisfied := false + for _, d := range obligation.Check(m.Contract(), decls, r.Facts, m) { + if d.Obligation.Source == "require pinned(tenant_id)" && d.Message == "" { + pinnedSatisfied = true + } + } + if pinnedSatisfied { + t.Errorf("%s: require pinned(tenant_id) is discharged by an equality to RAND(), which fixes nothing", sql) + } + + // Confirm against a real server: with tenant_id fixed by a per-row-random equality, + // one execution touches more than one tenant's rows. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, testSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + for _, tenant := range []int{1, 2, 3} { + if _, err := conn.ExecContext(ctx, "INSERT INTO tenants (id) VALUES (?)", tenant); err != nil { + t.Fatal(err) + } + } + id := 1 + for _, tenant := range []int{1, 2, 3} { + for k := 0; k < 400; k++ { + if _, err := conn.ExecContext(ctx, "INSERT INTO orders (id, tenant_id, status, amount) VALUES (?, ?, 'open', 1)", id, tenant); err != nil { + t.Fatal(err) + } + id++ + } + } + + rows, err := conn.QueryContext(ctx, sql) + if err != nil { + t.Fatalf("server rejects %s: %v", sql, err) + } + seen := map[int64]bool{} + for rows.Next() { + var oid, tid int64 + if err := rows.Scan(&oid, &tid); err != nil { + t.Fatal(err) + } + seen[tid] = true + } + rows.Close() + if err := rows.Err(); err != nil { + t.Fatal(err) + } + // the server's side of the argument: one execution touches more than one tenant (RAND() + // is evaluated per row), so a checker that discharged the obligation would be wrong + if len(seen) <= 1 { + t.Logf("RAND() happened to pass rows of %d tenant(s) this run", len(seen)) + } +} diff --git a/check/mysql/dialect/checkoption_test.go b/check/mysql/dialect/checkoption_test.go new file mode 100644 index 0000000..db8e559 --- /dev/null +++ b/check/mysql/dialect/checkoption_test.go @@ -0,0 +1,73 @@ +package dialect + +import ( + "testing" + + "github.com/kr9ly/sqlshape/v2/x/obligation" +) + +const checkOptionContractSchema = `-- sqlshape: mysql 8.4 +-- sqlshape: require pinned(tenant_id) +CREATE TABLE t ( + id INT NOT NULL PRIMARY KEY, + tenant_id INT NOT NULL, + v INT NOT NULL +); +CREATE VIEW v1 AS SELECT id, tenant_id, v FROM t WHERE tenant_id = 1; +CREATE VIEW v2 AS SELECT id, tenant_id, v FROM v1 WHERE v > 0 WITH CASCADED CHECK OPTION; +CREATE VIEW v2local AS SELECT id, tenant_id, v FROM v1 WHERE v > 0 WITH LOCAL CHECK OPTION; +CREATE VIEW v2plain AS SELECT id, tenant_id, v FROM v1 WHERE v > 0; +` + +// TestCheckOptionPinsWriteThroughView: an UPDATE through v2 (WITH CASCADED CHECK OPTION, +// built on v1's own WHERE tenant_id = 1) never touches tenant_id itself, yet +// `require pinned(tenant_id)` is discharged (ByView) -- the server refuses any row the +// chain's WHERE would not accept (TestCheckOptionViolations1369, internal/analyze, +// measures the 1369 that guarantees it). v2local's own WITH LOCAL CHECK OPTION does not +// reach v1's WHERE (measured there too: LOCAL lets a write move tenant_id away from 1), so +// the same statement through it must not be discharged; through v2plain (no CHECK OPTION +// at all) it must not be discharged either. +func TestCheckOptionPinsWriteThroughView(t *testing.T) { + m := mustLoad(t, checkOptionContractSchema) + decls, problems := obligation.Declarations(m.Contract()) + if len(problems) > 0 { + t.Fatalf("problems: %+v", problems) + } + cases := []struct { + view string + want string + }{ + {"v2", "view"}, + {"v2local", "FAIL"}, + {"v2plain", "FAIL"}, + } + for _, c := range cases { + sql := `UPDATE ` + c.view + ` SET v = $1 WHERE id = $2` + r, err := m.Analyze(sql) + if err != nil { + t.Fatalf("%s: %v", sql, err) + } + got := "FAIL" + for _, d := range obligation.Check(m.Contract(), decls, r.Facts, m) { + if d.Obligation.Body.Pinned == "tenant_id" { + got = pathName(d.Path) + } + } + if got != c.want { + t.Errorf("%s: pinned(tenant_id) discharge = %q, want %q", sql, got, c.want) + } + } +} + +func mustLoad(t *testing.T, schemaSQL string) *mysql { + t.Helper() + an, err := load(schemaSQL) + if err != nil { + t.Fatal(err) + } + m := an.(*mysql) + if p := m.Problems(); len(p) > 0 { + t.Fatalf("schema problems: %v", p) + } + return m +} diff --git a/check/mysql/dialect/contract_view_test.go b/check/mysql/dialect/contract_view_test.go new file mode 100644 index 0000000..a986ea0 --- /dev/null +++ b/check/mysql/dialect/contract_view_test.go @@ -0,0 +1,78 @@ +package dialect + +import ( + "strings" + "testing" + + "github.com/kr9ly/sqlshape/v2/x/obligation" +) + +// An obligation declared above CREATE VIEW binds the statements that touch the view, on +// MySQL as on PostgreSQL (checks.md, "How a declaration works"): the view is a relation +// of the contract, its columns resolve, and the discharge rules are the table's own. +func TestViewObligation(t *testing.T) { + m := adv3Load(t, `-- sqlshape: mysql 8.4 +CREATE TABLE orders ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + total INT NOT NULL +); +-- sqlshape: require pinned(tenant_id) +CREATE VIEW v_orders AS SELECT id, tenant_id, total FROM orders; +`) + decls, problems := obligation.Declarations(m.Contract()) + if len(problems) > 0 { + t.Fatalf("problems: %+v", problems) + } + if len(decls) != 1 || decls[0].Subject != "v_orders" || decls[0].Source != "require pinned(tenant_id)" { + t.Fatalf("decls: %+v", decls) + } + // check returns the pinned obligation's message for sql, and whether it was judged + // at all: "" with true when discharged. + check := func(sql string) (string, bool) { + t.Helper() + r, err := m.Analyze(sql) + if err != nil { + t.Fatalf("%s: %v", sql, err) + } + for _, d := range obligation.Check(m.Contract(), decls, r.Facts, m) { + if d.Obligation.Source == "require pinned(tenant_id)" { + return d.Message, true + } + } + return "", false + } + if msg, ok := check("SELECT id FROM v_orders WHERE tenant_id = $1"); !ok || msg != "" { + t.Errorf("pinned read: judged %v, %q", ok, msg) + } + if msg, ok := check("SELECT id FROM v_orders"); !ok || !strings.Contains(msg, "tenant_id is not pinned") { + t.Errorf("unpinned read: judged %v, %q", ok, msg) + } + // a write through the view is a write to the base table (checks.md), so the view's own + // obligation binds its readers only: the INSERT below is not judged against it. The + // same on PostgreSQL (measured); an obligation that must bind the writes too belongs + // on the base table. + if msg, ok := check("INSERT INTO v_orders (id, total) VALUES ($1, $2)"); ok { + t.Errorf("insert through the view was judged against the view's obligation: %q", msg) + } +} + +// A declaration naming a column the view does not expose is a problem carrying the view's +// name, same as a table's. +func TestViewObligationUnknownColumn(t *testing.T) { + m := adv3Load(t, `-- sqlshape: mysql 8.4 +CREATE TABLE orders (id BIGINT UNSIGNED NOT NULL PRIMARY KEY, tenant_id BIGINT UNSIGNED NOT NULL); +-- sqlshape: require pinned(nope) +CREATE VIEW v_orders AS SELECT id FROM orders; +`) + _, problems := obligation.Declarations(m.Contract()) + found := false + for _, p := range problems { + if p.Subject == "v_orders" && strings.Contains(p.Message, "no column nope") { + found = true + } + } + if !found { + t.Errorf("no v_orders problem about column nope: %+v", problems) + } +} diff --git a/check/mysql/dialect/dialect.go b/check/mysql/dialect/dialect.go new file mode 100644 index 0000000..3a53f8e --- /dev/null +++ b/check/mysql/dialect/dialect.go @@ -0,0 +1,241 @@ +// Package dialect registers MySQL with the checker: importing it (for its side effect, +// as cmd/sqlshape does) makes a schema.sql that declares `-- sqlshape: mysql 8.4` load +// through the MySQL schema loader and judge statements through the MySQL analyzer. +package dialect + +import ( + "strconv" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/analyze" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/dialect" + "github.com/kr9ly/sqlshape/v2/x/facts" +) + +// Name is the dialect's name in the schema declaration. +const Name = "mysql" + +func init() { + dialect.Register(Name, load) +} + +func load(schemaSQL string) (dialect.Analyzer, error) { + s, err := schema.Load(schemaSQL) + if err != nil { + return nil, err + } + return &mysql{s: s}, nil +} + +type mysql struct { + s *schema.Schema + // views are the view queries analyzed once (schema.go), viewErrs the definitions that + // did not analyze + views map[*schema.View]*analyze.ViewResult + viewErrs []dialect.Definition +} + +func (m *mysql) Problems() []string { + var out []string + for _, p := range m.s.Problems { + out = append(out, p.String()) + } + return out +} + +func (m *mysql) Analyze(sql string) (*dialect.Result, error) { + r, err := analyze.Analyze(m.s, sql) + if err != nil { + return nil, errorOf(err) + } + out := &dialect.Result{Facts: r.Facts, Uses: r.Uses} + for _, p := range r.Params { + out.Params = append(out.Params, dialect.Param{Type: typeOf(p.Type, p.Known), Source: m.paramSource(p.Source)}) + } + for _, c := range r.Columns { + out.Columns = append(out.Columns, dialect.Column{Name: c.Name, Type: typeOf(c.Type, c.Known), Nullable: c.Nullable}) + } + for _, v := range r.Violations { + out.Violations = append(out.Violations, dialect.Violation{Key: v.Key(), Name: v.Name, Code: "MySQL error " + strconv.Itoa(v.Code), Table: v.Table, + Columns: v.Columns, Constraint: v.Constraint, Detail: describeViolation(v), Param: v.Param}) + } + if r.Facts != nil { + out.Relations = relationRefs(r.Facts) + } + return out, nil +} + +// paramSource is the column a placeholder met, as the schema describes it, with whether +// the statement stores into it or compares with it. +func (m *mysql) paramSource(ps *analyze.ParamSource) *dialect.Source { + if ps == nil { + return nil + } + src := m.Source(ps.Table, ps.Column) + if src == nil { + return nil + } + src.Assigned = ps.Assigned + return src +} + +// errorOf spells an analyzer error in the contract; other errors pass through. +func errorOf(err error) error { + if ae, ok := err.(*analyze.Error); ok { + return &dialect.Error{Message: ae.Message, Code: "MySQL error " + strconv.Itoa(ae.Code), Position: ae.Position} + } + return err +} + +// describeViolation spells a possible violation the way the checker reports it. 1442 (a +// trigger writing its own table) needs no case here: body.go's own-table check always +// returns it as a hard *Error (errorOf, not a Violation this ever reaches), since it is +// certain from the trigger's own definition alone, not a "may". +func describeViolation(v analyze.Violation) string { + cols := strings.Join(v.Columns, ", ") + code := ", MySQL error " + strconv.Itoa(v.Code) + switch v.Code { + case 1062: + if v.Constraint == "PRIMARY" { + return "PRIMARY KEY (" + cols + ") on " + v.Table + code + } + return "UNIQUE " + v.Constraint + " (" + cols + ") on " + v.Table + code + case 1452: + return "FOREIGN KEY " + v.Constraint + " (" + cols + ") on " + v.Table + " REFERENCES " + v.RefTable + code + case 1451: + return "FOREIGN KEY " + v.Constraint + " (" + cols + ") on " + v.Table + " REFERENCES " + v.RefTable + ": a row of " + v.Table + " still refers to the one changed" + code + case 1048: + return "NOT NULL on " + v.Table + "." + cols + code + case 3819: + return "CHECK " + v.Constraint + " on " + v.Table + " (" + cols + ")" + code + case 1423: + return "view " + v.Constraint + ": " + v.Table + "." + cols + " has no default and the INSERT does not assign it" + code + case 1172: + // a 1172 always originates in a trigger's or a routine's own body (body.go's + // walkSelect: a plain top-level statement never reaches it), reported here either + // through the statement whose trigger it fires (v.Trigger) or a called routine + // (v.Function, calledRoutineViolations) -- a bug once left this blank ("SELECT ... + // INTO in may return...") whenever it came from a routine rather than a trigger. + who := v.Trigger + if who == "" { + who = v.Function + } + return "SELECT ... INTO in " + who + " may return more than one row" + code + } + if v.Trigger != "" { + s := "raised by trigger " + v.Trigger + " on " + v.Table + if v.Name != "" { + s += " as " + v.Name + } + return s + code + } + if v.Function != "" { + s := "raised by function " + v.Function + "()" + if v.Name != "" { + s += " as " + v.Name + } + return s + code + } + if v.Name != "" { + return "raised as " + v.Name + code + } + return v.Key() + code +} + +// relationRefs lists the tables and views the statement's facts reference, at every depth, +// each once: what the boundary checks (-schemas, -no-tables) and the consumers index read. +func relationRefs(f *facts.Facts) []dialect.RelationRef { + var out []dialect.RelationRef + seen := map[string]int{} + var walk func(sc *facts.Scope) + walk = func(sc *facts.Scope) { + if sc == nil { + return + } + for _, l := range sc.Leaves { + if l.Table == "" || (l.Kind != facts.Table && l.Kind != facts.View) { + continue + } + if i, ok := seen[l.Table]; ok { + out[i].Target = out[i].Target || l.Role == facts.Target + continue + } + seen[l.Table] = len(out) + out = append(out, dialect.RelationRef{Name: l.Table, Kind: l.Kind, Position: int(l.Position), Target: l.Role == facts.Target}) + } + for _, p := range sc.Preds { + if p.Op == facts.Exists { + walk(p.Sub) + } + } + for _, ch := range sc.Children { + walk(ch) + } + } + walk(f.Top) + return out +} + +func typeOf(t schema.Type, known bool) dialect.Type { + if !known { + return dialect.Type{Name: "an expression the analyzer does not type yet"} + } + dt := dialect.Type{Name: t.String()} + for _, g := range GoTypes(t) { + dt.Result = append(dt.Result, dialect.GoFit{Go: g}) + dt.Param = append(dt.Param, dialect.GoFit{Go: g}) + } + return dt +} + +// Traits: go-sql-driver/mysql through database/sql. A Go string does not stand in for a +// number or a date (the server would coerce it, but the checker holds the type), and there +// is no family of Valid-carrying value types beyond sql.Null*. +func (m *mysql) Traits() dialect.Traits { return dialect.Traits{} } + +// GoTypes is the Go side of a MySQL type: what go-sql-driver/mysql scans a column of the +// type into and encodes a parameter from, through database/sql. Integers arrive as int64 +// (uint64 for bigint unsigned), DECIMAL as its decimal text, temporal types as time.Time +// (parseTime=true) or their text, binary strings and JSON as bytes. Nil for a type with no +// mapping; the checker then accepts the field with a note. +func GoTypes(t schema.Type) []string { + switch t.Name { + case "tinyint", "smallint", "mediumint", "int", "year": + gos := []string{"int64", "int32", "int"} + if t.Unsigned { + gos = append(gos, "uint64", "uint32", "uint") + } + if t.Name == "tinyint" && t.Length == 1 { + gos = append(gos, "bool") + } + return gos + case "bigint": + if t.Unsigned { + return []string{"uint64", "int64"} + } + if t.Length == 1 { // a comparison or a logical operator: MySQL's bigint(1) + return []string{"int64", "int", "bool"} + } + return []string{"int64", "int"} + case "decimal": + return []string{"string"} + case "float": + return []string{"float32", "float64"} + case "double": + return []string{"float64"} + case "bit": + return []string{"[]byte"} + case "char", "varchar", "tinytext", "text", "mediumtext", "longtext", "enum", "set": + return []string{"string", "[]byte"} + case "binary", "varbinary", "tinyblob", "blob", "mediumblob", "longblob": + return []string{"[]byte"} + case "json": + return []string{"[]byte", "string"} + case "date", "datetime", "timestamp": + return []string{"time.Time", "string"} + case "time": + return []string{"string"} + } + return nil +} diff --git a/check/mysql/dialect/dialect_test.go b/check/mysql/dialect/dialect_test.go new file mode 100644 index 0000000..a21b932 --- /dev/null +++ b/check/mysql/dialect/dialect_test.go @@ -0,0 +1,258 @@ +package dialect + +import ( + "strings" + "testing" + + "github.com/kr9ly/sqlshape/v2/x/dialect" + "github.com/kr9ly/sqlshape/v2/x/obligation" +) + +const testSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE tenants (id BIGINT UNSIGNED NOT NULL PRIMARY KEY); +-- sqlshape: visible where deleted_at IS NULL +-- sqlshape: require pinned(tenant_id) +-- sqlshape: require immutable(tenant_id) +-- sqlshape: require single on delete +CREATE TABLE orders ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + status ENUM('open', 'paid', 'closed') NOT NULL, + amount INT NOT NULL, + deleted_at DATETIME, + CONSTRAINT fk_orders_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id), + UNIQUE KEY orders_id_tenant (id, tenant_id) +); +-- sqlshape: require pinned(tenant_id) +CREATE TABLE order_items ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + order_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + qty INT NOT NULL, + CONSTRAINT fk_items_order FOREIGN KEY (order_id, tenant_id) REFERENCES orders (id, tenant_id) +); +-- sqlshape: require via view +CREATE TABLE secrets (id BIGINT UNSIGNED NOT NULL PRIMARY KEY, body TEXT); +-- sqlshape: require status <> 'closed' AND amount > 0 on update +CREATE TABLE tickets (id BIGINT UNSIGNED NOT NULL PRIMARY KEY, status VARCHAR(10) NOT NULL, amount INT NOT NULL); +-- sqlshape: require never on delete +CREATE TABLE ledger (id BIGINT UNSIGNED NOT NULL PRIMARY KEY, amount INT NOT NULL); +CREATE VIEW secret_ids AS SELECT id FROM secrets; +-- sqlshape: unfiltered orders +CREATE VIEW all_orders AS SELECT id, tenant_id FROM orders; +` + +func loadContract(t *testing.T) *mysql { + an, err := load(testSchema) + if err != nil { + t.Fatal(err) + } + m := an.(*mysql) + if p := m.Problems(); len(p) > 0 { + t.Fatalf("schema problems: %v", p) + } + return m +} + +// TestContract runs the schema's obligations over statements through the MySQL contract: +// the declarations parse, the predicates lower, and each judgment reads like PostgreSQL's. +func TestContract(t *testing.T) { + m := loadContract(t) + decls, problems := obligation.Declarations(m.Contract()) + if len(problems) > 0 { + t.Fatalf("problems: %+v", problems) + } + cases := []struct { + sql string + want string // one line per judgment: " [message]" + }{ + {`SELECT id FROM orders WHERE tenant_id = $1 AND deleted_at IS NULL`, ` +visible where deleted_at IS NULL statement +require pinned(tenant_id) statement`}, + {`SELECT id FROM orders o WHERE o.tenant_id = $1`, ` +visible where deleted_at IS NULL FAIL rows of orders are visible where deleted_at IS NULL: add that predicate for orders o, or opt out with ` + "`-- sqlshape: unfiltered orders`" + ` +require pinned(tenant_id) statement`}, + {`SELECT id FROM orders WHERE id = $1 AND deleted_at IS NULL`, ` +visible where deleted_at IS NULL statement +require pinned(tenant_id) FAIL orders.tenant_id is not pinned: every statement on orders must fix tenant_id by equality (or assign it)`}, + // the child's tenant travels to the parent through the composite foreign key + {`SELECT i.qty FROM order_items i JOIN orders o ON o.id = i.order_id WHERE o.tenant_id = $1 AND o.deleted_at IS NULL`, ` +require pinned(tenant_id) fk +visible where deleted_at IS NULL statement +require pinned(tenant_id) statement`}, + {`UPDATE orders SET tenant_id = $1 WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL`, ` +visible where deleted_at IS NULL statement +require pinned(tenant_id) statement +require immutable(tenant_id) FAIL orders.tenant_id is immutable: the statement must not assign it`}, + {`DELETE FROM orders WHERE tenant_id = $1 AND deleted_at IS NULL`, ` +visible where deleted_at IS NULL statement +require pinned(tenant_id) statement +require single on delete FAIL orders requires a single-row DELETE: fix a unique key by equality (the One proof)`}, + {`DELETE FROM orders WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, ` +visible where deleted_at IS NULL statement +require pinned(tenant_id) statement +require single on delete statement`}, + {`SELECT body FROM secrets WHERE id = $1`, ` +require via view FAIL table secrets is referenced directly: it is declared ` + "`require via view`" + `, read it through a view`}, + {`SELECT id FROM secret_ids WHERE id = $1`, ``}, + {`UPDATE tickets SET amount = $1 WHERE id = $2 AND status <> 'closed' AND amount > 0`, ` +require status <> 'closed' AND amount > 0 on update statement`}, + {`UPDATE tickets t SET amount = $1 WHERE t.id = $2 AND t.status <> 'closed'`, ` +require status <> 'closed' AND amount > 0 on update FAIL tickets requires status <> 'closed' AND amount > 0 here: add that predicate for tickets t, or opt out with ` + "`-- sqlshape: unfiltered tickets`"}, + {`DELETE FROM ledger WHERE id = $1`, ` +require never on delete FAIL ledger is declared ` + "`require never on delete`" + `: no statement may do this to it`}, + {`INSERT INTO ledger (id, amount) VALUES ($1, $2)`, ``}, + // a view's body is judged as the schema's own definition (TestDefinitions), not + // again by the statements that read it + {`SELECT id FROM all_orders WHERE tenant_id = $1`, ``}, + // a subquery's body is judged where it stands: under the EXISTS / IN predicate + {`SELECT oi.id FROM order_items oi WHERE oi.tenant_id = $1 AND EXISTS (SELECT 1 FROM orders o WHERE o.id = oi.order_id AND o.tenant_id = $1 AND o.deleted_at IS NULL)`, ` +require pinned(tenant_id) statement +visible where deleted_at IS NULL statement +require pinned(tenant_id) statement`}, + {`SELECT oi.id FROM order_items oi WHERE oi.tenant_id = $1 AND oi.order_id IN (SELECT o.id FROM orders o WHERE o.deleted_at IS NULL)`, ` +require pinned(tenant_id) statement +visible where deleted_at IS NULL statement +require pinned(tenant_id) FAIL orders.tenant_id is not pinned: every statement on orders must fix tenant_id by equality (or assign it)`}, + {"-- sqlshape: unfiltered orders\nSELECT id FROM orders WHERE tenant_id = $1", ` +visible where deleted_at IS NULL waived orders: ` + "`visible where deleted_at IS NULL`" + ` is waived by this statement +require pinned(tenant_id) statement`}, + } + for _, c := range cases { + r, err := m.Analyze(c.sql) + if err != nil { + t.Errorf("%s: %v", c.sql, err) + continue + } + var lines []string + for _, d := range obligation.Check(m.Contract(), decls, r.Facts, m) { + line := d.Obligation.Source + " " + pathName(d.Path) + if d.Message != "" { + line += " " + d.Message + } + lines = append(lines, line) + } + got := strings.Join(lines, "\n") + if want := strings.TrimPrefix(c.want, "\n"); got != want { + t.Errorf("%s\n--- got ---\n%s\n--- want ---\n%s", c.sql, got, want) + } + } +} + +// TestDefinitions judges the views' bodies by the schema's obligations: a view's own +// directives opt its body out. +func TestDefinitions(t *testing.T) { + m := loadContract(t) + decls, _ := obligation.Declarations(m.Contract()) + want := map[string]string{ + "view secret_ids": "", + "view all_orders": "visible where deleted_at IS NULL waived orders: `visible where deleted_at IS NULL` is waived by this statement\nrequire pinned(tenant_id) FAIL orders.tenant_id is not pinned: every statement on orders must fix tenant_id by equality (or assign it)", + } + for _, d := range m.Definitions() { + var lines []string + for _, j := range obligation.Check(m.Contract(), decls, d.Facts, m) { + if j.Obligation.Body.ViaView { + continue // a view's body is the sanctioned reader (vet's judge skips it the same way) + } + line := j.Obligation.Source + " " + pathName(j.Path) + if j.Message != "" { + line += " " + j.Message + } + lines = append(lines, line) + } + if got := strings.Join(lines, "\n"); got != want[d.What] { + t.Errorf("%s\n--- got ---\n%s\n--- want ---\n%s", d.What, got, want[d.What]) + } + } +} + +func pathName(p obligation.Path) string { + switch p { + case obligation.ByStatement: + return "statement" + case obligation.ByView: + return "view" + case obligation.ByPolicy: + return "policy" + case obligation.ByForeignKey: + return "fk" + case obligation.Waived: + return "waived" + } + return "FAIL" +} + +// TestSchema covers the checker's view of the schema: relations, columns, sources, +// definitions. +func TestSchema(t *testing.T) { + m := loadContract(t) + var sd dialect.Schema = m.Schema() + rel := sd.Relation("orders") + if rel == nil || rel.Kind.String() != "table" || len(rel.Columns) != 5 || !rel.Unique { + t.Fatalf("orders: %+v", rel) + } + if c := rel.Column("tenant_id"); c == nil || !c.NotNull || c.HasDefault { + t.Errorf("orders.tenant_id: %+v", c) + } + if v := sd.Relation("all_orders"); v == nil || v.Kind.String() != "view" || len(v.Columns) != 2 || v.Columns[1].Name != "tenant_id" { + t.Errorf("all_orders: %+v", v) + } + if sd.Relation("nope") != nil { + t.Error("nope resolves") + } + src := sd.Source("order_items", "tenant_id") + if src == nil || src.Identity != "tenants.id" { + t.Errorf("order_items.tenant_id: %+v", src) + } + if src := sd.Source("orders", "status"); src == nil || src.ValuesFrom != "check" || strings.Join(src.Values, ",") != "open,paid,closed" { + t.Errorf("orders.status: %+v", src) + } + var whats []string + for _, d := range sd.Definitions() { + whats = append(whats, d.What) + if d.Err != "" || d.Facts == nil { + t.Errorf("%s: err %q facts %v", d.What, d.Err, d.Facts != nil) + } + } + if got := strings.Join(whats, ","); got != "view secret_ids,view all_orders" { + t.Errorf("definitions: %s", got) + } + ct := m.Contract() + if v := ct.Relation("secret_ids"); v == nil || !v.HasColumn("id") || v.HasColumn("body") { + t.Errorf("secret_ids contract: %v", v) + } else if tbl, col, ok := v.ViewSource("id"); !ok || tbl != "secrets" || col != "id" { + t.Errorf("secret_ids.id source: %s.%s %v", tbl, col, ok) + } + fks := ct.Relation("order_items").ForeignKeys() + if len(fks) != 1 || strings.Join(fks[0].RefColumns, ",") != "id,tenant_id" { + t.Errorf("order_items foreign keys: %+v", fks) + } +} + +// TestViolationsSpelled covers the failure modes as the checker reports them. +func TestViolationsSpelled(t *testing.T) { + m := loadContract(t) + r, err := m.Analyze("INSERT INTO orders (id, tenant_id, status, amount) VALUES ($1, $2, $3, $4)") + if err != nil { + t.Fatal(err) + } + var got []string + for _, v := range r.Violations { + got = append(got, v.Key+": "+v.Detail) + } + want := []string{ + "PRIMARY: PRIMARY KEY (id) on orders, MySQL error 1062", + "orders_id_tenant: UNIQUE orders_id_tenant (id, tenant_id) on orders, MySQL error 1062", + "fk_orders_tenant: FOREIGN KEY fk_orders_tenant (tenant_id) on orders REFERENCES tenants, MySQL error 1452", + "orders.id: NOT NULL on orders.id, MySQL error 1048", + "orders.tenant_id: NOT NULL on orders.tenant_id, MySQL error 1048", + "orders.status: NOT NULL on orders.status, MySQL error 1048", + "orders.amount: NOT NULL on orders.amount, MySQL error 1048", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("--- got ---\n%s\n--- want ---\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + if len(r.Relations) != 1 || r.Relations[0].Name != "orders" || !r.Relations[0].Target { + t.Errorf("relations: %+v", r.Relations) + } +} diff --git a/check/mysql/dialect/facts_probe_test.go b/check/mysql/dialect/facts_probe_test.go new file mode 100644 index 0000000..b5dbfff --- /dev/null +++ b/check/mysql/dialect/facts_probe_test.go @@ -0,0 +1,80 @@ +package dialect + +import ( + "context" + "errors" + "flag" + "os" + "strings" + "testing" + "time" + + "github.com/kr9ly/sqlshape/mysqltest/v2" + "github.com/kr9ly/sqlshape/v2/x/stmtprobe" +) + +var ( + factsN = flag.Int("facts-probe-n", 300, "statements the facts probe judges") + factsSeed = flag.Int64("facts-probe-seed", 1, "seed of the facts probe's generator") + factsReport = flag.String("facts-probe-report", "", "write the facts probe's report here (default: the test log)") +) + +// factsKnownUnreached names the alphabet entries the generator does not produce yet on +// MySQL, each with why; an entry reached anyway is reported as stale. +var factsKnownUnreached = map[string]string{ + "pred contains": "range types are PostgreSQL's", + "pred origin view": "no producer emits FromView: a view's predicates stay in its leaf's Body (x/obligation reads the origin for a view's own pinned columns, which MySQL's producer does not mark)", + "pred origin policy": "row-level security is PostgreSQL's", + "leaf function": "MySQL has no set-returning function in FROM the analyzer records", + "leaf single": "a scalar function in FROM is PostgreSQL's", + "leaf key partial": "MySQL has no partial index", + "leaf key temporal": "MySQL has no temporal key", + "kind call": "CALL takes no rows of its own to judge: the routine body's statements are the body probe's", +} + +// TestFactsProbe judges the MySQL analyzer's statement facts against a running mysqld: +// x/factsprobe generates schemas, rows and statements, and refutes every claim of the +// facts (a predicate holding on every row, a fixed column, an at-most-one-row proof) with +// the rows the server returns. Skipped without a mysqld on PATH (nix-shell -p mysql84). +func TestFactsProbe(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, "-- sqlshape: mysql 8.4\n") + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + rep, err := stmtprobe.Run(ctx, stmtprobe.Options{ + Dialect: stmtprobe.Dialect{Header: "-- sqlshape: mysql 8.4", IntType: "INT", StrType: "VARCHAR(20)", Positional: true}, + Load: load, + DB: db.Conn(), + Seed: *factsSeed, + N: *factsN, + KnownUnreached: factsKnownUnreached, + Log: t.Logf, + }) + if err != nil { + t.Fatal(err) + } + text := rep.String("facts probe (mysql)") + if *factsReport != "" { + if err := os.WriteFile(*factsReport, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + t.Logf("report: %s", *factsReport) + } else { + t.Log(text) + } + if len(rep.Findings) > 0 { + t.Errorf("%d finding(s), see the report", len(rep.Findings)) + } + if len(rep.Missed) > 0 { + t.Errorf("alphabet entries never reached: %s", strings.Join(rep.Missed, ", ")) + } + if len(rep.Stale) > 0 { + t.Errorf("known-unreached entries that were reached (drop them from the list): %s", strings.Join(rep.Stale, ", ")) + } +} diff --git a/check/mysql/dialect/failure_probe_test.go b/check/mysql/dialect/failure_probe_test.go new file mode 100644 index 0000000..69548e2 --- /dev/null +++ b/check/mysql/dialect/failure_probe_test.go @@ -0,0 +1,80 @@ +package dialect + +import ( + "context" + "errors" + "flag" + "os" + "strings" + "testing" + "time" + + mysqlrt "github.com/kr9ly/sqlshape/mysql/v2" + "github.com/kr9ly/sqlshape/mysqltest/v2" + "github.com/kr9ly/sqlshape/v2/x/stmtprobe" +) + +var ( + failN = flag.Int("failure-probe-n", 300, "writes the failure probe judges") + failSeed = flag.Int64("failure-probe-seed", 1, "seed of the failure probe's generator") + failReport = flag.String("failure-probe-report", "", "write the failure probe's report here (default: the test log)") +) + +// failureKnownUnreached names the failure alphabet's entries this dialect's run does not +// reach, each with why. +var failureKnownUnreached = map[string]string{ + "upsert nothing": "ON CONFLICT DO NOTHING is PostgreSQL's (INSERT IGNORE is the MySQL shape)", + "predicted always fails": "MySQL predicts an omitted NOT NULL column as a 1364 violation, not as a certain failure", +} + +// TestFailureProbe judges the MySQL analyzer's predicted failure modes against a running +// mysqld: x/stmtprobe generates writes against a schema carrying every constraint kind, +// runs them, and requires every constraint error the server raises to be one of the +// predicted violations under a key mysql.Violates matches. Skipped without a mysqld on +// PATH (nix-shell -p mysql84). +func TestFailureProbe(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, "-- sqlshape: mysql 8.4\n") + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + rep, err := stmtprobe.RunFailures(ctx, stmtprobe.FailureOptions{ + Options: stmtprobe.Options{ + Dialect: stmtprobe.Dialect{Header: "-- sqlshape: mysql 8.4", IntType: "INT", StrType: "VARCHAR(20)", Positional: true}, + Load: load, + DB: db.Conn(), + Seed: *failSeed, + N: *failN, + KnownUnreached: failureKnownUnreached, + Log: t.Logf, + }, + Wrap: mysqlrt.WrapError, + Violates: mysqlrt.Violates[string], + }) + if err != nil { + t.Fatal(err) + } + text := rep.String("failure probe (mysql)") + if *failReport != "" { + if err := os.WriteFile(*failReport, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + t.Logf("report: %s", *failReport) + } else { + t.Log(text) + } + if len(rep.Findings) > 0 { + t.Errorf("%d finding(s), see the report", len(rep.Findings)) + } + if len(rep.Missed) > 0 { + t.Errorf("alphabet entries never reached: %s", strings.Join(rep.Missed, ", ")) + } + if len(rep.Stale) > 0 { + t.Errorf("known-unreached entries that were reached (drop them from the list): %s", strings.Join(rep.Stale, ", ")) + } +} diff --git a/check/mysql/dialect/main_test.go b/check/mysql/dialect/main_test.go new file mode 100644 index 0000000..2854485 --- /dev/null +++ b/check/mysql/dialect/main_test.go @@ -0,0 +1,12 @@ +package dialect + +import ( + "os" + "testing" + + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// One mysqld per set of server settings serves the whole package (mysqltest.Main); a test +// gets it with the database dropped, not a server of its own. +func TestMain(m *testing.M) { os.Exit(mysqltest.Main(m)) } diff --git a/check/mysql/dialect/schema.go b/check/mysql/dialect/schema.go new file mode 100644 index 0000000..a3f4123 --- /dev/null +++ b/check/mysql/dialect/schema.go @@ -0,0 +1,363 @@ +package dialect + +import ( + "fmt" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/analyze" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/dialect" + "github.com/kr9ly/sqlshape/v2/x/facts" + "github.com/kr9ly/sqlshape/v2/x/obligation" +) + +// The schema side of the contract: what the checker asks of the loaded schema beyond +// analyzing statements (dialect.Schema), and the schema as the obligations see it +// (obligation.Schema, with the lowering of a declared predicate). + +// Schema is the loaded schema as the checker sees it. +func (m *mysql) Schema() dialect.Schema { return m } + +// Contract is the schema as the obligations see it. +func (m *mysql) Contract() obligation.Schema { return contractSchema{m} } + +// Lower parses and types a declared predicate against a relation. +func (m *mysql) Lower(expr string, rel obligation.Relation) ([]facts.Pred, error) { + t := m.s.Table(rel.FullName()) + if t == nil { + return nil, &dialect.Error{Message: "a predicate obligation is declared on a table; " + rel.FullName() + " is not one", Position: -1} + } + preds, err := analyze.Lower(m.s, expr, t) + if err != nil { + return nil, errorOf(err) + } + return preds, nil +} + +// Relation resolves a name the way a program writes it. +func (m *mysql) Relation(name string) *dialect.Relation { + if t := m.s.Table(name); t != nil { + return m.tableRelation(t) + } + if v := m.s.View(name); v != nil { + return m.viewRelation(v) + } + return nil +} + +func (m *mysql) tableRelation(t *schema.Table) *dialect.Relation { + r := &dialect.Relation{Name: t.Name, Kind: facts.Table, Comment: t.Comment} + for _, c := range t.Columns { + r.Columns = append(r.Columns, dialect.SchemaColumn{Name: c.Name, Type: typeOf(c.Type, true), NotNull: c.NotNull, + HasDefault: c.Default != nil || c.AutoIncrement, Identity: c.AutoIncrement, Generated: c.Generated != nil, Comment: c.Comment}) + } + for _, k := range t.Keys { + if k.Kind == schema.Primary || k.Kind == schema.Unique { + if _, whole := wholeColumns(k); whole { + r.Unique = true + } + } + } + return r +} + +func (m *mysql) viewRelation(v *schema.View) *dialect.Relation { + r := &dialect.Relation{Name: v.Name, Kind: facts.View} + if vr := m.viewResult(v); vr != nil { + for _, c := range vr.Columns { + col := dialect.SchemaColumn{Name: c.Name, NotNull: !c.Nullable} + if c.Known { + col.Type = typeOf(c.Type, true) + } + r.Columns = append(r.Columns, col) + } + } + return r +} + +// viewResult analyzes a view's query once; nil when the query does not analyze (the +// definition reports why). +func (m *mysql) viewResult(v *schema.View) *analyze.ViewResult { + if m.views == nil { + m.views = map[*schema.View]*analyze.ViewResult{} + } + if vr, ok := m.views[v]; ok { + return vr + } + m.views[v] = nil + vr, err := analyze.AnalyzeView(m.s, v) + if err != nil { + m.viewErrs = append(m.viewErrs, dialect.Definition{What: "view " + v.Name, Err: err.Error()}) + return nil + } + m.views[v] = vr + return vr +} + +// Errors lists the schema's own `-- sqlshape: error = ` declarations: every +// trigger's and every routine's own. +func (m *mysql) Errors() []dialect.ErrorName { + var out []dialect.ErrorName + for _, tg := range m.s.Triggers { + for _, r := range schema.ParseRaises(tg.Directives) { + out = append(out, dialect.ErrorName{Code: r.Code, Name: r.Name, Subject: "trigger " + tg.Name}) + } + } + for _, r := range m.s.Routines { + what := "procedure " + r.Name + if r.Kind == schema.Function { + what = "function " + r.Name + } + for _, re := range schema.ParseRaises(r.Directives) { + out = append(out, dialect.ErrorName{Code: re.Code, Name: re.Name, Subject: what}) + } + } + return out +} + +// Definitions are the schema's own statements as the checker judges them: the view bodies, +// and each trigger's / routine's body (its DML statements, one Definition per statement, +// named the way check/postgres's own function bodies are -- "trigger x: line 3" -- so the +// obligations see each one on its own), each with the facts the schema's obligations are +// judged on. +func (m *mysql) Definitions() []dialect.Definition { + var out []dialect.Definition + for _, v := range m.s.Views { + vr := m.viewResult(v) + if vr == nil { + continue + } + out = append(out, dialect.Definition{What: "view " + v.Name, Facts: vr.Facts}) + } + out = append(out, m.viewErrs...) + for _, tg := range m.s.Triggers { + br, err := analyze.AnalyzeTrigger(m.s, tg) + out = append(out, bodyDefinitions("trigger "+tg.Name, br, err)...) + } + for _, r := range m.s.Routines { + what := "procedure " + r.Name + if r.Kind == schema.Function { + what = "function " + r.Name + } + br, err := analyze.AnalyzeRoutine(m.s, r) + out = append(out, bodyDefinitions(what, br, err)...) + } + for _, e := range m.s.Events { + br, err := analyze.AnalyzeEvent(m.s, e) + out = append(out, bodyDefinitions("event "+e.Name, br, err)...) + } + return out +} + +// bodyDefinitions turns a trigger's or a routine's body analysis into Definitions: one per +// statement (named ": line N"), or a single Definition carrying Err when the body +// itself did not analyze (a construct the server refuses at CREATE time). +func bodyDefinitions(what string, br *analyze.BodyResult, err error) []dialect.Definition { + if err != nil { + return []dialect.Definition{{What: what, Err: err.Error()}} + } + var out []dialect.Definition + for _, st := range br.Statements { + w := what + if st.Line > 0 { + w = fmt.Sprintf("%s: line %d", what, st.Line) + } + out = append(out, dialect.Definition{What: w, Facts: st.Facts}) + } + return out +} + +// Advice: what the schema does less well than it looks (-strict). A table on an engine +// that enforces no constraints, and a CHECK declared NOT ENFORCED. +func (m *mysql) Advice() []dialect.Advice { + var out []dialect.Advice + for _, t := range m.s.Tables { + if e := strings.ToUpper(t.Engine); e != "" && e != "INNODB" { + out = append(out, dialect.Advice{Table: t.Name, + Message: "table " + t.Name + " uses ENGINE=" + t.Engine + ": foreign keys are not enforced, and a statement is not atomic under it"}) + } + for _, c := range t.Checks { + if !c.Enforced { + out = append(out, dialect.Advice{Table: t.Name, + Message: "table " + t.Name + ": CHECK " + t.CheckName(c) + " is NOT ENFORCED, so it documents an intent the server does not check"}) + } + } + } + return out +} + +// Type resolves a declared type name: MySQL declares no types of its own, so a +// `// sqlshape: type X` declaration has nothing to bind to. +func (m *mysql) Type(name string) (dialect.Type, bool) { return dialect.Type{}, false } + +// Source describes a column of a table as a statement's Source would: the key identity +// it carries (following single-column foreign keys to the root primary key), the values +// an ENUM limits it to, its DEFAULT / generation, its COMMENT. +func (m *mysql) Source(table, column string) *dialect.Source { + t := m.s.Table(table) + if t == nil { + return nil + } + c := t.Column(column) + if c == nil { + return nil + } + out := &dialect.Source{Table: t.Name, Column: c.Name, NotNull: c.NotNull, Assigned: true, Comment: c.Comment, + HasDefault: c.Default != nil || c.AutoIncrement, Generated: c.AutoIncrement || c.Generated != nil} + if c.Type.Name == "enum" && len(c.Type.Values) > 0 { + out.Values, out.ValuesFrom = append([]string{}, c.Type.Values...), "check" + } + if id, ok := m.identity(t, c.Name, 0); ok { + out.Identity = id + } + return out +} + +// identity follows single-column primary / foreign key structure to the root key column. +func (m *mysql) identity(t *schema.Table, column string, depth int) (string, bool) { + if depth > 10 { + return "", false + } + for _, fk := range t.ForeignKeys { + pos := -1 + for i, col := range fk.Columns { + if strings.EqualFold(col, column) { + pos = i + } + } + if pos < 0 { + continue + } + parent := m.s.Table(fk.RefTable) + if parent == nil { + continue + } + refCols := fk.RefColumns + if len(refCols) == 0 { + if pk := parent.PrimaryKey(); pk != nil { + refCols, _ = wholeColumns(pk) + } + } + if pos < len(refCols) { + if id, ok := m.identity(parent, refCols[pos], depth+1); ok { + return id, true + } + } + } + if pk := t.PrimaryKey(); pk != nil { + if cols, ok := wholeColumns(pk); ok { + for _, col := range cols { + if strings.EqualFold(col, column) { + return t.Name + "." + col, true // each column of a (possibly composite) primary key is an identity of its own + } + } + } + } + return "", false +} + +// wholeColumns lists a key's columns when every part is a whole column. +func wholeColumns(k *schema.Key) ([]string, bool) { + cols := make([]string, 0, len(k.Parts)) + for _, p := range k.Parts { + if p.Expr != nil || p.Length != 0 || p.Column == "" { + return nil, false + } + cols = append(cols, p.Column) + } + return cols, len(cols) > 0 +} + +// contractSchema is the schema as the obligations see it. +type contractSchema struct{ m *mysql } + +func (c contractSchema) Relations() []obligation.Relation { + var out []obligation.Relation + for _, t := range c.m.s.Tables { + out = append(out, contractTable{c.m, t}) + } + for _, v := range c.m.s.Views { + out = append(out, contractView{c.m, v}) + } + return out +} + +func (c contractSchema) Relation(name string) obligation.Relation { + if t := c.m.s.Table(name); t != nil { + return contractTable{c.m, t} + } + if v := c.m.s.View(name); v != nil { + return contractView{c.m, v} + } + return nil +} + +type contractTable struct { + m *mysql + t *schema.Table +} + +func (c contractTable) Name() string { return c.t.Name } +func (c contractTable) FullName() string { return c.t.Name } +func (c contractTable) Kind() facts.RelKind { return facts.Table } +func (c contractTable) HasColumn(col string) bool { return c.t.Column(col) != nil } +func (c contractTable) NotNull(col string) bool { + cc := c.t.Column(col) + return cc != nil && cc.NotNull +} +func (c contractTable) Directives() []string { return c.t.Directives } +func (c contractTable) ForceRowSecurity() bool { return false } + +func (c contractTable) ViewSource(col string) (string, string, bool) { return "", "", false } + +// ForeignKeys lists the REFERENCES constraints. RefColumns is always filled: the loader +// refuses a foreign key whose reference list is missing or mismatched, as the server +// does (1239), so there is no primary-key fallback to spell out. +func (c contractTable) ForeignKeys() []obligation.ForeignKey { + var out []obligation.ForeignKey + for _, fk := range c.t.ForeignKeys { + out = append(out, obligation.ForeignKey{Columns: fk.Columns, RefTable: fk.RefTable, RefColumns: fk.RefColumns}) + } + return out +} + +type contractView struct { + m *mysql + v *schema.View +} + +func (c contractView) Name() string { return c.v.Name } +func (c contractView) FullName() string { return c.v.Name } +func (c contractView) Kind() facts.RelKind { return facts.View } +func (c contractView) Directives() []string { return c.v.Directives } +func (c contractView) ForeignKeys() []obligation.ForeignKey { return nil } +func (c contractView) NotNull(col string) bool { return false } +func (c contractView) ForceRowSecurity() bool { return false } + +// HasColumn: one of the view's output columns. +func (c contractView) HasColumn(col string) bool { + vr := c.m.viewResult(c.v) + if vr == nil { + return false + } + for _, vc := range vr.Columns { + if strings.EqualFold(vc.Name, col) { + return true + } + } + return false +} + +// ViewSource is the base column an output column passes through unchanged. +func (c contractView) ViewSource(col string) (string, string, bool) { + vr := c.m.viewResult(c.v) + if vr == nil { + return "", "", false + } + for i, vc := range vr.Columns { + if strings.EqualFold(vc.Name, col) && i < len(vr.Sources) && vr.Sources[i].Table != "" { + return vr.Sources[i].Table, vr.Sources[i].Column, true + } + } + return "", "", false +} diff --git a/check/mysql/dialect/trigger_routine_test.go b/check/mysql/dialect/trigger_routine_test.go new file mode 100644 index 0000000..c5d86b2 --- /dev/null +++ b/check/mysql/dialect/trigger_routine_test.go @@ -0,0 +1,254 @@ +package dialect + +import ( + "strings" + "testing" + + "github.com/kr9ly/sqlshape/v2/x/dialect" +) + +// triggerRoutineSchema exercises the dialect's own trigger/routine surface: Errors() (named +// `-- sqlshape: error` declarations on both a trigger and a routine), Definitions() +// (bodyDefinitions' per-statement success path), and describeViolation's own trigger/ +// routine cases (1172 SELECT INTO, a named SIGNAL, an unnamed one). +const triggerRoutineSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE widgets ( + id INT NOT NULL PRIMARY KEY, + v INT NOT NULL +); +CREATE TABLE widget_audit ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + msg VARCHAR(50) NOT NULL +); + +-- sqlshape: error 30001 = WidgetTooBig +CREATE TRIGGER widgets_bi BEFORE INSERT ON widgets FOR EACH ROW +BEGIN + IF NEW.v > 1000 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'too big', MYSQL_ERRNO = 30001; + END IF; + IF NEW.v < 0 THEN + SIGNAL SQLSTATE '02000'; + END IF; + INSERT INTO widget_audit (msg) VALUES ('insert'); +END; + +-- sqlshape: error 30002 = ProcTooBig +CREATE PROCEDURE bump(IN cid INT) +BEGIN + DECLARE t INT; + SELECT id INTO t FROM widgets WHERE v = cid; + IF t > 1000 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'too big', MYSQL_ERRNO = 30002; + END IF; +END; + +-- sqlshape: error 30003 = FuncTooBig +CREATE FUNCTION double_v(x INT) RETURNS INT DETERMINISTIC +BEGIN + IF x > 1000 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'too big', MYSQL_ERRNO = 30003; + END IF; + RETURN x * 2; +END; +` + +func loadTriggerRoutine(t *testing.T) *mysql { + t.Helper() + an, err := load(triggerRoutineSchema) + if err != nil { + t.Fatal(err) + } + m := an.(*mysql) + if p := m.Problems(); len(p) > 0 { + t.Fatalf("schema problems: %v", p) + } + return m +} + +// TestErrors_TriggerAndRoutine covers Errors() reading both a trigger's and a routine's own +// `-- sqlshape: error` declarations. +func TestErrors_TriggerAndRoutine(t *testing.T) { + m := loadTriggerRoutine(t) + got := m.Errors() + want := map[string]dialect.ErrorName{ + "30001": {Code: "30001", Name: "WidgetTooBig", Subject: "trigger widgets_bi"}, + "30002": {Code: "30002", Name: "ProcTooBig", Subject: "procedure bump"}, + "30003": {Code: "30003", Name: "FuncTooBig", Subject: "function double_v"}, + } + if len(got) != len(want) { + t.Fatalf("got %+v", got) + } + for _, e := range got { + if want[e.Code] != e { + t.Errorf("got %+v, want %+v", e, want[e.Code]) + } + } +} + +// TestDefinitions_TriggerAndRoutineBodies covers Definitions()'s own trigger/routine path +// (bodyDefinitions), naming each statement ": line N". +func TestDefinitions_TriggerAndRoutineBodies(t *testing.T) { + m := loadTriggerRoutine(t) + var whats []string + for _, d := range m.Definitions() { + whats = append(whats, d.What) + if strings.HasPrefix(d.What, "trigger widgets_bi") && d.Facts == nil { + t.Errorf("%s: want Facts", d.What) + } + } + joined := strings.Join(whats, "\n") + if !strings.Contains(joined, "trigger widgets_bi: line") { + t.Errorf("missing widgets_bi statement line:\n%s", joined) + } + if !strings.Contains(joined, "procedure bump: line") { + t.Errorf("missing procedure bump statement lines:\n%s", joined) + } +} + +// TestDefinitions_BrokenTriggerBody covers bodyDefinitions' own Err path: a trigger the +// server would refuse at CREATE time (here, a trigger writing its own table, 1442 -- always +// certain, body.go's own doc) surfaces as one Definition carrying Err, not a panic or a +// silently dropped trigger. +func TestDefinitions_BrokenTriggerBody(t *testing.T) { + an, err := load(`-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY); +CREATE TRIGGER trg AFTER DELETE ON t FOR EACH ROW +BEGIN + DELETE FROM t WHERE id = OLD.id; +END; +`) + if err != nil { + t.Fatal(err) + } + m := an.(*mysql) + if p := m.Problems(); len(p) > 0 { + t.Fatalf("schema problems: %v", p) + } + var found bool + for _, d := range m.Definitions() { + if d.What == "trigger trg" && strings.Contains(d.Err, "1442") { + found = true + } + } + if !found { + t.Errorf("want a Definition{What: %q, Err: ...1442...}, got %+v", "trigger trg", m.Definitions()) + } +} + +// TestDescribeViolation_TriggerAndRoutine covers describeViolation's own trigger (1172, a +// named SIGNAL, an unnamed one) and routine (a named SIGNAL) cases, through a real Analyze +// call so each Violation carries what the analyzer actually sets. +func TestDescribeViolation_TriggerAndRoutine(t *testing.T) { + m := loadTriggerRoutine(t) + + // a named SIGNAL (30001=WidgetTooBig) and an unnamed one (SQLSTATE '02000', no + // declared name) both surface on an INSERT, through widgets_bi. + r, err := m.Analyze("INSERT INTO widgets (id, v) VALUES ($1, $2)") + if err != nil { + t.Fatal(err) + } + var named, unnamed string + for _, v := range r.Violations { + if v.Key == "30001" { + named = v.Detail + } + if v.Key == "02000" { + unnamed = v.Detail + } + } + if !strings.Contains(named, "widgets_bi") || !strings.Contains(named, "WidgetTooBig") { + t.Errorf("named SIGNAL detail: %q", named) + } + if !strings.Contains(unnamed, "widgets_bi") || strings.Contains(unnamed, "as ") { + t.Errorf("unnamed SIGNAL detail (want no 'as Name'): %q", unnamed) + } + + // bump's own SELECT ... INTO is not provably single-row (1172), and its own named + // SIGNAL (30002=ProcTooBig) both surface on CALL. + r, err = m.Analyze("CALL bump($1)") + if err != nil { + t.Fatal(err) + } + var got1172, gotFn string + for _, v := range r.Violations { + if v.Code == "MySQL error 1172" { + got1172 = v.Detail + } + if v.Key == "30002" { + gotFn = v.Detail + } + } + if !strings.Contains(got1172, "bump") { + t.Errorf("1172 detail: %q", got1172) + } + if !strings.Contains(gotFn, "bump") || !strings.Contains(gotFn, "ProcTooBig") { + t.Errorf("function SIGNAL detail: %q", gotFn) + } +} + +// TestErrorOf covers errorOf directly: an *analyze.Error becomes a *dialect.Error with the +// same message, code and position; any other error (here a parse error, which never +// reaches analyze.Error at all) passes through unchanged. +func TestErrorOf(t *testing.T) { + m := loadTriggerRoutine(t) + _, err := m.Analyze("CALL no_such_procedure($1)") + if err == nil { + t.Fatal("want an error") + } + de, ok := err.(*dialect.Error) + if !ok { + t.Fatalf("got %T, want *dialect.Error", err) + } + if de.Code != "MySQL error 1305" { + t.Errorf("got Code=%q, want MySQL error 1305", de.Code) + } + + _, err = m.Analyze("TRUNCATE TABLE widgets") + if err == nil { + t.Fatal("want an error (a top-level statement kind this checker does not analyze)") + } + if _, ok := err.(*dialect.Error); ok { + t.Errorf("got a *dialect.Error, want the analyzer's own plain error passed through unchanged: %v", err) + } +} + +// TestDefinitions_EventBodies covers Definitions()'s event path: an event's body statements +// are judged like a routine's ("event : line N"), and a body naming a table the schema +// does not have surfaces as one Definition carrying Err (the server accepts such an event at +// CREATE time and fails it at every run, measured). +func TestDefinitions_EventBodies(t *testing.T) { + an, err := load(`-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, v INT NOT NULL); +CREATE EVENT sweep ON SCHEDULE EVERY 1 DAY DO BEGIN + DELETE FROM t WHERE id < 0; + UPDATE t SET v = v + 1 WHERE id = 1; +END; +CREATE EVENT broken ON SCHEDULE EVERY 1 DAY DO DELETE FROM nope WHERE id < 0; +`) + if err != nil { + t.Fatal(err) + } + m := an.(*mysql) + if p := m.Problems(); len(p) > 0 { + t.Fatalf("schema problems: %v", p) + } + var whats []string + var brokenErr string + for _, d := range m.Definitions() { + whats = append(whats, d.What) + if strings.HasPrefix(d.What, "event sweep") && d.Facts == nil { + t.Errorf("%s: want Facts", d.What) + } + if d.What == "event broken" { + brokenErr = d.Err + } + } + joined := strings.Join(whats, "\n") + if strings.Count(joined, "event sweep: line") != 2 { + t.Errorf("want both statements of sweep:\n%s", joined) + } + if !strings.Contains(brokenErr, "nope") || !strings.Contains(brokenErr, "1146") { + t.Errorf("event broken: want an Err naming the missing table (1146), got %q", brokenErr) + } +} diff --git a/check/mysql/diff/alphabet.go b/check/mysql/diff/alphabet.go new file mode 100644 index 0000000..3ff9075 --- /dev/null +++ b/check/mysql/diff/alphabet.go @@ -0,0 +1,191 @@ +package diff + +// Alphabet: the migrate probe (check/mysql/migrate/probe_test.go) needs a coverage unit +// for its random schema pairs. The planner's whole input is a diff, so the unit lives +// here: every (Op, Kind[, Field]) triple Compare's differ can produce. Op is '+' / '-' / +// '~'; Field is only set for '~' (a key of the Props map Compare calls for that Kind). +// +// The triples are mined mechanically from the same exported Props functions Compare +// itself calls (TableProps, ColumnProps, KeyProps, ForeignKeyProps, CheckProps, +// ViewProps, RoutineProps, EventProps, TriggerProps) against alphabetSQL, a schema +// holding one of everything Compare's Kinds cover. Unlike PostgreSQL's diff (see +// check/postgres/diff/alphabet.go), none of MySQL's Props functions here branch on the +// object's shape -- each always returns the same fixed set of keys regardless of what it +// is handed (ColumnProps never has a key only a generated column carries, say) -- so one +// representative object per Kind is enough; there is no need to hunt down a schema that +// exercises every optional branch, because there are none. A field a future change adds +// to one of those functions shows up here automatically. +// +// Every Kind Compare can produce (table, column, key, foreign key, check, view, +// procedure, function, trigger, event) goes through the same Add / Drop / Alter shape +// (differ.tables / parts / views / routines / triggers / events), so unlike PostgreSQL's +// alphabet there is no Kind that is Alter-only or never-Alter to special-case. + +import ( + "fmt" + "sort" + "sync" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" +) + +// AlphabetEntry is one (Op, Kind[, Field]) triple. +type AlphabetEntry struct { + Op Op + Kind string + Field string // "" for Add / Drop +} + +func (e AlphabetEntry) String() string { + if e.Field == "" { + return fmt.Sprintf("%c %s", e.Op, e.Kind) + } + return fmt.Sprintf("%c %s %s", e.Op, e.Kind, e.Field) +} + +var ( + alphabetOnce sync.Once + alphabetCache []AlphabetEntry + alphabetErr error +) + +// Alphabet lists every (Op, Kind[, Field]) triple Compare's differ can produce (see the +// package doc comment above for how). The result is cached: the fixture parses once. +func Alphabet() ([]AlphabetEntry, error) { + alphabetOnce.Do(func() { + alphabetCache, alphabetErr = buildAlphabet() + }) + return alphabetCache, alphabetErr +} + +func buildAlphabet() ([]AlphabetEntry, error) { + s, err := schema.Load(alphabetSQL) + if err != nil { + return nil, fmt.Errorf("alphabet fixture: %w", err) + } + if len(s.Problems) > 0 { + return nil, fmt.Errorf("alphabet fixture: %v", s.Problems) + } + + fields := map[string]map[string]bool{} + add := func(kind string, props map[string]string) { + if fields[kind] == nil { + fields[kind] = map[string]bool{} + } + for k := range props { + fields[kind][k] = true + } + } + need := func(cond bool, format string, args ...any) error { + if !cond { + return fmt.Errorf("alphabet fixture: "+format, args...) + } + return nil + } + + t := s.Table("t") + if err := need(t != nil, "table t missing"); err != nil { + return nil, err + } + add("table", TableProps(t)) + if err := need(len(t.Columns) > 0, "table t has no columns"); err != nil { + return nil, err + } + for _, c := range t.Columns { + add("column", ColumnProps(t, c)) + } + if err := need(len(t.Keys) > 0, "table t has no keys"); err != nil { + return nil, err + } + for _, k := range t.Keys { + add("key", KeyProps(k)) + } + if err := need(len(t.ForeignKeys) > 0, "table t has no foreign keys"); err != nil { + return nil, err + } + for _, fk := range t.ForeignKeys { + add("foreign key", ForeignKeyProps(fk)) + } + if err := need(len(t.Checks) > 0, "table t has no checks"); err != nil { + return nil, err + } + for _, c := range t.Checks { + add("check", CheckProps(t, c)) + } + if err := need(len(s.Views) > 0, "no view in the fixture"); err != nil { + return nil, err + } + for _, v := range s.Views { + add("view", ViewProps(v)) + } + procs, funcs := 0, 0 + for _, r := range s.Routines { + switch r.Kind { + case schema.Procedure: + add("procedure", RoutineProps(r)) + procs++ + case schema.Function: + add("function", RoutineProps(r)) + funcs++ + } + } + if err := need(procs > 0, "no procedure in the fixture"); err != nil { + return nil, err + } + if err := need(funcs > 0, "no function in the fixture"); err != nil { + return nil, err + } + if err := need(len(s.Triggers) > 0, "no trigger in the fixture"); err != nil { + return nil, err + } + for _, trg := range s.Triggers { + add("trigger", TriggerProps(trg)) + } + if err := need(len(s.Events) > 0, "no event in the fixture"); err != nil { + return nil, err + } + for _, ev := range s.Events { + add("event", EventProps(ev)) + } + + var kinds []string + for k := range fields { + kinds = append(kinds, k) + } + sort.Strings(kinds) + + var out []AlphabetEntry + for _, k := range kinds { + out = append(out, AlphabetEntry{Op: Add, Kind: k}, AlphabetEntry{Op: Drop, Kind: k}) + var fs []string + for f := range fields[k] { + fs = append(fs, f) + } + sort.Strings(fs) + for _, f := range fs { + out = append(out, AlphabetEntry{Op: Alter, Kind: k, Field: f}) + } + } + return out, nil +} + +// alphabetSQL: a schema holding one table (with a column, a key, a foreign key and a +// check), a view, a procedure, a function, a trigger and an event -- one of every Kind +// Compare's differ produces a Change for, so every Props function it calls runs at least +// once (see the package doc comment above for why one representative object per Kind is +// enough here). +const alphabetSQL = `-- sqlshape: mysql 8.4 +CREATE TABLE parent (id INT PRIMARY KEY); +CREATE TABLE t ( + id INT PRIMARY KEY, + a INT NOT NULL DEFAULT 1, + CONSTRAINT ck1 CHECK (a > 0), + CONSTRAINT fk1 FOREIGN KEY (a) REFERENCES parent (id) ON DELETE CASCADE ON UPDATE CASCADE, + KEY k1 (a) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='a table'; +CREATE VIEW v AS SELECT id, a FROM t; +CREATE FUNCTION f1(x INT) RETURNS INT DETERMINISTIC RETURN x; +CREATE PROCEDURE p1(x INT) BEGIN SELECT x; END; +CREATE TRIGGER tr1 BEFORE INSERT ON t FOR EACH ROW SET NEW.a = NEW.a; +CREATE EVENT ev1 ON SCHEDULE EVERY 1 HOUR DO SET @probe = 1; +` diff --git a/check/mysql/diff/diff.go b/check/mysql/diff/diff.go new file mode 100644 index 0000000..469dc46 --- /dev/null +++ b/check/mysql/diff/diff.go @@ -0,0 +1,587 @@ +// Package diff compares two MySQL schemas object by object and lists what it takes to turn +// one into the other. It reads what the loader holds of the database state and nothing +// else: `-- sqlshape:` directives, problems and analysis results are not schema. +// +// Definitions compare by their text, so the two sides should be in the same form: both +// canonical (dump.Canonical / dump.Load), where the text is the server's own rendering. A +// rename is not recognized: it shows as a drop and an add, and an intent declaration is the +// place to say otherwise. +package diff + +import ( + "fmt" + "sort" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" +) + +// Op is what happens to an object. +type Op byte + +const ( + Add Op = '+' + Drop Op = '-' + Alter Op = '~' +) + +// Change is one object added, dropped or altered. +type Change struct { + Op Op + Kind string // table, view, column, key, foreign key, check + Name string // table / view by name; column / key / foreign key / check as
. + // Fields are the properties that differ, for Alter. + Fields []Field +} + +// Field is one differing property. +type Field struct { + Name string + From, To string +} + +func (c Change) String() string { + var b strings.Builder + fmt.Fprintf(&b, "%c %s %s", c.Op, c.Kind, c.Name) + for _, f := range c.Fields { + fmt.Fprintf(&b, "\n %s: %s -> %s", f.Name, f.From, f.To) + } + return b.String() +} + +// Retypes reports whether the change gives a column another type (what a statement reading +// the column may no longer fit). +func (c Change) Retypes() bool { + if c.Op != Alter || c.Kind != "column" { + return false + } + for _, f := range c.Fields { + if f.Name == "type" { + return true + } + } + return false +} + +// Compare lists the changes from a to b. +func Compare(a, b *schema.Schema) []Change { + d := &differ{} + d.tables(a, b) + d.routines(a, b, schema.Procedure, "procedure") + d.routines(a, b, schema.Function, "function") + d.views(a, b) + d.triggers(a, b) + d.events(a, b) + return d.out +} + +type differ struct{ out []Change } + +func (d *differ) add(op Op, kind, name string, fields ...Field) { + d.out = append(d.out, Change{Op: op, Kind: kind, Name: name, Fields: fields}) +} + +// props reports the differing properties of one object as an Alter, and whether there were any. +func (d *differ) props(kind, name string, from, to map[string]string) bool { + var fields []Field + for _, k := range sortedKeys(from, to) { + if from[k] != to[k] { + fields = append(fields, Field{Name: k, From: from[k], To: to[k]}) + } + } + if len(fields) == 0 { + return false + } + d.add(Alter, kind, name, fields...) + return true +} + +// parts compares the named parts of a table (columns, keys, ...): added, dropped, altered. +func (d *differ) parts(kind, table string, from, to map[string]map[string]string) { + for _, k := range sortedKeys(from, to) { + f, inFrom := from[k] + t, inTo := to[k] + switch { + case inFrom && !inTo: + d.add(Drop, kind, table+"."+k) + case !inFrom && inTo: + d.add(Add, kind, table+"."+k) + default: + d.props(kind, table+"."+k, f, t) + } + } +} + +func tables(s *schema.Schema) map[string]*schema.Table { + out := map[string]*schema.Table{} + for _, t := range s.Tables { + out[t.Name] = t + } + return out +} + +func (d *differ) tables(a, b *schema.Schema) { + from, to := tables(a), tables(b) + for _, name := range sortedKeys(from, to) { + f, inFrom := from[name] + t, inTo := to[name] + switch { + case inFrom && !inTo: + d.add(Drop, "table", name) + case !inFrom && inTo: + d.add(Add, "table", name) + default: + d.props("table", name, TableProps(f), TableProps(t)) + d.parts("column", name, columns(f), columns(t)) + d.parts("key", name, keys(f), keys(t)) + d.parts("foreign key", name, foreignKeys(f), foreignKeys(t)) + d.parts("check", name, checks(f), checks(t)) + } + } +} + +func (d *differ) views(a, b *schema.Schema) { + from, to := map[string]*schema.View{}, map[string]*schema.View{} + for _, v := range a.Views { + from[v.Name] = v + } + for _, v := range b.Views { + to[v.Name] = v + } + for _, name := range sortedKeys(from, to) { + f, inFrom := from[name] + t, inTo := to[name] + switch { + case inFrom && !inTo: + d.add(Drop, "view", name) + case !inFrom && inTo: + d.add(Add, "view", name) + default: + d.props("view", name, ViewProps(f), ViewProps(t)) + } + } +} + +// routines compares the procedures or functions (kind picks which; MySQL keeps them in +// separate namespaces, so a schema may have both a PROCEDURE and a FUNCTION of the same +// name and they are compared independently). +func (d *differ) routines(a, b *schema.Schema, kind schema.RoutineKind, label string) { + from, to := map[string]*schema.Routine{}, map[string]*schema.Routine{} + for _, r := range a.Routines { + if r.Kind == kind { + from[r.Name] = r + } + } + for _, r := range b.Routines { + if r.Kind == kind { + to[r.Name] = r + } + } + for _, name := range sortedKeys(from, to) { + f, inFrom := from[name] + t, inTo := to[name] + switch { + case inFrom && !inTo: + d.add(Drop, label, name) + case !inFrom && inTo: + d.add(Add, label, name) + default: + d.props(label, name, RoutineProps(f), RoutineProps(t)) + } + } +} + +func (d *differ) triggers(a, b *schema.Schema) { + from, to := map[string]*schema.Trigger{}, map[string]*schema.Trigger{} + for _, tr := range a.Triggers { + from[tr.Name] = tr + } + for _, tr := range b.Triggers { + to[tr.Name] = tr + } + for _, name := range sortedKeys(from, to) { + f, inFrom := from[name] + t, inTo := to[name] + switch { + case inFrom && !inTo: + d.add(Drop, "trigger", name) + case !inFrom && inTo: + d.add(Add, "trigger", name) + default: + d.props("trigger", name, TriggerProps(f), TriggerProps(t)) + } + } +} + +func (d *differ) events(a, b *schema.Schema) { + from, to := map[string]*schema.Event{}, map[string]*schema.Event{} + for _, e := range a.Events { + from[e.Name] = e + } + for _, e := range b.Events { + to[e.Name] = e + } + for _, name := range sortedKeys(from, to) { + f, inFrom := from[name] + t, inTo := to[name] + switch { + case inFrom && !inTo: + d.add(Drop, "event", name) + case !inFrom && inTo: + d.add(Add, "event", name) + default: + fp, tp := eventProps(f, t) + d.props("event", name, fp, tp) + } + } +} + +// EventProps are an event's properties: its schedule, the times bounding it, its +// completion, status and comment, and its body text. A time the target did not fix (an +// omitted or computed STARTS / AT / ENDS, Event.*Literal false) is left out on both sides: +// the server filled it in when each side's event was created, and two creation times +// never agree. +func EventProps(e *schema.Event) map[string]string { + schedule := e.Every + if e.At != "" { + schedule = "AT" + } else { + schedule = "EVERY " + schedule + } + return map[string]string{ + "schedule": schedule, + "at": e.At, + "starts": e.Starts, + "ends": e.Ends, + "on completion": e.Completion, + "status": e.Status, + "comment": e.Comment, + "body": e.BodyText, + } +} + +// eventProps is EventProps of both sides, the times the target does not fix removed from +// each. +func eventProps(from, to *schema.Event) (map[string]string, map[string]string) { + fp, tp := EventProps(from), EventProps(to) + for _, u := range []struct { + key string + lit bool + }{{"at", to.AtLiteral}, {"starts", to.StartsLiteral}, {"ends", to.EndsLiteral}} { + if !u.lit { + delete(fp, u.key) + delete(tp, u.key) + } + } + return fp, tp +} + +// EventChanged reports whether the event as from has it differs from the target's to, by +// the same comparison Compare makes (the times to does not fix left out). +func EventChanged(from, to *schema.Event) bool { + fp, tp := eventProps(from, to) + for _, k := range sortedKeys(fp, tp) { + if fp[k] != tp[k] { + return true + } + } + return false +} + +// TableProps are a table's own properties: the table options. +func TableProps(t *schema.Table) map[string]string { + return map[string]string{ + "engine": t.Engine, + "charset": t.Charset, + "collation": t.Collation, + "rowFormat": t.RowFormat, + "comment": t.Comment, + "partitioned": PartitioningProps(t.Partitioning), + } +} + +// PartitioningProps is a table's PARTITION BY clause, canonicalized to one comparable +// string: "" for an unpartitioned table (nil), else every piece alterTable itself decides +// DDL from -- LINEAR, kind, COLUMNS-ness, expression or column list, KEY's own ALGORITHM, +// the partition count (HASH/KEY) or every partition's own name, boundary, comment and +// explicit SUBPARTITION names (RANGE/LIST, in order), and the SUBPARTITION BY clause the +// same way -- so two clauses this +// package tells apart the same way (an ADD PARTITION reordering nothing, say) never look +// changed on account of some detail alterTable does not look at either. ENGINE is not a +// piece: SHOW CREATE TABLE writes it on every partition regardless of what the statement +// declared (measured), so comparing it would make a plan that changed nothing else look +// changed. +func PartitioningProps(p *schema.Partitioning) string { + if p == nil { + return "" + } + var b strings.Builder + if p.Linear { + b.WriteString("LINEAR ") + } + b.WriteString(p.Kind) + switch p.Kind { + case "KEY": + fmt.Fprintf(&b, " ALGORITHM=%d (%s) PARTITIONS %d", p.Algorithm, strings.Join(p.Cols, ","), p.Num) + case "HASH": + fmt.Fprintf(&b, " (%s) PARTITIONS %d", p.Expr, p.Num) + case "RANGE", "LIST": + if p.Columns { + fmt.Fprintf(&b, " COLUMNS (%s)", strings.Join(p.Cols, ",")) + } else { + fmt.Fprintf(&b, " (%s)", p.Expr) + } + for _, part := range p.Parts { + b.WriteString(" " + part.Name + ":") + if part.MaxValue { + b.WriteString("MAXVALUE") + } else { + b.WriteString(part.Bound) + } + if part.Comment != "" { + b.WriteString("/*" + part.Comment + "*/") + } + if len(part.Subs) > 0 { + b.WriteString("(" + strings.Join(part.Subs, ",") + ")") + } + } + } + if p.Sub != nil { + b.WriteString(" SUB ") + if p.Sub.Linear { + b.WriteString("LINEAR ") + } + b.WriteString(p.Sub.Kind) + if p.Sub.Kind == "KEY" { + fmt.Fprintf(&b, " ALGORITHM=%d (%s)", p.Sub.Algorithm, strings.Join(p.Sub.Cols, ",")) + } else { + fmt.Fprintf(&b, " (%s)", p.Sub.Expr) + } + fmt.Fprintf(&b, " SUBPARTITIONS %d", p.Sub.Num) + } + return b.String() +} + +// ColumnProps are a column's properties: its type, its whole definition as the server +// spells it (nullability, default, attributes and comment included), and its position. +func ColumnProps(t *schema.Table, c *schema.Column) map[string]string { + pos := "first" + for i, x := range t.Columns { + if x == c && i > 0 { + pos = "after " + t.Columns[i-1].Name + } + } + return map[string]string{ + "type": c.Type.String(), + "definition": Definition(c.Text, c.Name), + "position": pos, + } +} + +// Definition is an element's definition text without its leading name (`\x60id\x60 bigint +// NOT NULL` -> `bigint NOT NULL`), so a renamed element compares by what it is. +func Definition(text, name string) string { + text = strings.TrimSpace(text) + for _, prefix := range []string{"`" + strings.ReplaceAll(name, "`", "``") + "`", name} { + if strings.HasPrefix(text, prefix) { + text = strings.TrimSpace(text[len(prefix):]) + break + } + } + return stripRedundantCharset(text) +} + +// stripRedundantCharset removes an explicit "CHARACTER SET x" clause from a definition that +// also carries an explicit COLLATE: a collation names its charset uniquely in MySQL, so the +// two say the same thing, and canonicalizing is not otherwise idempotent for an ENUM / SET +// column under a table with its own DEFAULT CHARSET / COLLATE -- freshly creating one from +// raw declarative SQL omits CHARACTER SET (COLLATE alone, matching the table default), but +// reading the same column back from an existing table (SHOW CREATE TABLE, canonicalizing a +// second time) always spells both (measured against mysqld 8.4), so a plan comparing the two +// spellings of the same column would see one as changed forever. Definitions with and +// without the clause must compare equal; ColumnProps / alterTable's own MODIFY-COLUMN check +// both go through Definition, so this covers both diff.Compare and the plan itself. +func stripRedundantCharset(def string) string { + if !strings.Contains(def, "COLLATE ") { + return def + } + const marker = "CHARACTER SET " + i := strings.Index(def, marker) + if i < 0 { + return def + } + j := i + len(marker) + for j < len(def) && def[j] != ' ' { + j++ + } + if j >= len(def) { + return def[:i] + } + return def[:i] + def[j+1:] +} + +func columns(t *schema.Table) map[string]map[string]string { + out := map[string]map[string]string{} + for _, c := range t.Columns { + out[c.Name] = ColumnProps(t, c) + } + return out +} + +// KeyProps are a key's properties: its definition as the server spells it (kind, columns +// with prefix lengths and order, options), rendered from the model when no text is held. +func KeyProps(k *schema.Key) map[string]string { + def := k.Text + if def == "" { + def = RenderKey(k) + } + return map[string]string{"definition": stripKeyName(def, k)} +} + +// stripKeyName removes the key's name from its definition text, leaving the kind and the +// columns: `UNIQUE KEY \x60k\x60 (\x60a\x60)` -> `UNIQUE KEY (\x60a\x60)`. +func stripKeyName(def string, k *schema.Key) string { + if k.Kind == schema.Primary { + return def + } + return strings.Replace(def, " `"+k.Name+"` ", " ", 1) +} + +// RenderKey spells a key the way SHOW CREATE TABLE does. +func RenderKey(k *schema.Key) string { + var b strings.Builder + switch k.Kind { + case schema.Primary: + b.WriteString("PRIMARY KEY") + case schema.Unique: + b.WriteString("UNIQUE KEY `" + k.Name + "`") + case schema.Fulltext: + b.WriteString("FULLTEXT KEY `" + k.Name + "`") + case schema.Spatial: + b.WriteString("SPATIAL KEY `" + k.Name + "`") + default: + b.WriteString("KEY `" + k.Name + "`") + } + b.WriteString(" (") + for i, p := range k.Parts { + if i > 0 { + b.WriteString(",") + } + if p.Expr != nil && p.Column == "" { + b.WriteString("()") + } else { + b.WriteString("`" + p.Column + "`") + } + if p.Length > 0 { + fmt.Fprintf(&b, "(%d)", p.Length) + } + if p.Desc { + b.WriteString(" DESC") + } + } + b.WriteString(")") + if k.Invisible { + b.WriteString(" /*!80000 INVISIBLE */") + } + if k.Comment != "" { + b.WriteString(" COMMENT '" + strings.ReplaceAll(k.Comment, "'", "''") + "'") + } + return b.String() +} + +func keys(t *schema.Table) map[string]map[string]string { + out := map[string]map[string]string{} + for _, k := range t.Keys { + name := k.Name + if k.Kind == schema.Primary { + name = "PRIMARY" + } + out[name] = KeyProps(k) + } + return out +} + +// ForeignKeyProps are a foreign key's properties: its columns, what they reference, and the +// actions. +func ForeignKeyProps(fk *schema.ForeignKey) map[string]string { + return map[string]string{ + "columns": strings.Join(fk.Columns, ", "), + "references": fk.RefTable + " (" + strings.Join(fk.RefColumns, ", ") + ")", + "on delete": fk.OnDelete, + "on update": fk.OnUpdate, + } +} + +func foreignKeys(t *schema.Table) map[string]map[string]string { + out := map[string]map[string]string{} + for _, fk := range t.ForeignKeys { + out[t.ForeignKeyName(fk)] = ForeignKeyProps(fk) + } + return out +} + +// CheckProps are a check constraint's properties: its expression as the server spells it +// and whether it is enforced. +func CheckProps(t *schema.Table, c *schema.Check) map[string]string { + expr := c.Text + if i := strings.Index(expr, "CHECK"); i >= 0 { + expr = strings.TrimSpace(expr[i+len("CHECK"):]) + } + if j := strings.Index(expr, "/*!80016 NOT ENFORCED */"); j >= 0 { + expr = strings.TrimSpace(expr[:j]) + } else if j := strings.LastIndex(expr, " NOT ENFORCED"); j >= 0 { + expr = strings.TrimSpace(expr[:j]) + } + return map[string]string{"expression": expr, "enforced": fmt.Sprint(c.Enforced)} +} + +func checks(t *schema.Table) map[string]map[string]string { + out := map[string]map[string]string{} + for _, c := range t.Checks { + out[t.CheckName(c)] = CheckProps(t, c) + } + return out +} + +// ViewProps are a view's properties: its definition as the server spells it. +func ViewProps(v *schema.View) map[string]string { + def := strings.TrimSpace(v.Definition) + def = strings.TrimPrefix(def, "CREATE ") + def = strings.TrimPrefix(def, "OR REPLACE ") + return map[string]string{"definition": def} +} + +// RoutineProps are a procedure's or function's properties: its definition as the server +// spells it (its parameters, RETURNS, characteristics and body). Two routines with the same +// name in different kinds (a PROCEDURE and a FUNCTION) are never compared against each +// other; Compare keeps them apart by kind. +func RoutineProps(r *schema.Routine) map[string]string { + def := strings.TrimSpace(r.Definition) + def = strings.TrimPrefix(def, "CREATE ") + return map[string]string{"definition": def} +} + +// TriggerProps are a trigger's properties: its definition as the server spells it (the +// timing, event, table, FOLLOWS/PRECEDES ordering and body). A table rename changes the +// `ON
` a trigger's definition names, so a trigger whose table was renamed compares +// as changed even though its body did not. +func TriggerProps(t *schema.Trigger) map[string]string { + def := strings.TrimSpace(t.Definition) + def = strings.TrimPrefix(def, "CREATE ") + return map[string]string{"definition": def} +} + +func sortedKeys[V any](maps ...map[string]V) []string { + seen := map[string]bool{} + var out []string + for _, m := range maps { + for k := range m { + if !seen[k] { + seen[k] = true + out = append(out, k) + } + } + } + sort.Strings(out) + return out +} diff --git a/check/mysql/diff/diff_test.go b/check/mysql/diff/diff_test.go new file mode 100644 index 0000000..2ef1afa --- /dev/null +++ b/check/mysql/diff/diff_test.go @@ -0,0 +1,150 @@ +package diff + +import ( + "strings" + "testing" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" +) + +// load loads text, failing the test on an error or a Problem. +func load(t *testing.T, text string) *schema.Schema { + t.Helper() + s, err := schema.Load(text) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + return s +} + +func findChange(changes []Change, kind, name string) *Change { + for i, c := range changes { + if c.Kind == kind && c.Name == name { + return &changes[i] + } + } + return nil +} + +// TestCompare_RoutineAddDropAlter covers differ.routines' own three branches (added, +// dropped, altered), for both a PROCEDURE and a FUNCTION. +func TestCompare_RoutineAddDropAlter(t *testing.T) { + a := load(t, `-- sqlshape: mysql 8.4 +CREATE PROCEDURE gone() BEGIN SELECT 1; END; +CREATE PROCEDURE changed(IN x INT) BEGIN SELECT x; END; +CREATE FUNCTION f_changed() RETURNS INT BEGIN RETURN 1; END; +`) + b := load(t, `-- sqlshape: mysql 8.4 +CREATE PROCEDURE changed(IN x INT) BEGIN SELECT x + 1; END; +CREATE PROCEDURE added() BEGIN SELECT 2; END; +CREATE FUNCTION f_changed() RETURNS INT BEGIN RETURN 2; END; +`) + changes := Compare(a, b) + + if c := findChange(changes, "procedure", "gone"); c == nil || c.Op != Drop { + t.Errorf("want procedure gone dropped, got %+v", changes) + } + if c := findChange(changes, "procedure", "added"); c == nil || c.Op != Add { + t.Errorf("want procedure added added, got %+v", changes) + } + if c := findChange(changes, "procedure", "changed"); c == nil || c.Op != Alter { + t.Errorf("want procedure changed altered, got %+v", changes) + } + if c := findChange(changes, "function", "f_changed"); c == nil || c.Op != Alter { + t.Errorf("want function f_changed altered, got %+v", changes) + } +} + +// TestCompare_RoutineNamespacesIndependent: a PROCEDURE and a FUNCTION of the same name are +// compared independently (differ.routines' own kind filter), so one existing on both sides +// unchanged while the other kind is added does not confuse them. +func TestCompare_RoutineNamespacesIndependent(t *testing.T) { + a := load(t, `-- sqlshape: mysql 8.4 +CREATE PROCEDURE p() BEGIN SELECT 1; END; +`) + b := load(t, `-- sqlshape: mysql 8.4 +CREATE PROCEDURE p() BEGIN SELECT 1; END; +CREATE FUNCTION p() RETURNS INT BEGIN RETURN 1; END; +`) + changes := Compare(a, b) + if findChange(changes, "procedure", "p") != nil { + t.Errorf("procedure p should be unchanged, got %+v", changes) + } + if c := findChange(changes, "function", "p"); c == nil || c.Op != Add { + t.Errorf("want function p added, got %+v", changes) + } +} + +// TestCompare_TriggerAddDropAlter covers differ.triggers' own three branches. +func TestCompare_TriggerAddDropAlter(t *testing.T) { + a := load(t, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY, v INT); +CREATE TRIGGER gone BEFORE INSERT ON t FOR EACH ROW SET NEW.v = 1; +CREATE TRIGGER changed BEFORE INSERT ON t FOR EACH ROW SET NEW.v = 2; +`) + b := load(t, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY, v INT); +CREATE TRIGGER changed BEFORE INSERT ON t FOR EACH ROW SET NEW.v = 3; +CREATE TRIGGER added AFTER INSERT ON t FOR EACH ROW SET @x = 1; +`) + changes := Compare(a, b) + if c := findChange(changes, "trigger", "gone"); c == nil || c.Op != Drop { + t.Errorf("want trigger gone dropped, got %+v", changes) + } + if c := findChange(changes, "trigger", "added"); c == nil || c.Op != Add { + t.Errorf("want trigger added added, got %+v", changes) + } + if c := findChange(changes, "trigger", "changed"); c == nil || c.Op != Alter { + t.Errorf("want trigger changed altered, got %+v", changes) + } +} + +// TestCompare_RoutineTriggerUnchanged: an identical routine/trigger on both sides produces +// no Change at all (props' own "no differing fields" path). +func TestCompare_RoutineTriggerUnchanged(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY, v INT); +CREATE TRIGGER trg BEFORE INSERT ON t FOR EACH ROW SET NEW.v = 1; +CREATE PROCEDURE p() BEGIN SELECT 1; END; +` + a, b := load(t, text), load(t, text) + changes := Compare(a, b) + if findChange(changes, "trigger", "trg") != nil || findChange(changes, "procedure", "p") != nil { + t.Errorf("want no changes for identical routine/trigger, got %+v", changes) + } +} + +// TestRoutineProps_DropsCreatePrefix and TestTriggerProps_DropsCreatePrefix cover +// RoutineProps/TriggerProps directly: the definition is trimmed and its "CREATE " prefix +// dropped. +func TestRoutineProps_DropsCreatePrefix(t *testing.T) { + s := load(t, `-- sqlshape: mysql 8.4 +CREATE PROCEDURE p() BEGIN SELECT 1; END; +`) + r := s.Routine("p") + props := RoutineProps(r) + if strings.HasPrefix(props["definition"], "CREATE ") { + t.Errorf("got %q, want the CREATE prefix dropped", props["definition"]) + } + if !strings.Contains(props["definition"], "PROCEDURE p") { + t.Errorf("got %q", props["definition"]) + } +} + +func TestTriggerProps_DropsCreatePrefix(t *testing.T) { + s := load(t, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY); +CREATE TRIGGER trg BEFORE INSERT ON t FOR EACH ROW SET @x = 1; +`) + tg := s.Trigger("trg") + props := TriggerProps(tg) + if strings.HasPrefix(props["definition"], "CREATE ") { + t.Errorf("got %q, want the CREATE prefix dropped", props["definition"]) + } + if !strings.Contains(props["definition"], "TRIGGER trg") { + t.Errorf("got %q", props["definition"]) + } +} diff --git a/check/mysql/diff/events_test.go b/check/mysql/diff/events_test.go new file mode 100644 index 0000000..0cf6d6d --- /dev/null +++ b/check/mysql/diff/events_test.go @@ -0,0 +1,47 @@ +package diff + +import "testing" + +// TestCompare_EventAddDropAlter: events are compared by schedule, bounds, options and body; +// a STARTS the target does not fix (omitted, or an expression) is not compared, a literal one +// is. +func TestCompare_EventAddDropAlter(t *testing.T) { + a := load(t, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY, v INT); +CREATE EVENT gone ON SCHEDULE EVERY 1 DAY DO DELETE FROM t; +CREATE EVENT changed ON SCHEDULE EVERY 1 DAY DO DELETE FROM t WHERE id < 0; +CREATE EVENT floating ON SCHEDULE EVERY 1 DAY STARTS '2026-01-01 00:00:00' DO DELETE FROM t; +CREATE EVENT fixed ON SCHEDULE EVERY 1 DAY STARTS '2026-01-01 00:00:00' DO DELETE FROM t; +CREATE EVENT same ON SCHEDULE EVERY 1 DAY STARTS '2030-01-01 00:00:00' ON COMPLETION PRESERVE DO DELETE FROM t; +`) + b := load(t, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY, v INT); +CREATE EVENT changed ON SCHEDULE EVERY 2 DAY DO DELETE FROM t WHERE id < 0; +CREATE EVENT added ON SCHEDULE AT '2030-01-01 00:00:00' DO DELETE FROM t; +CREATE EVENT floating ON SCHEDULE EVERY 1 DAY DO DELETE FROM t; +CREATE EVENT fixed ON SCHEDULE EVERY 1 DAY STARTS '2030-01-01 00:00:00' DO DELETE FROM t; +CREATE EVENT same ON SCHEDULE EVERY 1 DAY STARTS '2030-01-01 00:00:00' ON COMPLETION PRESERVE DO DELETE FROM t; +`) + changes := Compare(a, b) + if c := findChange(changes, "event", "gone"); c == nil || c.Op != Drop { + t.Errorf("want event gone dropped, got %+v", changes) + } + if c := findChange(changes, "event", "added"); c == nil || c.Op != Add { + t.Errorf("want event added added, got %+v", changes) + } + if c := findChange(changes, "event", "changed"); c == nil || c.Op != Alter || len(c.Fields) != 1 || c.Fields[0].Name != "schedule" || c.Fields[0].To != "EVERY 2 DAY" { + t.Errorf("want event changed altered in its schedule, got %+v", c) + } + if c := findChange(changes, "event", "floating"); c != nil { + t.Errorf("a STARTS the target leaves to the server is no difference, got %+v", c) + } + if c := findChange(changes, "event", "fixed"); c == nil || c.Op != Alter || len(c.Fields) != 1 || c.Fields[0].Name != "starts" { + t.Errorf("a literal STARTS is compared, got %+v", c) + } + if c := findChange(changes, "event", "same"); c != nil { + t.Errorf("identical events differ: %+v", c) + } + if !EventChanged(a.Event("fixed"), b.Event("fixed")) || EventChanged(a.Event("floating"), b.Event("floating")) || EventChanged(a.Event("same"), b.Event("same")) { + t.Error("EventChanged disagrees with Compare") + } +} diff --git a/check/mysql/dump/dump.go b/check/mysql/dump/dump.go new file mode 100644 index 0000000..7854947 --- /dev/null +++ b/check/mysql/dump/dump.go @@ -0,0 +1,596 @@ +// Package dump reads a MySQL database back into a schema.Schema by way of the server's own +// rendering of it: `SHOW CREATE TABLE` and `SHOW CREATE VIEW` for every table and view of +// the database, parsed by the same loader that reads schema.sql. A schema text is given its +// canonical form by applying it to a scratch database and reading it back the same way, so +// both sides of a comparison are what MySQL stores (types in their canonical spelling, +// defaults quoted as the server quotes them, generated constraint names, explicit charset +// and collation), not the spelling of schema.sql. +// +// The scratch database is, when a live server is at hand, a database created on that server +// (`sqlshape_scratch_`, dropped when done): it is normalized by the very server the +// migration targets, version and settings included. Without a server (two schema texts, the +// tests) it is a mysqld started from PATH with the schema's declared settings (mysqltest). +package dump + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/go-sql-driver/mysql" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" + "github.com/kr9ly/sqlshape/v2/x/dialect" +) + +// Canonicalizer gives schema text its canonical form: the schema the server holds after +// applying the text, as the loader reads it back, and the text it was read from. +type Canonicalizer interface { + Canonical(ctx context.Context, schemaSQL string) (*schema.Schema, string, error) +} + +// Header is the schema text's declarations (`-- sqlshape: mysql 8.4` and the +// `-- sqlshape: server` lines), which a text read back from a server needs in front of it +// to load the way the declaring text does. +func Header(schemaSQL string) string { + var b strings.Builder + if d, err := dialect.Declared(schemaSQL); err == nil && d.Name != "" { + fmt.Fprintf(&b, "-- sqlshape: %s %s\n", d.Name, d.Version) + } else { + b.WriteString("-- sqlshape: mysql 8.4\n") + } + if settings, err := dialect.Settings(schemaSQL); err == nil { + for _, s := range settings { + fmt.Fprintf(&b, "-- sqlshape: server %s = '%s'\n", s.Name, strings.ReplaceAll(s.Value, "'", "''")) + } + } + return b.String() +} + +// Querier runs queries: *sql.DB and *sql.Conn. +type Querier interface { + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row +} + +// Read renders the database db is connected to as DDL text: SET FOREIGN_KEY_CHECKS=0, the +// tables in name order (their AUTO_INCREMENT counters left out), then the stored procedures +// and functions in name order (a view may call a function, so routines come first), then +// the views in dependency order, then the triggers (their DEFINER left out throughout). +// Triggers on the same table, action time and event keep the order the server runs them in +// (information_schema.TRIGGERS' ACTION_ORDER), reproduced as an explicit FOLLOWS clause +// (SHOW CREATE TRIGGER's own text never has one, measured against mysqld: it always reads +// back as if the trigger were declared alone). The text is what Load parses. +func Read(ctx context.Context, db Querier) (string, error) { + rows, err := db.QueryContext(ctx, "SELECT TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME") + if err != nil { + return "", err + } + var tables, views []string + for rows.Next() { + var name, kind string + if err := rows.Scan(&name, &kind); err != nil { + rows.Close() + return "", err + } + if kind == "VIEW" { + views = append(views, name) + } else { + tables = append(tables, name) + } + } + rows.Close() + if err := rows.Err(); err != nil { + return "", err + } + var b strings.Builder + b.WriteString("SET FOREIGN_KEY_CHECKS=0;\n") + for _, t := range tables { + var name, ddl string + if err := db.QueryRowContext(ctx, "SHOW CREATE TABLE `"+t+"`").Scan(&name, &ddl); err != nil { + return "", fmt.Errorf("SHOW CREATE TABLE %s: %w", t, err) + } + b.WriteString(normalizeTable(ddl)) + b.WriteString(";\n") + } + if err := readRoutines(ctx, db, &b); err != nil { + return "", err + } + defs := map[string]string{} + for _, v := range views { + var name, ddl, cs, cl string + if err := db.QueryRowContext(ctx, "SHOW CREATE VIEW `"+v+"`").Scan(&name, &ddl, &cs, &cl); err != nil { + return "", fmt.Errorf("SHOW CREATE VIEW %s: %w", v, err) + } + defs[v] = normalizeView(ddl) + } + for _, v := range viewOrder(views, defs) { + b.WriteString(defs[v]) + b.WriteString(";\n") + } + if err := readTriggers(ctx, db, &b); err != nil { + return "", err + } + if err := readEvents(ctx, db, &b); err != nil { + return "", err + } + return b.String(), nil +} + +// readEvents renders every event of the database in name order, last: an event's body is +// bound late (the server checks nothing of it at CREATE time), so nothing depends on its +// position. SHOW CREATE EVENT spells the schedule out in full: a STARTS the source omitted +// or wrote as an expression comes back as the literal time the server computed when the +// event was created (measured), which pinEvents marks for diff to skip. +func readEvents(ctx context.Context, db Querier, b *strings.Builder) error { + rows, err := db.QueryContext(ctx, "SELECT EVENT_NAME FROM information_schema.EVENTS WHERE EVENT_SCHEMA = DATABASE() ORDER BY EVENT_NAME") + if err != nil { + return err + } + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + // defensive: see readRoutines' own note. + rows.Close() + return err + } + names = append(names, name) + } + rows.Close() + if err := rows.Err(); err != nil { + // defensive: see readRoutines' own note. + return err + } + for _, name := range names { + // SHOW CREATE EVENT returns (Event, sql_mode, time_zone, Create Event, + // character_set_client, collation_connection, Database Collation), measured. + var ev, sqlMode, tz, ddl, cs, cl, dbCollation string + q := "SHOW CREATE EVENT `" + name + "`" + if err := db.QueryRowContext(ctx, q).Scan(&ev, &sqlMode, &tz, &ddl, &cs, &cl, &dbCollation); err != nil { + // defensive: see readRoutines' own note (the event was just listed). + return fmt.Errorf("%s: %w", q, err) + } + b.WriteString(normalizeRoutine(ddl)) + b.WriteString(";\n") + } + return nil +} + +// pinEvents carries the source text's own knowledge of each event's schedule into its +// canonical form: whether AT / STARTS / ENDS were written as literal times. The canonical +// text (SHOW CREATE EVENT's) always spells them as literals, so on its own it cannot tell a +// time the schema fixed from one the server filled in at creation; the source can. An event +// the source does not have (it cannot happen: the canonical form came from the source) stays +// as read, every time literal. +func pinEvents(canon *schema.Schema, source string) { + if len(canon.Events) == 0 { + return + } + src, err := schema.Load(source) + if err != nil { + return + } + for _, e := range canon.Events { + if se := src.Event(e.Name); se != nil { + e.AtLiteral, e.StartsLiteral, e.EndsLiteral = se.AtLiteral, se.StartsLiteral, se.EndsLiteral + } + } +} + +// pinAutoIncrement carries a table's own explicit `AUTO_INCREMENT=` (the author's chosen +// starting value for a table's first creation, a schema decision) from the source text into +// the canonical schema: normalizeTable strips the counter from every canonicalized CREATE +// TABLE unconditionally (a live counter is data, not schema), so schema.Load(text) never sees +// it there. The source can still have it (schema.Table.Definition is the statement's own text +// as written), so it is read back here and kept on Table.AutoIncrementStart for a fresh CREATE +// TABLE (migrate.adds) to reproduce; diff.TableProps has no field for it, so this makes no +// difference to any comparison of a table that already exists on both sides. +func pinAutoIncrement(canon *schema.Schema, source string) { + src, err := schema.Load(source) + if err != nil { + return + } + for _, t := range canon.Tables { + if st := src.Table(t.Name); st != nil { + t.AutoIncrementStart = st.AutoIncrementStart + } + } +} + +// readRoutines renders every stored procedure and function of the database, in name (then +// kind, so a PROCEDURE and a FUNCTION of the same name sort deterministically) order. +func readRoutines(ctx context.Context, db Querier, b *strings.Builder) error { + rows, err := db.QueryContext(ctx, "SELECT ROUTINE_NAME, ROUTINE_TYPE FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE() ORDER BY ROUTINE_NAME, ROUTINE_TYPE") + if err != nil { + return err + } + type routine struct{ name, kind string } + var list []routine + for rows.Next() { + var r routine + if err := rows.Scan(&r.name, &r.kind); err != nil { + // defensive: information_schema.ROUTINES' own two columns always scan into + // two strings; only a genuine connection failure reaches this in practice. + rows.Close() + return err + } + list = append(list, r) + } + rows.Close() + if err := rows.Err(); err != nil { + // defensive: a driver-level failure partway through the result set (a dropped + // connection); not deterministically reproducible without one. + return err + } + for _, r := range list { + // SHOW CREATE PROCEDURE / FUNCTION both return (name, sql_mode, create text, + // character_set_client, collation_connection, Database Collation), measured + // against mysqld. + var name, sqlMode, ddl, cs, cl, dbCollation string + q := "SHOW CREATE FUNCTION `" + r.name + "`" + if r.kind == "PROCEDURE" { + q = "SHOW CREATE PROCEDURE `" + r.name + "`" + } + if err := db.QueryRowContext(ctx, q).Scan(&name, &sqlMode, &ddl, &cs, &cl, &dbCollation); err != nil { + // defensive: the routine was just listed by information_schema.ROUTINES; only + // a concurrent DROP between the two queries, or a connection failure, reaches + // this (not deterministically reproducible without one). + return fmt.Errorf("%s: %w", q, err) + } + b.WriteString(normalizeRoutine(ddl)) + b.WriteString(";\n") + } + return nil +} + +// readTriggers renders every trigger of the database, grouped by its table, action time and +// event in the server's own firing order (information_schema.TRIGGERS' ACTION_ORDER), a +// FOLLOWS clause added to every trigger after the first of its group so the order survives a +// reload. +func readTriggers(ctx context.Context, db Querier, b *strings.Builder) error { + rows, err := db.QueryContext(ctx, "SELECT TRIGGER_NAME, EVENT_OBJECT_TABLE, ACTION_TIMING, EVENT_MANIPULATION FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE() ORDER BY EVENT_OBJECT_TABLE, ACTION_TIMING, EVENT_MANIPULATION, ACTION_ORDER") + if err != nil { + return err + } + type trig struct{ name, table, timing, event string } + var list []trig + for rows.Next() { + var t trig + if err := rows.Scan(&t.name, &t.table, &t.timing, &t.event); err != nil { + // defensive: see readRoutines' own note. + rows.Close() + return err + } + list = append(list, t) + } + rows.Close() + if err := rows.Err(); err != nil { + // defensive: see readRoutines' own note. + return err + } + last := map[string]string{} + for _, t := range list { + key := t.table + "|" + t.timing + "|" + t.event + follows := last[key] + // SHOW CREATE TRIGGER returns (name, sql_mode, SQL Original Statement, + // character_set_client, collation_connection, Database Collation, Created), one + // more column than SHOW CREATE PROCEDURE/FUNCTION, measured against mysqld. + var name, sqlMode, ddl, cs, cl, dbCollation, created string + q := "SHOW CREATE TRIGGER `" + t.name + "`" + if err := db.QueryRowContext(ctx, q).Scan(&name, &sqlMode, &ddl, &cs, &cl, &dbCollation, &created); err != nil { + // defensive: see readRoutines' own note (the trigger was just listed by + // information_schema.TRIGGERS). + return fmt.Errorf("%s: %w", q, err) + } + b.WriteString(normalizeTrigger(ddl, follows)) + b.WriteString(";\n") + last[key] = t.name + } + return nil +} + +var ( + autoIncrement = regexp.MustCompile(` AUTO_INCREMENT=\d+`) + definer = regexp.MustCompile(` DEFINER=\x60[^\x60]*\x60@\x60[^\x60]*\x60`) +) + +// normalizeTable drops the AUTO_INCREMENT counter from the table options: it is data, not +// schema. +func normalizeTable(ddl string) string { + if i := strings.LastIndex(ddl, "\n)"); i >= 0 { + return ddl[:i] + autoIncrement.ReplaceAllString(ddl[i:], "") + } + return ddl +} + +// normalizeView drops the DEFINER, which names the user that created the view on that +// server. +func normalizeView(ddl string) string { return definer.ReplaceAllString(ddl, "") } + +// normalizeRoutine drops the DEFINER of a CREATE PROCEDURE / CREATE FUNCTION. +func normalizeRoutine(ddl string) string { + return dropTrailingSemicolon(definer.ReplaceAllString(ddl, "")) +} + +var forEachRow = regexp.MustCompile(`FOR EACH ROW`) + +// dropTrailingSemicolon removes one trailing ';' (Read appends its own): a trigger or +// routine whose body is a single simple statement, not a BEGIN ... END block, ends its SHOW +// CREATE text with that statement's own ';' (measured against mysqld: a BEGIN ... END body +// does not, its text ends at END), which would otherwise double up. +func dropTrailingSemicolon(ddl string) string { + return strings.TrimSuffix(strings.TrimRight(ddl, " \t\r\n"), ";") +} + +// normalizeTrigger drops the DEFINER, and inserts a FOLLOWS clause naming follows (the +// trigger the server runs just before this one, for its table/action time/event) when +// follows is not "": SHOW CREATE TRIGGER's own text never carries FOLLOWS/PRECEDES, so a +// reload would otherwise put every trigger of a group last (measured against mysqld). +func normalizeTrigger(ddl, follows string) string { + ddl = dropTrailingSemicolon(definer.ReplaceAllString(ddl, "")) + if follows == "" { + return ddl + } + loc := forEachRow.FindStringIndex(ddl) + if loc == nil { + // defensive: SHOW CREATE TRIGGER's own text always has "FOR EACH ROW" (the only + // row-level trigger MySQL has); follows is only ever "" when there is nothing to + // insert anyway, but this guards a change in the server's own wording. + return ddl + } + return ddl[:loc[1]] + " FOLLOWS `" + strings.ReplaceAll(follows, "`", "``") + "`" + ddl[loc[1]:] +} + +// viewOrder sorts views so that a view comes after the views its text names. +func viewOrder(views []string, defs map[string]string) []string { + isView := map[string]bool{} + for _, v := range views { + isView[v] = true + } + deps := map[string][]string{} + for _, v := range views { + for _, m := range regexp.MustCompile("\x60([^\x60]+)\x60").FindAllStringSubmatch(defs[v], -1) { + if m[1] != v && isView[m[1]] { + deps[v] = append(deps[v], m[1]) + } + } + } + var out []string + done := map[string]bool{} + var visit func(v string, stack map[string]bool) + visit = func(v string, stack map[string]bool) { + if done[v] || stack[v] { + return + } + stack[v] = true + sort.Strings(deps[v]) + for _, d := range deps[v] { + visit(d, stack) + } + done[v] = true + out = append(out, v) + } + for _, v := range views { + visit(v, map[string]bool{}) + } + return out +} + +// Load reads the database at dsn into a Schema, and returns the text it was loaded from. +// header is the declaring schema's declarations (Header), so the live side loads under the +// same version and settings; the server's lower_case_table_names must agree with them. +func Load(ctx context.Context, dsn, header string) (*schema.Schema, string, error) { + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + return nil, "", err + } + db, err := open(cfg) + if err != nil { + return nil, "", err + } + defer db.Close() + if err := checkSettings(ctx, db, header); err != nil { + return nil, "", err + } + text, err := Read(ctx, db) + if err != nil { + return nil, "", err + } + text = header + text + s, err := schema.Load(text) + if err != nil { + return nil, "", fmt.Errorf("load dump: %w", err) + } + return s, text, nil +} + +// ServerVersion asks the database at dsn which MySQL it runs ("8.4.6"). +func ServerVersion(ctx context.Context, dsn string) (string, error) { + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + return "", err + } + db, err := open(cfg) + if err != nil { + return "", err + } + defer db.Close() + var v string + err = db.QueryRowContext(ctx, "SELECT VERSION()").Scan(&v) + return v, err +} + +// checkSettings refuses a server whose lower_case_table_names is not the declared one: the +// value is fixed when the server is initialized, so the names the two sides compare would +// never agree. +func checkSettings(ctx context.Context, db Querier, header string) error { + want := 0 + if settings, err := dialect.Settings(header); err == nil { + for _, s := range settings { + if s.Name == "lower_case_table_names" { + want, _ = strconv.Atoi(s.Value) + } + } + } + var got int + if err := db.QueryRowContext(ctx, "SELECT @@GLOBAL.lower_case_table_names").Scan(&got); err != nil { + return err + } + if got != want { + return fmt.Errorf("the server runs with lower_case_table_names = %d but schema.sql declares %d: declare `-- sqlshape: server lower_case_table_names = %d` if the server is the one meant", got, want, got) + } + return nil +} + +// sqlMode is the declared sql_mode, "" when the header declares none. +func sqlMode(header string) (string, bool) { + if settings, err := dialect.Settings(header); err == nil { + for _, s := range settings { + if s.Name == "sql_mode" { + return s.Value, true + } + } + } + return "", false +} + +func open(cfg *mysql.Config) (*sql.DB, error) { + c, err := mysql.NewConnector(cfg) + if err != nil { + return nil, err + } + return sql.OpenDB(c), nil +} + +// Scratch canonicalizes on a live server: each Canonical creates a database of its own +// there, applies the text under the declared sql_mode, reads it back and drops the +// database. It needs the CREATE and DROP DATABASE privileges. +type Scratch struct { + cfg *mysql.Config +} + +// NewScratch prepares canonicalization on the server at dsn. +func NewScratch(dsn string) (*Scratch, error) { + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + return nil, err + } + return &Scratch{cfg: cfg}, nil +} + +// Canonical applies schemaSQL to a fresh scratch database on the server and reads it back. +func (s *Scratch) Canonical(ctx context.Context, schemaSQL string) (*schema.Schema, string, error) { + var buf [6]byte + rand.Read(buf[:]) + name := "sqlshape_scratch_" + hex.EncodeToString(buf[:]) + admin, err := open(s.cfg) + if err != nil { + return nil, "", err + } + defer admin.Close() + if _, err := admin.ExecContext(ctx, "CREATE DATABASE `"+name+"`"); err != nil { + return nil, "", fmt.Errorf("scratch database: %w", err) + } + defer admin.ExecContext(ctx, "DROP DATABASE IF EXISTS `"+name+"`") + cfg := s.cfg.Clone() + cfg.DBName = name + cfg.MultiStatements = true + db, err := open(cfg) + if err != nil { + return nil, "", err + } + defer db.Close() + return canonicalOn(ctx, db, schemaSQL, Header(schemaSQL)) +} + +// canonicalOn applies schemaSQL to the database db is connected to (one connection, so the +// session settings hold) and reads it back. +func canonicalOn(ctx context.Context, db *sql.DB, schemaSQL, header string) (*schema.Schema, string, error) { + conn, err := db.Conn(ctx) + if err != nil { + return nil, "", err + } + defer conn.Close() + if mode, ok := sqlMode(header); ok { + if _, err := conn.ExecContext(ctx, "SET SESSION sql_mode = '"+strings.ReplaceAll(mode, "'", "''")+"'"); err != nil { + return nil, "", err + } + } + if err := checkSettings(ctx, conn, header); err != nil { + return nil, "", err + } + if text := strings.TrimSpace(schemaSQL); text != "" { + if _, err := conn.ExecContext(ctx, text); err != nil { + return nil, "", err + } + } + text, err := Read(ctx, conn) + if err != nil { + return nil, "", err + } + text = header + text + s, err := schema.Load(text) + if err != nil { + return nil, "", fmt.Errorf("load canonical: %w", err) + } + pinEvents(s, schemaSQL) + pinAutoIncrement(s, schemaSQL) + return s, text, nil +} + +// Local canonicalizes on a mysqld started from PATH for each text, with the text's +// declared settings (mysqltest). ErrNoServer when there is none. +type Local struct{} + +// ErrNoServer is mysqltest's: no mysqld on PATH. +var ErrNoServer = mysqltest.ErrNoServer + +// Canonical starts a server with schemaSQL loaded and reads it back. +func (Local) Canonical(ctx context.Context, schemaSQL string) (*schema.Schema, string, error) { + srv, err := mysqltest.Start(ctx, schemaSQL) + if err != nil { + return nil, "", err + } + defer srv.Close() + text, err := Read(ctx, srv.Conn()) + if err != nil { + return nil, "", err + } + text = Header(schemaSQL) + text + s, err := schema.Load(text) + if err != nil { + return nil, "", fmt.Errorf("load canonical: %w", err) + } + pinEvents(s, schemaSQL) + pinAutoIncrement(s, schemaSQL) + return s, text, nil +} + +// Problems lists what the loader could not apply of a schema text (a target must be +// clean: a statement the loader skipped would be missing from the canonical form too). +func Problems(schemaSQL string) error { + s, err := schema.Load(schemaSQL) + if err != nil { + return err + } + if len(s.Problems) == 0 { + return nil + } + var lines []string + for _, p := range s.Problems { + lines = append(lines, p.String()) + } + return fmt.Errorf("schema has problems:\n %s", strings.Join(lines, "\n ")) +} + +// Schema is the loaded schema the canonicalizers and Load return: the MySQL loader's, named +// here so that a caller outside the module can hold and pass it (to diff.Compare, +// migrate.Plan). +type Schema = schema.Schema diff --git a/check/mysql/dump/dump_test.go b/check/mysql/dump/dump_test.go new file mode 100644 index 0000000..54de977 --- /dev/null +++ b/check/mysql/dump/dump_test.go @@ -0,0 +1,188 @@ +package dump + +import ( + "context" + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +func example(t *testing.T) string { + t.Helper() + b, err := os.ReadFile("../../../examples/5-mysql/schema.sql") + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// Both canonicalizers give the same text, the loader reads it clean, and canonicalizing +// the canonical text again is a fixpoint. Load of a live database reads the same thing. +func TestCanonical(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + schemaSQL := example(t) + s1, text1, err := Local{}.Canonical(ctx, schemaSQL) + if errors.Is(err, ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + if len(s1.Problems) > 0 { + t.Fatalf("problems: %v", s1.Problems) + } + if !strings.HasPrefix(text1, "-- sqlshape: mysql 8.4\nSET FOREIGN_KEY_CHECKS=0;\nCREATE TABLE `customers`") { + t.Errorf("text:\n%s", text1) + } + if strings.Contains(text1, "AUTO_INCREMENT=") || strings.Contains(text1, "DEFINER=") { + t.Errorf("counters or definers left in:\n%s", text1) + } + for _, c := range s1.Tables[0].Columns { + if c.Text == "" { + t.Errorf("column %s has no text", c.Name) + } + } + if s1.Trigger("orders_before_insert") == nil { + t.Error("trigger orders_before_insert not read back") + } + if s1.RoutineOf(schema.Function, "customer_order_total") == nil { + t.Error("function customer_order_total not read back") + } + if !strings.Contains(text1, "CREATE TRIGGER `orders_before_insert`") { + t.Errorf("trigger text missing:\n%s", text1) + } + if !strings.Contains(text1, "CREATE FUNCTION `customer_order_total`") { + t.Errorf("function text missing:\n%s", text1) + } + // fixpoint + _, text2, err := Local{}.Canonical(ctx, text1) + if err != nil { + t.Fatal(err) + } + if text2 != text1 { + t.Errorf("not a fixpoint:\n%s\n---\n%s", text1, text2) + } + // a live server: Load and Scratch on it + srv, err := mysqltest.Start(ctx, schemaSQL) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + _, text3, err := Load(ctx, srv.DSN(), Header(schemaSQL)) + if err != nil { + t.Fatal(err) + } + if text3 != text1 { + t.Errorf("Load differs:\n%s\n---\n%s", text1, text3) + } + sc, err := NewScratch(srv.DSN()) + if err != nil { + t.Fatal(err) + } + _, text4, err := sc.Canonical(ctx, schemaSQL) + if err != nil { + t.Fatal(err) + } + if text4 != text1 { + t.Errorf("Scratch differs:\n%s\n---\n%s", text1, text4) + } + var n int + if err := srv.Conn().QueryRowContext(ctx, "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME LIKE 'sqlshape_scratch_%'").Scan(&n); err != nil || n != 0 { + t.Errorf("scratch databases left: %d %v", n, err) + } +} + +func TestHeader(t *testing.T) { + got := Header("-- sqlshape: mysql 8.4\n-- sqlshape: server sql_mode = 'ANSI'\n-- sqlshape: server lower_case_table_names = 1\nCREATE TABLE t (a INT);") + want := "-- sqlshape: mysql 8.4\n-- sqlshape: server sql_mode = 'ANSI'\n-- sqlshape: server lower_case_table_names = '1'\n" + if got != want { + t.Errorf("got %q", got) + } + if Header("CREATE TABLE t (a INT);") != "-- sqlshape: mysql 8.4\n" { + t.Error("default header") + } +} + +// Two triggers on the same table, action time and event: SHOW CREATE TRIGGER's own text +// never carries FOLLOWS (measured against mysqld: it reads as if each trigger stood alone), +// so Read must add it itself from information_schema.TRIGGERS' ACTION_ORDER for the second +// trigger to run after the first on a reload, and canonicalizing that text again must keep +// the same order (a fixpoint). +func TestTriggerFollowsOrder(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + schemaSQL := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY, a INT, b INT); +CREATE TRIGGER t_first BEFORE INSERT ON t FOR EACH ROW SET NEW.a = 1; +CREATE TRIGGER t_second BEFORE INSERT ON t FOR EACH ROW FOLLOWS t_first SET NEW.b = 2; +` + s1, text1, err := Local{}.Canonical(ctx, schemaSQL) + if errors.Is(err, ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + if len(s1.Problems) > 0 { + t.Fatalf("problems: %v", s1.Problems) + } + second := s1.Trigger("t_second") + if second == nil { + t.Fatal("t_second not read back") + } + if second.OrderClause != "FOLLOWS" || second.OrderTrigger != "t_first" { + t.Errorf("t_second: OrderClause=%q OrderTrigger=%q", second.OrderClause, second.OrderTrigger) + } + if !strings.Contains(text1, "FOLLOWS `t_first`") { + t.Errorf("no FOLLOWS clause in dumped text:\n%s", text1) + } + _, text2, err := Local{}.Canonical(ctx, text1) + if err != nil { + t.Fatal(err) + } + if text2 != text1 { + t.Errorf("not a fixpoint:\n%s\n---\n%s", text1, text2) + } +} + +func TestViewOrder(t *testing.T) { + defs := map[string]string{"a": "select * from `b`", "b": "select * from `c` join `t`", "c": "select 1"} + got := viewOrder([]string{"a", "b", "c"}, defs) + if strings.Join(got, ",") != "c,b,a" { + t.Errorf("%v", got) + } +} + +// TestReadRoutinesAndTriggers_QueryError: a context already cancelled before the call makes +// the listing query itself fail immediately (readRoutines' and readTriggers' own first +// QueryContext error, as opposed to every other test here, which only exercises the success +// path through Read/Canonical). +func TestReadRoutinesAndTriggers_QueryError(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + srv, err := mysqltest.Start(ctx, "-- sqlshape: mysql 8.4\nCREATE TABLE t (id INT PRIMARY KEY);\n") + if errors.Is(err, ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer srv.Close() + + cancelled, cancelNow := context.WithCancel(ctx) + cancelNow() + + var b strings.Builder + if err := readRoutines(cancelled, srv.Conn(), &b); err == nil { + t.Error("readRoutines(cancelled context) = nil error, want one") + } + if err := readTriggers(cancelled, srv.Conn(), &b); err == nil { + t.Error("readTriggers(cancelled context) = nil error, want one") + } +} diff --git a/check/mysql/dump/events_test.go b/check/mysql/dump/events_test.go new file mode 100644 index 0000000..c167cf9 --- /dev/null +++ b/check/mysql/dump/events_test.go @@ -0,0 +1,95 @@ +package dump + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +// TestCanonicalEvents: events read back through SHOW CREATE EVENT, DEFINER dropped, a STARTS +// the source omitted or computed filled in by the server as a literal time (measured) and +// marked unfixed by pinEvents, a literal one kept fixed; the canonical text is a fixpoint. +func TestCanonicalEvents(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + src := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, v INT NOT NULL); +CREATE EVENT floating ON SCHEDULE EVERY 1 DAY DO DELETE FROM t WHERE id < 0; +CREATE EVENT computed ON SCHEDULE EVERY 1 HOUR STARTS CURRENT_TIMESTAMP + INTERVAL 1 DAY DO DELETE FROM t WHERE id < 0; +CREATE EVENT fixed ON SCHEDULE EVERY 1 HOUR STARTS '2030-01-01 00:00:00' ENDS '2031-01-01 00:00:00' ON COMPLETION PRESERVE DISABLE COMMENT 'c' DO BEGIN DELETE FROM t WHERE id < 0; UPDATE t SET v = v + 1 WHERE id = 1; END; +CREATE EVENT once ON SCHEDULE AT '2030-01-01 00:00:00' DO INSERT INTO t VALUES (1, 1); +` + s, text, err := Local{}.Canonical(ctx, src) + if errors.Is(err, ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("problems: %v", s.Problems) + } + if strings.Contains(text, "DEFINER=") { + t.Errorf("definer left in:\n%s", text) + } + if len(s.Events) != 4 { + t.Fatalf("events read back: %d\n%s", len(s.Events), text) + } + fl := s.Event("floating") + if fl.Starts == "" || fl.StartsLiteral { + t.Errorf("floating: the server fills STARTS in, the source did not fix it: %+v", fl) + } + if co := s.Event("computed"); co.Starts == "" || co.StartsLiteral { + t.Errorf("computed: %+v", co) + } + fx := s.Event("fixed") + if fx.Starts != "'2030-01-01 00:00:00'" || !fx.StartsLiteral || fx.Ends != "'2031-01-01 00:00:00'" || !fx.EndsLiteral || fx.Completion != "PRESERVE" || fx.Status != "DISABLE" || fx.Comment != "c" { + t.Errorf("fixed: %+v", fx) + } + if on := s.Event("once"); on.At != "'2030-01-01 00:00:00'" || !on.AtLiteral { + t.Errorf("once: %+v", on) + } + _, text2, err := Local{}.Canonical(ctx, text) + if err != nil { + t.Fatal(err) + } + if text2 != text { + t.Errorf("not a fixpoint:\n%s\n---\n%s", text, text2) + } +} + +// TestCanonicalAlterEvent: a schema that CREATEs an event and ALTERs it canonicalizes to the +// altered event (the server's own SHOW CREATE EVENT of the result), the source text's +// knowledge of which times it fixed following the event through its rename. +func TestCanonicalAlterEvent(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + src := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, v INT NOT NULL); +CREATE EVENT e ON SCHEDULE EVERY 1 HOUR STARTS '2030-01-01 00:00:00' ON COMPLETION PRESERVE DISABLE COMMENT 'c' DO DELETE FROM t WHERE id < 0; +ALTER EVENT e ON SCHEDULE EVERY 2 DAY RENAME TO f ENABLE COMMENT 'd'; +ALTER EVENT f DO UPDATE t SET v = v + 1 WHERE id = 1; +` + s, text, err := Local{}.Canonical(ctx, src) + if errors.Is(err, ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("problems: %v", s.Problems) + } + if s.Event("e") != nil || len(s.Events) != 1 { + t.Fatalf("events: %d\n%s", len(s.Events), text) + } + f := s.Event("f") + if f.Every != "2 DAY" || f.Completion != "PRESERVE" || f.Status != "ENABLE" || f.Comment != "d" || f.BodyText != "UPDATE t SET v = v + 1 WHERE id = 1" { + t.Errorf("altered event read back: %+v\n%s", f, text) + } + if f.Starts == "" || f.StartsLiteral { + t.Errorf("the new schedule wrote no STARTS, so the server's is not fixed: %+v", f) + } +} diff --git a/check/mysql/dump/main_test.go b/check/mysql/dump/main_test.go new file mode 100644 index 0000000..804b0d8 --- /dev/null +++ b/check/mysql/dump/main_test.go @@ -0,0 +1,12 @@ +package dump + +import ( + "os" + "testing" + + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// One mysqld per set of server settings serves the whole package (mysqltest.Main); a test +// gets it with the database dropped, not a server of its own. +func TestMain(m *testing.M) { os.Exit(mysqltest.Main(m)) } diff --git a/check/mysql/go.mod b/check/mysql/go.mod new file mode 100644 index 0000000..eec8444 --- /dev/null +++ b/check/mysql/go.mod @@ -0,0 +1,19 @@ +module github.com/kr9ly/sqlshape/check/mysql/v2 + +go 1.26.1 + +// The root module is required by version; scripts/release.sh keeps it at the release's +// version (the three modules release in lockstep). internal/dialect is what is needed of it. + +require ( + github.com/go-sql-driver/mysql v1.10.1 + github.com/kr9ly/sqlshape/mysqltest/v2 v2.0.0-rc.2 + github.com/kr9ly/sqlshape/mysql/v2 v2.0.0-rc.2 + github.com/kr9ly/sqlshape/v2 v2.0.0-rc.2 + github.com/tetratelabs/wazero v1.12.0 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + golang.org/x/sys v0.47.0 // indirect +) diff --git a/check/mysql/go.sum b/check/mysql/go.sum new file mode 100644 index 0000000..3c0b947 --- /dev/null +++ b/check/mysql/go.sum @@ -0,0 +1,12 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME= +github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/kr9ly/sqlshape/mysqltest/v2 v2.0.0-rc.2 h1:XAZTd0FxmvZNA+/FbIw8mLKADI6zYl8mi3zVLcaqvCM= +github.com/kr9ly/sqlshape/mysqltest/v2 v2.0.0-rc.2/go.mod h1:p3sl/SdLlmsZvh6h3Mn5gZcng0O39YGz/Zz9jHazR9s= +github.com/kr9ly/sqlshape/v2 v2.0.0-rc.2 h1:BJLzDY0RI2+U3qcnyuRa6uLktWF+QdaEG2jmytF8Voc= +github.com/kr9ly/sqlshape/v2 v2.0.0-rc.2/go.mod h1:qMnSUVDy+MMtTjCCBq8TKTyPgLvzFCbvawmWFEiix8s= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/check/mysql/internal/analyze/adv2_body_test.go b/check/mysql/internal/analyze/adv2_body_test.go new file mode 100644 index 0000000..d8d45c6 --- /dev/null +++ b/check/mysql/internal/analyze/adv2_body_test.go @@ -0,0 +1,312 @@ +package analyze + +import ( + "context" + "errors" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// Adversarial round 2, lane "body" (trigger / stored routine / event body analysis: +// check/mysql/internal/analyze/body.go). Every test below reproduces its finding against a +// real mysqld 8.4.11 (nix-shell -p mysql84) before asserting what the checker does; each one +// currently FAILs, showing a gap between the two. See +// scratchpad/adv2/report-body.md for the write-up. + +// --------------------------------------------------------------------------------------- +// Finding 1: dynamic SQL (PREPARE / EXECUTE / DEALLOCATE PREPARE) inside a trigger or a +// stored FUNCTION is refused by the server at CREATE time (1336 "Dynamic SQL is not allowed +// in stored function or trigger", measured below); a PROCEDURE is exempt (measured +// separately, not reproduced here since it is not a finding). body.go's walkNode has no +// hook for PT_prepare / PT_execute / PT_deallocate -- they fall through to its own default +// "a body statement this milestone does not walk ... skipped, not an error" (body.go:706), +// so schema.Load accepts such a trigger with zero Problems and AnalyzeTrigger returns no +// error: the checker calls the schema fine although the real CREATE TRIGGER for it fails +// every time. Suspect: check/mysql/internal/analyze/body.go's walkNode (no PT_prepare / +// PT_execute / PT_deallocate case, and no CREATE-time check anywhere in this file), and +// body.go:706 in particular, which currently treats the construct as silently unwalked. +func TestAdv2DynamicSQLInTriggerRefusedByServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const dynamicTriggerSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, v INT NOT NULL); +CREATE TRIGGER trg_dyn BEFORE INSERT ON t FOR EACH ROW +BEGIN + SET @s = 'SELECT 1'; + PREPARE stmt FROM @s; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; +END; +` + // the server: CREATE TRIGGER with dynamic SQL inside it is refused outright (1336), + // measured against mysqld 8.4.11 + _, err := mysqltest.Start(ctx, dynamicTriggerSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + var me *driver.MySQLError + if !errors.As(err, &me) || me.Number != 1336 { + t.Fatalf("test premise wrong: want the server to refuse this schema with 1336, got %v", err) + } + + // the checker: schema.Load accepts the same trigger with no problems, and AnalyzeTrigger + // finds nothing wrong with its body -- disagreeing with the server that just refused it + s, err := schema.Load(dynamicTriggerSchema) + if err != nil { + t.Fatalf("schema.Load: %v", err) + } + if len(s.Problems) == 0 { + t.Errorf("schema.Load reports no problems for a trigger the server refuses at CREATE time with 1336 (dynamic SQL); want a problem naming it") + } + tg := s.Trigger("trg_dyn") + if tg == nil { + t.Fatal("trigger not loaded") + } + if _, err := AnalyzeTrigger(s, tg); errCode(err) != 1336 { + t.Errorf("AnalyzeTrigger(trg_dyn) = %v, want a 1336 Error (the server refuses CREATE TRIGGER for a body using PREPARE/EXECUTE)", err) + } +} + +// --------------------------------------------------------------------------------------- +// Finding 2: DECLAREing two variables of the same name in the same block is refused by the +// server at CREATE time (1331 "Duplicate variable: x", measured below); a parameter +// re-declared once as a local variable is fine (measured separately: only two DECLAREs of +// the same name in the very same block collide), and a nested block re-declaring an outer +// variable is ordinary shadowing, not a duplicate (measured separately too). body.go's +// declareVar (body.go:100-110) writes straight into a.vars.vars[name] with no check for an +// existing entry, so two DECLAREs of the same name in one block silently shadow each other +// in the checker's own bookkeeping instead of being reported. Suspect: +// check/mysql/internal/analyze/body.go's declareVar and walkDecl's "sp_decl_var" case +// (body.go:100-110, 809-823), which never compares against what a.vars.vars already holds +// for the current (innermost) block before overwriting it. +func TestAdv2DuplicateVariableRefusedByServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const dupVarSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, v INT NOT NULL); +CREATE PROCEDURE p_dup() +BEGIN + DECLARE x INT; + DECLARE x INT; +END; +` + // the server: two DECLAREs of "x" in the same block are refused outright (1331), + // measured against mysqld 8.4.11 + _, err := mysqltest.Start(ctx, dupVarSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + var me *driver.MySQLError + if !errors.As(err, &me) || me.Number != 1331 { + t.Fatalf("test premise wrong: want the server to refuse this schema with 1331, got %v", err) + } + + // the checker: schema.Load accepts the same procedure with no problems, and + // AnalyzeRoutine finds nothing wrong with its body + s, err := schema.Load(dupVarSchema) + if err != nil { + t.Fatalf("schema.Load: %v", err) + } + if len(s.Problems) == 0 { + t.Errorf("schema.Load reports no problems for a procedure the server refuses at CREATE time with 1331 (duplicate variable); want a problem naming it") + } + r := s.RoutineOf(schema.Procedure, "p_dup") + if r == nil { + t.Fatal("routine not loaded") + } + if _, err := AnalyzeRoutine(s, r); errCode(err) != 1331 { + t.Errorf("AnalyzeRoutine(p_dup) = %v, want a 1331 Error (the server refuses two DECLAREs of the same variable name in one block)", err) + } +} + +// --------------------------------------------------------------------------------------- +// Finding 3: a bare RESIGNAL (no named condition) that does carry a `SET MYSQL_ERRNO = n` +// information item changes the number the caller sees to n, not the number the caught +// SIGNAL originally carried (measured below: a handler catching a SIGNAL SQLSTATE '45000' +// SET MYSQL_ERRNO = 30001, then re-raising with RESIGNAL SET MYSQL_ERRNO = 30002, surfaces +// 30002 to CALL's caller -- not 30001). walkSignal's own RESIGNAL-without-condition branch +// (body.go:954-960) ignores the "info" items entirely and re-raises a.handlerRaise +// unchanged, so BodyResult.Violations keeps the original SIGNAL's key (30001) instead of +// the overridden one (30002): a caller matching on the checker's predicted key would watch +// for the wrong number. (A bare RESIGNAL with no SET at all is unaffected and already +// covered by TestResignalReraises; a RESIGNAL SET MESSAGE_TEXT = ... with no MYSQL_ERRNO +// leaves the number as the original SIGNAL's, which the checker already gets right -- +// verified against the server, not a finding.) Suspect: +// check/mysql/internal/analyze/body.go's walkSignal (body.go:943-992), specifically the +// `if cond == nil { if n.Class == "sp_resignal" { ... } }` branch at body.go:955-959, which +// never reads a RESIGNAL's own "info" items the way the named-condition branch below it +// reads a SIGNAL's own MYSQL_ERRNO (signalErrno, body.go:994-1010). +func TestAdv2ResignalSetErrnoOverridesNumber(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const resignalOverrideSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, v INT NOT NULL); +CREATE PROCEDURE p1() +BEGIN + DECLARE CONTINUE HANDLER FOR SQLSTATE '45000' + BEGIN + RESIGNAL SET MYSQL_ERRNO = 30002; + END; + SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 30001; +END; +` + db, err := mysqltest.Start(ctx, resignalOverrideSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + + // the server: CALL p1() raises 30002 (45000), the RESIGNAL's own overridden number, not + // the original SIGNAL's 30001 -- measured against mysqld 8.4.11 + _, execErr := db.Conn().ExecContext(ctx, "CALL p1()") + var me *driver.MySQLError + if !errors.As(execErr, &me) || me.Number != 30002 { + t.Fatalf("test premise wrong: want the server to raise 30002 from CALL p1(), got %v", execErr) + } + + s, err := schema.Load(resignalOverrideSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + br, err := AnalyzeRoutine(s, s.RoutineOf(schema.Procedure, "p1")) + if err != nil { + t.Fatal(err) + } + if got := violationKeys(br.Violations); got != "30002 30002" { + t.Errorf("AnalyzeRoutine(p1).Violations = %s, want \"30002 30002\" (the server's own RESIGNAL SET MYSQL_ERRNO overrides the caught SIGNAL's number)", got) + } +} + +// --------------------------------------------------------------------------------------- +// Finding 4: a stored FUNCTION (or a TRIGGER) whose body CALLs a PROCEDURE whose own body +// returns a result set (an INTO-less top-level SELECT) is accepted at CREATE time -- the +// server only catches this at execution, every single time (1415 "Not allowed to return a +// result set from a function"/"... trigger", measured below for both a function calling +// such a procedure and a trigger doing the same). This is the same shape AnalyzeEvent +// already handles for an event naming an unknown table (schema.go's own doc: "the server +// checks nothing of it at CREATE time ... fails at every run ... the checker reports it as +// a schema problem"), but walkCall (body.go:1096-1125) does not look at the called +// PROCEDURE's own result-set-producing statements at all when the caller is itself a +// FUNCTION or a TRIGGER, so AnalyzeRoutine/AnalyzeTrigger return no error for it: the +// checker calls the body fine although every execution of it is certain to fail. Suspect: +// check/mysql/internal/analyze/body.go's walkCall (body.go:1096-1125), which never checks +// resultColumnsOf(cbr) (call.go's own helper, already used by callStmt for a top-level CALL) +// against whether the *caller* is a FUNCTION/TRIGGER body -- and by extension AnalyzeRoutine +// / AnalyzeTrigger, which never re-raise 1415 for a callee found this way. +func TestAdv2CallResultSetProcedureFromFunctionIs1415(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const callResultSetSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, v INT NOT NULL); +CREATE PROCEDURE p_rs() +BEGIN + SELECT 1; +END; +CREATE FUNCTION f_call_rs() RETURNS INT DETERMINISTIC +BEGIN + CALL p_rs(); + RETURN 1; +END; +` + db, err := mysqltest.Start(ctx, callResultSetSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + + // the server: CREATE FUNCTION succeeds (p_rs's own result set is not caught at CREATE + // time), but every call to f_call_rs() fails with 1415 -- measured against mysqld 8.4.11 + _, execErr := db.Conn().ExecContext(ctx, "SELECT f_call_rs()") + var me *driver.MySQLError + if !errors.As(execErr, &me) || me.Number != 1415 { + t.Fatalf("test premise wrong: want SELECT f_call_rs() to fail with 1415, got %v", execErr) + } + + s, err := schema.Load(callResultSetSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + r := s.RoutineOf(schema.Function, "f_call_rs") + if r == nil { + t.Fatal("routine not loaded") + } + if _, err := AnalyzeRoutine(s, r); errCode(err) != 1415 { + t.Errorf("AnalyzeRoutine(f_call_rs) = %v, want a 1415 Error (every call to it fails: it CALLs a procedure that returns a result set)", err) + } +} + +// --------------------------------------------------------------------------------------- +// Finding 5: a bare RESIGNAL (no condition, no SET) written outside any active HANDLER -- +// directly in a routine's own top-level block, not inside a HANDLER's body -- creates fine +// (the grammar allows RESIGNAL anywhere a statement is allowed) but fails every single +// execution with 1645 ("RESIGNAL when handler not active", measured below). walkSignal's +// RESIGNAL branch (body.go:955-959) reads a.handlerRaise (empty outside a handler body, set +// only by absorb, body.go:791) and re-raises whatever it holds -- nothing, here -- instead +// of recognizing that a RESIGNAL reached outside any handler is itself certain to raise +// 1645. Suspect: check/mysql/internal/analyze/body.go's walkSignal (body.go:943-992), which +// has no notion of "no handler is currently active" to report 1645 by, and AnalyzeRoutine / +// AnalyzeTrigger, which consequently see no error and no Violation for this body at all. +func TestAdv2BareResignalOutsideHandlerIs1645(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const bareResignalSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, v INT NOT NULL); +CREATE PROCEDURE p_bare_resignal() +BEGIN + RESIGNAL; +END; +` + db, err := mysqltest.Start(ctx, bareResignalSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + + // the server: CREATE PROCEDURE succeeds, but CALL p_bare_resignal() fails every time + // with 1645 -- measured against mysqld 8.4.11 + _, execErr := db.Conn().ExecContext(ctx, "CALL p_bare_resignal()") + var me *driver.MySQLError + if !errors.As(execErr, &me) || me.Number != 1645 { + t.Fatalf("test premise wrong: want CALL p_bare_resignal() to fail with 1645, got %v", execErr) + } + + s, err := schema.Load(bareResignalSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + br, err := AnalyzeRoutine(s, s.RoutineOf(schema.Procedure, "p_bare_resignal")) + if err != nil { + t.Fatal(err) + } + if got := violationKeys(br.Violations); got != "1645 1645" { + t.Errorf("AnalyzeRoutine(p_bare_resignal).Violations = %q, want \"1645 1645\" (a RESIGNAL outside any HANDLER fails every call)", got) + } +} diff --git a/check/mysql/internal/analyze/adv2_calls_test.go b/check/mysql/internal/analyze/adv2_calls_test.go new file mode 100644 index 0000000..23ff3f4 --- /dev/null +++ b/check/mysql/internal/analyze/adv2_calls_test.go @@ -0,0 +1,407 @@ +package analyze + +// Adversarial pass 2 (calls lane): probes check/mysql/internal/analyze/call.go and its +// surroundings (expr.go's storedFuncCall, body.go's walkCall/AnalyzeRoutine, schema.go's +// column/DEFAULT/CHECK reading) against a running mysqld 8.4. See +// scratchpad/adv2/brief.md for the rules this pass follows: every finding is measured +// against a real server, not guessed. Findings are written as tests that state the CORRECT +// (server-measured) behaviour and currently FAIL against the checker as it stands today. + +import ( + "context" + "errors" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +func adv2ServerCode(err error) int { + if err == nil { + return 0 + } + var me *driver.MySQLError + if errors.As(err, &me) { + return int(me.Number) + } + return -1 +} + +// --- finding 1: a procedure that CALLs itself is certain to fail at every actual recursion, +// undetected ----------------------------------------------------------------------------- + +const adv2RecursiveCallSchema = `-- sqlshape: mysql 8.4 +CREATE PROCEDURE p_self(IN n INT) +BEGIN + IF n > 0 THEN + CALL p_self(n - 1); + END IF; +END; +` + +// TestAdv2RecursiveCallAlwaysFails: mysqld's max_sp_recursion_depth defaults to 0, so ANY +// actual recursive invocation of a stored routine -- even a single level -- is refused at +// run time with 1456 ("Recursive limit 0 ... was exceeded"), measured: `CALL p_self(1)` +// (whose body takes the recursive branch exactly once) raises 1456 every time, unlike a +// non-recursive CALL. +// +// The checker has no notion of this at all: AnalyzeRoutine's own cycle-breaking cache +// (body.go around line 375-394, "a cycle ... is broken here") exists only to stop the +// *checker itself* from looping forever walking p_self's body; the in-progress placeholder +// it returns on the recursive Load (body.go line 376-383) is silently treated by +// checkCalledRoutineOverlap (call.go line 56, via the AnalyzeRoutine call at call.go line +// 83) and finishCalls (body.go line 495, via the AnalyzeRoutine call at body.go line 504) as +// "no writes, no error" rather than "this branch always fails" -- so `CALL p_self(1)` +// analyzes clean, with no Violation and no Error, even though the branch it is certain to +// take (n=1 > 0) is certain to raise 1456 on the real server. +func TestAdv2RecursiveCallAlwaysFails(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, adv2RecursiveCallSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + s, err := schema.Load(adv2RecursiveCallSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + conn := db.Conn() + + // server: measured 1456 for a CALL whose branch recurses exactly once + _, execErr := conn.ExecContext(ctx, "CALL p_self(1)") + if adv2ServerCode(execErr) != 1456 { + t.Fatalf("CALL p_self(1) on the server: got %v, want 1456 (max_sp_recursion_depth=0)", execErr) + } + + // checker: predicts nothing wrong (r.Facts is Kind Call, no Violations, err nil) -- it + // should instead treat the recursive branch as certain to fail, the same way it already + // treats a trigger writing its own table (1442) as certain rather than a "may" + r, cerr := Analyze(s, "CALL p_self(1)") + if cerr == nil && len(r.Violations) == 0 { + t.Errorf("Analyze(CALL p_self(1)) = %+v, %v -- want a predicted failure (1456), since the server always raises it for this call", r, cerr) + } +} + +// --- finding 2: NEW.col is a valid OUT/INOUT argument in a BEFORE trigger, and the checker +// rejects it unconditionally ----------------------------------------------------------- + +const adv2NewOutArgSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, v INT); + +CREATE PROCEDURE p_out(OUT o INT) +BEGIN + SET o = 1; +END; + +CREATE TRIGGER t_bi BEFORE INSERT ON t FOR EACH ROW +BEGIN + CALL p_out(NEW.v); +END; +` + +// TestAdv2NewPseudoVariableAsOutArgInBeforeTrigger: mysqld accepts NEW.col as an OUT (or +// INOUT) argument of a CALL inside a BEFORE trigger -- the server's own 1414 message even +// names the exception ("... is not a variable or NEW pseudo-variable in BEFORE trigger"), +// and `INSERT INTO t VALUES (1, 5)` firing t_bi succeeds with no error (measured). +// +// The checker's isCallVariableTarget (call.go line 120) only recognizes a placeholder +// (isParam), a `@user` variable (PTI_user_variable) and a declared local +// variable/parameter (PTI_simple_ident_ident / PTI_simple_ident_nospvar_ident through +// a.lookupVar) as valid OUT/INOUT targets; a qualified NEW.v reference is none of those, so +// both walkCall (body.go line 1096, the check at line 1113) and callStmt's own copy (call.go +// line 150, the check inside its args loop) fall through and report 1414 "OUT or INOUT +// argument 1 for routine p_out is not a variable" for a CREATE TRIGGER body the server +// accepts outright: a valid schema is flagged as broken (AnalyzeTrigger returns a non-nil +// error), the false-positive direction the brief calls out as high severity. +func TestAdv2NewPseudoVariableAsOutArgInBeforeTrigger(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, adv2NewOutArgSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + s, err := schema.Load(adv2NewOutArgSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + conn := db.Conn() + + // server: the INSERT (which fires t_bi, whose body passes NEW.v as p_out's OUT arg) + // succeeds outright + if _, execErr := conn.ExecContext(ctx, "INSERT INTO t VALUES (1, 5)"); execErr != nil { + t.Fatalf("INSERT INTO t VALUES (1, 5) on the server: got %v, want no error (NEW.v is a valid OUT arg in a BEFORE trigger)", execErr) + } + + // checker: AnalyzeTrigger on t_bi should report no error for a body the server accepts + if _, aerr := AnalyzeTrigger(s, s.Trigger("t_bi")); aerr != nil { + t.Errorf("AnalyzeTrigger(t_bi) = %v, want nil -- checker rejects NEW.v as an OUT argument even though the server accepts it in a BEFORE trigger", aerr) + } +} + +// --- finding 3: a stored FUNCTION named in a generated column, a column DEFAULT expression +// or a CHECK constraint is refused by the server at CREATE TABLE time, unconditionally (not +// only when non-deterministic) -- schema.Load lets it straight through with no Problem ---- + +const adv2FuncInGeneratedSchema = `-- sqlshape: mysql 8.4 +CREATE FUNCTION f_det(x INT) RETURNS INT DETERMINISTIC +BEGIN + RETURN x + 1; +END; + +CREATE TABLE t_gen ( + id INT NOT NULL PRIMARY KEY, + v INT, + g INT GENERATED ALWAYS AS (f_det(v)) VIRTUAL +); +` + +const adv2FuncInDefaultSchema = `-- sqlshape: mysql 8.4 +CREATE FUNCTION f_det(x INT) RETURNS INT DETERMINISTIC +BEGIN + RETURN x + 1; +END; + +CREATE TABLE t_def ( + id INT NOT NULL PRIMARY KEY, + v INT DEFAULT (f_det(1)) +); +` + +const adv2FuncInCheckSchema = `-- sqlshape: mysql 8.4 +CREATE FUNCTION f_det(x INT) RETURNS INT DETERMINISTIC +BEGIN + RETURN x + 1; +END; + +CREATE TABLE t_chk ( + id INT NOT NULL PRIMARY KEY, + v INT, + CHECK (f_det(v) > 0) +); +` + +// TestAdv2StoredFunctionDisallowedInGeneratedDefaultCheck: measured on mysqld 8.4, a +// GENERATED ALWAYS AS expression naming any stored FUNCTION -- DETERMINISTIC or not -- is +// refused at CREATE TABLE time with 3763 ("Expression of generated column 'g' contains a +// disallowed function: `f_det`"); a column DEFAULT (expr) naming one is refused with 3770 +// ("Default value expression of column 'v' contains a disallowed function"); a CHECK +// constraint naming one is refused with 3814 ("An expression of a check constraint ... +// contains disallowed function"). All three are CREATE-time refusals docs/mysql.md already +// promises the checker reports as schema Problems ("What the server itself refuses at +// CREATE time is a problem the same way an unknown table is"). +// +// schema.go's column() (around line 687) reads col.Generated (line 704), col.Default (lines +// 721-723) and a table's Check.Expr (line 741) as bare AST with no validation at all of what +// they reference; nothing in the loader walks those expressions looking for a stored +// FUNCTION call. All three schemas here load with zero Problems even though mysqld refuses +// every one of them at CREATE TABLE time. +func TestAdv2StoredFunctionDisallowedInGeneratedDefaultCheck(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + cases := []struct { + name string + schema string + wantErr int + }{ + {"generated column", adv2FuncInGeneratedSchema, 3763}, + {"column DEFAULT expression", adv2FuncInDefaultSchema, 3770}, + {"CHECK constraint", adv2FuncInCheckSchema, 3814}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + db, err := mysqltest.Start(ctx, c.schema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + // the server refuses the CREATE TABLE itself, so mysqltest.Start fails with the + // server's own error text + if err == nil { + db.Close() + t.Fatalf("mysqltest.Start(%s) succeeded, want the server to refuse CREATE TABLE with %d", c.name, c.wantErr) + } + var me *driver.MySQLError + if !errors.As(err, &me) || int(me.Number) != c.wantErr { + t.Fatalf("mysqltest.Start(%s): got %v, want MySQL error %d", c.name, err, c.wantErr) + } + + s, lerr := schema.Load(c.schema) + if lerr != nil { + t.Fatalf("schema.Load(%s): unexpected error %v", c.name, lerr) + } + if len(s.Problems) == 0 { + t.Errorf("schema.Load(%s): 0 Problems, want a Problem reporting the server's %d refusal of a stored function in this position", c.name, c.wantErr) + } + }) + } +} + +// --- finding 4: a called routine's write cascades through another table's own trigger back +// onto a table the calling statement references, and the 1442 check misses it ------------ + +const adv2CascadingWriteSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE a_tbl (id INT NOT NULL PRIMARY KEY, v INT); +CREATE TABLE b_tbl (id INT NOT NULL PRIMARY KEY, v INT); + +CREATE FUNCTION f_write_b(x INT) RETURNS INT +BEGIN + UPDATE b_tbl SET v = x WHERE id = 1; + RETURN x; +END; + +CREATE TRIGGER b_au AFTER UPDATE ON b_tbl FOR EACH ROW +BEGIN + UPDATE a_tbl SET v = NEW.v WHERE id = 1; +END; +` + +// TestAdv2CalledRoutineWriteCascadesThroughTriggerTo1442: f_write_b's own body only writes +// b_tbl directly, but b_tbl's own AFTER UPDATE trigger (b_au) writes a_tbl in turn -- +// measured: `SELECT f_write_b(v) FROM a_tbl WHERE id = 1` (a statement that only reads +// a_tbl and calls f_write_b) raises 1442 on the real server ("Can't update table 'a_tbl' ... +// because it is already used by statement which invoked this stored function/trigger"), +// the same collision docs/mysql.md documents for a routine that writes the referenced table +// directly -- it is exactly as certain here, since b_au always fires on b_tbl's UPDATE. +// +// checkCalledRoutineOverlap (call.go line 56) computes the collision from +// AnalyzeRoutine(...).WriteTables alone (call.go lines 82-90), and WriteTables (body.go +// line 30, populated at body.go lines 1264-1273 and folded transitively through nested CALLs +// at body.go lines 503-511) only ever grows from a body's own embedded DML and from routines +// it CALLs or RETURNs -- never from a trigger AnalyzeRoutine's own writes fire in turn. So +// f_write_b's BodyResult.WriteTables is ["b_tbl"] only, a_tbl never enters it, and the +// checker predicts no error at all for a statement the server always refuses. +func TestAdv2CalledRoutineWriteCascadesThroughTriggerTo1442(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, adv2CascadingWriteSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + s, err := schema.Load(adv2CascadingWriteSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + conn := db.Conn() + if _, err := conn.ExecContext(ctx, "INSERT INTO a_tbl VALUES (1, 0)"); err != nil { + t.Fatal(err) + } + if _, err := conn.ExecContext(ctx, "INSERT INTO b_tbl VALUES (1, 0)"); err != nil { + t.Fatal(err) + } + + const sqlText = "SELECT f_write_b(v) FROM a_tbl WHERE id = 1" + + // server: measured 1442, through the cascade b_tbl -> b_au trigger -> a_tbl + rows, execErr := conn.QueryContext(ctx, sqlText) + if execErr == nil { + for rows.Next() { + } + execErr = rows.Err() + } + if adv2ServerCode(execErr) != 1442 { + t.Fatalf("%s on the server: got %v, want 1442 (f_write_b's write to b_tbl fires b_au, which writes a_tbl)", sqlText, execErr) + } + + // checker: should predict the same certain failure + if _, cerr := Analyze(s, sqlText); cerr == nil { + t.Errorf("Analyze(%s) = nil error, want a 1442 Error: the checker misses the write that cascades from f_write_b's own UPDATE of b_tbl, through b_au, into a_tbl", sqlText) + } else if ae, ok := cerr.(*Error); !ok || ae.Code != 1442 { + t.Errorf("Analyze(%s) = %v, want a 1442 Error", sqlText, cerr) + } +} + +// --- finding 5: a CALL of a procedure that itself CALLs another procedure producing a +// result set loses that inner result set's columns ---------------------------------------- + +const adv2NestedCallResultSchema = `-- sqlshape: mysql 8.4 +CREATE PROCEDURE q_inner(IN a INT) +BEGIN + SELECT a AS x, a * 2 AS y; +END; + +CREATE PROCEDURE p_outer(IN a INT) +BEGIN + CALL q_inner(a); +END; +` + +// TestAdv2NestedCallResultColumnsPropagate: mysqld propagates a nested CALL's own result set +// out through every enclosing CALL exactly as if the inner SELECT sat directly in the +// outer body -- measured: `CALL p_outer(5)` returns q_inner's two columns (x, y) to the +// client, the same as calling q_inner directly would. +// +// resultColumnsOf (call.go line 196) only ever looks at br.Statements (populated by +// appendStatement / appendStatementCols, body.go's walkSelect around lines 1200 and 1224): +// walkCall (body.go line 1096) types the nested CALL's arguments and folds its callee's +// writes and failure modes into the body through finishCalls (body.go line 1124), but never +// appends q_inner's own result columns as one of p_outer's br.Statements entries -- so +// callStmt's own resultColumnsOf(br) for p_outer sees zero Columns-bearing statements and +// reports "no result columns" (ok=true, cols=nil) for a CALL the server actually returns two +// columns from. +func TestAdv2NestedCallResultColumnsPropagate(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, adv2NestedCallResultSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + s, err := schema.Load(adv2NestedCallResultSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + conn := db.Conn() + + // server: measured 2 result columns (x, y) from CALL p_outer(5), via the nested CALL + rows, execErr := conn.QueryContext(ctx, "CALL p_outer(?)", 5) + if execErr != nil { + t.Fatalf("CALL p_outer(5) on the server: %v", execErr) + } + cts, err := rows.ColumnTypes() + rows.Close() + if err != nil { + t.Fatal(err) + } + if len(cts) != 2 { + t.Fatalf("server returned %d columns for CALL p_outer(5), want 2 (q_inner's x, y)", len(cts)) + } + + // checker: predicts no result columns at all for the same CALL + r, cerr := Analyze(s, "CALL p_outer($1)") + if cerr != nil { + t.Fatal(cerr) + } + if len(r.Columns) != 2 { + t.Errorf("Analyze(CALL p_outer($1)).Columns = %v (%d columns), want 2 -- the nested CALL to q_inner's own result set should propagate out", r.Columns, len(r.Columns)) + } +} diff --git a/check/mysql/internal/analyze/adv2_failure_test.go b/check/mysql/internal/analyze/adv2_failure_test.go new file mode 100644 index 0000000..895e238 --- /dev/null +++ b/check/mysql/internal/analyze/adv2_failure_test.go @@ -0,0 +1,283 @@ +package analyze + +// Adversarial pass 2 (failure lane): probes check/mysql/internal/analyze's failure-mode +// enumeration (violations.go), trigger-driven failure modes, and the runtime's SIGNAL / +// constraint key mapping (mysql/errors.go), against a running mysqld 8.4. See +// scratchpad/adv2/brief.md for the rules this pass follows: every finding is measured +// against a real server, not guessed. Findings are written as tests that state the CORRECT +// (server-measured) behaviour and currently FAIL against the checker as it stands today. + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "testing" + "time" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +func adv2Keys(vs []Violation) string { + var keys []string + for _, v := range vs { + keys = append(keys, fmt.Sprintf("%d %s", v.Code, v.Key())) + } + sort.Strings(keys) + return strings.Join(keys, ", ") +} + +// --- finding 1: a composite UNIQUE key is only recognized as "a NULL leaves it alone" when +// EVERY one of its columns is left to default to NULL (violations.go's leftNull) ----------- + +const adv2CompositeNullSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE widgets ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + a INT NOT NULL, + b INT, + UNIQUE KEY uq_a_b (a, b) +); +` + +// TestAdv2CompositeUniqueKeyLeftPartiallyNull: docs/mysql.md's own constraint table says a +// UNIQUE key "a NULL leaves alone... cannot be violated" -- MySQL's multi-column UNIQUE key +// semantics are that a NULL in ANY of its columns takes the whole tuple out of duplicate +// checking, not only when every column is NULL. Measured: two rows sharing the same `a` +// with `b` left NULL both times insert without error (1062 never raised). +// +// violations.go's leftNull requires every column of the key to be always-NULL to skip the +// 1062 prediction (it returns false as soon as any one column, here `a`, is inserted), so +// insertViolations lists "1062 uq_a_b" for an INSERT that in fact can never violate it. +// Suspect: check/mysql/internal/analyze/violations.go leftNull (around line 256) and its use +// in insertViolations (the `continue` guard around line 211) only skip the whole key when +// ALL of cols pass the always-NULL test, instead of skipping when ANY column does. +func TestAdv2CompositeUniqueKeyLeftPartiallyNull(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, adv2CompositeNullSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + s, err := schema.Load(adv2CompositeNullSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + + r, err := Analyze(s, "INSERT INTO widgets (id, a) VALUES ($1, $2)") + if err != nil { + t.Fatal(err) + } + if got := adv2Keys(r.Violations); strings.Contains(got, "1062 uq_a_b") { + t.Errorf("checker predicts 1062 uq_a_b for an INSERT that always leaves b NULL: %s (want no 1062 uq_a_b: a NULL in any column of a composite UNIQUE key exempts the row)", got) + } + + conn := db.Conn() + for i, a := range []int{1, 1, 1} { + if _, err := conn.ExecContext(ctx, "INSERT INTO widgets (id, a) VALUES (?, ?)", i+1, a); err != nil { + t.Fatalf("insert #%d (a=%d, b left NULL): server rejected a row the composite key should let through: %v", i+1, a, err) + } + } +} + +// --- finding 2: an omitted NOT NULL column with no DEFAULT is not predicted at all ---------- + +const adv2NoDefaultSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE items ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + name VARCHAR(50) NOT NULL, + qty INT NOT NULL +); +` + +// TestAdv2OmittedNotNullColumnNoDefault: an INSERT that simply leaves a NOT NULL column with +// no DEFAULT out of its column list is a common mistake -- measured on mysqld 8.4 (default +// strict mode) as error 1364 "Field 'qty' doesn't have a default value", not 1048 (which the +// checker only predicts for a column the statement actually stores a possibly-NULL +// expression into). notNullViolations (violations.go, iterating w.values) never sees an +// omitted column at all, so the checker predicts no failure mode whatsoever for a statement +// that mysqld always rejects. +func TestAdv2OmittedNotNullColumnNoDefault(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, adv2NoDefaultSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + s, err := schema.Load(adv2NoDefaultSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + + sql := "INSERT INTO items (id, name) VALUES ($1, $2)" + r, err := Analyze(s, sql) + if err != nil { + t.Fatal(err) + } + got := adv2Keys(r.Violations) + if !strings.Contains(got, "1364 items.qty") { + t.Errorf("checker predicts %q for %s (omits NOT NULL qty with no default; qty is never mentioned), want a 1364 items.qty failure mode among them (measured on mysqld)", got, sql) + } + + conn := db.Conn() + _, execErr := conn.ExecContext(ctx, "INSERT INTO items (id, name) VALUES (?, ?)", 1, "widget") + if execErr == nil { + t.Fatal("server accepted an INSERT omitting a NOT NULL column with no default -- test schema/assumption is wrong") + } + if !strings.Contains(execErr.Error(), "1364") { + t.Fatalf("expected 1364, server said: %v", execErr) + } +} + +// --- finding 3: a CHECK on a generated column is not predicted for a write to the source +// column the generated column is computed from ------------------------------------------- + +const adv2GeneratedCheckSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE prices ( + id INT NOT NULL PRIMARY KEY, + cents INT NOT NULL, + dollars INT AS (cents / 100) STORED, + CONSTRAINT chk_dollars CHECK (dollars < 1000) +); +` + +// TestAdv2GeneratedColumnCheckViaSourceColumn: `dollars` is a STORED generated column +// computed from `cents`; `chk_dollars` names `dollars`, not `cents`. Measured: INSERT INTO +// prices (id, cents) VALUES (1, 200000) raises 3819 on chk_dollars (the server recomputes +// the generated column and re-checks CHECK on every write that can change it, whether or +// not the statement names the generated column itself). +// +// violations.go's exprColumns (used by both insertViolations and updateViolations) walks +// only the literal column names chk_dollars's own expression spells (`dollars`), and +// anyIn(cols, w.inserted) then looks for `dollars` in the statement's own written columns +// (`id`, `cents`) -- never present, since a generated column is never itself assigned. The +// checker never expands a CHECK's column set through schema.Column.Generated to the source +// columns a generated column reads, so it predicts nothing at all for an INSERT/UPDATE that +// in fact always risks 3819 through the column it actually wrote. +func TestAdv2GeneratedColumnCheckViaSourceColumn(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, adv2GeneratedCheckSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + s, err := schema.Load(adv2GeneratedCheckSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + + sql := "INSERT INTO prices (id, cents) VALUES ($1, $2)" + r, err := Analyze(s, sql) + if err != nil { + t.Fatal(err) + } + if got := adv2Keys(r.Violations); !strings.Contains(got, "3819 chk_dollars") { + t.Errorf("checker predicts %s for %s, want 3819 chk_dollars among them (writing cents can trip the CHECK on the generated dollars column, measured)", got, sql) + } + + conn := db.Conn() + _, execErr := conn.ExecContext(ctx, "INSERT INTO prices (id, cents) VALUES (?, ?)", 1, 200000) + if execErr == nil { + t.Fatal("server accepted a cents value that should trip chk_dollars through the generated column -- test schema/assumption is wrong") + } + if !strings.Contains(execErr.Error(), "3819") { + t.Fatalf("expected 3819, server said: %v", execErr) + } +} + +// --- finding 4: a trigger chain across two different tables that loops back into the +// original table is never predicted as 1442, unlike a trigger writing its own table ------- + +const adv2ChainedTableReuseSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE x ( + id INT NOT NULL PRIMARY KEY +); +CREATE TABLE y ( + id INT NOT NULL PRIMARY KEY +); +CREATE TRIGGER x_bi BEFORE INSERT ON x FOR EACH ROW +BEGIN + INSERT INTO y (id) VALUES (NEW.id + 100); +END; +CREATE TRIGGER y_bi BEFORE INSERT ON y FOR EACH ROW +BEGIN + INSERT INTO x (id) VALUES (NEW.id + 100); +END; +` + +// TestAdv2ChainedTriggerTableReuseIs1442: MySQL's "table already in use" restriction (1442) +// is not limited to a trigger writing its OWN table directly -- it covers every table +// already in use anywhere up the invoking statement's chain. Measured: INSERT INTO x fires +// x_bi (BEFORE INSERT ON x), which inserts into y, which fires y_bi (BEFORE INSERT ON y), +// which inserts into x -- table x is already in use by the original INSERT INTO x, and +// mysqld always raises 1442 on x here, even though neither trigger's own table (x for +// x_bi, y for y_bi) is the one its own body writes. +// +// The checker's model only catches the direct case: walkDML's ownTableWrite (body.go, +// around line 1298) compares a write's table against a.trigTable, the CURRENT trigger +// being walked -- when AnalyzeTrigger(y_bi) walks "INSERT INTO x", a.trigTable is y, not x, +// so ownTableWrite says false and the write is treated as an ordinary embedded INSERT +// instead of a runtime-certain 1442. Since AnalyzeTrigger caches and analyzes each trigger's +// body independently (body.go's bodyCache), nothing threads "the set of tables already in +// use by the statement so far" through triggerViolations' cross-trigger recursion +// (violations.go's triggerViolations / triggerFailureModes), so Analyze predicts nothing +// for the INSERT INTO x that mysqld always refuses. +func TestAdv2ChainedTriggerTableReuseIs1442(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, adv2ChainedTableReuseSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + s, err := schema.Load(adv2ChainedTableReuseSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + + sql := "INSERT INTO x (id) VALUES ($1)" + r, err := Analyze(s, sql) + if err != nil { + t.Fatal(err) + } + if got := adv2Keys(r.Violations); !strings.Contains(got, "1442") { + t.Errorf("checker predicts %s for %s, want a 1442 failure mode among them (the x -> y -> x trigger chain always fails at run time on mysqld, measured)", got, sql) + } + + conn := db.Conn() + _, execErr := conn.ExecContext(ctx, "INSERT INTO x (id) VALUES (?)", 1) + if execErr == nil { + t.Fatal("server accepted an insert that should always fail through the chained trigger's table reuse -- test schema/assumption is wrong") + } + if !strings.Contains(execErr.Error(), "1442") { + t.Fatalf("expected 1442, server said: %v", execErr) + } +} diff --git a/check/mysql/internal/analyze/adv2_null_test.go b/check/mysql/internal/analyze/adv2_null_test.go new file mode 100644 index 0000000..9f918f3 --- /dev/null +++ b/check/mysql/internal/analyze/adv2_null_test.go @@ -0,0 +1,190 @@ +package analyze + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/oracle" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// adv2_null_test.go: adversarial findings for the "nulltype" lane (result column +// nullability/types, second round): each test was confirmed to disagree with a real +// mysqld (nix-shell -p mysql84) before being written down, and is written as the behavior +// sqlshape should have, so it currently fails. Round 1's adv_null_test.go findings are not +// retested here. + +const adv2NullSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE a ( + id INT NOT NULL PRIMARY KEY, + n INT NULL +); +` + +func adv2NullLoad(t *testing.T) *schema.Schema { + t.Helper() + s, err := schema.Load(adv2NullSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + return s +} + +func startAdv2NullOracle(t *testing.T) (context.Context, *oracle.Oracle) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + o, err := oracle.Start(ctx, adv2NullSchema) + if errors.Is(err, oracle.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { o.Close() }) + return ctx, o +} + +// TestAdv2NthValueWindowFunctionParses: NTH_VALUE(expr, n) OVER (...) is a real MySQL 8.4 +// window function (added in 8.0), grammatically registered in the reader +// (mysqlparse/shapes.go: "NTH_VALUE_SYM '(' expr ',' simple_expr ')' ..." with Kind: +// ActUnknown, meaning the reader treats it as an unmodelled construct rather than building +// a node the analyzer can walk). A real server accepts and types it (confirmed against +// mysqld 8.4: BIGINT, nullable), but sqlshape rejects the whole statement with "unsupported +// construct" -- spuriously flagging valid application SQL, the same failure mode as round +// 1's JSON `->` finding (mysqlparse/shapes.go:2479). +// +// repro: SELECT NTH_VALUE(id, 2) OVER (ORDER BY id) FROM a +// server: accepts it, types the column BIGINT, nullable +// sqlshape: rejects it (mysqlast: unsupported construct window_func_call/10) +func TestAdv2NthValueWindowFunctionParses(t *testing.T) { + ctx, o := startAdv2NullOracle(t) + s := adv2NullLoad(t) + const sql = "SELECT NTH_VALUE(id, 2) OVER (ORDER BY id) FROM a" + + if _, err := o.Describe(ctx, sql); err != nil { + t.Fatalf("test setup: server rejects %s: %v", sql, err) + } + if _, err := Analyze(s, sql); err != nil { + t.Errorf("%s: analyzer rejects what the server accepts: %v", sql, err) + } +} + +// TestAdv2LeadLagFirstLastValueUntyped: LAG / LEAD / FIRST_VALUE / LAST_VALUE are ordinary +// MySQL 8.4 window functions the reader does build a node for (Item_lead_lag, +// Item_first_last_value: hooks_dml.go / views.go), and a real server always types them +// (confirmed against mysqld 8.4: BIGINT for LAG/LEAD over an INT argument, nullable unless +// a non-NULL default value is given). Item_lead_lag has no entry in +// catalog.Items (check/mysql/internal/catalog/functions.go), so node()'s generic dispatch +// (expr.go's "any other Item class" branch) finds no catalog entry and leaves the column +// untyped; Item_first_last_value does have an entry (Facts: set_nullable(true), +// set_data_type_from_item(args[0])) but classType (expr.go) has no case that reads +// set_data_type_from_item to type it from its own argument, so it too stays untyped. A +// column following the previous/next/first/last row of an ORDER BY -- a common pattern for +// deltas and running comparisons -- is one of the few remaining window-function families +// sqlshape cannot type at all, unlike ROW_NUMBER/RANK/NTILE/SUM/AVG OVER, which it already +// gets right. +// +// repro: SELECT LAG(id) OVER (ORDER BY id), LEAD(id) OVER (ORDER BY id), +// FIRST_VALUE(id) OVER (ORDER BY id), LAST_VALUE(id) OVER (ORDER BY id) FROM a +// server: every column BIGINT (LAG/LEAD) or BIGINT (FIRST_VALUE/LAST_VALUE over an INT), nullable +// sqlshape: every column left untyped (Known=false) +func TestAdv2LeadLagFirstLastValueUntyped(t *testing.T) { + ctx, o := startAdv2NullOracle(t) + s := adv2NullLoad(t) + sqls := []string{ + "SELECT LAG(id) OVER (ORDER BY id) FROM a", + "SELECT LEAD(id) OVER (ORDER BY id) FROM a", + "SELECT FIRST_VALUE(id) OVER (ORDER BY id) FROM a", + "SELECT LAST_VALUE(id) OVER (ORDER BY id) FROM a", + } + for _, sql := range sqls { + d, err := o.Describe(ctx, sql) + if err != nil { + t.Fatalf("test setup: server rejects %s: %v", sql, err) + } + if d.Columns[0].Type == "" { + t.Fatalf("test setup: expected the server itself to type the column") + } + r, err := Analyze(s, sql) + if err != nil { + t.Fatalf("analyzer rejects %s: %v", sql, err) + } + if !r.Columns[0].Known { + t.Errorf("%s: analyzer leaves the column untyped, the server says %s", sql, d.Columns[0].Type) + } + } +} + +const adv2TriggerAutoIncSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE orders ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + total DECIMAL(10,2) NOT NULL +); +CREATE TABLE audit_log ( + order_id BIGINT UNSIGNED NOT NULL +); +CREATE TRIGGER orders_bi BEFORE INSERT ON orders FOR EACH ROW +BEGIN + INSERT INTO audit_log (order_id) VALUES (NEW.id); +END; +` + +// TestAdv2TriggerAutoIncrementNewColNotActuallyNullable: body.go's trigRowColumn makes +// every NEW.col read nullable in a BEFORE INSERT/UPDATE trigger, unconditionally +// (body.go:282-284: "nullable := !col.NotNull; if row == "NEW" && timing == "BEFORE" { +// nullable = true }"), citing that a NOT NULL column's NEW.col may still be NULL there +// (true, and correctly measured, when the column has no default and no AUTO_INCREMENT). +// But when the column is AUTO_INCREMENT, MySQL has already substituted the placeholder 0 +// before the BEFORE INSERT trigger body runs (omitted or written as NULL alike), so NEW.id +// is never actually NULL there. (A non-NULL DEFAULT is different: an explicit NULL reaches +// the body as NULL and the body's own write of it into a NOT NULL column is 1048, measured +// -- so a defaulted column's NEW.col stays nullable.) Confirmed against mysqld 8.4: `INSERT INTO orders (total) VALUES (5.00)`, which +// omits the AUTO_INCREMENT id and therefore fires the trigger with NEW.id = 0, succeeds +// without error, including the trigger's own `INSERT INTO audit_log (order_id) VALUES +// (NEW.id)` into a NOT NULL column with no default. sqlshape nonetheless predicts a 1048 +// (NOT NULL) violation on audit_log.order_id for the outer INSERT INTO orders statement -- +// a false positive that would make a passing statement look unsafe to a user relying on +// sqlshape's violation predictions. +// +// repro: INSERT INTO orders (total) VALUES (5.00) (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, +// BEFORE INSERT trigger does INSERT INTO audit_log (order_id) VALUES (NEW.id), order_id NOT NULL) +// server: the statement succeeds (NEW.id is 0, never NULL, for an omitted AUTO_INCREMENT column) +// sqlshape: predicts a 1048 NOT NULL violation on audit_log.order_id +func TestAdv2TriggerAutoIncrementNewColNotActuallyNullable(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, adv2TriggerAutoIncSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + + const sql = "INSERT INTO orders (total) VALUES (5.00)" + if _, err := db.Conn().ExecContext(ctx, sql); err != nil { + t.Fatalf("test setup: server rejects %s: %v", sql, err) + } + + s, err := schema.Load(adv2TriggerAutoIncSchema) + if err != nil { + t.Fatal(err) + } + r, err := Analyze(s, sql) + if err != nil { + t.Fatalf("analyzer rejects %s: %v", sql, err) + } + for _, v := range r.Violations { + if v.Code == 1048 && v.Table == "audit_log" { + t.Errorf("%s: analyzer predicts a NOT NULL violation on audit_log.%v, but the server runs the statement without error (NEW.id was the AUTO_INCREMENT placeholder, never NULL): %+v", sql, v.Columns, v) + } + } +} diff --git a/check/mysql/internal/analyze/adv2_settings_test.go b/check/mysql/internal/analyze/adv2_settings_test.go new file mode 100644 index 0000000..a2a96f3 --- /dev/null +++ b/check/mysql/internal/analyze/adv2_settings_test.go @@ -0,0 +1,102 @@ +package analyze + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// adv2_settings_test.go: adversarial findings for the "settings" lane (see the brief for the +// ground rules): the `-- sqlshape: server ...` declarations and how each sql_mode bit changes +// a judgment. Every case below was reproduced against a real mysqld 8.4.11 (nix-shell -p +// mysql84) before being written down; it states the correct behavior and is expected to fail +// against the current implementation. + +// TestAdv2StrictTransTablesIgnoresEngine: STRICT_TRANS_TABLES enforces strict mode only for +// transactional storage engines; the manual is explicit that for a nontransactional table +// (MyISAM) "an error occurs if the invalid data occurs in a single-row statement or the +// first row of a multiple-row statement" but a later row of a multiple-row statement gets +// the column's implicit default with a warning instead, exactly as under no strict mode at +// all. Only STRICT_ALL_TABLES enforces strict mode unconditionally, on every engine and +// every row. +// +// Measured on 8.4.11 with sql_mode='STRICT_TRANS_TABLES' and CREATE TABLE a (id INT NOT NULL +// PRIMARY KEY, s VARCHAR(20) NOT NULL) ENGINE=MyISAM: +// +// INSERT INTO a (id, s) VALUES (1, 'x'), (2, NULL) -- succeeds; row 2 stores '' with a warning +// INSERT INTO a (id, s) VALUES (1, NULL) -- Error 1048 (single row, still strict) +// +// (with sql_mode='STRICT_ALL_TABLES' on the same table, both statements give Error 1048: the +// engine makes no difference there, which the analyzer already gets right.) +// +// The analyzer does not read the table's engine at all when deciding whether a NULL into a +// NOT NULL column is a violation: `notNull := a.s.Settings.Strict() || w.rows == 1` in +// check/mysql/internal/analyze/violations.go:228 treats STRICT_TRANS_TABLES exactly like +// STRICT_ALL_TABLES (both set the same bit tested by sqlmode.Mode.Strict(), +// x/sqlmode/sqlmode.go), so it lists a NOT NULL violation for row 2 above no matter the +// table's storage engine. schema.Table already carries Engine (set from CREATE TABLE's +// ENGINE=... clause, check/mysql/internal/schema/schema.go:765), so the information needed +// to tell the two modes apart is there and simply unused. +func TestAdv2StrictTransTablesIgnoresEngine(t *testing.T) { + const schemaSQL = `-- sqlshape: mysql 8.4 +-- sqlshape: server sql_mode = 'STRICT_TRANS_TABLES' +CREATE TABLE a (id INT NOT NULL PRIMARY KEY, s VARCHAR(20) NOT NULL) ENGINE=MyISAM; +` + s, err := schema.Load(schemaSQL) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + r, err := Analyze(s, "INSERT INTO a (id, s) VALUES (1, 'x'), (2, $1)") + if err != nil { + t.Fatalf("Analyze: %v", err) + } + for _, v := range r.Violations { + if v.Code == 1048 && v.Key() == "a.s" { + t.Errorf("STRICT_TRANS_TABLES on a MyISAM table: got a NOT NULL violation for the "+ + "second row of a multi-row INSERT, but a real mysqld 8.4.11 accepts it (stores "+ + "the column's implicit default with a warning): %+v", r.Violations) + } + } +} + +// TestAdv2StrictTransTablesServer is the server-side confirmation of the case above: a fresh +// mysqld started under sql_mode='STRICT_TRANS_TABLES' with the MyISAM table accepts the +// second row's NULL (no error), while the same statement on a MyISAM table under +// STRICT_ALL_TABLES, and a single-row INSERT under STRICT_TRANS_TABLES, are both rejected +// with 1048 -- so the divergence is specific to STRICT_TRANS_TABLES, a non-transactional +// engine, and a row after the first. +func TestAdv2StrictTransTablesServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + const schemaSQL = `-- sqlshape: mysql 8.4 +-- sqlshape: server sql_mode = 'STRICT_TRANS_TABLES' +CREATE TABLE a (id INT NOT NULL PRIMARY KEY, s VARCHAR(20) NOT NULL) ENGINE=MyISAM; +` + db, err := mysqltest.Start(ctx, schemaSQL) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + + if _, err := conn.ExecContext(ctx, "INSERT INTO a (id, s) VALUES (1, 'x'), (2, NULL)"); err != nil { + t.Errorf("multi-row INSERT, 2nd row NULL, MyISAM, STRICT_TRANS_TABLES: want success, got %v", err) + } + if _, err := conn.ExecContext(ctx, "DELETE FROM a"); err != nil { + t.Fatal(err) + } + if _, err := conn.ExecContext(ctx, "INSERT INTO a (id, s) VALUES (1, NULL)"); err == nil || !strings.Contains(err.Error(), "1048") { + t.Errorf("single-row INSERT, NULL, MyISAM, STRICT_TRANS_TABLES: want Error 1048, got %v", err) + } +} diff --git a/check/mysql/internal/analyze/adv3_body_test.go b/check/mysql/internal/analyze/adv3_body_test.go new file mode 100644 index 0000000..e2dc2ca --- /dev/null +++ b/check/mysql/internal/analyze/adv3_body_test.go @@ -0,0 +1,174 @@ +package analyze + +// Adversarial round 3, lane "newcode": round 2 fixed one form of "the same name declared +// twice in one block" -- DECLARE ... of a variable (1331, TestAdv2DuplicateVariableRefusedByServer, +// body.go's declareVar). MySQL raises the identical shape of error for a DECLARE ... +// CONDITION FOR, a DECLARE ... CURSOR FOR, and a DECLARE ... HANDLER FOR whose own condition +// set overlaps another handler's in the same block -- three siblings of the fixed case, each +// with its own error number, and none of the three registration paths that grew alongside +// declareVar (declareCondition, declareCursor, walkBlock's own handler collection) got the +// same duplicate check. See scratchpad/adv3/brief-newcode.md and +// scratchpad/adv3/adv3-newcode-report.md. + +import ( + "context" + "errors" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +const adv3DupDeclSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, v INT NOT NULL); +` + +// --------------------------------------------------------------------------------------- +// Finding: two DECLARE ... CONDITION FOR of the same name in one block are refused by the +// server at CREATE time (1332 "Duplicate condition: cond1", measured below) -- the +// CONDITION sibling of the DECLARE variable case round 2 already fixed (1331, +// TestAdv2DuplicateVariableRefusedByServer). declareCondition (body.go) writes straight into +// a.vars.conds[name] with no check for an existing entry (unlike declareVar's own dup check, +// added for 1331), so two DECLARE ... CONDITION FORs of the same name silently shadow each +// other in the checker's bookkeeping instead of being reported. Suspect: +// check/mysql/internal/analyze/body.go's declareCondition and walkDecl's "sp_decl_condition" +// case, which never compares against what a.vars.conds already holds for the current block. +func TestAdv3DuplicateConditionRefusedByServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const dupCondSchema = adv3DupDeclSchema + ` +CREATE PROCEDURE p_dupcond() +BEGIN + DECLARE cond1 CONDITION FOR SQLSTATE '45000'; + DECLARE cond1 CONDITION FOR SQLSTATE '42000'; +END; +` + // the server: two DECLARE ... CONDITION FORs of "cond1" in the same block are refused + // outright (1332), measured against mysqld 8.4.11 + _, err := mysqltest.Start(ctx, dupCondSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + var me *driver.MySQLError + if !errors.As(err, &me) || me.Number != 1332 { + t.Fatalf("test premise wrong: want the server to refuse this schema with 1332, got %v", err) + } + + s, err := schema.Load(dupCondSchema) + if err != nil { + t.Fatalf("schema.Load: %v", err) + } + if len(s.Problems) == 0 { + t.Errorf("schema.Load reports no problems for a procedure the server refuses at CREATE time with 1332 (duplicate condition); want a problem naming it") + } + r := s.RoutineOf(schema.Procedure, "p_dupcond") + if r == nil { + t.Fatal("routine not loaded") + } + if _, err := AnalyzeRoutine(s, r); errCode(err) != 1332 { + t.Errorf("AnalyzeRoutine(p_dupcond) = %v, want a 1332 Error (the server refuses two DECLARE ... CONDITION FORs of the same name in one block)", err) + } +} + +// --------------------------------------------------------------------------------------- +// Finding: two DECLARE ... CURSOR FOR of the same name in one block are refused by the +// server at CREATE time (1333 "Duplicate cursor: cur1", measured below) -- the CURSOR +// sibling of the same 1331 case. declareCursor (body.go) writes straight into +// a.vars.cursors[name] with no check for an existing entry either. Suspect: +// check/mysql/internal/analyze/body.go's declareCursor and walkDecl's "sp_decl_cursor" case. +// +// (Measured separately, not a finding: a variable and a cursor sharing the same name in the +// same block -- `DECLARE cur1 INT; DECLARE cur1 CURSOR FOR SELECT ...;` -- is accepted by the +// server without error: variables and cursors are different namespaces, and the checker +// already keeps them in separate maps (a.vars.vars vs a.vars.cursors), so it already agrees.) +func TestAdv3DuplicateCursorRefusedByServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const dupCursSchema = adv3DupDeclSchema + ` +CREATE PROCEDURE p_dupcurs() +BEGIN + DECLARE cur1 CURSOR FOR SELECT id FROM t; + DECLARE cur1 CURSOR FOR SELECT id FROM t; +END; +` + // the server: two DECLARE ... CURSOR FORs of "cur1" in the same block are refused + // outright (1333), measured against mysqld 8.4.11 + _, err := mysqltest.Start(ctx, dupCursSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + var me *driver.MySQLError + if !errors.As(err, &me) || me.Number != 1333 { + t.Fatalf("test premise wrong: want the server to refuse this schema with 1333, got %v", err) + } + + s, err := schema.Load(dupCursSchema) + if err != nil { + t.Fatalf("schema.Load: %v", err) + } + if len(s.Problems) == 0 { + t.Errorf("schema.Load reports no problems for a procedure the server refuses at CREATE time with 1333 (duplicate cursor); want a problem naming it") + } + r := s.RoutineOf(schema.Procedure, "p_dupcurs") + if r == nil { + t.Fatal("routine not loaded") + } + if _, err := AnalyzeRoutine(s, r); errCode(err) != 1333 { + t.Errorf("AnalyzeRoutine(p_dupcurs) = %v, want a 1333 Error (the server refuses two DECLARE ... CURSOR FORs of the same name in one block)", err) + } +} + +// --------------------------------------------------------------------------------------- +// Finding: two DECLARE ... HANDLER FOR in the same block whose own condition sets overlap +// (here, both FOR SQLSTATE '45000') are refused by the server at CREATE time (1413 +// "Duplicate handler declared in the same block", measured below) -- the HANDLER sibling of +// the same 1331 case, one block further out (the overlap is on what each handler catches, +// not a DECLAREd name). walkBlock's own handler pass (body.go, around line 783) collects +// every sp_decl_handler of the block into a plain `handlers []pendingHandler` slice with no +// comparison between one handler's own resolved condition set and another's in the same +// collection, so two handlers catching the very same condition never conflict in the +// checker's bookkeeping. Suspect: check/mysql/internal/analyze/body.go's walkBlock (the +// handlers collection loop, ~body.go:783-799), which never cross-checks pendingHandler.conds +// between the handlers gathered for one block. +func TestAdv3DuplicateHandlerRefusedByServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const dupHandlerSchema = adv3DupDeclSchema + ` +CREATE PROCEDURE p_duphandler() +BEGIN + DECLARE CONTINUE HANDLER FOR SQLSTATE '45000' BEGIN END; + DECLARE CONTINUE HANDLER FOR SQLSTATE '45000' BEGIN END; +END; +` + // the server: two HANDLERs for the same SQLSTATE '45000' in the same block are refused + // outright (1413), measured against mysqld 8.4.11 + _, err := mysqltest.Start(ctx, dupHandlerSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + var me *driver.MySQLError + if !errors.As(err, &me) || me.Number != 1413 { + t.Fatalf("test premise wrong: want the server to refuse this schema with 1413, got %v", err) + } + + s, err := schema.Load(dupHandlerSchema) + if err != nil { + t.Fatalf("schema.Load: %v", err) + } + if len(s.Problems) == 0 { + t.Errorf("schema.Load reports no problems for a procedure the server refuses at CREATE time with 1413 (duplicate handler); want a problem naming it") + } + r := s.RoutineOf(schema.Procedure, "p_duphandler") + if r == nil { + t.Fatal("routine not loaded") + } + if _, err := AnalyzeRoutine(s, r); errCode(err) != 1413 { + t.Errorf("AnalyzeRoutine(p_duphandler) = %v, want a 1413 Error (the server refuses two overlapping HANDLERs in the same block)", err) + } +} diff --git a/check/mysql/internal/analyze/adv3_facts_test.go b/check/mysql/internal/analyze/adv3_facts_test.go new file mode 100644 index 0000000..ce4b668 --- /dev/null +++ b/check/mysql/internal/analyze/adv3_facts_test.go @@ -0,0 +1,102 @@ +package analyze + +// Adversarial round 3, lane "newcode": probes the new code from round 2 (2026-09-15, +// commit 20a55af) -- body.go, violations.go, facts.go, call.go, expr.go, fullgroup.go, +// schema.go -- against a running mysqld 8.4, one rung further out from what adv2_*_test.go +// already pinned. See scratchpad/adv3/brief-newcode.md and adv3-newcode-report.md. + +import ( + "context" + "errors" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" + "github.com/kr9ly/sqlshape/v2/x/facts" +) + +// --------------------------------------------------------------------------------------- +// Finding: WITH CHECK OPTION, a LOCAL view built on top of a CASCADED one -- MySQL's own +// docs (CREATE VIEW, "LOCAL and CASCADED Check Options"): "If the LOCAL keyword is used, +// checking of underlying views only takes place if the underlying view itself specifies a +// check option." v_inner DOES specify one (CASCADED), so writing through v_outer (LOCAL) +// must still enforce v_inner's own WHERE (tenant_id = 1), even though v_outer's own WHERE +// (v > 0) says nothing about tenant_id. Measured below: UPDATE v_outer SET tenant_id = 2 +// through a row that started at tenant_id = 1 is refused every time with 1369 naming +// v_outer, on mysqld 8.4.11. +// +// facts.go's checkOptionFacts computes the cascade flag it hands to x/facts.LiftThroughView +// from only the WRITTEN-THROUGH view's own CheckOption (`v.CheckOption == "CASCADED"`) -- +// here v_outer's, "LOCAL", so cascade = false. x/facts/lift.go's liftPreds only descends +// into a nested view's own body when its caller's cascaded flag is true (the `if cascaded +// && len(body.Leaves) == 1 ...` branch), so it never even looks at v_inner's body, let alone +// v_inner's own CheckOption -- the single bool threaded down ignores that an inner view can +// independently force cascading regardless of what a view built on top of it asks for. The +// result: Analyze's facts.Top.Preds for `UPDATE v_outer SET tenant_id = ...` carries no +// FromView equality on tenant_id at all, so a `require pinned(tenant_id)` on t's writes +// (x/obligation, checked from check/mysql/dialect, not reproduced here) would wrongly see +// this UPDATE as not fixing tenant_id through the view chain, even though the server +// guarantees tenant_id stays 1 for every row that can be written through v_outer. +// Suspect: check/mysql/internal/analyze/facts.go's checkOptionFacts (the +// `v.CheckOption == "CASCADED"` argument to LiftThroughView) and x/facts/lift.go's liftPreds +// (the single `cascaded bool` it threads all the way down, instead of asking each nested +// view's own CheckOption before deciding whether to keep descending). +func TestAdv3CheckOptionCascadedUnderLocalViewMissesInnerEquality(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const nestedCheckOptionSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE t ( + id INT NOT NULL PRIMARY KEY, + tenant_id INT NOT NULL, + v INT NOT NULL +); +CREATE VIEW v_inner AS SELECT id, tenant_id, v FROM t WHERE tenant_id = 1 WITH CASCADED CHECK OPTION; +CREATE VIEW v_outer AS SELECT id, tenant_id, v FROM v_inner WHERE v > 0 WITH LOCAL CHECK OPTION; +` + db, err := mysqltest.Start(ctx, nestedCheckOptionSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + if _, err := conn.ExecContext(ctx, `INSERT INTO t (id, tenant_id, v) VALUES (1, 1, 5)`); err != nil { + t.Fatal(err) + } + // the server: moving the row to a different tenant_id through v_outer is refused with + // 1369, though v_outer's own WHERE (v > 0) says nothing about tenant_id -- because + // v_inner is itself CASCADED, its own WHERE (tenant_id = 1) is still enforced through a + // LOCAL view built on top of it (measured against mysqld 8.4.11) + _, execErr := conn.ExecContext(ctx, `UPDATE v_outer SET tenant_id = 2 WHERE id = 1`) + var me *driver.MySQLError + if !errors.As(execErr, &me) || me.Number != 1369 { + t.Fatalf("test premise wrong: want the server to refuse this UPDATE with 1369, got %v", execErr) + } + + s, err := schema.Load(nestedCheckOptionSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + r, aerr := Analyze(s, "UPDATE v_outer SET tenant_id = $1 WHERE id = $2") + if aerr != nil { + t.Fatal(aerr) + } + found := false + for _, p := range r.Facts.Top.Preds { + if p.Op == facts.Eq && p.Origin == facts.FromView && p.Term.Kind == facts.Const && p.Term.Const == "i1" { // constText's tagged spelling (facts.go) + found = true + } + } + if !found { + t.Errorf("Analyze(UPDATE v_outer SET tenant_id = ...).Facts.Top.Preds = %+v, want a FromView equality fixing tenant_id = 1 among them (v_inner's own CASCADED check option is still enforced through the LOCAL v_outer, measured on mysqld) -- checkOptionFacts's cascade flag comes only from the written-through view's own CheckOption, so it never looks at v_inner's", r.Facts.Top.Preds) + } +} diff --git a/check/mysql/internal/analyze/adv3b_body_test.go b/check/mysql/internal/analyze/adv3b_body_test.go new file mode 100644 index 0000000..221c735 --- /dev/null +++ b/check/mysql/internal/analyze/adv3b_body_test.go @@ -0,0 +1,380 @@ +package analyze + +// Adversarial round 3, lane "newcode", follow-up half: face 3 ("本体のエラーの残り"), the items +// brief-followup-newcode.md carries over from brief-newcode.md's own face 3 list that the +// front half did not reach (1331's CONDITION/CURSOR/HANDLER siblings, in adv3_body_test.go, +// were the only face-3 item the front half touched). Every finding below reproduces its +// shape against a real mysqld 8.4.11 (nix-shell -p mysql84) before asserting what the checker +// (check/mysql/internal/analyze) does. See scratchpad/adv3/brief-newcode.md, +// brief-followup-newcode.md and scratchpad/adv3/adv3b-newcode-report.md (the write-up this +// file's findings back). + +import ( + "context" + "errors" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +const adv3bBaseSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, v INT NOT NULL); +` + +// --------------------------------------------------------------------------------------- +// severity: high +// +// Finding: FLUSH (here FLUSH TABLES) inside a stored FUNCTION or a TRIGGER is refused by the +// server at CREATE time every time (1336 "FLUSH is not allowed in stored function or +// trigger", measured below for both shapes) -- the same 1336 family PREPARE/EXECUTE/ +// DEALLOCATE PREPARE already carry (round 2's own TestAdv2DynamicSQLInTriggerRefusedByServer), +// and the same "a PROCEDURE is exempt" shape too (measured separately: FLUSH TABLES inside a +// PROCEDURE body is accepted by the server with no error at all, so this is not a finding for +// PROCEDURE). commitCheck (body.go:632-648) is reached for FLUSH the same way it is for +// PREPARE/EXECUTE/DEALLOCATE and COMMIT/DDL (FLUSH folds to a generic {sql_command: +// SQLCOM_FLUSH} Struct, walkOne's own generic case at body.go:594-597 calls commitCheck(class, +// 0) for any such Struct unconditionally), but commitCheck's own class comparisons only +// recognize SQLCOM_PREPARE/SQLCOM_DEALLOCATE_PREPARE/SQLCOM_EXECUTE (its own 1336 branch) and +// a DDL/transaction-control class prefix (its own 1422 branch) -- SQLCOM_FLUSH matches +// neither, so commitCheck's final `return nil` is reached and AnalyzeRoutine/AnalyzeTrigger +// report no error for a body the server refuses to CREATE every time. Suspect: +// check/mysql/internal/analyze/body.go's commitCheck (body.go:632-648), whose own dynamic-SQL +// class list at body.go:636 does not include FLUSH's own SQLCOM_FLUSH. +func TestAdv3BodyFlushInFunctionOrTriggerIs1336(t *testing.T) { + t.Run("function", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const flushFuncSchema = adv3bBaseSchema + ` +CREATE FUNCTION f_flush() RETURNS INT DETERMINISTIC +BEGIN + FLUSH TABLES; + RETURN 1; +END; +` + _, err := mysqltest.Start(ctx, flushFuncSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + var me *driver.MySQLError + if !errors.As(err, &me) || me.Number != 1336 { + t.Fatalf("test premise wrong: want the server to refuse this schema with 1336, got %v", err) + } + + s, err := schema.Load(flushFuncSchema) + if err != nil { + t.Fatalf("schema.Load: %v", err) + } + if len(s.Problems) == 0 { + t.Errorf("schema.Load reports no problems for a function the server refuses at CREATE time with 1336 (FLUSH); want a problem naming it") + } + r := s.RoutineOf(schema.Function, "f_flush") + if r == nil { + t.Fatal("routine not loaded") + } + if _, err := AnalyzeRoutine(s, r); errCode(err) != 1336 { + t.Errorf("AnalyzeRoutine(f_flush) = %v, want a 1336 Error (the server refuses CREATE FUNCTION for a body using FLUSH)", err) + } + }) + + t.Run("trigger", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const flushTrigSchema = adv3bBaseSchema + ` +CREATE TRIGGER trg_flush BEFORE INSERT ON t FOR EACH ROW +BEGIN + FLUSH TABLES; +END; +` + _, err := mysqltest.Start(ctx, flushTrigSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + var me *driver.MySQLError + if !errors.As(err, &me) || me.Number != 1336 { + t.Fatalf("test premise wrong: want the server to refuse this schema with 1336, got %v", err) + } + + s, err := schema.Load(flushTrigSchema) + if err != nil { + t.Fatalf("schema.Load: %v", err) + } + if len(s.Problems) == 0 { + t.Errorf("schema.Load reports no problems for a trigger the server refuses at CREATE time with 1336 (FLUSH); want a problem naming it") + } + tg := s.Trigger("trg_flush") + if tg == nil { + t.Fatal("trigger not loaded") + } + if _, err := AnalyzeTrigger(s, tg); errCode(err) != 1336 { + t.Errorf("AnalyzeTrigger(trg_flush) = %v, want a 1336 Error (the server refuses CREATE TRIGGER for a body using FLUSH)", err) + } + }) +} + +// --------------------------------------------------------------------------------------- +// severity: high +// +// Finding: a SIGNAL/RESIGNAL whose own SQLSTATE literal is not exactly 5 characters (measured +// below with a 4-digit one, '4500') is refused by the server at CREATE time every time (1407 +// "Bad SQLSTATE: '4500'"); a well-formed 5-character SQLSTATE that happens to be +// non-numeric ('ABCDE') is accepted, measured separately, not a finding). walkSignal +// (body.go:1035-1084) reads a SIGNAL's condition through resolveCondValue and only branches on +// whether it names a bare number (1646) or inspects its own two-character class prefix +// (ref.sqlstate[:2], for "01"/"02"); it never validates the literal's own length against the +// 5-character SQLSTATE shape the grammar itself does not enforce, so schema.Load accepts the +// CREATE PROCEDURE outright (zero Problems) and AnalyzeRoutine finds nothing wrong with the +// body, although the real CREATE PROCEDURE for it always fails. Suspect: +// check/mysql/internal/analyze/body.go's walkSignal (body.go:1035-1084) and whatever resolves +// a SIGNAL's own literal SQLSTATE (resolveCondValue / condRef, referenced there), none of +// which checks len(ref.sqlstate) == 5. +func TestAdv3BodyMalformedSQLStateAcceptedAtCreateTime(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const badStateSchema = adv3bBaseSchema + ` +CREATE PROCEDURE p_badstate() +BEGIN + SIGNAL SQLSTATE '4500'; +END; +` + _, err := mysqltest.Start(ctx, badStateSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + var me *driver.MySQLError + if !errors.As(err, &me) || me.Number != 1407 { + t.Fatalf("test premise wrong: want the server to refuse this schema with 1407, got %v", err) + } + + s, err := schema.Load(badStateSchema) + if err != nil { + t.Fatalf("schema.Load: %v", err) + } + if len(s.Problems) == 0 { + t.Errorf("schema.Load reports no problems for a procedure the server refuses at CREATE time with 1407 (malformed SQLSTATE); want a problem naming it") + } + r := s.RoutineOf(schema.Procedure, "p_badstate") + if r == nil { + t.Fatal("routine not loaded") + } + if _, err := AnalyzeRoutine(s, r); errCode(err) != 1407 { + t.Errorf("AnalyzeRoutine(p_badstate) = %v, want a 1407 Error (the server refuses CREATE PROCEDURE for a SIGNAL naming a malformed SQLSTATE)", err) + } +} + +// --------------------------------------------------------------------------------------- +// severity: high +// +// Finding: a stored FUNCTION whose body RETURNs the result of calling itself is accepted at +// CREATE time by the server (measured: CREATE FUNCTION succeeds outright, unlike a +// PROCEDURE's own direct recursion, which is a run-time-only failure too but through a +// different, allowed-up-to-a-limit mechanism, 1456), yet every single execution fails with +// 1424 "Recursive stored functions and triggers are not allowed." -- measured below calling +// it just one level deep (n=3 calling itself with n=2, whose own base case returns +// immediately). Functions (and triggers) can never recurse at all, regardless of +// max_sp_recursion_depth, unlike a PROCEDURE's own direct or indirect recursion. +// +// walkCall's own recursion check (body.go:1253-1262, "a.routine != nil && r == a.routine" -> +// 1456) exists only for a `CALL` statement, which a FUNCTION never uses to invoke itself -- +// expr.go's storedFuncCall (expr.go:521 on) is what types a FUNCTION-call expression +// (including one naming the very FUNCTION whose own body is being walked, the RETURN +// f_rec(n-1) shape here), and it has no analogous check at all: no comparison between the +// callee (r) and a.routine, and even if it did, the right number for that shape is 1424, not +// the CALL-statement branch's own 1456. AnalyzeRoutine therefore returns no error and no +// Violation for a function every execution of which is certain to fail. Suspect: +// check/mysql/internal/analyze/expr.go's storedFuncCall (expr.go:521 on, and its caller +// `call`, expr.go:462-506), which never compares r against a.routine the way walkCall +// (body.go:1253) does for a PROCEDURE's own CALL. +func TestAdv3BodyFunctionDirectRecursionMissing1424(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const funcRecSchema = adv3bBaseSchema + ` +CREATE FUNCTION f_rec(n INT) RETURNS INT DETERMINISTIC +BEGIN + IF n <= 0 THEN + RETURN 0; + END IF; + RETURN f_rec(n - 1); +END; +` + db, err := mysqltest.Start(ctx, funcRecSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + + // the server: CREATE FUNCTION succeeds (recursion is not caught at CREATE time), but every + // call fails with 1424 -- measured against mysqld 8.4.11, one level of recursion is enough + _, execErr := db.Conn().ExecContext(ctx, "SELECT f_rec(3)") + var me *driver.MySQLError + if !errors.As(execErr, &me) || me.Number != 1424 { + t.Fatalf("test premise wrong: want SELECT f_rec(3) to fail with 1424, got %v", execErr) + } + + s, err := schema.Load(funcRecSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + r := s.RoutineOf(schema.Function, "f_rec") + if r == nil { + t.Fatal("routine not loaded") + } + br, aerr := AnalyzeRoutine(s, r) + if aerr != nil { + t.Fatalf("AnalyzeRoutine: unexpected CREATE-time error %v", aerr) + } + if got := adv2Keys(br.Violations); got != "1424 1424" { + t.Errorf("AnalyzeRoutine(f_rec).Violations = %q, want \"1424 1424\" (every call to a directly recursive FUNCTION fails, measured)", got) + } +} + +// --------------------------------------------------------------------------------------- +// severity: medium +// +// Finding: a PROCEDURE's own direct recursion is not certain to fail once the schema raises +// max_sp_recursion_depth above its default of 0 -- measured below with `-- sqlshape: server +// max_sp_recursion_depth = 200` (mysqltest.Start passes it straight through to mysqld as +// --max-sp-recursion-depth=200, dialect.Settings/mysqltest.go's own mechanism for any `-- +// sqlshape: server = ` line) and a procedure recursing 5 levels deep: CALL +// succeeds with no error at all. walkCall's own recursion check (body.go:1253-1262) predicts +// 1456 unconditionally on any direct self-CALL, with no notion of the schema's own declared +// max_sp_recursion_depth (the comment there already says as much: "max_sp_recursion_depth +// defaults to 0 (not a setting sqlshape itself tracks): any actual recursive invocation -- +// even this first one -- is refused ... every time"), so AnalyzeRoutine still predicts a +// certain 1456 for a call chain that in fact always succeeds under the raised limit. +// (schema.Load itself also reports a Problem for the setting name, "not a variable sqlshape +// reads for MySQL" -- SettingNames, schema.go:59, lists only sql_mode and +// lower_case_table_names -- a related but separate gap: the setting reaches mysqld fine +// through mysqltest's own dialect.Settings pass-through, which does not go through +// SettingNames at all, but schema.Load's own reading of it does.) Suspect: +// check/mysql/internal/analyze/body.go's walkCall (body.go:1253-1262), which raises 1456 +// unconditionally rather than checking any recorded max_sp_recursion_depth (and +// internal/schema/schema.go's SettingNames, schema.go:59, which does not recognize the +// variable at all). +func TestAdv3BodyRecursionDepthSettingIgnoredBy1456(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const recDepthSchema = `-- sqlshape: mysql 8.4 +-- sqlshape: server max_sp_recursion_depth = 200 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, v INT NOT NULL); +CREATE PROCEDURE p_rec(IN n INT) +BEGIN + IF n > 0 THEN + CALL p_rec(n - 1); + END IF; +END; +` + db, err := mysqltest.Start(ctx, recDepthSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatalf("Start (server option max_sp_recursion_depth=200): %v", err) + } + defer db.Close() + + // the server: 5 levels of direct recursion under max_sp_recursion_depth=200 succeeds -- + // measured against mysqld 8.4.11 + if _, execErr := db.Conn().ExecContext(ctx, "CALL p_rec(5)"); execErr != nil { + t.Fatalf("test premise wrong: want CALL p_rec(5) to succeed under max_sp_recursion_depth=200, got %v", execErr) + } + + s, err := schema.Load(recDepthSchema) + if err != nil { + t.Fatal(err) + } + r := s.RoutineOf(schema.Procedure, "p_rec") + if r == nil { + t.Fatal("routine not loaded") + } + br, aerr := AnalyzeRoutine(s, r) + if aerr != nil { + t.Fatalf("AnalyzeRoutine: unexpected CREATE-time error %v", aerr) + } + for _, v := range br.Violations { + if v.Code == 1456 { + t.Errorf("AnalyzeRoutine(p_rec).Violations includes a 1456, but the server's own CALL p_rec(5) succeeds under this schema's max_sp_recursion_depth=200 (measured); want no 1456 here") + } + } +} + +// --------------------------------------------------------------------------------------- +// severity: low +// +// Finding: a SIGNAL naming an explicit `SET MYSQL_ERRNO = 0` is not, in fact, certain to +// raise the SQLSTATE's own default number (1644 for an arbitrary SQLSTATE without its own +// number, the checker's own prediction here) -- measured below: the server instead raises +// 1231 "Variable 'MYSQL_ERRNO' can't be set to the value of '0'" every time (MYSQL_ERRNO must +// be in 1..65535; CREATE PROCEDURE itself is unaffected, only every CALL fails, and always +// with 1231, not the SIGNAL's own declared SQLSTATE/number at all). +// +// signalErrno (body.go:1127-1142) returns (0, true) for an explicit `SET MYSQL_ERRNO = 0` -- +// indistinguishable from (0, false), which walkSignal's own caller only reads back through +// `errno, _ := signalErrno(items)` (body.go:1067), discarding the second return value +// entirely. `if code == 0 { ... default to 1643/1644 ... }` (body.go:1069-1074) then cannot +// tell "no MYSQL_ERRNO given" (for which 1644 is the server's own default, correct) from " +// MYSQL_ERRNO explicitly given as 0" (for which the server never even reaches the SQLSTATE's +// own number, raising 1231 instead, unconditionally). Suspect: +// check/mysql/internal/analyze/body.go's walkSignal (body.go:1067-1077), whose `errno, _ := +// signalErrno(items)` throws away the very bit (signalErrno's own second return value) that +// would let it single out this case. +func TestAdv3BodySignalMysqlErrnoZeroIs1231(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const errnoZeroSchema = adv3bBaseSchema + ` +CREATE PROCEDURE p_errno0() +BEGIN + SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 0; +END; +` + db, err := mysqltest.Start(ctx, errnoZeroSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + + // the server: CREATE PROCEDURE succeeds, but CALL always fails with 1231, not the + // SIGNAL's own SQLSTATE/number -- measured against mysqld 8.4.11 + _, execErr := db.Conn().ExecContext(ctx, "CALL p_errno0()") + var me *driver.MySQLError + if !errors.As(execErr, &me) || me.Number != 1231 { + t.Fatalf("test premise wrong: want CALL p_errno0() to fail with 1231, got %v", execErr) + } + + s, err := schema.Load(errnoZeroSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + r := s.RoutineOf(schema.Procedure, "p_errno0") + if r == nil { + t.Fatal("routine not loaded") + } + br, aerr := AnalyzeRoutine(s, r) + if aerr != nil { + t.Fatalf("AnalyzeRoutine: unexpected CREATE-time error %v", aerr) + } + if got := adv2Keys(br.Violations); got != "1231 1231" { + t.Errorf("AnalyzeRoutine(p_errno0).Violations = %q, want \"1231 1231\" (a SIGNAL with SET MYSQL_ERRNO = 0 always fails 1231, never the SQLSTATE's own default number, measured)", got) + } +} diff --git a/check/mysql/internal/analyze/adv3b_chain_test.go b/check/mysql/internal/analyze/adv3b_chain_test.go new file mode 100644 index 0000000..8730ff7 --- /dev/null +++ b/check/mysql/internal/analyze/adv3b_chain_test.go @@ -0,0 +1,279 @@ +package analyze + +// Adversarial round 3, lane "newcode", second half, face 2 (the 1442 chain): round 2 pinned +// "a trigger chain as a whole" and "a called routine's write reaching a table through the +// trigger it fires" (adv2_calls_test.go, adv2_failure_test.go). This file probes the rungs +// around those against a running mysqld 8.4: a CALL inside a trigger whose callee's write +// cascades through another trigger back into the caller's own table, a locking read +// (SELECT ... FOR UPDATE) of a table already in use, and a recursive CALL sitting inside a +// trigger. See scratchpad/adv3/brief-followup-newcode.md and +// scratchpad/adv3/adv3b-newcode-report.md (which also lists the faces measured here that +// agreed with the server and got no test). + +import ( + "context" + "errors" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// --------------------------------------------------------------------------------------- +// severity: high +// +// Finding: a trigger's own CALL to a procedure, whose write cascades through another +// table's trigger back into the CALLing trigger's own table, is a certain 1442 on the real +// server (measured below: INSERT INTO ca_a, with ca_b seeded, is refused every time with +// 1442 naming ca_a) -- yet AnalyzeTrigger(ca_a_bi), called directly, ALREADY computes this +// exact 1442 (checkCalledRoutineOverlap sees ca_write_b's WriteTables, folded transitively +// through ca_b_au's AFTER UPDATE, include ca_a, the calling trigger's own table). It is +// triggerViolations (violations.go), walking ca_a's INSERT-event triggers for the top-level +// `INSERT INTO ca_a`, that throws this correctly computed answer away: `br, err := +// cachedAnalyzeTrigger(s, tg); if err != nil || br == nil { continue }` (violations.go:214) +// treats ANY error from analyzing a chained trigger's body -- a certain 1442 collision +// included -- as "this trigger contributes nothing", instead of folding the trigger's own +// certain failure into the firing statement's predicted violations. So Analyze("INSERT +// INTO ca_a ...") reports no 1442 at all for a statement mysqld always refuses. +// +// Suspect: check/mysql/internal/analyze/violations.go's triggerViolations, the `if err != +// nil || br == nil { continue }` at line 214 (the same pattern recurs at +// calledRoutineViolations, line 120, and at body.go's appendTriggerWriteTables, ~1497-1499 +// -- none of the three surfaces a chained trigger's own certain failure to its caller). +func TestAdv3ChainCallCascadeThroughAnotherTriggerSwallowed(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const callCascadeSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE ca_a (id INT NOT NULL PRIMARY KEY, v INT); +CREATE TABLE ca_b (id INT NOT NULL PRIMARY KEY, v INT); +CREATE PROCEDURE ca_write_b(IN x INT) +BEGIN + UPDATE ca_b SET v = x WHERE id = 1; +END; +CREATE TRIGGER ca_a_bi BEFORE INSERT ON ca_a FOR EACH ROW +BEGIN + CALL ca_write_b(NEW.v); +END; +CREATE TRIGGER ca_b_au AFTER UPDATE ON ca_b FOR EACH ROW +BEGIN + UPDATE ca_a SET v = NEW.v WHERE id = 1; +END; +` + db, err := mysqltest.Start(ctx, callCascadeSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + if _, err := conn.ExecContext(ctx, "INSERT INTO ca_b (id, v) VALUES (1, 0)"); err != nil { + t.Fatal(err) + } + // the server: firing ca_a_bi, which CALLs ca_write_b, whose UPDATE of ca_b fires + // ca_b_au, which writes back into ca_a -- the table the very first INSERT is already + // using -- is refused every time with 1442 naming ca_a (measured on mysqld 8.4.11) + _, execErr := conn.ExecContext(ctx, "INSERT INTO ca_a (id, v) VALUES (1, 5)") + var me *driver.MySQLError + if !errors.As(execErr, &me) || me.Number != 1442 { + t.Fatalf("test premise wrong: want the server to refuse this INSERT with 1442, got %v", execErr) + } + + s, err := schema.Load(callCascadeSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + + // the checker, asked about the trigger directly: already gets this right + if _, terr := AnalyzeTrigger(s, s.Trigger("ca_a_bi")); errCode(terr) != 1442 { + t.Fatalf("AnalyzeTrigger(ca_a_bi) = %v, want a 1442 Error -- if this no longer reproduces internally, the finding below needs re-measuring", terr) + } + + // the checker, asked about the firing statement: predicts nothing at all + r, cerr := Analyze(s, "INSERT INTO ca_a (id, v) VALUES ($1, $2)") + if cerr != nil { + t.Fatalf("Analyze(INSERT INTO ca_a ...) returned an unexpected error: %v", cerr) + } + for _, v := range r.Violations { + if v.Code == 1442 { + return + } + } + t.Errorf("Analyze(INSERT INTO ca_a (id, v) VALUES ($1, $2)).Violations = %+v, want a 1442 violation among them: firing ca_a_bi always fails on the real server (CALL ca_write_b cascades through ca_b_au back into ca_a), and AnalyzeTrigger(ca_a_bi) already computes that very 1442 on its own -- triggerViolations discards it instead of folding it into the firing statement's predicted violations", r.Violations) +} + +// --------------------------------------------------------------------------------------- +// severity: high +// +// Finding: a trigger elsewhere in the chain that only READS an already-in-use table with a +// locking SELECT ... FOR UPDATE is a certain 1442 on the real server. A plain SELECT (no +// lock) or a subquery read of the same table does NOT collide (measured separately, not a +// finding: see the read-only and subquery-read rows of adv3b-newcode-report.md), but +// SELECT ... FOR UPDATE does, every time (measured below: INSERT INTO rf_x, whose BEFORE +// INSERT trigger inserts into rf_y, whose BEFORE INSERT trigger runs `SELECT id INTO c FROM +// rf_x LIMIT 1 FOR UPDATE` -- rf_x already in use by the very first INSERT -- is refused +// with 1442 naming rf_x, on mysqld 8.4.11). triggerViolations only ever walks a trigger's +// br.Statements' Facts.Writes (INSERT / UPDATE / DELETE) to decide whether a further hop +// collides with the tables already in use; a SELECT (FOR UPDATE or not) records no Write at +// all (facts.Writes is appended to only by the embedded INSERT / UPDATE / DELETE paths of +// analyze.go), so the checker has no notion of a locking read counting toward the "already +// in use" set: it predicts nothing wrong for a statement mysqld always refuses. +// +// Suspect: check/mysql/internal/analyze/violations.go's triggerViolations (the +// `br.Statements` / `st.Facts.Writes` walk, ~violations.go:216-228), which never asks +// whether a body statement was a SELECT ... FOR UPDATE (or LOCK IN SHARE MODE) reading a +// table already in the chain's inUse set. +func TestAdv3ChainSelectForUpdateInsideTriggerIs1442(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const selectForUpdateSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE rf_x (id INT NOT NULL PRIMARY KEY); +CREATE TABLE rf_y (id INT NOT NULL PRIMARY KEY); +CREATE TRIGGER rf_x_bi BEFORE INSERT ON rf_x FOR EACH ROW +BEGIN + INSERT INTO rf_y (id) VALUES (NEW.id); +END; +CREATE TRIGGER rf_y_bi BEFORE INSERT ON rf_y FOR EACH ROW +BEGIN + DECLARE c INT; + SELECT id INTO c FROM rf_x LIMIT 1 FOR UPDATE; +END; +` + db, err := mysqltest.Start(ctx, selectForUpdateSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + // the server: rf_x_bi inserts into rf_y, which fires rf_y_bi, which reads rf_x with FOR + // UPDATE -- rf_x already in use by the very statement inserting into it -- refused + // every time with 1442 naming rf_x (measured on mysqld 8.4.11) + _, execErr := conn.ExecContext(ctx, "INSERT INTO rf_x (id) VALUES (1)") + var me *driver.MySQLError + if !errors.As(execErr, &me) || me.Number != 1442 { + t.Fatalf("test premise wrong: want the server to refuse this INSERT with 1442, got %v", execErr) + } + + s, err := schema.Load(selectForUpdateSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + r, cerr := Analyze(s, "INSERT INTO rf_x (id) VALUES ($1)") + if cerr != nil { + t.Fatalf("Analyze(INSERT INTO rf_x ...) returned an unexpected error: %v", cerr) + } + for _, v := range r.Violations { + if v.Code == 1442 { + return + } + } + t.Errorf("Analyze(INSERT INTO rf_x (id) VALUES ($1)).Violations = %+v, want a 1442 violation among them: rf_y_bi's SELECT ... FOR UPDATE reads rf_x, already in use by the firing INSERT, and mysqld always refuses this -- triggerViolations only tracks a chained trigger's INSERT / UPDATE / DELETE writes, never a locking read", r.Violations) +} + +// --------------------------------------------------------------------------------------- +// severity: high +// +// Finding: a trigger's CALL to a procedure that both recurses (a certain 1456, since +// max_sp_recursion_depth defaults to 0) and writes the calling trigger's own table (a +// second, independent certain-1442 shape, call.go's own rule): the real server reaches +// only the first, since the recursive CALL is the first statement rc_p's body runs +// (measured below: INSERT INTO rc_t is refused every time with 1456). AnalyzeRoutine(rc_p), +// asked directly, already gets the recursion right (its Violations carry a 1456 entry, from +// walkCall's own self-call check, body.go ~1253-1261), and AnalyzeTrigger(rc_t_bi) computes +// an internal 1442 Error (checkCalledRoutineOverlap: rc_p's WriteTables include rc_t). But +// Analyze("INSERT INTO rc_t ...") predicts NEITHER: triggerViolations' `if err != nil || +// br == nil { continue }` (violations.go:214) discards rc_t_bi's analysis outright once +// cachedAnalyzeTrigger returns that internal 1442, so even the 1456 AnalyzeRoutine(rc_p) +// already worked out never reaches the firing statement's Violations -- the same swallow +// as TestAdv3ChainCallCascadeThroughAnotherTriggerSwallowed, landing here on a different +// failure mode to show it discards whatever the chained trigger's certain failure is. +// +// Suspect: check/mysql/internal/analyze/violations.go's triggerViolations, the `if err != +// nil || br == nil { continue }` at line 214. +func TestAdv3ChainCallRecursionAnd1442BothSwallowed(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const recursionSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE rc_t (id INT NOT NULL PRIMARY KEY, v INT); +CREATE PROCEDURE rc_p(IN n INT) +BEGIN + IF n > 0 THEN + CALL rc_p(n - 1); + END IF; + UPDATE rc_t SET v = n WHERE id = 1; +END; +CREATE TRIGGER rc_t_bi BEFORE INSERT ON rc_t FOR EACH ROW +BEGIN + CALL rc_p(1); +END; +` + db, err := mysqltest.Start(ctx, recursionSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + // the server: firing rc_t_bi CALLs rc_p(1), which immediately CALLs rc_p(0) -- an + // actual recursive invocation, refused every time with 1456 (max_sp_recursion_depth + // defaults to 0), before rc_p ever reaches its UPDATE of rc_t (measured on mysqld + // 8.4.11) + _, execErr := conn.ExecContext(ctx, "INSERT INTO rc_t (id, v) VALUES (1, 0)") + var me *driver.MySQLError + if !errors.As(execErr, &me) || me.Number != 1456 { + t.Fatalf("test premise wrong: want the server to refuse this INSERT with 1456, got %v", execErr) + } + + s, err := schema.Load(recursionSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + + // the checker, asked about the procedure directly: already gets the recursion right + pbr, perr := AnalyzeRoutine(s, s.RoutineOf(schema.Procedure, "rc_p")) + if perr != nil || pbr == nil { + t.Fatalf("AnalyzeRoutine(rc_p) = %v, %v, want a result with no error -- if this no longer reproduces internally, the finding below needs re-measuring", pbr, perr) + } + found1456 := false + for _, v := range pbr.Violations { + if v.Code == 1456 { + found1456 = true + } + } + if !found1456 { + t.Fatalf("AnalyzeRoutine(rc_p).Violations = %+v, want a 1456 entry -- if this no longer reproduces internally, the finding below needs re-measuring", pbr.Violations) + } + + // the checker, asked about the firing statement: predicts nothing at all + r, cerr := Analyze(s, "INSERT INTO rc_t (id, v) VALUES ($1, $2)") + if cerr != nil { + t.Fatalf("Analyze(INSERT INTO rc_t ...) returned an unexpected error: %v", cerr) + } + for _, v := range r.Violations { + if v.Code == 1456 { + return + } + } + t.Errorf("Analyze(INSERT INTO rc_t (id, v) VALUES ($1, $2)).Violations = %+v, want a 1456 violation among them: firing rc_t_bi always fails on the real server (its CALL rc_p(1) always recurses once), and AnalyzeRoutine(rc_p) already computes that 1456 on its own -- triggerViolations discards rc_t_bi's whole analysis instead of folding in what it already knows", r.Violations) +} diff --git a/check/mysql/internal/analyze/adv3b_facts_test.go b/check/mysql/internal/analyze/adv3b_facts_test.go new file mode 100644 index 0000000..f6c7556 --- /dev/null +++ b/check/mysql/internal/analyze/adv3b_facts_test.go @@ -0,0 +1,232 @@ +package analyze + +// Adversarial round 3, lane "newcode", second half, face 4 (the rest of facts.go): probes +// HAVING folding, single-element IN facts and the string-column/numeric-literal +// non-fixation rule against a running mysqld 8.4, one rung further out from what +// adv2_*_test.go pinned. See scratchpad/adv3/brief-followup-newcode.md and +// scratchpad/adv3/adv3b-newcode-report.md (which also lists the faces measured here that +// agreed with the server and got no test). + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" + "github.com/kr9ly/sqlshape/v2/x/facts" +) + +const adv3bCoercionSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE t ( + id INT NOT NULL PRIMARY KEY, + c VARCHAR(8) NOT NULL UNIQUE, + tag INT NOT NULL +); +` + +// --------------------------------------------------------------------------------------- +// severity: high +// +// Finding: a string-typed column compared with the literal TRUE is treated by eqFacts as +// an ordinary, fixing equality (facts.Eq, and c lands in Facts.Top.Fixed) -- but TRUE is +// exactly the integer 1, and mysqld's own comparison rules (Type Conversion in Expression +// Evaluation) convert the *column's* stored value to a number for such a comparison, the +// very hazard stringNumberCoercion already guards against for a bare numeric literal +// (`c = 5` against a VARCHAR column, round 2). Measured against mysqld 8.4.11: on a +// VARCHAR(8) UNIQUE column holding '1' and '01', `UPDATE t SET tag = 9 WHERE c = TRUE` +// updates 2 rows -- two distinct column values satisfy the "equality", so it proves neither +// `require single` (x/cardinality's One) nor `require pinned` (Fixed / Eq read as "exactly +// one value here"). +// +// Suspect: check/mysql/internal/analyze/facts.go's numericLiteralClass (facts.go:812: only +// Item_int / Item_uint / Item_decimal / Item_float -- Item_func_true and Item_func_false +// are missing, though the server compares them as the numbers 1 and 0), read by +// stringNumberCoercion (facts.go:849), which eqFacts consults before recording col = TRUE +// as a Fixed equality. +func TestAdv3StringColumnEqTrueCoercionMissed(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + db, err := mysqltest.Start(ctx, adv3bCoercionSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + if _, err := conn.ExecContext(ctx, "INSERT INTO t VALUES (1,'1',0),(2,'01',0)"); err != nil { + t.Fatal(err) + } + // the server: '1' and '01' both satisfy c = TRUE (the column is converted to a number, + // TRUE is 1), measured against mysqld 8.4.11 + const sql = "UPDATE t SET tag = 9 WHERE c = TRUE" + res, err := conn.ExecContext(ctx, sql) + if err != nil { + t.Fatal(err) + } + if n, _ := res.RowsAffected(); n != 2 { + t.Fatalf("test premise wrong: want the server to update 2 rows ('1' and '01' both coerce to TRUE = 1), got %d", n) + } + + s, err := schema.Load(adv3bCoercionSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + r, aerr := Analyze(s, "UPDATE t SET tag = $1 WHERE c = TRUE") + if aerr != nil { + t.Fatal(aerr) + } + for _, c := range r.Facts.Top.Fixed { + if c.Column == "c" { + t.Errorf("Analyze(%s).Facts.Top.Fixed = %v: treats c as fixed to one value by c = TRUE, but the server updates 2 distinct rows ('1' and '01' both coerce to TRUE = 1) -- stringNumberCoercion does not count Item_func_true as a numeric literal class", sql, r.Facts.Top.Fixed) + } + } +} + +// --------------------------------------------------------------------------------------- +// severity: high +// +// Finding: a string-typed column compared with CAST($n AS SIGNED) is treated as a fixing +// equality -- but the CAST's result is a number, so the server converts the *column* to a +// number for the comparison exactly as it does for a bare numeric literal. Measured against +// mysqld 8.4.11: `UPDATE t SET tag = 9 WHERE c = CAST(1 AS SIGNED)` on the same VARCHAR(8) +// UNIQUE column ('1', '01') updates 2 rows. +// +// Suspect: check/mysql/internal/analyze/facts.go's stringNumberCoercion (facts.go:849), +// which only asks numericLiteralClass of the other operand's node class: a computed +// expression such as CAST(... AS SIGNED) (mysqlparse's create_func_cast) is never a literal +// class, so the hazard detection is literal-shaped and blind to any non-literal operand +// that is nonetheless guaranteed numeric. +func TestAdv3StringColumnEqCastToSignedCoercionMissed(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + db, err := mysqltest.Start(ctx, adv3bCoercionSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + if _, err := conn.ExecContext(ctx, "INSERT INTO t VALUES (1,'1',0),(2,'01',0)"); err != nil { + t.Fatal(err) + } + // the server: '1' and '01' both satisfy c = CAST(1 AS SIGNED), measured against + // mysqld 8.4.11 + const sql = "UPDATE t SET tag = 9 WHERE c = CAST(1 AS SIGNED)" + res, err := conn.ExecContext(ctx, sql) + if err != nil { + t.Fatal(err) + } + if n, _ := res.RowsAffected(); n != 2 { + t.Fatalf("test premise wrong: want the server to update 2 rows ('1' and '01' both coerce to the number 1), got %d", n) + } + + s, err := schema.Load(adv3bCoercionSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + r, aerr := Analyze(s, "UPDATE t SET tag = $1 WHERE c = CAST($2 AS SIGNED)") + if aerr != nil { + t.Fatal(aerr) + } + for _, c := range r.Facts.Top.Fixed { + if c.Column == "c" { + t.Errorf("Analyze(%s).Facts.Top.Fixed = %v: treats c as fixed to one value by c = CAST($2 AS SIGNED), but the server updates 2 distinct rows ('1' and '01' both coerce to the number 1) -- stringNumberCoercion only recognizes numericLiteralClass node classes, never a computed CAST", sql, r.Facts.Top.Fixed) + } + } +} + +// --------------------------------------------------------------------------------------- +// severity: medium +// +// Finding: a two-column row-constructor IN with a single alternative, `(a, b) IN (($1, +// $2))`, is left entirely Opaque instead of being decomposed into `a = $1 AND b = $2` -- +// unlike the scalar case (`v IN ($1)`, handled through the grammar's single-item IN rule, +// PTI_handle_sql2003_note184_exception) and unlike a direct row-constructor equality +// (`(a, b) = ($1, $2)`, unpacked by predFacts's comparison case via rowElements). Measured +// against mysqld 8.4.11: with rows (a=5,b=6), (a=5,b=7), (a=9,b=6), `UPDATE t SET tag = 1 +// WHERE (a, b) IN ((5, 6))` updates exactly 1 row -- the form is the conjunction of the +// per-column equalities, the same as the already-unpacked `=` case. +// +// This under-proves rather than over-proves (the analyzer is more conservative, not less +// safe), so a `require pinned(a)` / `require single` on a write that fixes both columns +// through this form is wrongly refused -- the same false-positive shape as round 3's +// nested CHECK OPTION finding. +// +// Suspect: check/mysql/internal/analyze/facts.go's predFacts, case +// "PTI_handle_sql2003_note184_exception" (facts.go:399), which hands the two sides to +// eqFacts as single scalars; eqFacts never calls rowElements (facts.go:583) on its +// operands, so an Item_row on either side is opaque to it, unlike predFacts's own +// comparison case for a bare `=`. +func TestAdv3RowConstructorInNotDecomposedToEqualities(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const sc = `-- sqlshape: mysql 8.4 +CREATE TABLE t ( + id INT NOT NULL PRIMARY KEY, + a INT NOT NULL, + b INT NOT NULL, + tag INT NOT NULL +); +` + db, err := mysqltest.Start(ctx, sc) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + if _, err := conn.ExecContext(ctx, "INSERT INTO t VALUES (1,5,6,0),(2,5,7,0),(3,9,6,0)"); err != nil { + t.Fatal(err) + } + // the server: (a, b) IN ((5, 6)) selects exactly the row with a = 5 AND b = 6, + // measured against mysqld 8.4.11 + const sql = "UPDATE t SET tag = 1 WHERE (a, b) IN ((5, 6))" + res, err := conn.ExecContext(ctx, sql) + if err != nil { + t.Fatal(err) + } + if n, _ := res.RowsAffected(); n != 1 { + t.Fatalf("test premise wrong: want the server to update exactly 1 row (only a=5 AND b=6), got %d", n) + } + + s, err := schema.Load(sc) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + r, aerr := Analyze(s, "UPDATE t SET tag = $1 WHERE (a, b) IN (($2, $3))") + if aerr != nil { + t.Fatal(aerr) + } + gotA, gotB := false, false + for _, p := range r.Facts.Top.Preds { + if p.Op == facts.Eq && p.Col.Column == "a" { + gotA = true + } + if p.Op == facts.Eq && p.Col.Column == "b" { + gotB = true + } + } + if !gotA || !gotB { + t.Errorf("Analyze(%s).Facts.Top.Preds = %+v, want an Eq fact on both a and b ((a, b) IN ((5, 6)) is exactly a = 5 AND b = 6 on the server, measured: 1 row of 3) -- eqFacts never unpacks the Item_row operands the single-element IN grammar path hands it", sql, r.Facts.Top.Preds) + } +} diff --git a/check/mysql/internal/analyze/adv_facts_test.go b/check/mysql/internal/analyze/adv_facts_test.go new file mode 100644 index 0000000..8276c30 --- /dev/null +++ b/check/mysql/internal/analyze/adv_facts_test.go @@ -0,0 +1,79 @@ +package analyze + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" + "github.com/kr9ly/sqlshape/v2/x/cardinality" +) + +// TestAdvGroupByVolatileFunctionIsNotSingle: GROUP BY groups the whole result into one row +// only when every grouping expression is pinned to one value before any row is examined +// (x/facts' Term doc: a Known term is "a value fixed before the row is examined... a stable +// function of the session"). RAND() is not stable across rows -- the server evaluates it +// per row (MySQL: functions without a fixed seed are not treated as constant for a single +// execution), so GROUP BY RAND() yields one group per (distinct) random value, i.e. one row +// per input row in practice, not one row overall. +// +// The MySQL producer's termFacts (facts.go) classifies any expression that reads no column +// of the block as a Known term, with no check for determinism; cardinality's proof then +// treats a Known term in Groups as pinning the group (facts.go doc: "a Column term whose +// column is known, or a Param / Const / Known / Outer term" -- all treated as fixed), so +// `SELECT COUNT(*) FROM t GROUP BY RAND()` is wrongly proved AtMostOne. +func TestAdvGroupByVolatileFunctionIsNotSingle(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + const schemaSQL = `-- sqlshape: mysql 8.4 +CREATE TABLE t (id BIGINT UNSIGNED NOT NULL PRIMARY KEY, v INT NOT NULL); +` + db, err := mysqltest.Start(ctx, schemaSQL) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + for i := 1; i <= 5; i++ { + if _, err := conn.ExecContext(ctx, "INSERT INTO t (id, v) VALUES (?, ?)", i, i*10); err != nil { + t.Fatal(err) + } + } + + const sql = `SELECT COUNT(*) AS c FROM t GROUP BY RAND()` + rows, err := conn.QueryContext(ctx, sql) + if err != nil { + t.Fatalf("server rejects %s: %v", sql, err) + } + n := 0 + for rows.Next() { + n++ + } + rows.Close() + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if n <= 1 { + t.Skipf("RAND() happened to collide across rows this run (%d groups); cannot demonstrate the divergence", n) + } + + s, err := schema.Load(schemaSQL) + if err != nil { + t.Fatal(err) + } + r, err := Analyze(s, sql) + if err != nil { + t.Fatalf("analyzer rejects what the server accepts: %v", err) + } + ok, why := cardinality.AtMostOne(r.Facts) + if ok { + t.Errorf("AtMostOne(%q) = true, but the server returned %d rows for 5 seeded rows: GROUP BY RAND() is not pinned by a Known term", sql, n) + } else { + t.Logf("correctly not single: %s", why) + } +} diff --git a/check/mysql/internal/analyze/adv_null_test.go b/check/mysql/internal/analyze/adv_null_test.go new file mode 100644 index 0000000..b0eccdb --- /dev/null +++ b/check/mysql/internal/analyze/adv_null_test.go @@ -0,0 +1,183 @@ +package analyze + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/oracle" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" +) + +// adv_null_test.go: adversarial findings for the "null" lane (result column types and +// nullability). Each test was confirmed to disagree with a real mysqld (nix-shell -p +// mysql84) before being written down; it is written as the behavior sqlshape should have, +// so it currently fails. + +const advNullSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE a ( + id INT NOT NULL PRIMARY KEY, + n INT NULL, + s VARCHAR(20) NULL, + bt BIT(4) NULL, + j JSON NULL, + dt DATETIME NULL +); +` + +func advNullLoad(t *testing.T) *schema.Schema { + t.Helper() + s, err := schema.Load(advNullSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + return s +} + +// startOracle skips the test when no mysqld is on PATH. +func startAdvNullOracle(t *testing.T) (context.Context, *oracle.Oracle) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + o, err := oracle.Start(ctx, advNullSchema) + if errors.Is(err, oracle.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { o.Close() }) + return ctx, o +} + +// TestAdvNullBitArithmeticIsUnsigned: BIT is an unsigned type in arithmetic context +// (Item_num_op::set_numeric_type on a BIT operand). A real server types `bt + 0` as +// UNSIGNED BIGINT (confirmed against mysqld 8.4), but the analyzer types it as a signed +// bigint because schema.Load never sets Type.Unsigned for a BIT column, and numOp/num1 +// then compose from an operand that looks signed. +// +// repro: SELECT bt + 0 FROM a (bt BIT(4) NULL) +// server: bt + 0 -> BIGINT UNSIGNED, nullable +// sqlshape: bt + 0 -> bigint (signed), nullable +func TestAdvNullBitArithmeticIsUnsigned(t *testing.T) { + ctx, o := startAdvNullOracle(t) + s := advNullLoad(t) + const sql = "SELECT bt + 0 FROM a" + + d, err := o.Describe(ctx, sql) + if err != nil { + t.Fatalf("server rejects %s: %v", sql, err) + } + if d.Columns[0].Type != "UNSIGNED BIGINT" { + t.Fatalf("test setup: expected the server itself to say UNSIGNED BIGINT, got %s", d.Columns[0].Type) + } + + r, err := Analyze(s, sql) + if err != nil { + t.Fatalf("analyzer rejects %s: %v", sql, err) + } + col := r.Columns[0] + if !col.Known { + t.Fatalf("%s: analyzer leaves the column untyped", sql) + } + if !col.Type.Unsigned { + t.Errorf("%s: analyzer says %s (signed), the server says BIGINT UNSIGNED", sql, col.Type) + } +} + +// TestAdvNullRollupMakesGroupColumnNullable: WITH ROLLUP adds a super-aggregate row per +// grouping level, where every rolled-up group column reads NULL -- including one declared +// NOT NULL in the base table. A real server therefore reports such a column as nullable in +// the result set metadata, but the analyzer keeps the base column's own nullability +// (confirmed against mysqld 8.4: `id` is the table's PRIMARY KEY, itself NOT NULL). +// +// repro: SELECT a.id, COUNT(*) FROM a GROUP BY a.id WITH ROLLUP (a.id INT NOT NULL PRIMARY KEY) +// server: column "id" is nullable +// sqlshape: column "id" is not nullable +func TestAdvNullRollupMakesGroupColumnNullable(t *testing.T) { + ctx, o := startAdvNullOracle(t) + s := advNullLoad(t) + const sql = "SELECT a.id, COUNT(*) FROM a GROUP BY a.id WITH ROLLUP" + + d, err := o.Describe(ctx, sql) + if err != nil { + t.Fatalf("server rejects %s: %v", sql, err) + } + if !d.Columns[0].Nullable { + t.Fatalf("test setup: expected the server itself to say nullable") + } + + r, err := Analyze(s, sql) + if err != nil { + t.Fatalf("analyzer rejects %s: %v", sql, err) + } + if !r.Columns[0].Nullable { + t.Errorf("%s: analyzer says column %q is not nullable, the server says it is (the ROLLUP super-aggregate row)", sql, r.Columns[0].Name) + } +} + +// TestAdvNullStrToDateFollowsLiteralFormatConstant: STR_TO_DATE's result type depends on +// the format string (Item_func_str_to_date::fix_from_format): a format with no time parts +// makes a DATE, one with no date parts makes a TIME, and only a format with both (or a +// non-constant format) makes a DATETIME. The analyzer's rule (expr.go, the +// Item_temporal_hybrid_func case) always answers DATETIME for STR_TO_DATE, without reading +// a literal format argument. Confirmed against mysqld 8.4: a pure date format yields DATE. +// +// repro: SELECT STR_TO_DATE(a.s, '%Y-%m-%d') FROM a +// server: DATE +// sqlshape: datetime +func TestAdvNullStrToDateFollowsLiteralFormatConstant(t *testing.T) { + ctx, o := startAdvNullOracle(t) + s := advNullLoad(t) + const sql = "SELECT STR_TO_DATE(a.s, '%Y-%m-%d') FROM a" + + d, err := o.Describe(ctx, sql) + if err != nil { + t.Fatalf("server rejects %s: %v", sql, err) + } + if d.Columns[0].Type != "DATE" { + t.Fatalf("test setup: expected the server itself to say DATE, got %s", d.Columns[0].Type) + } + + r, err := Analyze(s, sql) + if err != nil { + t.Fatalf("analyzer rejects %s: %v", sql, err) + } + col := r.Columns[0] + if !col.Known { + t.Fatalf("%s: analyzer leaves the column untyped", sql) + } + if col.Type.Name != "date" { + t.Errorf("%s: analyzer says %s, the server says DATE (a format string with no time parts)", sql, col.Type) + } +} + +// TestAdvNullJsonArrowOperatorParses: `->` and `->>` are MySQL's shorthand for +// JSON_EXTRACT / JSON_UNQUOTE(JSON_EXTRACT(...)) on a column. A real server accepts them +// (confirmed against mysqld 8.4), but mysqlast's grammar does not implement simple_expr's +// `->` / `->>` production, so the analyzer rejects every statement that uses them -- +// spuriously flagging valid application SQL. +// +// repro: SELECT j -> '$.x' FROM a (j JSON NULL) +// server: accepts it, types the column JSON, nullable +// sqlshape: rejects it (mysqlast: unsupported construct simple_expr/34) +func TestAdvNullJsonArrowOperatorParses(t *testing.T) { + ctx, o := startAdvNullOracle(t) + s := advNullLoad(t) + + for _, sql := range []string{ + "SELECT j -> '$.x' FROM a", + "SELECT j ->> '$.x' FROM a", + } { + if _, err := o.Describe(ctx, sql); err != nil { + t.Fatalf("test setup: server rejects %s: %v", sql, err) + } + if _, err := Analyze(s, sql); err != nil { + t.Errorf("%s: analyzer rejects what the server accepts: %v", sql, err) + } + } +} diff --git a/check/mysql/internal/analyze/adv_param_test.go b/check/mysql/internal/analyze/adv_param_test.go new file mode 100644 index 0000000..eb0b548 --- /dev/null +++ b/check/mysql/internal/analyze/adv_param_test.go @@ -0,0 +1,110 @@ +package analyze + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// TestAdvParamSourceThroughUpdatableView covers a placeholder assigned through an +// updatable, merged view (v_users is `SELECT id, name FROM users`, plain column +// references only, so the server accepts writes through it and applies them to the base +// table). update() resolves the assigned column to the base schema.Column (col.col, via +// relation.column's `ref.col = c.base`) but passes col.rel.table -- nil for a view +// relation, which only carries .cols -- to assign()/noteParamSource() instead of the +// column's own baseTable (col.c.baseTable, which does hold *users*). The placeholder's +// Type still comes out right (it only needs col.col.Type), but its Source is silently +// dropped: ParamSource is nil where it should name users.name, NotNull=true, +// Assigned=true, exactly as it does for the very same statement rewritten to update the +// base table directly, and exactly as it still does for a real base-table column assigned +// in the same UPDATE's other placeholder. +// +// Confirmed against a real mysqld: a real server accepts `UPDATE v_users SET name = ? +// WHERE id = ?` and applies it to users.name (see TestAdvParamSourceThroughViewServer), +// so users.name is the placeholder's genuine Source, not a case the analyzer is right to +// decline. +func TestAdvParamSourceThroughUpdatableView(t *testing.T) { + s := load(t) + cases := []struct { + sql string + want string // "-" for none, else "table.column notnull=%v assigned=%v" per placeholder + }{ + {"UPDATE v_users SET name = $1 WHERE id = $2", "users.name notnull=true assigned=true"}, + {"UPDATE v_users SET name = $1", "users.name notnull=true assigned=true"}, + { + "UPDATE v_users JOIN orders o ON o.user_id = v_users.id SET v_users.name = $1, o.note = $2 WHERE v_users.id = $3", + "users.name notnull=true assigned=true", + }, + } + for _, c := range cases { + r, err := Analyze(s, c.sql) + if err != nil { + t.Fatalf("%s: %v", c.sql, err) + } + if len(r.Params) == 0 { + t.Fatalf("%s: no placeholders found", c.sql) + } + p := r.Params[0] + if p.Source == nil { + t.Errorf("%s: $1's Source is nil, want %s (the view is updatable and the write reaches users.name on a real server)", c.sql, c.want) + continue + } + got := formatParamSource(p.Source) + if got != c.want { + t.Errorf("%s: $1's Source is %q, want %q", c.sql, got, c.want) + } + } +} + +func formatParamSource(s *ParamSource) string { + if s == nil { + return "-" + } + return s.Table + "." + s.Column + " notnull=" + boolStr(s.NotNull) + " assigned=" + boolStr(s.Assigned) +} + +func boolStr(b bool) string { + if b { + return "true" + } + return "false" +} + +// TestAdvParamSourceThroughViewServer is the ground truth TestAdvParamSourceThroughUpdatableView +// relies on: a real mysqld accepts `UPDATE v_users SET name = ? WHERE id = ?` (v_users is +// a plain, mergeable view over users) and the write lands on users.name, proving the view +// update is a genuine, server-sanctioned write to that base column -- not a case the +// analyzer is entitled to treat as sourceless. +func TestAdvParamSourceThroughViewServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, testSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + if _, err := conn.ExecContext(ctx, "INSERT INTO users (id, name) VALUES (1, 'orig')"); err != nil { + t.Fatal(err) + } + res, err := conn.ExecContext(ctx, "UPDATE v_users SET name = ? WHERE id = ?", "changed", 1) + if err != nil { + t.Fatalf("the server rejects an update through v_users: %v", err) + } + if n, _ := res.RowsAffected(); n != 1 { + t.Fatalf("update through v_users affected %d rows, want 1", n) + } + var name string + if err := conn.QueryRowContext(ctx, "SELECT name FROM users WHERE id = 1").Scan(&name); err != nil { + t.Fatal(err) + } + if name != "changed" { + t.Fatalf("the write through v_users did not reach users.name: got %q", name) + } +} diff --git a/check/mysql/internal/analyze/adv_semantic_test.go b/check/mysql/internal/analyze/adv_semantic_test.go new file mode 100644 index 0000000..1bcef36 --- /dev/null +++ b/check/mysql/internal/analyze/adv_semantic_test.go @@ -0,0 +1,165 @@ +package analyze + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/oracle" +) + +// TestAdvSemantic checks name-resolution and error-reporting cases the analyzer's own +// oracle/error-case tables do not cover (USING / NATURAL JOIN column merging, GROUP BY on an +// aggregate's own alias, multi-table DELETE, and which of several problems in one statement +// gets reported). Each subtest states what a real mysqld does and checks that the analyzer +// agrees; skipped without a mysqld on PATH (nix-shell -p mysql84). +func TestAdvSemantic(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + o, err := oracle.Start(ctx, testSchema) + if errors.Is(err, oracle.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer o.Close() + s := load(t) + + // serverOK asserts the server accepts sql (sanity: the test itself must describe real + // server behavior, not a guess). + serverOK := func(t *testing.T, sql string) { + t.Helper() + if _, err := o.Describe(ctx, sql); err != nil { + t.Fatalf("%s: the server rejects it (test premise is wrong): %v", sql, err) + } + } + + // TestAdvUsingUnqualifiedColumnResolves: MySQL's JOIN ... USING (col) coalesces the + // named column from both sides into one output column, so an unqualified reference to it + // is not ambiguous -- the server accepts `SELECT id FROM users u JOIN orders o USING + // (id)` (both tables have `id`; USING merges them into a single visible `id`). The + // analyzer instead resolves `id` against both sides independently and reports it + // ambiguous, rejecting a statement the server runs. + t.Run("UsingUnqualifiedColumnResolves", func(t *testing.T) { + sql := "SELECT id FROM users u JOIN orders o USING (id)" + serverOK(t, sql) + r, err := Analyze(s, sql) + if err != nil { + t.Fatalf("%s: analyzer rejects what the server accepts: %v", sql, err) + } + if len(r.Columns) != 1 || r.Columns[0].Name != "id" { + t.Fatalf("%s: got columns %+v, want one column named id", sql, r.Columns) + } + }) + + // TestAdvNaturalJoinMergesCommonColumns: NATURAL JOIN implicitly USINGs every column + // name common to both sides (here just `id`, present on both users and orders); the + // server accepts an unqualified `id` and, for `SELECT *`, emits it once, not once per + // side. The analyzer treats NATURAL JOIN as an unconditional cross join: it flags + // unqualified `id` as ambiguous, and `SELECT *` would carry two `id` columns instead of + // one (checked here via the unqualified-reference case, which is the one that turns a + // valid statement into a rejection). + t.Run("NaturalJoinMergesCommonColumns", func(t *testing.T) { + sql := "SELECT id FROM users NATURAL JOIN orders" + serverOK(t, sql) + if _, err := Analyze(s, sql); err != nil { + t.Fatalf("%s: analyzer rejects what the server accepts: %v", sql, err) + } + }) + + // TestAdvNaturalJoinStarColumnCount: SELECT * over a NATURAL JOIN lists each common + // column once (server: 7 columns -- the shared `id` plus the other 6), not once per + // side. The analyzer does not fold the duplicate and reports 8 columns, which would + // misalign a caller scanning the result by position. + t.Run("NaturalJoinStarColumnCount", func(t *testing.T) { + sql := "SELECT * FROM users u NATURAL JOIN orders o" + d, err := o.Describe(ctx, sql) + if err != nil { + t.Fatalf("%s: the server rejects it (test premise is wrong): %v", sql, err) + } + r, err := Analyze(s, sql) + if err != nil { + t.Fatalf("%s: analyzer rejects what the server accepts: %v", sql, err) + } + if len(r.Columns) != len(d.Columns) { + t.Fatalf("%s: analyzer reports %d columns, the server %d (%v vs server %v)", sql, len(r.Columns), len(d.Columns), columnNames(r), describeColumnNames(d)) + } + }) + + // TestAdvGroupByAggregateAliasRejected: MySQL rejects GROUP BY on a select-list alias + // whose expression is itself an aggregate (error 1056, "Can't group on ''") -- + // grouping by the very value being aggregated is circular. The analyzer accepts it + // silently instead of reporting 1056. + t.Run("GroupByAggregateAliasRejected", func(t *testing.T) { + sql := "SELECT COUNT(*) AS c FROM users GROUP BY c" + _, err := o.Describe(ctx, sql) + var oe *oracle.Error + if !errors.As(err, &oe) || oe.Number != 1056 { + t.Fatalf("%s: test premise is wrong: server error is %v, want 1056", sql, err) + } + r, aerr := Analyze(s, sql) + if aerr == nil { + t.Fatalf("%s: analyzer accepts what the server rejects with 1056; got columns %v", sql, columnNames(r)) + } + ae, ok := aerr.(*Error) + if !ok || ae.Code != 1056 { + t.Fatalf("%s: analyzer error is %v, want *Error with code 1056", sql, aerr) + } + }) + + // TestAdvMultiTableDeleteSupported: `DELETE t1 FROM t1 JOIN t2 ON ... WHERE ...` (the + // multi-table delete form, target list before FROM) is valid MySQL that the server runs. + // The analyzer's delete() only understands the single-table `DELETE FROM t ...` form; fed + // the multi-table form it fails target() on the delete's target-list node and returns a + // bare fmt.Errorf ("analyze: table name not understood: nil") -- not even a *Error, so a + // caller that type-switches on *Error (as the analyzer's own callers do) cannot tell this + // apart from a real internal fault. + t.Run("MultiTableDeleteSupported", func(t *testing.T) { + sql := "DELETE u FROM users u JOIN orders o ON o.user_id = u.id WHERE o.id = 1" + serverOK(t, sql) + if _, err := Analyze(s, sql); err != nil { + t.Fatalf("%s: analyzer rejects what the server accepts: %v", sql, err) + } + }) + + // TestAdvSelectListErrorReportedBeforeWhereError: when a statement has two problems -- + // an ambiguous unqualified column in the select list, and an unknown column in WHERE -- + // the server reports the select-list problem first (1052, ambiguous 'id'). The analyzer + // validates WHERE before it validates the select list's ambiguity, so for this statement + // it reports a different error entirely (1054, unknown column 'o.userid') than the one + // the server would give the same client. + t.Run("SelectListErrorReportedBeforeWhereError", func(t *testing.T) { + sql := "SELECT id FROM users u JOIN orders o ON TRUE WHERE o.userid = 1" + _, err := o.Describe(ctx, sql) + var oe *oracle.Error + if !errors.As(err, &oe) || oe.Number != 1052 { + t.Fatalf("%s: test premise is wrong: server error is %v, want 1052", sql, err) + } + _, aerr := Analyze(s, sql) + ae, ok := aerr.(*Error) + if !ok { + t.Fatalf("%s: analyzer error is %v, want *Error", sql, aerr) + } + if ae.Code != 1052 { + t.Fatalf("%s: analyzer reports %d %q, server reports 1052 %q", sql, ae.Code, ae.Message, oe.Message) + } + }) +} + +func columnNames(r *Result) []string { + names := make([]string, len(r.Columns)) + for i, c := range r.Columns { + names[i] = c.Name + } + return names +} + +func describeColumnNames(d *oracle.Description) []string { + names := make([]string, len(d.Columns)) + for i, c := range d.Columns { + names[i] = c.Name + } + return names +} diff --git a/check/mysql/internal/analyze/adv_violations_test.go b/check/mysql/internal/analyze/adv_violations_test.go new file mode 100644 index 0000000..54dc299 --- /dev/null +++ b/check/mysql/internal/analyze/adv_violations_test.go @@ -0,0 +1,288 @@ +package analyze + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "testing" + "time" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// Adversarial findings, lane "violations" (see the brief for the ground rules): each test +// names the correct behavior and is expected to fail against the current implementation. +// Every case below was reproduced against a real mysqld (nix-shell -p mysql84). + +// keysOf renders a Result's predicted violations the way TestViolations does, for a +// stable, sorted comparison. +func keysOf(vs []Violation) string { + var ks []string + for _, v := range vs { + k := fmt.Sprintf("%d %s", v.Code, v.Key()) + if v.Param > 0 { + k += fmt.Sprintf(":%d", v.Param) + } + ks = append(ks, k) + } + sort.Strings(ks) + return strings.Join(ks, ", ") +} + +// TestAdvReplaceNeverDuplicates covers REPLACE INTO: it resolves a colliding PRIMARY / +// UNIQUE key by deleting the old row and inserting the new one, so it can never raise +// 1062 (ER_DUP_ENTRY) the way a plain INSERT can. sqlshape's analyzer does not read +// PT_insert's is_replace flag at all (grep turns up nothing in internal/analyze), so +// insertViolations predicts the same PRIMARY / UNIQUE key violations for REPLACE as for +// INSERT. +// +// Reproduced: against violationSchema, with accounts holding (id=1,email='a@x',nick='a') +// and (id=2,email='b@x',nick='b'), `REPLACE INTO accounts (id, email, nick) VALUES (99, +// 'z@x', 'b')` collides on the UNIQUE key `nick` (a row with nick='b' already exists) and +// yet the server accepts it without error (it deletes the old row and inserts the new +// one). sqlshape's Analyze predicts "1062 nick" (among others) for this statement. +func TestAdvReplaceNeverDuplicates(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, violationSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + s, err := schema.Load(violationSchema) + if err != nil { + t.Fatal(err) + } + conn := db.Conn() + for _, seed := range []string{ + "INSERT INTO accounts (id, email, nick, balance) VALUES (1, 'a@x', 'a', 10), (2, 'b@x', 'b', 0)", + } { + if _, err := conn.ExecContext(ctx, seed); err != nil { + t.Fatalf("%s: %v", seed, err) + } + } + const sql = "REPLACE INTO accounts (id, email, nick) VALUES (99, 'z@x', 'b')" + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + t.Fatal(err) + } + _, execErr := tx.ExecContext(ctx, sql) + tx.Rollback() + if execErr != nil { + t.Fatalf("the server rejects the reproduction itself: %v", execErr) + } + + r, err := Analyze(s, sql) + if err != nil { + t.Fatalf("analyze: %v", err) + } + for _, v := range r.Violations { + if v.Code == codeDuplicateKey { + t.Errorf("REPLACE INTO is predicted to violate a duplicate key (%s), but the server never raises 1062 for REPLACE: got %s", v.Key(), keysOf(r.Violations)) + return + } + } +} + +// TestAdvReplaceCanViolateReferencingKey covers the other half of REPLACE's semantics: +// its implicit DELETE of a colliding row can itself be rejected by a foreign key that +// references the table with the default RESTRICT / NO ACTION action -- 1451, the same +// as an explicit DELETE. sqlshape's insertViolations (used for REPLACE too, see above) +// never calls referencingViolations, so this failure mode is missing entirely from the +// predicted set, even for a same-row REPLACE that changes nothing about the reference. +// +// Reproduced: against violationSchema, with accounts(id=1,...) referenced by +// payments(account_id=1), `REPLACE INTO accounts (id, email, nick) VALUES (1, 'a@x', +// 'a')` -- replacing the row with itself -- fails on the server with 1451 +// fk_payments_account (the implicit DELETE of id=1 is rejected). sqlshape predicts no +// 1451 violation for this statement. +func TestAdvReplaceCanViolateReferencingKey(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, violationSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + s, err := schema.Load(violationSchema) + if err != nil { + t.Fatal(err) + } + conn := db.Conn() + for _, seed := range []string{ + "INSERT INTO accounts (id, email, nick, balance) VALUES (1, 'a@x', 'a', 10)", + "INSERT INTO payments (id, account_id, amount) VALUES (10, 1, 5)", + } { + if _, err := conn.ExecContext(ctx, seed); err != nil { + t.Fatalf("%s: %v", seed, err) + } + } + const sql = "REPLACE INTO accounts (id, email, nick) VALUES (1, 'a@x', 'a')" + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + t.Fatal(err) + } + _, execErr := tx.ExecContext(ctx, sql) + tx.Rollback() + if execErr == nil || !strings.Contains(execErr.Error(), "1451") { + t.Fatalf("the server does not raise 1451 for the reproduction: %v", execErr) + } + + r, err := Analyze(s, sql) + if err != nil { + t.Fatalf("analyze: %v", err) + } + for _, v := range r.Violations { + if v.Code == codeForeignKeyRef && v.Constraint == "fk_payments_account" { + return + } + } + t.Errorf("REPLACE INTO on a row referenced by another table's FK (RESTRICT) should predict 1451 fk_payments_account (its implicit DELETE can be rejected), got %s", keysOf(r.Violations)) +} + +// TestAdvMultiTableUpdateAttributesEachAssignmentToItsOwnTable covers a multi-table +// UPDATE (a JOIN over two tables, each assigned to). update() tracks only one *write* +// for the whole statement: w.table is set to whichever table's column is assigned +// *first*, and updateViolations(w.table, w.values, nil) then walks w.table's own keys / +// foreign keys / CHECKs against the set of *all* assigned column names, regardless of +// which table each assignment actually belongs to. Two failures follow from this: +// - a NOT NULL violation is reported with the wrong table name (notNullViolations +// labels it with the `t` it was called with, not as.col's own table), so it can name +// a table.column pair that does not exist in the schema; +// - a foreign key on the *other* table is never checked at all, so a real 1452/1451 is +// silently dropped from the predicted set. +// +// Reproduced: against violationSchema, `UPDATE accounts a JOIN payments p ON +// p.account_id = a.id SET a.nick = $1, p.account_id = $2 WHERE p.id = $3` with $2 = +// 999 (no such account) fails on the server with 1452 fk_payments_account. sqlshape +// predicts no 1452 (or 1451) violation at all, and instead predicts a NOT NULL +// violation "1048 accounts.account_id:2" -- accounts has no column named account_id. +func TestAdvMultiTableUpdateAttributesEachAssignmentToItsOwnTable(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, violationSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + s, err := schema.Load(violationSchema) + if err != nil { + t.Fatal(err) + } + conn := db.Conn() + for _, seed := range []string{ + "INSERT INTO accounts (id, email, nick, balance) VALUES (1, 'a@x', 'a', 10), (2, 'b@x', 'b', 0)", + "INSERT INTO payments (id, account_id, amount) VALUES (10, 1, 5)", + } { + if _, err := conn.ExecContext(ctx, seed); err != nil { + t.Fatalf("%s: %v", seed, err) + } + } + const sql = "UPDATE accounts a JOIN payments p ON p.account_id = a.id SET a.nick = $1, p.account_id = $2 WHERE p.id = $3" + const runSQL = "UPDATE accounts a JOIN payments p ON p.account_id = a.id SET a.nick = ?, p.account_id = ? WHERE p.id = ?" + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + t.Fatal(err) + } + _, execErr := tx.ExecContext(ctx, runSQL, "z", 999, 10) + tx.Rollback() + if execErr == nil || !strings.Contains(execErr.Error(), "1452") { + t.Fatalf("the server does not raise 1452 for the reproduction: %v", execErr) + } + + r, err := Analyze(s, sql) + if err != nil { + t.Fatalf("analyze: %v", err) + } + var foundFK, bogusColumn bool + for _, v := range r.Violations { + if v.Code == codeForeignKeyRow && v.Constraint == "fk_payments_account" { + foundFK = true + } + if v.Code == codeNotNull && v.Table == "accounts" && len(v.Columns) == 1 && v.Columns[0] == "account_id" { + bogusColumn = true + } + } + if !foundFK { + t.Errorf("assigning payments.account_id in a multi-table UPDATE should predict 1452 fk_payments_account, got %s", keysOf(r.Violations)) + } + if bogusColumn { + t.Errorf("the NOT NULL violation for payments.account_id is mislabeled as accounts.account_id, which is not a real column: got %s", keysOf(r.Violations)) + } +} + +// TestAdvUpdatableViewViolations covers UPDATE through a merged (updatable) view. +// update()'s write.table comes from the target column's relation (col.rel.table), and a +// view relation always has table == nil (views hold their columns in .cols, not +// .table) even when the view merges into a real base table and is genuinely writable +// (relation.updatable / .merged, set at analyze.go:729-733). violations() bails out as +// soon as w.table is nil ("if w == nil || w.table == nil || w.ignore { return nil }"), +// so an UPDATE through an updatable view predicts zero violations no matter what the +// underlying base table's constraints are. +// +// Reproduced: against violationSchema plus `CREATE VIEW v_accounts AS SELECT * FROM +// accounts`, with two accounts seeded, `UPDATE v_accounts SET email = $1 WHERE id = +// $2` binding email to an already-used address fails on the server with 1062 +// accounts.accounts_email. sqlshape predicts no violations at all for this statement. +func TestAdvUpdatableViewViolations(t *testing.T) { + schemaSQL := violationSchema + "\nCREATE VIEW v_accounts AS SELECT * FROM accounts;\n" + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, schemaSQL) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + s, err := schema.Load(schemaSQL) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + conn := db.Conn() + for _, seed := range []string{ + "INSERT INTO accounts (id, email, nick, balance) VALUES (1, 'a@x', 'a', 10), (2, 'b@x', 'b', 0)", + } { + if _, err := conn.ExecContext(ctx, seed); err != nil { + t.Fatalf("%s: %v", seed, err) + } + } + const sql = "UPDATE v_accounts SET email = $1 WHERE id = $2" + const runSQL = "UPDATE v_accounts SET email = ? WHERE id = ?" + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + t.Fatal(err) + } + _, execErr := tx.ExecContext(ctx, runSQL, "b@x", 1) + tx.Rollback() + if execErr == nil || !strings.Contains(execErr.Error(), "1062") { + t.Fatalf("the server does not raise 1062 for the reproduction: %v", execErr) + } + + r, err := Analyze(s, sql) + if err != nil { + t.Fatalf("analyze: %v", err) + } + for _, v := range r.Violations { + if v.Code == codeDuplicateKey && v.Constraint == "accounts_email" { + return + } + } + t.Errorf("UPDATE through an updatable view should predict the base table's violations (1062 accounts_email), got %s", keysOf(r.Violations)) +} diff --git a/check/mysql/internal/analyze/analyze.go b/check/mysql/internal/analyze/analyze.go new file mode 100644 index 0000000..b7a3ed7 --- /dev/null +++ b/check/mysql/internal/analyze/analyze.go @@ -0,0 +1,2318 @@ +// Package analyze types a MySQL statement against a loaded schema: the result columns +// with their types and nullability, the parameters with the types their context gives +// them, and the errors MySQL itself would raise (unknown table or column, ambiguity). +// +// It reads the statement through mysqlast, the server's own parse tree, so the shapes it +// switches on are MySQL's PT_ / Item_ classes. What it does not know yet it leaves +// untyped rather than guessing: a column whose Known is false is accepted by the checker +// with a note. +package analyze + +import ( + "fmt" + "sort" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlast" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlparse" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/facts" + "github.com/kr9ly/sqlshape/v2/x/obligation" + "github.com/kr9ly/sqlshape/v2/x/placeholder" +) + +// Result is the analysis of one statement. +type Result struct { + // Params are the placeholders by number ($1 is Params[0]). + Params []Param + Columns []Column + // Facts is the statement's record for the contracts written on facts (One, the + // obligations): its kind, the top scope's leaves, predicates, fixed columns and + // equalities, its writes. Nil when the statement is one the analyzer does not record. + Facts *facts.Facts + // Violations are the constraints the statement may violate (violations.go). + Violations []Violation + // Uses are the relation columns the statement references (a view's as the view's). + Uses []facts.Use +} + +// Param is one placeholder: the type its context gives it, when the context is one the +// analyzer reads (compared with or assigned to a column, LIMIT). +type Param struct { + Type schema.Type + Known bool + // Source is the table column the placeholder met: compared with (`col = $1`, `col IN + // ($1, ...)`, `$1 IN (SELECT col ...)`) or stored into (INSERT / UPDATE). Nil otherwise. + Source *ParamSource +} + +// ParamSource is the table column a placeholder stands for. +type ParamSource struct { + Table string + Column string + NotNull bool + Assigned bool // stored into the column, not compared with it +} + +// Column is one result column. +type Column struct { + Name string + Type schema.Type + Known bool // the type was inferred; false leaves Type empty + Nullable bool + + base *schema.Column // the base table column this is a plain reference to, if any + baseTable *schema.Table // its table + aliased bool // a select item written with an alias (find_item_in_list prefers these) + // matLeaf: a plain reference into a materialized relation (a TEMPTABLE view, a derived + // table), whose every column -- an expression of its own query included -- is a field + // of that non-updatable leaf: assigning it through a view is the view's 1288, where an + // expression of the view's own select list is the column's 1348 (measured, + // TestViewWriteServer) + matLeaf bool + // leaf1 / leafCol: the relation of the producing block (1-based index into its rels, + // 0 when the column is not a plain reference) and the column's name there + leaf1 int + leafCol string +} + +// Error is what MySQL would raise for the statement. +type Error struct { + Message string + Code int // MySQL's error number: 1064 syntax, 1146 no table, 1054 no column, 1052 ambiguous + Position int // 0-based byte offset into the statement as given ($n form); -1 when unknown +} + +func (e *Error) Error() string { return fmt.Sprintf("%s (MySQL error %d)", e.Message, e.Code) } + +// Analyze types sql, whose placeholders are `$n`, against s. +func Analyze(s *schema.Schema, sql string) (*Result, error) { + text, ph := placeholder.Rewrite(sql) + cst, err := mysqlparse.Parse(text, s.Settings.ParseMode()) + if err != nil { + if pe, ok := err.(*mysqlparse.Error); ok { + return nil, &Error{Message: pe.Message, Code: 1064, Position: ph.Back(pe.Offset)} + } + return nil, err + } + root, err := mysqlast.BuildMode(text, cst, s.Settings.ParseMode()) + if err != nil { + if u, ok := err.(*mysqlast.Unsupported); ok && strings.Contains(u.Text, "syntax error") { + // a construct the grammar accepts and the server's action rejects + return nil, &Error{Message: fmt.Sprintf("syntax error at byte %d: %s", ph.Back(u.Start), u.Text), Code: 1064, Position: ph.Back(u.Start)} + } + return nil, err + } + a := &analyzer{s: s, text: text, ph: ph, params: make([]Param, ph.Count()), waived: canonicalWaivers(s, obligation.StatementWaivers(sql))} + if err := a.statement(root); err != nil { + return nil, err + } + if err := a.checkCalledRoutineOverlap(); err != nil { + return nil, err + } + for i := range a.params { + a.params[i].Source = a.paramSrc[i+1] + } + sort.SliceStable(a.uses, func(i, j int) bool { return a.uses[i].Position < a.uses[j].Position }) + if a.facts != nil { + a.facts.Uses = a.uses + } + return &Result{Params: a.params, Columns: a.columns, Facts: a.facts, Violations: a.violations(), Uses: a.uses}, nil +} + +// canonicalWaivers keys a statement's opt-outs by the tables' stored names, which the facts' +// leaves carry (lower_case_table_names decides how a directive's spelling matches). +func canonicalWaivers(s *schema.Schema, waived map[string][]string) map[string][]string { + out := map[string][]string{} + for table, specs := range waived { + out = obligation.AddWaiver(out, s.CanonicalName(table), specs...) + } + return out +} + +type analyzer struct { + s *schema.Schema + text string // the statement with `?` placeholders, what mysqlparse saw + ph placeholder.Map + params []Param + columns []Column + views map[string]bool // the views being expanded, against a cycle + facts *facts.Facts // the statement's record, assembled by statement + depth int // query nesting: 0 at the statement's own block + setOp int // > 0 while inside a set operation's arms + // uses are the relation columns the statement references, each once, in order of + // first appearance; readUses marks those read (not only assigned). assigning is set + // while an INSERT's column list or an UPDATE's SET targets are resolved. + uses []facts.Use + useIdx map[string]int + readUses map[string]bool + assigning bool + // waived are the statement's opt-outs (`-- sqlshape: unfiltered t`, `waive t ...`), by + // table; viewWaived are those of the views being expanded, innermost last + waived map[string][]string + viewWaived []map[string][]string + // definingView is the view AnalyzeView is analyzing: its body's references are its own + // uses (a view expanded inside a statement contributes none) + definingView string + // write is the statement's write, for the failure modes: its target, the columns + // assigned with what is stored in them, and IGNORE / ON DUPLICATE KEY UPDATE + write *write + // checkOptionViews are the WITH CHECK OPTION views (and, down a CASCADED chain, + // further ones) a write reaching table through it must satisfy (1369), by the base + // table: computed once in facts.go's checkOptionFacts, alongside the pinned lift it + // does through the same view chain for the same reason + checkOptionViews map[*schema.Table][]string + // outerRefs are the column references a nested query resolved in an enclosing block + // (fullgroup.go reads them: a correlated reference is a column of the block it names) + outerRefs []outerRef + // blocks are the SELECT blocks typed so far, by their facts, for the functional + // dependencies a derived table's body gives (fullgroup.go) + blocks map[*facts.Scope]*blockInfo + // fdConst says of an equality's known side (pred text + term text) whether the server + // treats it as a constant for functional dependencies (a literal, not a parameter) + fdConst map[string]bool + // nullEq are the columns a block compares with the literal NULL (`u = NULL`): never true, + // so no fact fixes them, but the server's ONLY_FULL_GROUP_BY check takes the equality + // for a constant one and derives functional dependencies from it (aggregate_check.cc) + nullEq map[*facts.Scope][]facts.ColRef + // lists are the SELECT blocks whose select list, GROUP BY, HAVING or ORDER BY is being + // typed, innermost last: a nested query's unqualified name may be one of their select + // aliases (Item_field::fix_outer_field's resolve_in_select_list; see lookup) + lists []listLookup + // subFacts are the subqueries' bodies by their PT_subquery node, for the EXISTS / IN + // predicates; claimed are the bodies such a predicate carries (not Children then) + subFacts map[*mysqlast.Node]*facts.Scope + claimed map[*facts.Scope]bool + // paramSrc is the column each placeholder ($n, 1-based) met first + paramSrc map[int]*ParamSource + // calledRoutines are the schema-declared FUNCTIONs a call site in this statement + // resolved to (expr.go's storedFuncCall), and the PROCEDURE a CALL statement itself + // runs (call.go's callStmt), each once (calledSeen dedupes by pointer), in the order + // first met: what violations() folds in (its own failure modes) and Analyze folds in + // checkCalledRoutineOverlap (1442: a function writing a table this statement already + // reads or writes). + calledRoutines []calledRoutine + calledSeen map[*schema.Routine]bool + // refRels are the tables and views the statement names (target(), a view's own + // underlying tables included as its body is typed), lower-cased: what a called + // routine's write collides with (1442) even where no column of them is read, as in + // `SELECT f(1) FROM t` (measured: 1442 when f writes t). + refRels map[string]bool + // bodyResult is the BodyResult the trigger's or routine's body walk is filling, nil + // for a top-level statement: exprAt hands the routines a body expression calls to it + // (finishCalls). + bodyResult *BodyResult + + // The rest is body.go's: a trigger's or routine's body walk (nil outside it). trig / + // trigTable are set for a trigger's body (NEW / OLD resolve against trigTable, and the + // trigger's own timing / event decide their rules); routine is set for a routine's + // body (RETURN's context; nil for a trigger). vars is the block-scoped chain of + // declared variables and parameters (innermost first); cursors are the declared + // cursors of the innermost block that has one in scope; labels are the enclosing + // labeled blocks / loops, for LEAVE / ITERATE. sawReturn records whether the routine's + // body had a RETURN anywhere (a function without one is refused). + trig *schema.Trigger + // storeRow is the VALUES row (1-based) whose literals literalStore is judging; 0 + // outside a multi-row INSERT (the message then says row 1) + storeRow int + // noFold turns constant folding (fold.go) off while positive: a branch a constant + // condition decides against, a GROUP BY / ORDER BY item, an EXISTS's select list; + // foldPerRow says a constant's 1690 is the violation of an expression that runs per + // row (a select item over a FROM, an UPDATE's SET), not the statement's error + noFold int + foldPerRow bool + // insertTarget is the INSERT's target relation while its ON DUPLICATE KEY UPDATE is + // typed: what VALUES(c) resolves c against + insertTarget *relation + // parents are the expression nodes being typed, outermost first; condBases the + // indexes in it where each condition being typed starts (optimizeTime reads them) + parents []*mysqlast.Node + condBases []int + existsList bool // the next SELECT block typed is an EXISTS's: its select list is not evaluated + limitZero bool // the next SELECT block typed is under LIMIT 0: likewise + // storeRaised are the failure modes a store of a value into a column adds (a geometry + // of another type into a typed spatial column, geometryStore), folded into violations() + storeRaised []Violation + trigTable *schema.Table + routine *schema.Routine + vars *varScope + labels []string + sawReturn bool + // raised accumulates the body's own failure modes as the walk finds them (a SIGNAL, an + // embedded write's own violations, a SELECT INTO's 1172): what block's own DECLARE ... + // HANDLER absorption filters, and what AnalyzeTrigger/AnalyzeRoutine hand back as + // BodyResult.Violations once the outermost block has closed. + raised []Violation + // handlerRaise is, while walking a HANDLER's own body, the exact violations this + // handler is catching (the block's own subset that matched its conditions): a bare + // RESIGNAL (no condition, no SET) re-raises them unchanged (its own SET MYSQL_ERRNO, + // when it has one, overrides the number instead, walkSignal). + handlerRaise []Violation + // inHandler counts the HANDLER bodies currently being walked (absorb), nested ones + // included: a bare RESIGNAL reached with this at 0 is outside any HANDLER, which the + // server always refuses at 1645 (walkSignal), regardless of what handlerRaise holds + // (left over from an enclosing statement's own last HANDLER, never this one's). + inHandler int + // raises names the trigger's/routine's own `-- sqlshape: error = ` + // annotations, by key (a MYSQL_ERRNO as decimal text, or a SQLSTATE). + raises map[string]string +} + +// condKind is which failure modes a condition (a SIGNAL's, a HANDLER's, a DECLARE +// CONDITION's) names. +type condKind int + +const ( + condSQLState condKind = iota // an exact 5-character SQLSTATE + condNumber // an exact MySQL error number + condWarning // SQLWARNING: SQLSTATE class "01" + condNotFound // NOT FOUND: SQLSTATE class "02" + condException // SQLEXCEPTION: any class but "00", "01", "02" +) + +// condRef is one resolved condition value (sp_condition_value's _mysqlerr, or a +// DECLARE ... CONDITION a sp_condition_name resolves to). +type condRef struct { + kind condKind + number int + sqlstate string +} + +// catches reports whether cond matches v, the way MySQL's HANDLER search does (measured: +// SQLEXCEPTION catches a custom SQLSTATE and a schema constraint's alike; a HANDLER FOR a +// MySQL error number matches the server's own number, whichever form raised it). +func (c condRef) catches(v Violation) bool { + class := "" + if len(v.SQLState) >= 2 { + class = v.SQLState[:2] + } + switch c.kind { + case condNumber: + return v.Code == c.number + case condSQLState: + return strings.EqualFold(v.SQLState, c.sqlstate) + case condWarning: + return class == "01" + case condNotFound: + return class == "02" + case condException: + return class != "" && class != "00" && class != "01" && class != "02" + } + return false +} + +// write is what a statement stores, for the failure modes (violations.go). +type write struct { + kind facts.StmtKind + table *schema.Table + values []assignment + // ignore: INSERT / UPDATE / DELETE IGNORE turns every constraint error into a warning + ignore bool + // onDuplicate: INSERT ... ON DUPLICATE KEY UPDATE absorbs the unique violations and + // updates these columns instead + onDuplicate []assignment + // insertsAll: an INSERT without a column list, or a SET-form one, names every column + inserted map[string]bool + // query: the values come from a query (INSERT ... SELECT), nullability per column + query bool + // rows: the VALUES rows (0 for the query form). Without strict mode only a single-row + // INSERT / REPLACE rejects a NULL for a NOT NULL column (1048); more rows, a query and + // an UPDATE store the type's implicit default with a warning instead + rows int + // replace: REPLACE INTO -- a colliding row is deleted first, so no key is violated + // but the rows referring to the replaced one are (1451) + replace bool + // load: LOAD DATA -- a NOT NULL column the column list leaves out takes its type's + // implicit default, not 1364 (measured) + load bool + // targetViews: every view a write targets (lower-cased), for targetInSubquery: a + // subquery reading the very view the statement writes is the server's 1093, not the + // 1443 a view over the same base table gets (measured, TestViewWriteServer) + targetViews map[string]bool + // view: the view an INSERT / REPLACE writes through, "" for a base table. A base + // column with no default the write leaves unassigned is then the view's 1423 ("Field + // of view ... underlying table doesn't have a default value", measured -- whether the + // view exposes the column or not), not the table's own 1364. + view string + // more are the further tables a multi-table UPDATE assigns or a multi-table DELETE + // deletes from, each with its own assignments; table / values are the first's + more []moreTarget +} + +// targetView records a view the write goes through (see write.targetViews). +func (w *write) targetView(name string) { + if name == "" { + return + } + if w.targetViews == nil { + w.targetViews = map[string]bool{} + } + w.targetViews[strings.ToLower(name)] = true +} + +// moreTarget is one further table of a multi-table write. +type moreTarget struct { + table *schema.Table + values []assignment +} + +// appendTarget adds an assignment to the table's entry in more. +func appendTarget(more []moreTarget, t *schema.Table, as assignment) []moreTarget { + for i := range more { + if more[i].table == t { + more[i].values = append(more[i].values, as) + return more + } + } + return append(more, moreTarget{table: t, values: []assignment{as}}) +} + +// assignment is one value stored into a column. +type assignment struct { + col *schema.Column + nullable bool + param int // the bare placeholder stored ($n), 0 otherwise + // fromFile: a LOAD DATA field, whose NULL for a NOT NULL column is 1263, not 1048 + fromFile bool +} + +// relation is a table in scope, under its alias: a base table, or a derived one (a +// derived table, a view, a common table expression) whose columns are its query's. +type relation struct { + alias string + table *schema.Table // nil for a derived relation + cols []Column // the derived relation's columns + // updatable / insertable are the server's own view flags, computed when it merges the + // view (sql_resolver.cc): updatable when any FROM leaf is updatable, insertable when + // every one is, neither when any leaf sits on the nullable side of an outer join + // (viewWritability). leaves counts the FROM leaves (a join view has more than one), + // viewBase is the single leaf's base table when there is exactly one and it bottoms + // out in a base table. All zero for anything but a merged view. + updatable bool + insertable bool + leaves int + viewBase *schema.Table + nullable bool // on the nullable side of an outer join + pos int // offset of the reference in the text + view string // the view's name when the relation is a view + target bool // a write's target + body *facts.Scope // a derived relation's own block, for the proof to look into + cte bool // a common table expression + merged bool // a derived relation the server merges into the query (not materialized) +} + +// columns lists the relation's columns as a SELECT * expands them (a base table's +// invisible columns are left out). +func (r *relation) columns() []Column { + if r.table == nil { + out := make([]Column, len(r.cols)) + for i, c := range r.cols { + c.Nullable = c.Nullable || r.nullable + if !r.merged { + c.matLeaf = true // a materialized relation's column, expression or not + } + out[i] = c + } + return out + } + var out []Column + for _, col := range r.table.Columns { + if col.Invisible { + continue + } + out = append(out, Column{Name: col.Name, Type: col.Type, Known: true, Nullable: !col.NotNull || r.nullable, base: col, baseTable: r.table}) + } + return out +} + +// column resolves a column name in the relation. +func (r *relation) column(name string) (colRef, bool) { + if r.table == nil { + for _, c := range r.cols { + if strings.EqualFold(c.Name, name) { + c.Nullable = c.Nullable || r.nullable + if !r.merged { + c.matLeaf = true // a materialized relation's column, expression or not + } + ref := colRef{rel: r, c: c} + if r.updatable { + ref.col = c.base + } + return ref, true + } + } + return colRef{}, false + } + col := r.table.Column(name) + if col == nil { + return colRef{}, false + } + return colRef{rel: r, col: col, c: Column{Name: col.Name, Type: col.Type, Known: true, Nullable: !col.NotNull || r.nullable, base: col, baseTable: r.table}}, true +} + +// rowid is the column `_rowid` names in a base table (find_field_in_table's rowid_field_offset, +// measured in TestNameResolutionServer): the table's first key once the server has sorted them +// (PRIMARY, then the unique keys over NOT NULL columns in declaration order, then the +// rest) when that key is unique over exactly one column of an integer type (the INT +// family, YEAR, BIT) that is NOT NULL; a view or a derived table has none. +func (r *relation) rowid() (colRef, bool) { + if r.table == nil || r.view != "" { + return colRef{}, false + } + var first *schema.Key + rank := func(k *schema.Key) int { + switch { + case k.Kind == schema.Primary: + return 0 + case k.Kind == schema.Unique: + for _, p := range k.Parts { + c := r.table.Column(p.Column) + if p.Expr != nil || c == nil || !c.NotNull { + return 2 + } + } + return 1 + } + return 2 + } + for _, k := range r.table.Keys { + if first == nil || rank(k) < rank(first) { + first = k + } + } + if first == nil || (first.Kind != schema.Primary && first.Kind != schema.Unique) || len(first.Parts) != 1 || first.Parts[0].Expr != nil { + return colRef{}, false + } + col := r.table.Column(first.Parts[0].Column) + if col == nil || !col.NotNull { + return colRef{}, false + } + switch col.Type.Name { + case "tinyint", "smallint", "mediumint", "int", "bigint", "year", "bit": + default: + return colRef{}, false + } + return colRef{rel: r, col: col, c: Column{Name: col.Name, Type: col.Type, Known: true, Nullable: r.nullable, base: col, baseTable: r.table}}, true +} + +// scope is the relations a name resolves against: the query's own, then, for a +// correlated subquery, the enclosing queries'. ctes are the common table expressions in +// force, by name, for the FROM clauses of this query and its subqueries. +type scope struct { + rels []relation + outer *scope + ctes []relation + // tag identifies the SELECT block across the copies of its scope (a block without a + // FROM has no relation to stand for it), for lists + tag *byte + // items are the block's select-list columns once typed: HAVING resolves a name that + // is not a table column against them (a MySQL extension). + items []Column + // facts is the block's record for the contracts (One and the obligations), filled by + // querySpecification; nil until then. + facts *facts.Scope + // joins are the join conditions of the FROM clause, for the facts: which conjuncts + // hold, and for an outer join which leaves they are allowed to restrict. + joins []joinCond + // kids collects the nested blocks analyzed under this one (derived tables, the + // subqueries of its conditions and select list): the facts' Children. Shared by the + // copies of the scope; nil when the block does not record them. + kids *[]*facts.Scope + // nnMarks are the columns the block's conjuncts reject NULL for (a comparison, LIKE, + // BETWEEN, IN, IS NOT NULL with the column as a direct argument), each with the + // nullable side of the outer join whose ON says so (nil: the WHERE), for the functional + // dependencies (fullgroup.go) + nnMarks []nnMark + // ndJoins are the nullable sides of the outer joins whose ON is not deterministic: + // its equalities give no dependency + ndJoins [][]int + // info is the block's record for the ONLY_FULL_GROUP_BY and DISTINCT checks, set by + // querySpecification + info *blockInfo + // itemList is the select list as written, for the clauses that name its aliases + itemList mysqlast.List + // merged are the columns a USING / NATURAL join coalesces: one name over the leaves + // that carry it. An unqualified reference to it is not ambiguous, and `*` lists it once, + // first (the left side's). + merged []mergedCol + // wherePreds is how many of facts.Preds, and whereNN how many of nnMarks, came from + // WHERE and JOIN ON (block() sets both right after those are recorded, before HAVING's + // own are folded in): fullgroup.go's fdClosure reads only these prefixes, since a WHERE + // (or an inner join's ON) equality or non-null mark genuinely extends ONLY_FULL_GROUP_BY's + // functional dependency (measured: `WHERE u = 1 GROUP BY a` and `WHERE u IS NOT NULL + // GROUP BY u` both accept a nonaggregated column that depends on u), but the same + // predicate written in HAVING does not (measured: `GROUP BY u HAVING u IS NOT NULL` and + // `GROUP BY a, u HAVING u = 1` still require every nonaggregated select-list column to + // depend on the GROUP BY columns alone) -- HAVING runs after grouping, so it restricts + // which groups come out, not what the server may assume about a row while deciding + // whether the query is well-formed. + wherePreds, whereNN int +} + +// mergedCol is one coalesced join column. +type mergedCol struct { + name string + leaves []int +} + +// mergedLeaves reports whether every leaf in leaves is one a merged column of that name +// spans (the reference is to the coalesced column). +func (sc *scope) mergedLeaves(name string, leaves []int) bool { + for _, m := range sc.merged { + if !strings.EqualFold(m.name, name) { + continue + } + all := true + for _, l := range leaves { + found := false + for _, ml := range m.leaves { + found = found || ml == l + } + all = all && found + } + if all { + return true + } + } + return false +} + +// nnMark is a column a conjunct rejects NULL for; restrict is the outer join's nullable +// side when the conjunct is its ON (nil for the WHERE). +type nnMark struct { + col facts.ColRef + restrict []int +} + +// child records a nested block's facts under this one. +func (sc *scope) child(fs *facts.Scope) { + if sc != nil && sc.kids != nil && fs != nil { + *sc.kids = append(*sc.kids, fs) + } +} + +// joinCond is one join's condition as the facts see it. +type joinCond struct { + on mysqlast.Value // the ON expression, nil for USING + using []string // USING (a, b) + left []int // leaf indices of the two sides + right []int + kind string // JTT_INNER, JTT_LEFT, JTT_RIGHT ... +} + +// derived makes a scope for a nested query: its relations start empty, the enclosing +// scope is outer, and the enclosing CTEs stay visible. +func (sc *scope) derived() scope { + if sc == nil { + return scope{} + } + return scope{outer: sc, ctes: sc.ctes} +} + +func (a *analyzer) statement(v mysqlast.Value) error { + n, ok := v.(*mysqlast.Node) + if !ok { + return fmt.Errorf("analyze: statement not understood: %s", mysqlast.Sprint(v)) + } + switch n.Class { + case "PT_select_stmt": + if err := a.selectStmt(n); err != nil { + return err + } + if len(selectInto(n)) > 0 { + // INTO @var / INTO var: the row goes into the variables and no result set + // reaches the client (measured on mysqld 8.4, the corpus probe's `SELECT ... + // INTO @v` statements, in the trailing and the in-query positions alike); a + // body's walkSelect keeps the columns to match them against the targets + a.columns = nil + } + return nil + case "PT_insert": + return a.insert(n) + case "PT_update": + if err := a.update(n); err != nil { + return err + } + return a.targetInSubquery("UPDATE") + case "PT_delete": + if err := a.delete(n); err != nil { + return err + } + return a.targetInSubquery("DELETE") + case "PT_call": + return a.callStmt(n) + case "lock_tables": + return a.lockTables(n) + case "unlock": + return a.unlockTables(n) + case "PT_load_table": + return a.loadData(n) + } + return fmt.Errorf("analyze: %s is not supported yet", strings.TrimPrefix(n.Class, "PT_")) +} + +// targetInSubquery is the server's refusal of an UPDATE / DELETE whose subquery reads the +// table it writes: 1093 "You can't specify target table 't' for update in FROM clause" for +// a subquery naming the target itself (in WHERE, EXISTS or IN alike), 1443 "The definition +// of table 'v' prevents operation UPDATE on table 't'." for one reading a view over it +// (measured on 8.4, found by x/stmtprobe). A derived table over the target is +// materialized and allowed (measured), as is an INSERT ... SELECT from its own table; a +// scalar subquery in SET is not recorded in the facts and goes unchecked here. +func (a *analyzer) targetInSubquery(op string) error { + f := a.facts + if f == nil || f.Top == nil { + return nil + } + var target, alias string + for _, l := range f.Top.Leaves { + if l.Role == facts.Target && l.Kind == facts.Table { + target, alias = l.Table, l.Alias + break + } + } + if target == "" { + return nil + } + var visit func(sc *facts.Scope, top bool) error + visit = func(sc *facts.Scope, top bool) error { + if sc == nil { + return nil + } + derived := map[*facts.Scope]bool{} + for _, l := range sc.Leaves { + if l.Kind == facts.Derived || l.Kind == facts.CTE { + derived[l.Body] = true // materialized: allowed, and not followed + continue + } + if top { + continue + } + switch l.Kind { + case facts.Table: + if a.s.Table(l.Table) == a.s.Table(target) { + return &Error{Message: fmt.Sprintf("You can't specify target table '%s' for update in FROM clause", alias), Code: 1093, Position: int(l.Position)} + } + case facts.View: + if a.write != nil && a.write.targetViews[strings.ToLower(l.Table)] { + // the subquery reads the very view the statement writes: the server + // treats it as the target itself (measured: 1093, not the 1443 a view + // over the same base table gets) + return &Error{Message: fmt.Sprintf("You can't specify target table '%s' for update in FROM clause", alias), Code: 1093, Position: int(l.Position)} + } + if a.viewReads(l.Body, target) { + return &Error{Message: fmt.Sprintf("The definition of table '%s' prevents operation %s on table '%s'.", l.Alias, op, alias), Code: 1443, Position: int(l.Position)} + } + } + } + for _, p := range sc.Preds { + if err := visit(p.Sub, false); err != nil { + return err + } + } + for _, ch := range sc.Children { + if derived[ch] { + continue + } + if err := visit(ch, false); err != nil { + return err + } + } + return nil + } + return visit(f.Top, true) +} + +// viewReads: a view body (or a view it reads in turn) names table. +func (a *analyzer) viewReads(body *facts.Scope, table string) bool { + if body == nil { + return false + } + for _, l := range body.Leaves { + if l.Kind == facts.Table && a.s.Table(l.Table) == a.s.Table(table) || l.Kind == facts.View && a.viewReads(l.Body, table) { + return true + } + } + for _, p := range body.Preds { + if a.viewReads(p.Sub, table) { + return true + } + } + for _, ch := range body.Children { + if a.viewReads(ch, table) { + return true + } + } + return false +} + +func (a *analyzer) selectStmt(n *mysqlast.Node) error { + a.facts = &facts.Facts{Kind: facts.Select} + cols, err := a.queryExpression(n.Arg("qe"), nil) + if err != nil { + return err + } + a.columns = cols + if qe, ok := n.Arg("qe").(*mysqlast.Node); ok && limitOne(qe.Arg("limit")) { + a.facts.AtMostOne = true + } + if selectIntoFile(n) { + // INTO OUTFILE / DUMPFILE: the rows go to a file on the server, none to the + // client (measured: the statement returns no result set) + a.columns = nil + } + return nil +} + +func (a *analyzer) insert(n *mysqlast.Node) error { + rel, err := a.target(arg(n, "table_ident", 3), nil, nil) + if err != nil { + return err + } + replace := isTrue(n.Arg("is_replace")) + base := rel.table + if base == nil { + // a view. The server's checks, in its order (measured, TestViewWriteServer): the + // view must be insertable (every FROM leaf insertable, no outer join: 1471), a + // join view under REPLACE never works (1395: the delete half) and needs an + // explicit column list (1394) naming columns of exactly one base table + // (insertColumn's 1393, the ON DUPLICATE KEY UPDATE assignments included); the + // fields resolve against the view (1054; a derived one among them is its 1348); + // and the columns outside the fields must all be plain, distinct base columns + // (check_view_insertability: 1471). + if !rel.insertable || rel.cte { + return &Error{Message: fmt.Sprintf("The target table %s of the INSERT is not insertable-into", rel.alias), Code: 1471, Position: a.ph.Back(rel.pos)} + } + if rel.leaves > 1 { + if replace { + return &Error{Message: fmt.Sprintf("Can not delete from join view '%s'", rel.view), Code: 1395, Position: a.ph.Back(rel.pos)} + } + if cols, ok := arg(n, "column_list", 5).(mysqlast.List); !ok || len(cols) == 0 { + return &Error{Message: fmt.Sprintf("Can not insert into join view '%s' without fields list", rel.view), Code: 1394, Position: a.ph.Back(rel.pos)} + } + } else { + base = rel.viewBase + } + if base != nil && a.viewBodyRereads(rel.body, base.Name) { + // the view's own body reads the target base table again in a subquery: the + // server refuses the INSERT as non-insertable (measured: 1471, where the same + // re-read on UPDATE / DELETE is targetInSubquery's 1093 / 1443) + return &Error{Message: fmt.Sprintf("The target table %s of the INSERT is not insertable-into", rel.alias), Code: 1471, Position: a.ph.Back(rel.pos)} + } + } + rel.target = true + w := &write{kind: facts.Insert, table: base, view: rel.view, ignore: isTrue(arg(n, "ignore", 2)), inserted: map[string]bool{}, replace: replace} + w.targetView(rel.view) + a.write = w + // the column list names the targets; without one the row lists every column in order + // (a view's own columns, each mapped to its base column) + var targets []*schema.Column + if cols, ok := arg(n, "column_list", 5).(mysqlast.List); ok && len(cols) > 0 { + for _, c := range cols { + a.assigning = true + col, err := a.insertColumn(rel, &base, c) + a.assigning = false + if err != nil { + return err + } + targets = append(targets, col) + } + } else if rel.table != nil { + targets = rel.table.Columns + } else { + // no column list: the view's own columns are the fields, a derived one among them + // its own 1348 (measured: `INSERT INTO v4 VALUES ...` over `SELECT c+1 AS x, ...` + // is 1348 on x, where the same view under a list of plain columns is 1471 below) + for _, c := range rel.cols { + if c.base == nil { + return &Error{Message: fmt.Sprintf("Column '%s' is not updatable", c.Name), Code: 1348, Position: a.ph.Back(rel.pos)} + } + targets = append(targets, c.base) + } + } + if rel.table == nil { + // check_view_insertability: every view column -- a field or not -- must be a plain + // base column (a derived field already failed above, so any left is 1471), and no + // base column of the target table may stand behind two view columns + seen := map[*schema.Column]bool{} + for _, c := range rel.cols { + if c.base == nil || c.baseTable == base && seen[c.base] { + return &Error{Message: fmt.Sprintf("The target table %s of the INSERT is not insertable-into", rel.alias), Code: 1471, Position: a.ph.Back(rel.pos)} + } + if c.baseTable == base { + seen[c.base] = true + } + } + if base == nil { + base = baseTableOf(rel.cols) + } + w.table = base + } + for _, c := range targets { + w.inserted[c.Name] = true + } + a.facts = &facts.Facts{Kind: facts.Insert, Top: &facts.Scope{At: -1, Leaves: []facts.Leaf{a.leafFacts(*rel)}}} + if rel.view != "" { + // a WITH CHECK OPTION view pins the inserted rows and is one of the write's failure + // modes (1369), exactly as a write reached through update()'s scope is + a.checkOptionFacts(&scope{rels: []relation{*rel}}, a.facts.Top) + } + var values []facts.Term + dupScope := scope{rels: []relation{*rel}} + a.insertTarget = rel + defer func() { a.insertTarget = nil }() + if q := arg(n, "insert_query_expression", 7); q != nil { + // INSERT ... SELECT: the query's columns feed the targets in order; a bare + // placeholder in its select list takes the target's type + cols, body, block, err := a.queryExpressionBlock(q, nil) + if err != nil { + return err + } + if block != nil && !(block.info != nil && block.info.aggregated) { + // ON DUPLICATE KEY UPDATE sees the SELECT's tables beside the target (measured: + // `INSERT INTO t SELECT ... FROM u ... ON DUPLICATE KEY UPDATE c = u.c` runs, an + // unqualified name both have is 1052) -- unless the SELECT is grouped or + // aggregated, when only the target is in view (`... GROUP BY a ON DUPLICATE KEY + // UPDATE k = a` is 1054, corpus). A select alias is never visible to it, and + // VALUES(c) names the target's column alone (Item_insert_value in node) + dupScope = *block + dupScope.rels = append(append([]relation{}, block.rels...), *rel) // the target last: the join's merged columns keep their leaf indexes + } + if len(cols) != len(targets) { + return &Error{Message: "Column count doesn't match value count at row 1", Code: 1136, Position: -1} + } + w.query = true + for i, c := range cols { + w.values = append(w.values, assignment{col: targets[i], nullable: c.Nullable || !c.Known}) + values = append(values, facts.Term{Kind: facts.Known, Text: "?"}) + if a.routine == nil && a.trig == nil && c.Known { + if e, viol := a.geometryStore(base, targets[i], nil, typed{typ: c.Type, known: true, nullable: c.Nullable}); e != nil { + e.Position = a.ph.Back(nodeStart(q)) + return e + } else if viol != nil { + a.storeRaised = append(a.storeRaised, *viol) + } + } + } + if qe, ok := q.(*mysqlast.Node); ok { + if body, ok := qe.Arg("body").(*mysqlast.Node); ok && body.Class == "PT_query_specification" { + items, _ := body.Arg("item_list").(mysqlast.List) + for i, item := range items { + if it, ok := item.(*mysqlast.Node); ok && it.Class == "PTI_expr_with_alias" && isParam(it.Arg("expr")) && i < len(targets) { + a.setParam(it.Arg("expr"), targets[i].Type) + a.noteParamSource(it.Arg("expr"), base, targets[i], true) + } + } + } + } + if body != nil { + a.facts.Source = body + a.facts.Top.Children = append(a.facts.Top.Children, body) + } + } + rows, _ := arg(n, "row_value_list", 6).(mysqlast.List) + if len(rows) > 0 && arg(n, "insert_query_expression", 7) == nil { + // `VALUES ()` (each row empty) inserts a row of defaults whatever the column list + // says: no column is assigned, so no 1136 (measured on mysqld 8.4, the corpus + // probe's `INSERT INTO t1 VALUES ()` and `INSERT INTO t1 () VALUES (), ()`); an + // omitted NOT NULL column without a default is the usual 1364 + empty := true + for _, row := range rows { + if vals, _ := row.(mysqlast.List); len(vals) > 0 { + empty = false + } + } + if empty { + targets = nil + w.inserted = map[string]bool{} + } + } + w.rows = len(rows) + switch { + case arg(n, "insert_query_expression", 7) != nil: + case len(rows) == 1: + a.facts.Top.Single = true + case len(rows) > 1: + a.facts.Top.Many = fmt.Sprintf("VALUES has %d rows", len(rows)) + } + for ri, row := range rows { + vals, _ := row.(mysqlast.List) + if len(vals) != len(targets) { + return &Error{Message: "Column count doesn't match value count at row 1", Code: 1136, Position: -1} + } + for i, v := range vals { + a.storeRow = ri + 1 + as, err := a.assign(scope{rels: []relation{*rel}}, base, targets[i], v) + a.storeRow = 0 + if err != nil { + return err + } + w.values = append(w.values, as) + if ri == 0 { + values = append(values, a.storedTerm(scope{rels: []relation{*rel}}, targets[i], v)) + } + } + } + a.facts.Writes = []facts.Write{a.writeFacts(facts.Insert, rel, base, targets, values)} + if w.replace { + // REPLACE deletes the colliding row before it inserts the new one (measured: an + // AFTER DELETE trigger on the table fires) -- a second write, of Kind Delete, so + // `require never on delete` and other OnDelete obligations see it (x/obligation's + // writes() keys off facts.Write.Kind, not the statement's own top-level Kind). + a.facts.Writes = append(a.facts.Writes, facts.Write{Table: base.Name, Kind: facts.Delete, Position: int32(a.ph.Back(rel.pos))}) + } + if al := arg(n, "opt_values_table_alias", 9); al != nil { + // the row alias (8.0.19): `VALUES (...) AS new [(names)]` exposes the inserted + // columns -- the insert's own fields, not the whole table -- to ON DUPLICATE KEY + // UPDATE under the alias. Measured on 8.4: new.c of a column outside the insert's + // list is 1054, a name list renames positionally and hides the old names, a name + // list of the wrong count is 1353, an alias colliding with the target is 1066, an + // unqualified name the target and the alias share is 1052, and VALUES(c) stays + // usable beside the alias. + cols := make([]Column, 0, len(targets)) + for _, c := range targets { + cols = append(cols, Column{Name: c.Name, Type: c.Type, Known: true, Nullable: !c.NotNull}) + } + if names, ok := arg(n, "opt_values_column_list", 10).(mysqlast.List); ok && len(names) > 0 { + if len(names) != len(cols) { + return &Error{Message: "In definition of view, derived table or common table expression, SELECT list and column names list have different column counts", Code: 1353, Position: a.ph.Back(nodeStart(al))} + } + seen := map[string]bool{} + for i, nm := range names { + if lower := strings.ToLower(str(nm)); seen[lower] { + return &Error{Message: fmt.Sprintf("Duplicate column name '%s'", str(nm)), Code: 1060, Position: a.ph.Back(nodeStart(al))} + } else { + seen[lower] = true + } + cols[i].Name = str(nm) + } + } + aliasRel := relation{alias: str(al), cols: cols} + if err := a.addRelation(&dupScope, &aliasRel, nodeStart(al)); err != nil { + return err + } + } + dupCols, _ := arg(n, "opt_on_duplicate_column_list", 10).(mysqlast.List) + dupVals, _ := arg(n, "opt_on_duplicate_value_list", 11).(mysqlast.List) + if len(dupCols) > 0 { + w.onDuplicate = []assignment{} + } + var dupTargets []*schema.Column + var dupTerms []facts.Term + for i, c := range dupCols { + a.assigning = true + col, err := a.insertColumn(rel, &base, c) + a.assigning = false + if err != nil { + return err + } + if i < len(dupVals) { + a.foldPerRow = true // the update runs per colliding row (fold.go) + as, err := a.assign(dupScope, base, col, dupVals[i]) + a.foldPerRow = false + if err != nil { + return err + } + w.onDuplicate = append(w.onDuplicate, as) + dupTargets = append(dupTargets, col) + dupTerms = append(dupTerms, a.storedTerm(dupScope, col, dupVals[i])) + } + } + if len(dupTargets) > 0 { + // ON DUPLICATE KEY UPDATE is a second write, of Kind Update, on the same row as + // the INSERT branch would have targeted -- the same shape x/obligation already + // has a case for on PostgreSQL's ON CONFLICT DO UPDATE (a branch reassigning a + // pinned column needs its own WHERE-less write checked, since nothing in the + // UPDATE branch itself fixes the row it moves). + a.facts.Writes = append(a.facts.Writes, a.writeFacts(facts.Update, rel, base, dupTargets, dupTerms)) + } + return nil +} + +func (a *analyzer) update(n *mysqlast.Node) error { + ctes, err := a.with(n.Arg("with_clause"), nil) + if err != nil { + return err + } + sc, err := a.from(n.Arg("join_table_list"), scope{ctes: ctes, kids: new([]*facts.Scope)}) + if err != nil { + return err + } + cols, _ := n.Arg("column_list").(mysqlast.List) + vals, _ := n.Arg("value_list").(mysqlast.List) + type perTarget struct { + rel *relation + table *schema.Table + assigned []*schema.Column + values []facts.Term + } + var targets []*perTarget + w := &write{kind: facts.Update, ignore: isTrue(n.Arg("opt_ignore"))} + a.write = w + for i, c := range cols { + a.assigning = true + col, err := a.column(sc, c, "field list") + a.assigning = false + if err != nil { + return err + } + if col.rel == nil { + // the name resolved to a routine variable of the same name: a SET target is a + // column, which the server resolves against the tables (the analyzer used to + // fall over on the missing relation -- found by the corpus probe) + name := identName(c) + var found *relation + for j := range sc.rels { + if sc.rels[j].table != nil && sc.rels[j].table.Column(name) != nil { + found = &sc.rels[j] + break + } + } + if found == nil { + return &Error{Message: fmt.Sprintf("Unknown column '%s' in 'field list'", name), Code: 1054, Position: a.ph.Back(nodeStart(c))} + } + col = colRef{rel: found, col: found.table.Column(name)} + } + if col.col == nil { + if col.rel.table == nil && col.rel.merged && col.rel.updatable && !col.rel.cte && !col.c.matLeaf { + // an updatable view's own derived column: the column's error, not the view's + // (measured: `UPDATE v SET expr_col = 1` is 1348; a non-updatable view, or a + // column of a materialized leaf inside an updatable one, is the view's 1288) + return &Error{Message: fmt.Sprintf("Column '%s' is not updatable", identName(c)), Code: 1348, Position: a.ph.Back(nodeStart(c))} + } + return &Error{Message: fmt.Sprintf("The target table %s of the UPDATE is not updatable", col.rel.alias), Code: 1288, Position: a.ph.Back(nodeStart(c))} + } + col.rel.target = true + if len(sc.rels) == 1 { + // a single-table UPDATE reading its own view in a subquery is 1093, the target + // itself; in the multi-table form the server spells it as the view preventing + // the operation (1443, measured), which targetInSubquery's view branch keeps + a.write.targetView(col.rel.view) + } + table := col.rel.table + if table == nil { + table = col.c.baseTable // an updatable view: the write reaches its base table + } + var tg *perTarget + for _, t := range targets { + if t.rel == col.rel { + tg = t + } + } + if tg != nil && col.rel.table == nil && tg.table != table { + // a join view's SET naming columns of two base tables (measured: 1393 whatever + // the values) + return &Error{Message: fmt.Sprintf("Can not modify more than one base table through a join view '%s'", col.rel.view), Code: 1393, Position: a.ph.Back(nodeStart(c))} + } + if tg == nil { + tg = &perTarget{rel: col.rel, table: table} + targets = append(targets, tg) + } + tg.assigned = append(tg.assigned, col.col) + if i < len(vals) { + a.foldPerRow = true // a SET expression runs per matched row (fold.go) + as, err := a.assign(sc, table, col.col, vals[i]) + a.foldPerRow = false + if err != nil { + return err + } + tg.values = append(tg.values, a.storedTerm(sc, col.col, vals[i])) + if w.table == nil { + w.table = table + } + if table == w.table { + w.values = append(w.values, as) + } else { + w.more = appendTarget(w.more, table, as) + } + } + } + if err := a.condition(sc, n.Arg("opt_where_clause"), "where clause"); err != nil { + return err + } + if err := a.orderBy(n.Arg("opt_order_clause"), nil, &sc, "order clause"); err != nil { + return err + } + if err := a.limit(n.Arg("opt_limit_clause")); err != nil { + return err + } + a.facts = &facts.Facts{Kind: facts.Update, AtMostOne: limitOne(n.Arg("opt_limit_clause"))} + for _, tg := range targets { + if tg.rel.table != nil || tg.table != nil { + a.facts.Writes = append(a.facts.Writes, a.writeFacts(facts.Update, tg.rel, tg.table, tg.assigned, tg.values)) + } + } + a.facts.Top = a.block(&sc, n) + a.facts.Top.Children = append(a.facts.Top.Children, cteBodies(ctes)...) + return nil +} + +func (a *analyzer) delete(n *mysqlast.Node) error { + ctes, err := a.with(n.Arg("with_clause"), nil) + if err != nil { + return err + } + if list, ok := n.Arg("table_list").(mysqlast.List); ok && len(list) > 0 { + return a.multiDelete(n, list, ctes) + } + rel, err := a.target(n.Arg("table_ident"), n.Arg("opt_table_alias"), &scope{ctes: ctes}) + if err != nil { + return err + } + base, derr := a.deleteTarget(rel, rel.pos) + if derr != nil { + return derr + } + rel.target = true + a.write = &write{kind: facts.Delete, table: base, ignore: deleteIgnore(n.Arg("opt_delete_options"))} + a.write.targetView(rel.view) + sc := scope{rels: []relation{*rel}, ctes: ctes, kids: new([]*facts.Scope)} + if err := a.condition(sc, n.Arg("opt_where_clause"), "where clause"); err != nil { + return err + } + if err := a.orderBy(n.Arg("opt_order_clause"), nil, &sc, "order clause"); err != nil { + return err + } + if err := a.limit(n.Arg("opt_delete_limit_clause")); err != nil { + return err + } + a.facts = &facts.Facts{Kind: facts.Delete, AtMostOne: limitOne(n.Arg("opt_delete_limit_clause")), Writes: []facts.Write{a.writeFacts(facts.Delete, rel, base, nil, nil)}} + a.facts.Top = a.block(&sc, n) + a.facts.Top.Children = append(a.facts.Top.Children, cteBodies(ctes)...) + return nil +} + +// deleteTarget is the base table a DELETE's target lands on: the table itself, or an +// updatable single-leaf view's base (derived columns do not block a DELETE the way they +// block an INSERT). A CTE or a non-updatable view is the statement's 1288, a join view its +// 1395 (measured, TestViewWriteServer). +func (a *analyzer) deleteTarget(rel *relation, at int) (*schema.Table, error) { + if rel.table != nil { + return rel.table, nil + } + if rel.cte || !rel.updatable { + return nil, &Error{Message: fmt.Sprintf("The target table %s of the DELETE is not updatable", rel.alias), Code: 1288, Position: a.ph.Back(at)} + } + if rel.leaves > 1 { + return nil, &Error{Message: fmt.Sprintf("Can not delete from join view '%s'", rel.view), Code: 1395, Position: a.ph.Back(at)} + } + if rel.viewBase == nil { + return nil, &Error{Message: fmt.Sprintf("The target table %s of the DELETE is not updatable", rel.alias), Code: 1288, Position: a.ph.Back(at)} + } + return rel.viewBase, nil +} + +// deleteIgnore reads IGNORE among DELETE's options. +func deleteIgnore(v mysqlast.Value) bool { + switch x := v.(type) { + case mysqlast.Flags: + for _, f := range x { + if strings.Contains(strings.ToUpper(string(f)), "IGNORE") { + return true + } + } + return false + case mysqlast.List: + for _, e := range x { + if deleteIgnore(e) { + return true + } + } + case *mysqlast.Struct: + return isTrue(x.Fields["ignore"]) || isTrue(x.Fields["opt_ignore"]) + case *mysqlast.Node: + return isTrue(x.Arg("ignore")) || isTrue(x.Arg("opt_ignore")) + } + return strings.Contains(strings.ToUpper(str(v)), "IGNORE") // the options come as "DELETE_IGNORE|..." +} + +// cteBodies lists the recorded bodies of a WITH clause's items. +func cteBodies(ctes []relation) []*facts.Scope { + var out []*facts.Scope + for _, c := range ctes { + if c.body != nil { + out = append(out, c.body) + } + } + return out +} + +// arg reads a named argument, or the positional one when the node carries no names (a +// hook that built the node by position; PT_insert's positional form, INSERT ... SET, has +// no opt_hints, so its indices are the named ones less one from table_ident on). +func arg(n *mysqlast.Node, name string, i int) mysqlast.Value { + if len(n.Names) > 0 { + return n.Arg(name) + } + if i < len(n.Args) { + return n.Args[i] + } + return nil +} + +// multiDelete types `DELETE t1, t2 FROM ... JOIN ...` and `DELETE FROM t1, t2 USING ...`: +// the joined tables are the scope, the listed names (aliases of the join) the targets. +func (a *analyzer) multiDelete(n *mysqlast.Node, list mysqlast.List, ctes []relation) error { + sc, err := a.from(n.Arg("join_table_list"), scope{ctes: ctes, kids: new([]*facts.Scope)}) + if err != nil { + return err + } + w := &write{kind: facts.Delete, ignore: deleteIgnore(n.Arg("opt_delete_options"))} + a.write = w + a.facts = &facts.Facts{Kind: facts.Delete} + for _, item := range list { + ti, ok := item.(*mysqlast.Node) + if !ok { + return fmt.Errorf("analyze: DELETE target not understood: %s", mysqlast.Sprint(item)) + } + name := str(ti.Arg("table")) + var rel *relation + for i := range sc.rels { + if strings.EqualFold(sc.rels[i].alias, name) { + rel = &sc.rels[i] + } + } + if rel == nil { + return &Error{Message: fmt.Sprintf("Unknown table '%s' in MULTI DELETE", name), Code: 1109, Position: a.ph.Back(ti.Start)} + } + base, derr := a.deleteTarget(rel, ti.Start) + if derr != nil { + return derr + } + rel.target = true + w.targetView(rel.view) + if w.table == nil { + w.table = base + } else { + w.more = append(w.more, moreTarget{table: base}) + } + a.facts.Writes = append(a.facts.Writes, a.writeFacts(facts.Delete, rel, base, nil, nil)) + } + if err := a.condition(sc, n.Arg("opt_where_clause"), "where clause"); err != nil { + return err + } + a.facts.Top = a.block(&sc, n) + a.facts.Top.Children = append(a.facts.Top.Children, cteBodies(ctes)...) + return nil +} + +// from builds the scope of a FROM / UPDATE table list, starting from sc (the enclosing +// scope and the CTEs in force). +func (a *analyzer) from(v mysqlast.Value, sc scope) (scope, error) { + list, _ := v.(mysqlast.List) + for _, t := range list { + if err := a.tableRef(&sc, t, false); err != nil { + return sc, err + } + } + return sc, nil +} + +func (a *analyzer) tableRef(sc *scope, v mysqlast.Value, nullable bool) error { + n, ok := v.(*mysqlast.Node) + if !ok { + return fmt.Errorf("analyze: table reference not understood: %s", mysqlast.Sprint(v)) + } + switch n.Class { + case "PT_table_factor_table_ident": + rel, err := a.target(n.Arg("table_ident"), n.Arg("opt_table_alias"), sc) + if err != nil { + return err + } + rel.nullable = nullable + return a.addRelation(sc, rel, n.Start) + case "PT_derived_table": + // (SELECT ...) AS alias [(col, ...)]: the columns are the query's, renamed by the + // list when given; LATERAL sees the relations to its left + alias := str(n.Arg("table_alias")) + if alias == "" { + return &Error{Message: "Every derived table must have its own alias", Code: 1248, Position: a.ph.Back(n.Start)} + } + outer := sc.outer + if isTrue(n.Arg("lateral")) { + outer = sc + } + cols, body, err := a.subqueryFacts(n.Arg("subquery"), outer) + if err != nil { + return err + } + merged := mergeable(subqueryExpression(n.Arg("subquery"))) + if !merged { + cols = materialized(cols, subqueryExpression(n.Arg("subquery"))) + } + names, _ := n.Arg("column_names").(mysqlast.List) + if cols, err = renamed(cols, names, alias, a.ph.Back(n.Start)); err != nil { + return err + } + sc.child(body) + return a.addRelation(sc, &relation{alias: alias, cols: cols, nullable: nullable, body: body, merged: merged}, n.Start) + case "PT_joined_table_on", "PT_joined_table_using", "PT_cross_join": + jt := str(n.Arg("type")) + left, right := nullable, nullable + switch { + case strings.Contains(jt, "LEFT"): + right = true + case strings.Contains(jt, "RIGHT"): + left = true + } + before := len(sc.rels) + if err := a.tableRef(sc, n.Arg("tab1_node"), left); err != nil { + return err + } + mid := len(sc.rels) + if err := a.tableRef(sc, n.Arg("tab2_node"), right); err != nil { + return err + } + jc := joinCond{left: indices(before, mid), right: indices(mid, len(sc.rels)), kind: jt} + if n.Class == "PT_joined_table_on" { + jc.on = n.Arg("on") + sc.joins = append(sc.joins, jc) + return a.condition(*sc, n.Arg("on"), "on clause") + } + fields, _ := n.Arg("using_fields").(mysqlast.List) + if strings.Contains(jt, "NATURAL") { + // NATURAL JOIN: USING over every column name both sides have + fields = nil + for _, l := range jc.left { + for _, c := range sc.rels[l].columns() { + if _, ok := a.colIn(sc, jc.right, c.Name); ok { + fields = append(fields, mysqlast.Token{Text: c.Name, Value: c.Name}) + } + } + } + } + if fields != nil { + // USING (c): c must be a column of both sides (not ambiguous: it names the pair) + for _, f := range fields { + name := str(f) + if _, okl := a.colIn(sc, jc.left, name); !okl { + return &Error{Message: fmt.Sprintf("Unknown column '%s' in 'from clause'", name), Code: 1054, Position: a.ph.Back(nodeStart(f))} + } + if _, okr := a.colIn(sc, jc.right, name); !okr { + return &Error{Message: fmt.Sprintf("Unknown column '%s' in 'from clause'", name), Code: 1054, Position: a.ph.Back(nodeStart(f))} + } + jc.using = append(jc.using, name) + leaves := []int{} + for _, i := range append(append([]int{}, jc.left...), jc.right...) { + if _, ok := sc.rels[i].column(name); ok { + leaves = append(leaves, i) + } + } + sc.merged = append(sc.merged, mergedCol{name: name, leaves: leaves}) + } + sc.joins = append(sc.joins, jc) + } + return nil + case "PT_table_factor_joined_table": + // (a JOIN b ON ...) as one side of a join: the nest is its joins + return a.tableRef(sc, n.Arg("joined_table"), nullable) + case "PT_table_reference_list_parens": + list, _ := n.Arg("table_list").(mysqlast.List) + for _, t := range list { + if err := a.tableRef(sc, t, nullable); err != nil { + return err + } + } + return nil + } + return fmt.Errorf("analyze: %s is not supported yet", n.Class) +} + +// indices lists the integers in [from, to). +func indices(from, to int) []int { + out := make([]int, 0, to-from) + for i := from; i < to; i++ { + out = append(out, i) + } + return out +} + +// addRelation puts rel into sc, rejecting a second relation under the same alias. +func (a *analyzer) addRelation(sc *scope, rel *relation, at int) error { + rel.pos = at + for _, r := range sc.rels { + if strings.EqualFold(r.alias, rel.alias) { + return &Error{Message: fmt.Sprintf("Not unique table/alias: '%s'", rel.alias), Code: 1066, Position: a.ph.Back(at)} + } + } + sc.rels = append(sc.rels, *rel) + return nil +} + +// target resolves a Table_ident, under alias when given: a common table expression in +// force (the innermost wins, as in the server), else a base table, else a view (analyzed +// on the spot, its columns are its query's). +func (a *analyzer) target(ident, alias mysqlast.Value, sc *scope) (*relation, error) { + n, ok := ident.(*mysqlast.Node) + if !ok || n.Class != "Table_ident" { + return nil, fmt.Errorf("analyze: table name not understood: %s", mysqlast.Sprint(ident)) + } + name := str(n.Arg("table")) + if name == "" && len(n.Args) > 0 { + name = str(n.Args[len(n.Args)-1]) + } + var rel *relation + if sc != nil && str(n.Arg("db")) == "" { + for i := len(sc.ctes) - 1; i >= 0; i-- { + if strings.EqualFold(sc.ctes[i].alias, name) { + r := sc.ctes[i] + r.cte = true + rel = &r + break + } + } + } + if rel == nil { + if a.refRels == nil { + a.refRels = map[string]bool{} + } + a.refRels[strings.ToLower(name)] = true + if t := a.s.Table(name); t != nil { + rel = &relation{alias: t.Name, table: t} + } else if v := a.s.View(name); v != nil { + cols, body, err := a.view(v) + if err != nil { + return nil, err + } + // a view is merged into the query unless it says TEMPTABLE or its query cannot be + // merged; a merged view carries the server's own writability flags + rel = &relation{alias: v.Name, cols: cols, view: v.Name, body: body, merged: true} + w := a.viewWritability(v, nil) + rel.updatable, rel.insertable, rel.leaves, rel.viewBase = w.updatable, w.insertable, w.leaves, w.base + if v.Algorithm == "TEMPTABLE" || !mergeable(v.Query) { + rel.cols, rel.merged = materialized(cols, v.Query), false + } + } else { + return nil, &Error{Message: fmt.Sprintf("Table '%s' doesn't exist", name), Code: 1146, Position: a.ph.Back(n.Start)} + } + } + if s := str(alias); s != "" { + rel.alias = s + } + return rel, nil +} + +// view types a view's query, in a scope of its own (a view sees no CTE and no outer query), +// and returns its block's facts for the proof. +func (a *analyzer) view(v *schema.View) ([]Column, *facts.Scope, error) { + if a.views[v.Name] { + return nil, nil, fmt.Errorf("analyze: view %s refers to itself", v.Name) + } + if a.views == nil { + a.views = map[string]bool{} + } + a.views[v.Name] = true + defer delete(a.views, v.Name) + waived := v.Waived + if waived == nil { + waived = map[string][]string{} // the view's own, not the reading statement's + } + a.viewWaived = append(a.viewWaived, waived) + defer func() { a.viewWaived = a.viewWaived[:len(a.viewWaived)-1] }() + // the view's placeholders, if any, are not ours: keep the statement's parameter table; + // its text is the definition's (the facts' opaque predicates read their text from it) + saved, savedText, savedPh := a.params, a.text, a.ph + a.params = nil + a.text, a.ph = v.Definition, identityMap(v.Definition) + defer func() { a.params, a.text, a.ph = saved, savedText, savedPh }() + a.depth++ + cols, body, err := a.queryExpressionFacts(v.Query, nil) + a.depth-- + if err != nil { + if e, ok := err.(*Error); ok { + return nil, nil, fmt.Errorf("analyze: view %s: %s (MySQL error %d)", v.Name, e.Message, e.Code) + } + return nil, nil, fmt.Errorf("analyze: view %s: %w", v.Name, err) + } + var names mysqlast.List + for _, c := range v.Columns { + names = append(names, c) + } + cols, err = renamed(cols, names, v.Name, -1) + clearPositions(body) // offsets into the view's definition mean nothing to the statement + return cols, body, err +} + +// identityMap is the placeholder map of a text without placeholders. +func identityMap(text string) placeholder.Map { + _, ph := placeholder.Rewrite(text) + return ph +} + +// renamed applies a derived relation's column list: the same count, the new names. +func renamed(cols []Column, names mysqlast.List, alias string, at int) ([]Column, error) { + if len(names) == 0 { + return cols, nil + } + if len(names) != len(cols) { + return nil, &Error{Message: fmt.Sprintf("View's SELECT and view's field list have different column counts"), Code: 1353, Position: at} + } + out := make([]Column, len(cols)) + for i, c := range cols { + c.Name = str(names[i]) + out[i] = c + } + return out, nil +} + +// targetColumn resolves a column name against one relation (INSERT's column list). +// writability is a merged view's server-computed write flags (viewWritability). +type writability struct { + updatable bool + insertable bool + leaves int + base *schema.Table +} + +// viewWritability computes a view's is_updatable / is_insertable the way sql_resolver.cc +// does when it merges it: over the FROM leaves of the view's own query, updatable when any +// leaf is updatable, insertable when every one is, and neither when any leaf sits on the +// nullable side of an outer join. A base table is both; a derived table, a table function +// or anything unknown is neither; a view recurses (a non-merged one is neither). leaves is +// the leaf count (a join view has more than one) and base the single leaf's base table, +// through however many single-leaf views (what a DELETE through the view deletes from even +// when no view column maps a base column plainly). +func (a *analyzer) viewWritability(v *schema.View, seen map[string]bool) writability { + if seen[strings.ToLower(v.Name)] { + return writability{} + } + if seen == nil { + seen = map[string]bool{} + } + seen[strings.ToLower(v.Name)] = true + if v.Algorithm == "TEMPTABLE" || !mergeable(v.Query) { + return writability{} + } + w := writability{insertable: true} + outer := false + viewFromLeaves(v.Query, func(n *mysqlast.Node, nullable bool) { + w.leaves++ + outer = outer || nullable + if n.Class != "PT_table_factor_table_ident" { + w.insertable = false + return + } + name := targetTableName(n.Arg("table_ident")) + if t := a.s.Table(name); t != nil { + w.updatable = true + if w.leaves == 1 { + w.base = t + } + return + } + if uv := a.s.View(name); uv != nil { + uw := a.viewWritability(uv, seen) + if uw.leaves > 1 { + // a merged view flattens into its own leaves (measured: a single-leaf view + // over a join view is still "Can not delete from join view", 1395) + w.leaves += uw.leaves - 1 + } + w.updatable = w.updatable || uw.updatable + w.insertable = w.insertable && uw.insertable + if w.leaves == 1 { + w.base = uw.base + } + return + } + w.insertable = false + }) + if outer || w.leaves == 0 { + return writability{leaves: w.leaves} + } + if w.leaves > 1 { + w.base = nil + } + return w +} + +// viewFromLeaves walks the FROM clause of a view's (single-block) query, calling fn once +// per leaf table factor; nullable says the leaf sits on the nullable side of an outer join. +func viewFromLeaves(q mysqlast.Value, fn func(n *mysqlast.Node, nullable bool)) { + n, ok := q.(*mysqlast.Node) + if !ok { + return + } + switch n.Class { + case "PT_query_expression": + viewFromLeaves(n.Arg("body"), fn) + return + case "PT_query_specification": + list, _ := n.Arg("from_clause").(mysqlast.List) + for _, t := range list { + walkFromLeaf(t, false, fn) + } + } +} + +func walkFromLeaf(v mysqlast.Value, nullable bool, fn func(n *mysqlast.Node, nullable bool)) { + n, ok := v.(*mysqlast.Node) + if !ok { + return + } + switch n.Class { + case "PT_joined_table_on", "PT_joined_table_using", "PT_cross_join": + jt := str(n.Arg("type")) + left, right := nullable, nullable + switch { + case strings.Contains(jt, "LEFT"): + right = true + case strings.Contains(jt, "RIGHT"): + left = true + } + walkFromLeaf(n.Arg("tab1_node"), left, fn) + walkFromLeaf(n.Arg("tab2_node"), right, fn) + case "PT_table_factor_joined_table": + walkFromLeaf(n.Arg("joined_table"), nullable, fn) + case "PT_table_reference_list_parens": + list, _ := n.Arg("table_list").(mysqlast.List) + for _, t := range list { + walkFromLeaf(t, nullable, fn) + } + default: + fn(n, nullable) + } +} + +// viewBodyRereads reports whether a view body reads table anywhere but its own FROM +// leaves: in a predicate's subquery, or a nested block (the reason mysqld refuses an +// INSERT through the view as non-insertable). +func (a *analyzer) viewBodyRereads(body *facts.Scope, table string) bool { + if body == nil { + return false + } + for _, p := range body.Preds { + if a.viewReads(p.Sub, table) { + return true + } + } + for _, ch := range body.Children { + if a.viewReads(ch, table) { + return true + } + } + for _, l := range body.Leaves { + if l.Kind == facts.View && a.viewBodyRereads(l.Body, table) { + return true + } + } + return false +} + +// viewUpdateField unwraps the wrappers the server's field_for_view_update sees through (a +// COLLATE clause, however nested), returning the wrapped expression, or nil when expr is +// no such wrapper. +func viewUpdateField(expr mysqlast.Value) mysqlast.Value { + n, ok := expr.(*mysqlast.Node) + if !ok || n.Class != "Item_func_set_collation" { + return nil + } + inner := n.Arg("a") + if deeper := viewUpdateField(inner); deeper != nil { + return deeper + } + return inner +} + +// targetTableName is a Table_ident's bare table name. +func targetTableName(v mysqlast.Value) string { + n, ok := v.(*mysqlast.Node) + if !ok || n.Class != "Table_ident" { + return "" + } + name := str(n.Arg("table")) + if name == "" && len(n.Args) > 0 { + name = str(n.Args[len(n.Args)-1]) + } + return name +} + +// chainHasCheckOption reports whether writing through v is checked by some WITH CHECK +// OPTION: v's own, or one declared by any view below it (an underlying view's own option +// is enforced whatever the outer view says -- the server's 1369 then still names the view +// written through). +func (a *analyzer) chainHasCheckOption(v *schema.View, seen map[string]bool) bool { + if v.CheckOption != "" && v.CheckOption != "NONE" { + return true + } + if seen[strings.ToLower(v.Name)] { + return false + } + if seen == nil { + seen = map[string]bool{} + } + seen[strings.ToLower(v.Name)] = true + found := false + viewFromLeaves(v.Query, func(n *mysqlast.Node, _ bool) { + if n.Class != "PT_table_factor_table_ident" { + return + } + if uv := a.s.View(targetTableName(n.Arg("table_ident"))); uv != nil && a.chainHasCheckOption(uv, seen) { + found = true + } + }) + return found +} + +// insertColumn resolves one column of an INSERT's list (or its ON DUPLICATE KEY UPDATE's) +// against the target: a base table's column as targetColumn does, or a view's own column, +// mapped to its base column -- a derived one is the column's 1348 (measured), and a column +// of another base table than the ones before it, through a join view, the statement's 1393. +// base carries the one base table the write lands on across the calls (nil until the first +// view column fixes it). +func (a *analyzer) insertColumn(rel *relation, base **schema.Table, v mysqlast.Value) (*schema.Column, error) { + if rel.table != nil { + return a.targetColumn(rel, v, "field list") + } + name := identName(v) + for _, c := range rel.cols { + if !strings.EqualFold(c.Name, name) { + continue + } + if c.base == nil { + return nil, &Error{Message: fmt.Sprintf("Column '%s' is not updatable", name), Code: 1348, Position: a.ph.Back(nodeStart(v))} + } + if *base == nil { + *base = c.baseTable + } else if *base != c.baseTable { + return nil, &Error{Message: fmt.Sprintf("Can not modify more than one base table through a join view '%s'", rel.view), Code: 1393, Position: a.ph.Back(nodeStart(v))} + } + return c.base, nil + } + return nil, &Error{Message: fmt.Sprintf("Unknown column '%s' in 'field list'", name), Code: 1054, Position: a.ph.Back(nodeStart(v))} +} + +func (a *analyzer) targetColumn(rel *relation, v mysqlast.Value, where string) (*schema.Column, error) { + ref, err := a.column(scope{rels: []relation{*rel}}, v, where) + if err != nil { + return nil, err + } + if ref.col == nil { + // resolved to something other than a column of the target table: a routine + // variable of the same name (a body's INSERT INTO t (v) names the column, which the + // server resolves against the table; the analyzer's lookup let the variable shadow + // it and used to fall over on the nil column -- found by the corpus probe) + if rel.table != nil { + if c := rel.table.Column(identName(v)); c != nil { + return c, nil + } + } + return nil, &Error{Message: fmt.Sprintf("Unknown column '%s' in '%s'", identName(v), where), Code: 1054, Position: a.ph.Back(nodeStart(v))} + } + return ref.col, nil +} + +// identName is the bare name a column reference node spells (its last part). +func identName(v mysqlast.Value) string { + n, ok := v.(*mysqlast.Node) + if !ok { + return str(v) + } + for _, k := range []string{"field", "ident"} { + if s := str(n.Arg(k)); s != "" { + return s + } + } + return mysqlast.Sprint(v) +} + +// colRef is a resolved column reference: the relation, the schema column when the +// relation is a base table, and the column as the query sees it. +type colRef struct { + rel *relation + col *schema.Column + c Column +} + +// column resolves a PTI_simple_ident_* node in sc. where names the clause the way MySQL's +// message does ("field list", "where clause", "on clause"). +func (a *analyzer) column(sc scope, v mysqlast.Value, where string) (colRef, error) { + n, ok := v.(*mysqlast.Node) + if !ok { + if s := str(v); s != "" { // a bare identifier token (USING (id)) + return a.lookup(sc, "", s, where, 0) + } + return colRef{}, fmt.Errorf("analyze: column reference not understood: %s", mysqlast.Sprint(v)) + } + switch n.Class { + case "PTI_simple_ident_ident", "PTI_simple_ident_nospvar_ident": + name := str(n.Arg("ident")) + if ref, ok := a.lookupVar(name); ok { + return ref, nil + } + return a.lookup(sc, "", name, where, n.Start) + case "PTI_simple_ident_q_2d": + table, field := str(n.Arg("table")), str(n.Arg("field")) + if a.trig != nil && (strings.EqualFold(table, "new") || strings.EqualFold(table, "old")) { + col, nullable, err := a.trigRowColumn(table, field, n.Start, false) + if err != nil { + return colRef{}, err + } + return colRef{c: Column{Name: col.Name, Type: col.Type, Known: true, Nullable: nullable, base: col, baseTable: a.trigTable}}, nil + } + return a.lookup(sc, table, field, where, n.Start) + case "PTI_simple_ident_q_3d": + return a.lookup(sc, str(n.Arg("table")), str(n.Arg("field")), where, n.Start) + } + return colRef{}, fmt.Errorf("analyze: column reference not understood: %s", mysqlast.Sprint(v)) +} + +// lookup resolves table.field (table may be "") in sc: the innermost query whose +// relations know the name wins, an outer query is tried only when none of the inner one's +// do (a correlated reference); two matches at one level are ambiguous. +// +// The select list's aliases are visible where the server's is_item_list_lookup / +// resolve_in_select_list say (measured, TestNameResolutionServer): an unqualified name in +// the block's own HAVING, GROUP BY or ORDER BY expression resolves against them first +// (two different items of the name: 1052); a nested query placed in the select list, +// GROUP BY, HAVING or ORDER BY of an enclosing block sees that block's aliases too (not +// one placed in its WHERE or ON), the ones declared before it when placed in the select +// list -- a later one is 1247 "forward reference in item list" -- and an alias of an +// aggregate only from the nested query's HAVING, and never from a GROUP BY placement +// (1247 "reference to group function"). +// +// `_rowid` names the table's first key when that key is unique over one NOT NULL integer +// column (relation.rowid), for a qualified reference or a level with a single table. +func (a *analyzer) lookup(sc scope, table, field, where string, at int) (colRef, error) { + if table == "" && (where == "having clause" || where == "order clause" || where == "group statement") { + ref, ok, ambiguous := a.lookupItem(sc, field) + if ambiguous { + return colRef{}, &Error{Message: fmt.Sprintf("Column '%s' in %s is ambiguous", field, where), Code: 1052, Position: a.ph.Back(at)} + } + if ok { + return ref, nil + } + } + for s := ≻ s != nil; s = s.outer { + if s != &sc && table == "" { + if ll := a.listOf(s); ll != nil { + ref, found, err := a.outerItem(ll, *s, field, where, at) + if err != nil { + return colRef{}, err + } + if found { + return ref, nil + } + } + } + var found []colRef + var leaves []int + leaf := 0 + for i := range s.rels { + rel := &s.rels[i] + if table != "" && !strings.EqualFold(rel.alias, table) { + continue + } + ref, ok := rel.column(field) + if !ok && strings.EqualFold(field, "_rowid") && (table != "" || len(s.rels) == 1) { + ref, ok = rel.rowid() + } + if ok { + found = append(found, ref) + leaves = append(leaves, i) + leaf = i + } + } + if len(found) > 1 && table == "" && s.mergedLeaves(field, leaves) { + found, leaf = found[:1], leaves[0] // the coalesced column of a USING / NATURAL join + } + switch len(found) { + case 1: + a.use(found[0], at) + if s != &sc { + a.outerRefs = append(a.outerRefs, outerRef{block: &s.rels[0], leaf: leaf, col: found[0].c.Name, at: at, where: where}) + } + return found[0], nil + case 0: + continue + } + return colRef{}, &Error{Message: fmt.Sprintf("Column '%s' in %s is ambiguous", field, where), Code: 1052, Position: a.ph.Back(at)} + } + qualified := field + if table != "" { + qualified = table + "." + field + } + return colRef{}, &Error{Message: fmt.Sprintf("Unknown column '%s' in '%s'", qualified, where), Code: 1054, Position: a.ph.Back(at)} +} + +// listLookup is a block whose clause resolves names against its select list: items are +// the select-list columns typed so far (all of them once the list is done), all the +// list's nodes, for the aliases not yet typed and for what each item is. +type listLookup struct { + tag *byte + clause string // "field list", "group statement", "having clause", "order clause" + items []Column + all mysqlast.List +} + +// listOf is the list lookup in force for the block s, nil when none of its clauses that +// see the select list is being typed. +func (a *analyzer) listOf(s *scope) *listLookup { + if s.tag == nil { + return nil + } + for i := len(a.lists) - 1; i >= 0; i-- { + if a.lists[i].tag == s.tag { + return &a.lists[i] + } + } + return nil +} + +// pushList / popList bracket the typing of a block's clause that sees its select list. +func (a *analyzer) pushList(sc *scope, clause string, items []Column, all mysqlast.List) { + a.lists = append(a.lists, listLookup{tag: sc.tag, clause: clause, items: items, all: all}) +} + +func (a *analyzer) popList() { a.lists = a.lists[:len(a.lists)-1] } + +// outerItem resolves an unqualified name of a nested query against an enclosing block's +// select list (the block's clause ll is being typed; where is the nested query's own +// clause): the alias found, a 1247 the server raises for it, or nothing. +func (a *analyzer) outerItem(ll *listLookup, s scope, field, where string, at int) (colRef, bool, error) { + items := ll.items + if ll.clause != "field list" { + items = s.items + } + var found *Column + for i := range items { + if strings.EqualFold(items[i].Name, field) { + found = &items[i] + break + } + } + if found == nil { + if ll.clause == "field list" { + for _, it := range ll.all { + if ewa, ok := it.(*mysqlast.Node); ok && ewa.Class == "PTI_expr_with_alias" && strings.EqualFold(str(ewa.Arg("alias")), field) { + return colRef{}, false, &Error{Message: fmt.Sprintf("Reference '%s' not supported (forward reference in item list)", field), Code: 1247, Position: a.ph.Back(at)} + } + } + } + return colRef{}, false, nil + } + if aliasOfWindow(ll.all, field) { + return colRef{}, false, &Error{Message: fmt.Sprintf("You cannot use the alias '%s' of an expression containing a window function in this context.'", field), Code: 3594, Position: a.ph.Back(at)} + } + if aliasOfAggregate(ll.all, field) && (where != "having clause" || ll.clause == "group statement") { + return colRef{}, false, &Error{Message: fmt.Sprintf("Reference '%s' not supported (reference to group function)", field), Code: 1247, Position: a.ph.Back(at)} + } + return colRef{c: *found}, true, nil +} + +// use records a resolved reference to a table's or a view's column, once, in order of +// first appearance; a reference inside a view's body is the view's, not the statement's. +func (a *analyzer) use(ref colRef, at int) { + inView := len(a.views) > 0 && !(a.definingView != "" && len(a.views) == 1 && a.views[a.definingView]) + if inView || ref.rel == nil { + return + } + table := ref.rel.view + if ref.rel.table != nil { + table = ref.rel.table.Name + } + if table == "" { + return + } + key := table + "." + ref.c.Name + if a.useIdx == nil { + a.useIdx = map[string]int{} + a.readUses = map[string]bool{} + } + if !a.assigning { + a.readUses[key] = true + } + if i, ok := a.useIdx[key]; ok { + a.uses[i].Assigned = !a.readUses[key] + if p := int32(a.ph.Back(at)); p < a.uses[i].Position { + a.uses[i].Position = p // the select list is typed after FROM and WHERE: first appearance is by position + } + return + } + a.useIdx[key] = len(a.uses) + a.uses = append(a.uses, facts.Use{Table: table, Column: ref.c.Name, Position: int32(a.ph.Back(at)), Assigned: !a.readUses[key]}) +} + +// lookupItem resolves a name against the block's typed select list (HAVING, GROUP BY and +// ORDER BY expressions): the item's column, with no schema column behind it. As +// find_item_in_list does, an item written with the alias wins over an unaliased column of +// the name; two aliased items, or two unaliased ones that are not the same table column +// of the same relation, are ambiguous. +func (a *analyzer) lookupItem(sc scope, name string) (ref colRef, ok, ambiguous bool) { + var found *Column + for aliased := true; ; aliased = false { + for i := range sc.items { + c := &sc.items[i] + if c.aliased != aliased || !strings.EqualFold(c.Name, name) { + continue + } + if found != nil { + if found.base == nil || found.base != c.base || found.leaf1 != c.leaf1 { + return colRef{}, false, true + } + continue + } + found = c + } + if found != nil || !aliased { + break + } + } + if found == nil { + return colRef{}, false, false + } + return colRef{c: *found}, true, false +} + +// items types the select list. +func (a *analyzer) items(sc scope, v mysqlast.Value) ([]Column, error) { + var out []Column + list, _ := v.(mysqlast.List) + for _, item := range list { + n, ok := item.(*mysqlast.Node) + if !ok { + return nil, fmt.Errorf("analyze: select item not understood: %s", mysqlast.Sprint(item)) + } + switch n.Class { + case "Item_asterisk": + table := str(arg(n, "opt_table_name", 1)) + matched := false + seen := map[string]bool{} // the coalesced columns already listed + if table == "" { + for _, m := range sc.merged { + if seen[strings.ToLower(m.name)] || len(m.leaves) == 0 { + continue + } + rel := &sc.rels[m.leaves[0]] + if c, ok := rel.column(m.name); ok { + col := c.c + col.leaf1, col.leafCol = m.leaves[0]+1, col.Name + out = append(out, col) + seen[strings.ToLower(m.name)] = true + } + } + } + for i := range sc.rels { + rel := &sc.rels[i] + if table != "" && !strings.EqualFold(rel.alias, table) { + continue + } + matched = true + for _, c := range rel.columns() { + if table == "" && seen[strings.ToLower(c.Name)] && sc.mergedLeaves(c.Name, []int{i}) { + continue + } + c.leaf1, c.leafCol = i+1, c.Name + out = append(out, c) + } + } + if !matched { + if table != "" { + return nil, &Error{Message: fmt.Sprintf("Unknown table '%s'", table), Code: 1051, Position: a.ph.Back(n.Start)} + } + return nil, &Error{Message: "No tables used", Code: 1096, Position: a.ph.Back(n.Start)} + } + case "PTI_expr_with_alias": + expr := n.Arg("expr") + t, err := a.expr(sc, expr, "field list") + if err != nil { + return nil, err + } + name := str(n.Arg("alias")) + if name == "" { + name = a.itemName(expr, n) + } + c := Column{Name: name, Type: t.typ, Known: t.known, Nullable: t.nullable, aliased: str(n.Arg("alias")) != ""} + if ref, ok := a.plainColumn(sc, expr); ok { + c.base, c.baseTable, c.matLeaf = ref.c.base, ref.c.baseTable, ref.c.matLeaf + if ref.rel != nil { // nil for a body-walk variable/NEW/OLD reference: no leaf to record + for i := range sc.rels { + if &sc.rels[i] == ref.rel || sc.rels[i].alias == ref.rel.alias { + c.leaf1, c.leafCol = i+1, ref.c.Name + break + } + } + } + } else if inner := viewUpdateField(expr); inner != nil { + // a COLLATE wrapper is transparent for view updating (the server's + // field_for_view_update; measured: `SELECT s1 COLLATE x AS s1` stays an + // updatable, insertable column) -- for the write mapping alone, not for the + // proof's outputs (the wrapper changes what an equality on the view's + // column says about the base one) + if r2, ok := a.plainColumn(sc, inner); ok { + c.base, c.baseTable, c.matLeaf = r2.c.base, r2.c.baseTable, r2.c.matLeaf + } + } + out = append(out, c) + default: + return nil, fmt.Errorf("analyze: select item not understood: %s", n.Class) + } + if ll := a.listOf(&sc); ll != nil && ll.clause == "field list" { + ll.items = out // a nested query later in the list sees the items typed so far + } + } + return out, nil +} + +// plainColumn resolves expr when it is a bare column reference (already typed without +// error by the caller). +func (a *analyzer) plainColumn(sc scope, expr mysqlast.Value) (colRef, bool) { + n, ok := expr.(*mysqlast.Node) + if !ok { + return colRef{}, false + } + switch n.Class { + case "PTI_simple_ident_ident", "PTI_simple_ident_nospvar_ident", "PTI_simple_ident_q_2d", "PTI_simple_ident_q_3d": + ref, err := a.column(sc, n, "field list") + return ref, err == nil + } + return colRef{}, false +} + +// itemName is the name MySQL gives an unaliased select item: the column's name for a +// column reference, else the expression's text. +// itemName names an unaliased select item the way the server does: a column by its field +// name, a string, numeric or NULL literal by its own token (wrapping parentheses dropped), +// anything else by its source text -- the whole item's (outer's) span, parentheses +// included, since the fold of `(expr)` passes up the inner node and would otherwise lose +// them. Measured: `SELECT (a > b)` is the column "(a > b)", `( a + 1 )` keeps its inner +// spaces, `(-1)`, `(TRUE)`, `(0x1F)` and `(DATE'...')` keep their parentheses, while +// `(a)`, `('x')`, `(3)`, `(1.5)`, `(1e0)` and `(NULL)` do not. +func (a *analyzer) itemName(v mysqlast.Value, outer *mysqlast.Node) string { + if n, ok := v.(*mysqlast.Node); ok { + switch n.Class { + case "PTI_simple_ident_ident", "PTI_simple_ident_nospvar_ident": + return str(n.Arg("ident")) + case "PTI_simple_ident_q_2d", "PTI_simple_ident_q_3d": + return str(n.Arg("field")) + case "PTI_text_literal_text_string", "PTI_text_literal_nchar_string", "PTI_text_literal_underscore_charset": + if tok, ok := n.Arg("literal").(mysqlast.Token); ok { + return tok.Value // a string literal is named by its value, without the quotes + } + case "Item_int", "Item_uint", "Item_decimal", "Item_float", "Item_null": + outer = nil // named by the literal's own token, wrapping parentheses dropped + } + if outer != nil && outer.Start >= 0 && outer.End <= len(a.text) && outer.Start < outer.End { + return strings.TrimSpace(a.text[outer.Start:outer.End]) + } + if n.Start >= 0 && n.End <= len(a.text) && n.Start < n.End { + return a.text[n.Start:n.End] + } + } + return "" +} + +// condition types a WHERE / HAVING / ON clause for its errors and its parameters. +func (a *analyzer) condition(sc scope, v mysqlast.Value, where string) error { + if v == nil { + return nil + } + if n, ok := v.(*mysqlast.Node); ok && (n.Class == "PTI_where" || n.Class == "PTI_having") { + v = n.Arg("expr") + } + a.condBases = append(a.condBases, len(a.parents)) + _, err := a.expr(sc, v, where) + a.condBases = a.condBases[:len(a.condBases)-1] + return err +} + +// limit types LIMIT / OFFSET placeholders as bigint unsigned. SELECT and UPDATE / DELETE +// differ in shape: a PT_limit_clause with a limit_options struct, or the bare option. +func (a *analyzer) limit(v mysqlast.Value) error { + n, ok := v.(*mysqlast.Node) + if !ok { + return nil + } + options := []mysqlast.Value{n} + if opts, ok := n.Arg("limit_options").(*mysqlast.Struct); ok { + options = []mysqlast.Value{opts.Fields["limit"], opts.Fields["opt_offset"]} + } + for _, opt := range options { + if o, ok := opt.(*mysqlast.Node); ok && o.Class == "PTI_limit_option_param_marker" { + a.setParam(o.Arg("param_marker"), schema.Type{Name: "bigint", Unsigned: true, Length: -1, Dec: -1}) + } + } + return nil +} + +// assign types v, stored into col: a placeholder takes the column's type. The assignment +// says whether the value may be NULL (a placeholder always may; the checker drops the NOT +// NULL violation when the Go type cannot be nil). An expression the server rejects (an +// unknown column, 1054) is the statement's error. +func (a *analyzer) assign(sc scope, table *schema.Table, col *schema.Column, v mysqlast.Value) (assignment, error) { + if isParam(v) { + a.setParam(v, col.Type) + a.noteParamSource(v, table, col, true) + return assignment{col: col, nullable: true, param: a.ph.Number(nodeStart(v))}, nil + } + if n, ok := v.(*mysqlast.Node); ok && n.Class == "Item_default_value" { + return assignment{col: col, nullable: col.Default == nil && !col.NotNull}, nil + } + t, err := a.expr(sc, v, "field list") + if err != nil { + return assignment{}, err + } + if a.routine == nil && a.trig == nil { + if e, viol := a.geometryStore(table, col, v, t); e != nil { + return assignment{}, e + } else if viol != nil { + a.storeRaised = append(a.storeRaised, *viol) + } + } + if a.storeChecks(table, a.storeRow) { + if e := a.literalStore(col, v, max(a.storeRow, 1)); e != nil { + return assignment{}, e + } + } + return assignment{col: col, nullable: !t.known || t.nullable}, nil +} + +// storedTerm is the value stored into col as the facts spell it: a parameter, a literal, +// a value known before the statement runs, or an expression (Known "?"). DEFAULT is the +// column's default: a literal default is the value the row gets, any other default an +// expression the statement does not spell (Known "DEFAULT"). +func (a *analyzer) storedTerm(sc scope, col *schema.Column, v mysqlast.Value) facts.Term { + if n, ok := v.(*mysqlast.Node); ok && n.Class == "Item_default_value" { + if col != nil && col.Default != nil { + if d, ok := col.Default.(*mysqlast.Node); ok && literalClass(d.Class) { + // constText (facts.go), not literalText: a Const must carry the same + // tagged spelling termFacts gives every other literal, so a DEFAULT's + // value compares equal to a literal written in the statement (Eq/state + // comparisons in x/obligation/check.go read Const as plain text). + if text := constText(d); text != "" { + return facts.Term{Kind: facts.Const, Const: text} + } + } + } + return facts.Term{Kind: facts.Known, Text: "DEFAULT"} + } + if t, ok := a.termFacts(&sc, v); ok { + return t + } + return facts.Term{Kind: facts.Known, Text: "?"} +} + +// nodeStart is v's start offset in the text, -1 when it has none. +func nodeStart(v mysqlast.Value) int { + switch x := v.(type) { + case *mysqlast.Node: + return x.Start + case mysqlast.Token: + return x.Start + } + return -1 +} + +// str reads a Token's value or a Const's text; "" for anything else. +func str(v mysqlast.Value) string { + switch x := v.(type) { + case mysqlast.Token: + if x.Value != "" { + return x.Value + } + return strings.Trim(x.Text, "`") + case mysqlast.Const: + return string(x) + case string: + return x + } + return "" +} diff --git a/check/mysql/internal/analyze/analyze_test.go b/check/mysql/internal/analyze/analyze_test.go new file mode 100644 index 0000000..c4b3d7c --- /dev/null +++ b/check/mysql/internal/analyze/analyze_test.go @@ -0,0 +1,396 @@ +package analyze + +import ( + "strings" + "testing" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/cardinality" +) + +const testSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(255), + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + secret VARCHAR(10) INVISIBLE +); +CREATE TABLE orders ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + total DECIMAL(10,2) NOT NULL, + note TEXT, + FOREIGN KEY (user_id) REFERENCES users (id) +); +CREATE VIEW v_users AS SELECT id, name FROM users; +CREATE VIEW v_stats AS SELECT user_id, COUNT(*) AS n FROM orders GROUP BY user_id; +CREATE TABLE metrics ( + id INT NOT NULL PRIMARY KEY, + small SMALLINT NOT NULL, + big BIGINT, + tiny TINYINT UNSIGNED NOT NULL, + y YEAR NOT NULL, + hits INT UNSIGNED NOT NULL DEFAULT 0 +); +` + +func load(t *testing.T) *schema.Schema { + s, err := schema.Load(testSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + return s +} + +// col spells a result column as "name type[?]" (? = nullable), "name ?" when untyped. +func col(c Column) string { + s := c.Name + " " + if c.Known { + s += c.Type.String() + } else { + s += "?" + } + if c.Nullable { + s += " null" + } + return s +} + +func param(p Param) string { + if !p.Known { + return "?" + } + return p.Type.String() +} + +type analyzeCase struct { + sql string + columns []string + params []string +} + +// analyzeCases are the statements the analyzer types; TestOracle checks the same +// statements against a real server. +var analyzeCases = []analyzeCase{ + // what the corpus probe (TestCorpusReplay) found, each pinned against mysqld by TestOracle: + // a window function's integer widens through the window's temporary table (INT / BIGINT + // to BIGINT, narrower to INT, YEAR to INT UNSIGNED) while a ROLLUP group column keeps its + // type; a ROLLUP constant stays NOT NULL; temporal literals are typed; <=> is never NULL; the system constants are + // utf8mb3 strings; SELECT ... INTO @var and INSERT ... VALUES () return / assign nothing + {"SELECT id, MIN(id) OVER (), MAX(small) OVER (), FIRST_VALUE(big) OVER (), NTH_VALUE(tiny, 2) OVER (), LAG(y) OVER (), LEAD(hits) OVER (), SUM(id) OVER (), ABS(id), id + 0 FROM metrics", []string{"id int", "MIN(id) OVER () bigint null", "MAX(small) OVER () int null", "FIRST_VALUE(big) OVER () bigint null", "NTH_VALUE(tiny, 2) OVER () int unsigned null", "LAG(y) OVER () int unsigned null", "LEAD(hits) OVER () bigint unsigned null", "SUM(id) OVER () decimal null", "ABS(id) bigint", "id + 0 bigint"}, nil}, + {"SELECT id, small, tiny, 1+1, COUNT(*) FROM metrics GROUP BY id, small, tiny WITH ROLLUP", []string{"id int null", "small smallint null", "tiny tinyint unsigned null", "1+1 bigint", "COUNT(*) bigint"}, nil}, + {"SELECT DATE'2000-01-01', TIME'10:00:00.12', TIMESTAMP'2000-01-01 10:00:00', CAST(DATE'2000-01-01' AS DOUBLE), CAST(TIME'10:00:00' AS SIGNED)", []string{"DATE'2000-01-01' date", "TIME'10:00:00.12' time(2)", "TIMESTAMP'2000-01-01 10:00:00' datetime(0)", "CAST(DATE'2000-01-01' AS DOUBLE) double", "CAST(TIME'10:00:00' AS SIGNED) bigint"}, nil}, + {"SELECT TIMESTAMP'2015-01-01 10:10:10+05:30', TIMESTAMP'2015-01-01T10:10:10.5', DATE'2004-02-29', DATE'20040229', TIME'-838:59:59', TIME'1 10:00:00', TIME'100000', TIME'010203.12345', TIMESTAMP'2019-09-20 10:00:00.999999+02:00 '", []string{"TIMESTAMP'2015-01-01 10:10:10+05:30' datetime(0)", "TIMESTAMP'2015-01-01T10:10:10.5' datetime(1)", "DATE'2004-02-29' date", "DATE'20040229' date", "TIME'-838:59:59' time(0)", "TIME'1 10:00:00' time(0)", "TIME'100000' time(0)", "TIME'010203.12345' time(5)", "TIMESTAMP'2019-09-20 10:00:00.999999+02:00 ' datetime(6)"}, nil}, + {"SELECT id <=> big, NULL <=> 1, id = big FROM metrics", []string{"id <=> big bigint(1)", "NULL <=> 1 bigint(1)", "id = big bigint(1) null"}, nil}, + {"SELECT USER(), CURRENT_USER(), DATABASE(), SCHEMA(), VERSION(), CURRENT_ROLE()", []string{"USER() varchar null", "CURRENT_USER() varchar null", "DATABASE() varchar null", "SCHEMA() varchar null", "VERSION() varchar", "CURRENT_ROLE() varchar null"}, nil}, + {"SELECT id INTO @v FROM metrics LIMIT 1", nil, nil}, + {"SELECT id FROM metrics LIMIT 1 INTO @v", nil, nil}, + {"INSERT INTO metrics () VALUES ()", nil, nil}, + {"INSERT INTO metrics VALUES (), ()", nil, nil}, + {"SELECT id, name, email FROM users", []string{"id bigint unsigned", "name varchar(100)", "email varchar(255) null"}, nil}, + {"SELECT * FROM users", []string{"id bigint unsigned", "name varchar(100)", "email varchar(255) null", "created_at datetime(6)"}, nil}, + {"SELECT u.id AS uid, o.total, o.note FROM users u JOIN orders o ON o.user_id = u.id WHERE u.id = $1", []string{"uid bigint unsigned", "total decimal(10,2)", "note text null"}, []string{"bigint unsigned"}}, + {"SELECT u.id, o.total FROM users u LEFT JOIN orders o ON o.user_id = u.id", []string{"id bigint unsigned", "total decimal(10,2) null"}, nil}, + {"SELECT u.id, o.total FROM users u RIGHT JOIN orders o ON o.user_id = u.id", []string{"id bigint unsigned null", "total decimal(10,2)"}, nil}, + {"SELECT o.* FROM users u JOIN orders o ON o.user_id = u.id", []string{"id bigint unsigned", "user_id bigint unsigned", "total decimal(10,2)", "note text null"}, nil}, + {"SELECT count(*), count(email) c, 1, 'x', 1.5, NULL FROM users", []string{"count(*) bigint", "c bigint", "1 bigint(1)", "x varchar(1)", "1.5 decimal(2,1)", "NULL null null"}, nil}, + // the row alias (8.0.19): the inserted columns under the alias's names, visible to + // ON DUPLICATE KEY UPDATE beside the target and beside VALUES() + {"INSERT INTO metrics (id, small) VALUES ($1, $2) AS new(p, q) ON DUPLICATE KEY UPDATE small = q + new.p", + nil, []string{"int", "smallint"}}, + {"INSERT INTO metrics SET id = $1, small = $2 AS new ON DUPLICATE KEY UPDATE small = new.small + VALUES(small)", + nil, []string{"int", "smallint"}}, + // the predicate alternatives folded by hand (hooks_dml.go): NOT LIKE is the negated + // LIKE, REGEXP is REGEXP_LIKE, SOUNDS LIKE is SOUNDEX(a) = SOUNDEX(b) + {"SELECT name NOT LIKE 'a%', name NOT LIKE 'a%' ESCAPE '!', email REGEXP '^a', name NOT REGEXP '^a', name SOUNDS LIKE 'abc' FROM users", + []string{"name NOT LIKE 'a%' bigint(1)", "name NOT LIKE 'a%' ESCAPE '!' bigint(1)", "email REGEXP '^a' bigint(1) null", "name NOT REGEXP '^a' bigint(1)", "name SOUNDS LIKE 'abc' bigint(1) null"}, nil}, + {"SELECT id = 1, id > 1 AND name = 'x', NOT id, id IS NULL, email IS NOT NULL, TRUE FROM users", []string{"id = 1 bigint(1)", "id > 1 AND name = 'x' bigint(1)", "NOT id bigint(1)", "id IS NULL bigint(1)", "email IS NOT NULL bigint(1)", "TRUE bigint(1)"}, nil}, + {"SELECT id FROM users WHERE name = $1 AND email = $2 LIMIT $3 OFFSET $4", []string{"id bigint unsigned"}, []string{"varchar(100)", "varchar(255)", "bigint unsigned", "bigint unsigned"}}, + {"SELECT id FROM users WHERE $1 = name", []string{"id bigint unsigned"}, []string{"varchar(100)"}}, + {"SELECT id FROM users WHERE id IN ($1, $2) AND name LIKE $3 AND created_at BETWEEN $4 AND $5", []string{"id bigint unsigned"}, []string{"bigint unsigned", "bigint unsigned", "varchar(100)", "datetime(6)", "datetime(6)"}}, + {"SELECT id FROM users WHERE id = $1 AND name = $1", []string{"id bigint unsigned"}, []string{"bigint unsigned"}}, + {"SELECT COALESCE(email, name) FROM users WHERE id = $1", []string{"COALESCE(email, name) varchar"}, []string{"bigint unsigned"}}, + // arithmetic: Item_num_op promotion, unsigned propagation, division + {"SELECT u.id + 1, u.id - 1, total * 2, total + 1.5, u.id / 2, u.id DIV 2, u.id % 3, -u.id, total / 0.5, name + 1, created_at + 0 FROM users u JOIN orders o ON o.user_id = u.id", []string{"u.id + 1 bigint unsigned", "u.id - 1 bigint unsigned", "total * 2 decimal", "total + 1.5 decimal", "u.id / 2 decimal null", "u.id DIV 2 bigint unsigned null", "u.id % 3 bigint unsigned null", "-u.id bigint", "total / 0.5 decimal null", "name + 1 double", "created_at + 0 decimal"}, nil}, + // branches: aggregate_type and its nullability rules + {"SELECT IF(u.id > 1, name, email), IF(u.id > 1, u.id, total), IF(u.id > 1, name, NULL), IFNULL(email, 'x'), IFNULL(email, NULL) FROM users u JOIN orders o ON o.user_id = u.id", []string{"IF(u.id > 1, name, email) varchar null", "IF(u.id > 1, u.id, total) decimal", "IF(u.id > 1, name, NULL) varchar null", "IFNULL(email, 'x') varchar", "IFNULL(email, NULL) varchar null"}, nil}, + {"SELECT CASE WHEN u.id = 1 THEN 'a' WHEN u.id = 2 THEN 'b' END, CASE u.id WHEN 1 THEN 1 ELSE 2.5 END, CASE WHEN u.id = 1 THEN u.id ELSE -1 END, GREATEST(u.id, 2), LEAST(total, 1), NULLIF(name, 'x') FROM users u JOIN orders o ON o.user_id = u.id", []string{"CASE WHEN u.id = 1 THEN 'a' WHEN u.id = 2 THEN 'b' END varchar null", "CASE u.id WHEN 1 THEN 1 ELSE 2.5 END decimal", "CASE WHEN u.id = 1 THEN u.id ELSE -1 END decimal", "GREATEST(u.id, 2) decimal", "LEAST(total, 1) decimal", "NULLIF(name, 'x') varchar(100) null"}, nil}, + // registry functions by family and facts + {"SELECT LENGTH(name), UPPER(name), CONCAT(name, email), ABS(total), ROUND(total), ROUND(total, 1), FLOOR(total), CEIL(u.id), SQRT(u.id), NOW(), CURDATE(), CURTIME(), UNIX_TIMESTAMP(), YEAR(created_at), DATE(created_at), DATEDIFF(created_at, NOW()), DATE_FORMAT(created_at, '%Y'), JSON_EXTRACT('{}', '$'), MD5(name), UUID(), LAST_INSERT_ID(), HEX(u.id), CONV(u.id, 10, 16), ISNULL(email), CHAR_LENGTH(email) FROM users u JOIN orders o ON o.user_id = u.id", []string{"LENGTH(name) bigint", "UPPER(name) varchar null", "CONCAT(name, email) varchar null", "ABS(total) decimal", "ROUND(total) decimal(10,0)", "ROUND(total, 1) decimal", "FLOOR(total) bigint", "CEIL(u.id) bigint unsigned", "SQRT(u.id) double null", "NOW() datetime", "CURDATE() date", "CURTIME() time", "UNIX_TIMESTAMP() bigint", "YEAR(created_at) year null", "DATE(created_at) date null", "DATEDIFF(created_at, NOW()) bigint null", "DATE_FORMAT(created_at, '%Y') varchar null", "JSON_EXTRACT('{}', '$') json null", "MD5(name) varchar null", "UUID() varchar null", "LAST_INSERT_ID() bigint unsigned", "HEX(u.id) varchar null", "CONV(u.id, 10, 16) varchar null", "ISNULL(email) bigint(1)", "CHAR_LENGTH(email) bigint null"}, nil}, + // aggregates + {"SELECT SUM(total), SUM(u.id), AVG(total), MIN(name), MAX(created_at), COUNT(DISTINCT email), GROUP_CONCAT(name), BIT_OR(u.id), STD(total), SUM(name) FROM users u JOIN orders o ON o.user_id = u.id", []string{"SUM(total) decimal null", "SUM(u.id) decimal null", "AVG(total) decimal null", "MIN(name) varchar(100) null", "MAX(created_at) datetime(6) null", "COUNT(DISTINCT email) bigint", "GROUP_CONCAT(name) text null", "BIT_OR(u.id) bigint unsigned", "STD(total) double null", "SUM(name) double null"}, nil}, + // casts + {"SELECT CAST(u.id AS SIGNED), CAST(name AS UNSIGNED), CAST(total AS CHAR(5)), CAST(name AS DECIMAL(10,2)), CAST(name AS DECIMAL), CAST(name AS DATE), CAST(name AS DATETIME(3)), CAST(name AS JSON), CONVERT(name, BINARY), BINARY name, CAST(u.id AS DOUBLE) FROM users u JOIN orders o ON o.user_id = u.id", []string{"CAST(u.id AS SIGNED) bigint", "CAST(name AS UNSIGNED) bigint unsigned", "CAST(total AS CHAR(5)) varchar(5) null", "CAST(name AS DECIMAL(10,2)) decimal(10,2)", "CAST(name AS DECIMAL) decimal(10,0)", "CAST(name AS DATE) date null", "CAST(name AS DATETIME(3)) datetime(3) null", "CAST(name AS JSON) json null", "CONVERT(name, BINARY) varbinary(100) null", "BINARY name varbinary(100) null", "CAST(u.id AS DOUBLE) double"}, nil}, + // placeholders typed through function facts and branch aggregation + {"SELECT id FROM users WHERE name = CONCAT($1, 'x') AND id > IF($2, 1, 2) AND created_at > DATE_ADD($3, INTERVAL 1 DAY) AND email = COALESCE($4, name) AND id IN ($5, id + $6) AND LENGTH($7) > 1 AND name LIKE CONCAT('%', $8, '%') LIMIT $9", []string{"id bigint unsigned"}, []string{"varchar", "bigint", "?", "varchar(100)", "bigint unsigned", "bigint unsigned", "varchar", "varchar", "bigint unsigned"}}, + {"SELECT u.id FROM users u JOIN orders o ON o.user_id = u.id WHERE u.id = $1 + 1 AND total = ROUND($2, 2) AND u.id DIV $3 = 1 AND $4 = CASE WHEN u.id = 1 THEN 'a' ELSE 'b' END", []string{"id bigint unsigned"}, []string{"bigint", "decimal", "bigint", "varchar"}}, // $1 takes the other operand's type, the literal's + // subqueries: entered, with the enclosing query's names in reach + {"SELECT id, EXISTS (SELECT 1 FROM orders WHERE total > 1), id IN (SELECT user_id FROM orders), (SELECT 1) FROM users", []string{"id bigint unsigned", "EXISTS (SELECT 1 FROM orders WHERE total > 1) bigint(1)", "id IN (SELECT user_id FROM orders) bigint(1) null", "(SELECT 1) bigint(1)"}, nil}, + {"SELECT (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS c, (SELECT MAX(total) FROM orders o WHERE o.user_id = u.id) AS m FROM users u", []string{"c bigint null", "m decimal(10,2) null"}, nil}, + {"SELECT u.id FROM users u WHERE u.id IN (SELECT user_id FROM orders WHERE total > $1) AND EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.note = $2)", []string{"id bigint unsigned"}, []string{"decimal(10,2)", "text"}}, + {"SELECT id FROM users WHERE $1 IN (SELECT user_id FROM orders) AND id = ANY (SELECT user_id FROM orders) AND id > ALL (SELECT user_id FROM orders WHERE note IS NULL)", []string{"id bigint unsigned"}, []string{"bigint unsigned"}}, + {"SELECT id, email IN (SELECT note FROM orders), (1, 2) IN (SELECT id, user_id FROM orders) FROM users", []string{"id bigint unsigned", "email IN (SELECT note FROM orders) bigint(1) null", "(1, 2) IN (SELECT id, user_id FROM orders) bigint(1) null"}, nil}, + // derived tables, views, common table expressions + {"SELECT d.id, d.n, d.c FROM (SELECT id, name AS n, COUNT(*) c FROM users GROUP BY id, name) AS d WHERE d.id = $1", []string{"id bigint unsigned", "n varchar(100)", "c bigint"}, []string{"bigint unsigned"}}, + {"SELECT d.* FROM (SELECT u.id, o.total FROM users u LEFT JOIN orders o ON o.user_id = u.id) d", []string{"id bigint unsigned", "total decimal(10,2) null"}, nil}, + {"SELECT x, y FROM (SELECT 1, 'a') AS d(x, y)", []string{"x int(1)", "y varchar(1)"}, nil}, // materialized: a small integer literal becomes an int + {"SELECT d.b, d.c, d.i FROM (SELECT id = 1 AS b, COUNT(*) AS c, id AS i FROM users GROUP BY id) d", []string{"b int(1)", "c bigint", "i bigint unsigned"}, nil}, + {"SELECT d.b FROM (SELECT id = 1 AS b FROM users) d", []string{"b bigint(1)"}, nil}, // merged: the item's own type + {"SELECT u.id, l.total FROM users u JOIN LATERAL (SELECT o.total FROM orders o WHERE o.user_id = u.id LIMIT 1) l ON TRUE", []string{"id bigint unsigned", "total decimal(10,2)"}, nil}, + {"SELECT id, name FROM v_users WHERE id = $1", []string{"id bigint unsigned", "name varchar(100)"}, []string{"bigint unsigned"}}, + {"SELECT v.*, o.total FROM v_users v LEFT JOIN orders o ON o.user_id = v.id", []string{"id bigint unsigned", "name varchar(100)", "total decimal(10,2) null"}, nil}, + {"WITH t AS (SELECT id, total FROM orders WHERE total > $1) SELECT t.id, t.total FROM t", []string{"id bigint unsigned", "total decimal(10,2)"}, []string{"decimal(10,2)"}}, + {"WITH a AS (SELECT id FROM users), b AS (SELECT a.id, o.total FROM a JOIN orders o ON o.user_id = a.id) SELECT * FROM b", []string{"id bigint unsigned", "total decimal(10,2)"}, nil}, + {"WITH RECURSIVE t(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM t WHERE n < 5) SELECT n FROM t", []string{"n bigint(1) null"}, nil}, // a UNION's types merge through Item_type_holder, and a recursive CTE is nullable throughout + {"SELECT (SELECT 1), (SELECT id FROM users LIMIT 1), (SELECT 1 UNION SELECT id FROM users), (SELECT 1 WHERE (SELECT COUNT(*) FROM orders) > 0) FROM users", []string{"(SELECT 1) bigint(1)", "(SELECT id FROM users LIMIT 1) bigint unsigned null", "(SELECT 1 UNION SELECT id FROM users) decimal", "(SELECT 1 WHERE (SELECT COUNT(*) FROM orders) > 0) bigint(1) null"}, nil}, + {"UPDATE v_users SET name = $1 WHERE id = $2", nil, []string{"varchar(100)", "bigint unsigned"}}, + {"WITH t AS (SELECT id FROM users) SELECT id FROM users WHERE id IN (SELECT id FROM t)", []string{"id bigint unsigned"}, nil}, + // ORDER BY / GROUP BY / HAVING: positions, select-list aliases, table columns + {"SELECT id AS n, COUNT(*) c FROM users GROUP BY n HAVING c > $1 AND n > 0 ORDER BY 2 DESC, n, users.id", []string{"n bigint unsigned", "c bigint"}, []string{"bigint"}}, + {"SELECT id, name FROM users u GROUP BY id, name HAVING name = $1 ORDER BY u.name, id DESC LIMIT $2", []string{"id bigint unsigned", "name varchar(100)"}, []string{"varchar(100)", "bigint unsigned"}}, + {"SELECT id AS name, COUNT(*) c FROM users GROUP BY name, id", []string{"name bigint unsigned", "c bigint"}, nil}, // GROUP BY name is the table column, not the alias + {"(SELECT id FROM users) UNION (SELECT user_id FROM orders) ORDER BY 1 LIMIT 3", []string{"id bigint unsigned"}, nil}, + {"SELECT u.id, o.total FROM users u JOIN orders o ON o.user_id = u.id ORDER BY o.total DESC, u.created_at", []string{"id bigint unsigned", "total decimal(10,2)"}, nil}, + // set operations: the first operand names the columns, the types merge + {"SELECT id, name FROM users UNION ALL SELECT id, note FROM orders", []string{"id bigint unsigned", "name text null"}, nil}, + {"SELECT id FROM users UNION SELECT total FROM orders", []string{"id decimal"}, nil}, + {"(SELECT id FROM users) UNION (SELECT user_id FROM orders) ORDER BY id LIMIT $1", []string{"id bigint unsigned"}, []string{"bigint unsigned"}}, + {"SELECT id FROM users EXCEPT SELECT user_id FROM orders", []string{"id bigint unsigned"}, nil}, + {"SELECT id FROM users INTERSECT SELECT user_id FROM orders WHERE total > $1", []string{"id bigint unsigned"}, []string{"decimal(10,2)"}}, + {"SELECT 1 AS a UNION SELECT 'x' UNION SELECT NULL", []string{"a varchar null"}, nil}, + // INSERT ... SELECT: the query feeds the targets in order + {"INSERT INTO orders (id, user_id, total) SELECT id, id, $1 FROM users WHERE name = $2", nil, []string{"decimal(10,2)", "varchar(100)"}}, + {"INSERT INTO users (name, email) SELECT name, email FROM users WHERE id = $1 ON DUPLICATE KEY UPDATE email = $2", nil, []string{"bigint unsigned", "varchar(255)"}}, + {"UPDATE users SET email = (SELECT note FROM orders o WHERE o.user_id = users.id LIMIT 1) WHERE id = $1", nil, []string{"bigint unsigned"}}, + {"DELETE FROM users WHERE id IN (SELECT user_id FROM orders WHERE total > $1)", nil, []string{"decimal(10,2)"}}, + {"WITH big AS (SELECT user_id FROM orders WHERE total > $1) DELETE FROM users WHERE id IN (SELECT user_id FROM big)", nil, []string{"decimal(10,2)"}}, + {"SELECT id FROM users WHERE created_at > NOW() - INTERVAL $1 DAY", []string{"id bigint unsigned"}, []string{"?"}}, + {"INSERT INTO users (name, email) VALUES ($1, $2)", nil, []string{"varchar(100)", "varchar(255)"}}, + {"INSERT INTO orders VALUES ($1, $2, $3, $4)", nil, []string{"bigint unsigned", "bigint unsigned", "decimal(10,2)", "text"}}, + {"INSERT INTO users (name) VALUES ($1) ON DUPLICATE KEY UPDATE email = $2", nil, []string{"varchar(100)", "varchar(255)"}}, + {"INSERT INTO users SET name = $1, email = $2", nil, []string{"varchar(100)", "varchar(255)"}}, + {"UPDATE users SET name = $1, email = $2 WHERE id = $3", nil, []string{"varchar(100)", "varchar(255)", "bigint unsigned"}}, + {"UPDATE users u JOIN orders o ON o.user_id = u.id SET o.note = $1 WHERE u.name = $2", nil, []string{"text", "varchar(100)"}}, + {"DELETE FROM users WHERE id = $1 LIMIT $2", nil, []string{"bigint unsigned", "bigint unsigned"}}, + {"SELECT id FROM users u WHERE u.name = 'a$1' AND u.email = $1", []string{"id bigint unsigned"}, []string{"varchar(255)"}}, +} + +func TestAnalyze(t *testing.T) { + s := load(t) + for _, c := range analyzeCases { + r, err := Analyze(s, c.sql) + if err != nil { + t.Errorf("%s: %v", c.sql, err) + continue + } + var cols, params []string + for _, col0 := range r.Columns { + cols = append(cols, col(col0)) + } + for _, p := range r.Params { + params = append(params, param(p)) + } + if strings.Join(cols, "; ") != strings.Join(c.columns, "; ") { + t.Errorf("%s:\n columns %q\n want %q", c.sql, cols, c.columns) + } + if strings.Join(params, "; ") != strings.Join(c.params, "; ") { + t.Errorf("%s:\n params %q\n want %q", c.sql, params, c.params) + } + } +} + +type errorCase struct { + sql string + code int + msg string + pos int +} + +// errorCases are the statements MySQL rejects, with its error number and message. +var errorCases = []errorCase{ + {"SELECT id FROM nobody", 1146, "Table 'nobody' doesn't exist", 15}, + // the row alias's refusals, each as the server spells it: a column outside the insert's + // list, a name list of the wrong count, an alias colliding with the target, and an + // unqualified name the target and the alias share + {"INSERT INTO metrics (id, small) VALUES (1, 2) AS new ON DUPLICATE KEY UPDATE small = new.tiny", 1054, "Unknown column 'new.tiny' in 'field list'", 85}, + {"INSERT INTO metrics (id, small) VALUES (1, 2) AS new(x, y, z) ON DUPLICATE KEY UPDATE small = y", 1353, "In definition of view, derived table or common table expression, SELECT list and column names list have different column counts", 49}, + {"INSERT INTO metrics (id, small) VALUES (1, 2) AS metrics ON DUPLICATE KEY UPDATE small = metrics.small", 1066, "Not unique table/alias: 'metrics'", 49}, + {"INSERT INTO metrics (id, small) VALUES (1, 2) AS new ON DUPLICATE KEY UPDATE small = small + new.small", 1052, "Column 'small' in field list is ambiguous", 85}, + {"INSERT INTO metrics (id, small) VALUES (1, 2) AS new(x, x) ON DUPLICATE KEY UPDATE small = new.x", 1060, "Duplicate column name 'x'", 49}, + // temporal literals the server refuses (create_temporal_literal) + {"SELECT TIMESTAMP'2015-01-01 10:10:10+5:30'", 1525, "Incorrect DATETIME value: '2015-01-01 10:10:10+5:30'", 7}, + {"SELECT TIMESTAMP'2015-01-01 10:10:10+14:01'", 1525, "Incorrect DATETIME value: '2015-01-01 10:10:10+14:01'", 7}, + {"SELECT TIMESTAMP'2010-01-01'", 1525, "Incorrect DATETIME value: '2010-01-01'", 7}, + {"SELECT TIMESTAMP'2001-00-01 00:00:00'", 1525, "Incorrect DATETIME value: '2001-00-01 00:00:00'", 7}, + {"SELECT TIMESTAMP'20130710010203123456'", 1525, "Incorrect DATETIME value: '20130710010203123456'", 7}, + {"SELECT DATE'2010-01'", 1525, "Incorrect DATE value: '2010-01'", 7}, + {"SELECT DATE'2010-01-01 10:00:00'", 1525, "Incorrect DATE value: '2010-01-01 10:00:00'", 7}, + {"SELECT DATE'2005-02-29'", 1525, "Incorrect DATE value: '2005-02-29'", 7}, + {"SELECT TIME'10:70:00'", 1525, "Incorrect TIME value: '10:70:00'", 7}, + {"SELECT TIME'839:00:00'", 1525, "Incorrect TIME value: '839:00:00'", 7}, + {"SELECT TIME'xxxx'", 1525, "Incorrect TIME value: 'xxxx'", 7}, + {"SELECT idd FROM users", 1054, "Unknown column 'idd' in 'field list'", 7}, + {"SELECT u.idd FROM users u", 1054, "Unknown column 'u.idd' in 'field list'", 7}, + {"SELECT x.id FROM users u", 1054, "Unknown column 'x.id' in 'field list'", 7}, + {"SELECT id FROM users u JOIN orders o ON o.user_id = u.id", 1052, "Column 'id' in field list is ambiguous", 7}, + {"SELECT u.id FROM users u JOIN orders o ON o.user_id = u.id WHERE total = $1 AND nope = $2", 1054, "Unknown column 'nope' in 'where clause'", 80}, + {"SELECT u.id FROM users u JOIN orders o ON o.user_idd = u.id", 1054, "Unknown column 'o.user_idd' in 'on clause'", 42}, + {"SELECT x.* FROM users u", 1051, "Unknown table 'x'", 7}, + {"SELECT id FROM users u, orders u", 1066, "Not unique table/alias: 'u'", 24}, + {"SELECT id FROM users WHERE", 1064, "", 26}, + {"INSERT INTO users (name) VALUES ($1, $2)", 1136, "Column count doesn't match value count at row 1", -1}, + {"SELECT id FROM users WHERE name = $1 AND $2 = $3 AND nope = 1", 1054, "Unknown column 'nope' in 'where clause'", 53}, + {"SELECT NOPE(id) FROM users", 1305, "FUNCTION NOPE does not exist", 7}, + {"SELECT LENGTH(id, name) FROM users", 1582, "Incorrect parameter count in the call to native function 'LENGTH'", 7}, + {"SELECT (SELECT id, name FROM users) FROM users", 1241, "Operand should contain 1 column(s)", 7}, + {"SELECT id FROM users WHERE id IN (SELECT id, user_id FROM orders)", 1241, "Operand should contain 1 column(s)", 27}, + {"SELECT id FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.idd)", 1054, "Unknown column 'u.idd' in 'where clause'", 78}, + {"SELECT d.id FROM (SELECT name FROM users) d", 1054, "Unknown column 'd.id' in 'field list'", 7}, + {"SELECT id FROM (SELECT id FROM users)", 1248, "Every derived table must have its own alias", 15}, + {"SELECT id FROM users UNION SELECT id, user_id FROM orders", 1222, "The used SELECT statements have a different number of columns", 0}, + {"WITH t AS (SELECT id FROM users) SELECT name FROM t", 1054, "Unknown column 'name' in 'field list'", 40}, + {"SELECT x FROM (SELECT 1, 2) AS d(x)", 1353, "View's SELECT and view's field list have different column counts", 14}, + {"UPDATE v_stats SET n = 1", 1288, "The target table v_stats of the UPDATE is not updatable", 19}, + {"SELECT id FROM users ORDER BY nope", 1054, "Unknown column 'nope' in 'order clause'", 30}, + {"SELECT id FROM users ORDER BY 2", 1054, "Unknown column '2' in 'order clause'", 30}, + {"SELECT id FROM users GROUP BY nope", 1054, "Unknown column 'nope' in 'group statement'", 30}, + {"SELECT id FROM users HAVING nope > 1", 1054, "Unknown column 'nope' in 'having clause'", 28}, + {"SELECT id AS x, name AS x FROM users ORDER BY x", 1052, "Column 'x' in order clause is ambiguous", 46}, + {"SELECT id FROM users UNION SELECT user_id FROM orders ORDER BY name", 1054, "Unknown column 'name' in 'order clause'", 63}, + {"UPDATE users SET name = 'x' ORDER BY nope LIMIT 1", 1054, "Unknown column 'nope' in 'order clause'", 37}, + {"SELECT id FROM users WHERE id <=> ALL (SELECT user_id FROM orders)", 1064, "syntax error at byte 27: <=> ALL / ANY is a syntax error", 27}, +} + +// oneCases are the statements the One proof judges: proven means every expansion touches +// at most one row, by the facts the analyzer records (x/cardinality does the proving). +var oneCases = []struct { + sql string + proven bool +}{ + {"SELECT id, name FROM users WHERE id = $1", true}, + {"SELECT id, name FROM users WHERE name = $1", false}, + {"SELECT id FROM users WHERE id = 1 AND name = 'x'", true}, + {"SELECT id FROM users WHERE id = $1 OR name = $2", false}, + {"SELECT id FROM users u WHERE u.id = $1 + 1", true}, + {"SELECT COUNT(*) FROM users", true}, + {"SELECT COUNT(*), MAX(id) FROM users WHERE name = 'x'", true}, + {"SELECT COUNT(*) FROM users GROUP BY name", false}, + {"SELECT id FROM users LIMIT 1", true}, + {"SELECT id FROM users LIMIT 2", false}, + {"SELECT 1", true}, + {"SELECT u.id, o.total FROM users u JOIN orders o ON o.user_id = u.id WHERE o.id = $1", true}, + {"SELECT u.id, o.total FROM users u JOIN orders o ON o.user_id = u.id WHERE u.id = $1", false}, + {"SELECT u.id, o.total FROM users u JOIN orders o ON o.id = u.id WHERE u.id = $1", true}, + {"SELECT u.id, o.total FROM users u LEFT JOIN orders o ON o.id = u.id WHERE u.id = $1", true}, + {"SELECT u.id, o.total FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE u.id = $1", false}, + {"SELECT u.id FROM users u JOIN orders o USING (id) WHERE o.id = $1", true}, + {"SELECT id FROM users WHERE id IN ($1, $2)", false}, + {"SELECT id FROM users WHERE id = (SELECT MAX(user_id) FROM orders)", false}, + {"SELECT d.id FROM (SELECT id FROM users WHERE id = 1) d", true}, + {"SELECT d.id FROM (SELECT id FROM users) d WHERE d.id = 1", true}, // the derived table's output is users.id: the proof looks into the body + {"SELECT id FROM v_users WHERE id = $1", true}, + {"SELECT id FROM users UNION SELECT user_id FROM orders WHERE id = 1", false}, + {"INSERT INTO users (name) VALUES ($1)", true}, + {"INSERT INTO users (name) VALUES ($1), ($2)", false}, + {"INSERT INTO users (name) SELECT name FROM users", false}, + {"UPDATE users SET name = $1 WHERE id = $2", true}, + {"UPDATE users SET name = $1 WHERE name = $2", false}, + {"UPDATE users SET name = $1 WHERE name = $2 LIMIT 1", true}, + {"UPDATE users u JOIN orders o ON o.user_id = u.id SET o.note = $1 WHERE o.id = $2", true}, + {"DELETE FROM users WHERE id = $1", true}, + {"DELETE FROM users WHERE email = $1", false}, + {"DELETE FROM users WHERE name = $1 LIMIT 1", true}, +} + +func TestOne(t *testing.T) { + s := load(t) + for _, c := range oneCases { + r, err := Analyze(s, c.sql) + if err != nil { + t.Errorf("%s: %v", c.sql, err) + continue + } + got, why := cardinality.AtMostOne(r.Facts) + if got != c.proven { + t.Errorf("%s: proven=%v (%s), want %v", c.sql, got, why, c.proven) + } + } +} + +func TestErrors(t *testing.T) { + s := load(t) + for _, c := range errorCases { + _, err := Analyze(s, c.sql) + e, ok := err.(*Error) + if !ok { + t.Errorf("%s: got %v, want *Error %d", c.sql, err, c.code) + continue + } + if e.Code != c.code || (c.msg != "" && e.Message != c.msg) || e.Position != c.pos { + t.Errorf("%s:\n got %d %q at %d\n want %d %q at %d", c.sql, e.Code, e.Message, e.Position, c.code, c.msg, c.pos) + } + } +} + +func TestUnsupported(t *testing.T) { + s := load(t) + for _, sql := range []string{ + "SHOW TABLES", + } { + _, err := Analyze(s, sql) + if err == nil { + t.Errorf("%s: analyzed, want an unsupported error", sql) + continue + } + if _, isDB := err.(*Error); isDB { + t.Errorf("%s: %v is a database error, want the analyzer's own limit", sql, err) + } + } +} + +// TestParamSources covers the table column a placeholder stands for: the column it is +// compared with (=, IN, BETWEEN, a subquery's output) or stored into (assigned). +func TestParamSources(t *testing.T) { + s := load(t) + cases := []struct { + sql string + want string // one entry per placeholder: table.column, "=" when assigned, "-" for none + }{ + {"SELECT id FROM users WHERE id = $1", "users.id"}, + {"SELECT id FROM users WHERE $1 = id AND name IN ($2, $3)", "users.id users.name users.name"}, + {"SELECT id FROM users WHERE created_at BETWEEN $1 AND $2", "users.created_at users.created_at"}, + {"SELECT id FROM users WHERE id = $1 + 1", "-"}, + {"SELECT u.id FROM users u JOIN orders o ON o.user_id = u.id WHERE $1 IN (SELECT user_id FROM orders)", "orders.user_id"}, + {"SELECT id FROM users WHERE name = $1 AND email = $1", "users.name"}, + {"INSERT INTO users (name, email) VALUES ($1, $2)", "users.name= users.email="}, + {"UPDATE users SET name = $1 WHERE id = $2", "users.name= users.id"}, + {"INSERT INTO orders (id, user_id, total) SELECT $1, id, 0 FROM users WHERE id = $2", "orders.id= users.id"}, + {"SELECT id FROM users LIMIT $1", "-"}, + } + for _, c := range cases { + r, err := Analyze(s, c.sql) + if err != nil { + t.Errorf("%s: %v", c.sql, err) + continue + } + var parts []string + for _, p := range r.Params { + if p.Source == nil { + parts = append(parts, "-") + continue + } + e := p.Source.Table + "." + p.Source.Column + if p.Source.Assigned { + e += "=" + } + parts = append(parts, e) + } + if got := strings.Join(parts, " "); got != c.want { + t.Errorf("%s:\n got %s\n want %s", c.sql, got, c.want) + } + } +} diff --git a/check/mysql/internal/analyze/body.go b/check/mysql/internal/analyze/body.go new file mode 100644 index 0000000..ea5b0bc --- /dev/null +++ b/check/mysql/internal/analyze/body.go @@ -0,0 +1,1672 @@ +package analyze + +import ( + "fmt" + "sort" + "strconv" + "strings" + "sync" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlast" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/cardinality" + "github.com/kr9ly/sqlshape/v2/x/facts" + "github.com/kr9ly/sqlshape/v2/x/placeholder" +) + +// BodyResult is a trigger's or a routine's body walked and typed on its own: the facts of +// each DML statement it contains (a Definition per the dialect's obligation, positioned at +// -1 the way a view's are), ready for dialect.Definitions. +type BodyResult struct { + Statements []BodyStatement + // Violations are the failure modes the body itself can produce once its own DECLARE ... + // HANDLERs have absorbed what they catch (see block, walkSignal): its SIGNALs, what its + // own embedded INSERT/UPDATE/DELETE may violate (including, recursively, what firing + // their own tables' triggers may raise), and a SELECT ... INTO that cannot be proved to + // return at most one row. A trigger's are what triggerViolations (violations.go) adds to + // the statement that fires it; a routine's are not consumed by this milestone (m6's + // concern for CALL and function-call sites), only computed correctly for it to use. + Violations []Violation + // WriteTables are the tables this body's own embedded INSERT/UPDATE/DELETE write to, + // and the ones the routines it calls (a function in an expression, a CALL) write in + // turn, named once each (walkDML, finishCalls): what a call site invoking this routine + // may collide with (1442, call.go's checkCalledRoutineOverlap) when the invoking + // statement itself references (reads or writes) the same table. + WriteTables []string + // NestedCallColumns are the result-column shapes of every CALL this body itself contains + // (walkCall), one entry per nested CALL whose own callee has a single agreed shape: + // mysqld propagates a nested CALL's own result set out through every enclosing CALL + // exactly as if the inner SELECT sat directly in the outer body (measured), so + // resultColumnsOf (call.go) folds these in alongside Statements' own Columns rather than + // recording the nested CALL as one of Statements (which would misrepresent it as this + // body's own Facts for the dialect's obligations -- a CALL is "no scope of its own", + // callStmt's own doc). + NestedCallColumns [][]Column +} + +// BodyStatement is one DML statement's facts, with the 1-based line of the definition +// text it starts at (0 when the position is not known), the way dialect.Definition's +// What names a function's statement ("function pay: line 3", check/postgres's own +// convention, mirrored here). Columns is set only for a PROCEDURE's own INTO-less top-level +// SELECT (walkSelect): the CALL statement's own possible result columns (call.go's +// resultColumnsOf); nil for every other kind of body statement (an INSERT/UPDATE/DELETE, a +// SELECT ... INTO, a cursor's DECLARE ... FOR), which consume their own columns rather than +// producing a result. +type BodyStatement struct { + Facts *facts.Facts + Line int + Columns []Column + // LockedReads are the tables a locking read (SELECT ... FOR UPDATE / FOR SHARE / LOCK + // IN SHARE MODE) this statement is names: a further hop in the trigger chain writing + // back into one of them is 1442, the same certain way a further write is (measured: + // TestAdv3ChainSelectForUpdateInsideTriggerIs1442 -- a plain SELECT or a subquery read + // does not collide, only a locking one). Populated by walkSelect for a statement with + // a trailing locking clause, read back by violations.go's triggerViolations. + LockedReads []string +} + +// lineAt is pos's 1-based line within the definition text (schema.Trigger.Definition / +// schema.Routine.Definition), for BodyStatement.Line. +func (a *analyzer) lineAt(pos int) int { + if pos < 0 || pos > len(a.text) { + // defensive: pos is always a.ph.Back of a parsed node's own Start, which Back + // only ever maps to -1 for a negative input (never produced here) or to an + // offset within a.text (the very text the node was parsed from). + return 0 + } + return strings.Count(a.text[:pos], "\n") + 1 +} + +// varScope is one block's declared variables (and parameters, in the outermost one), +// cursors and named conditions, chained to the enclosing block. +type varScope struct { + outer *varScope + vars map[string]bodyVar + cursors map[string]*cursorInfo + // conds are this block's DECLARE ... CONDITION FOR names, resolved to a condRef (a + // SQLSTATE or a MySQL error number: the only two forms sp_cond accepts). + conds map[string]condRef +} + +// bodyVar is one declared variable or parameter: always potentially NULL (there is no NOT +// NULL for a routine variable; a DEFAULT does not stop a later assignment from clearing +// it), so nullable is always true. +type bodyVar struct { + name string + typ schema.Type +} + +// cursorInfo is a declared cursor: its query's result columns, for FETCH's count check. +type cursorInfo struct { + name string + cols []Column +} + +// pushVars enters a new block. +func (a *analyzer) pushVars() { a.vars = &varScope{outer: a.vars} } + +// popVars leaves the current block. +func (a *analyzer) popVars() { + if a.vars != nil { + a.vars = a.vars.outer + } +} + +// declareVar adds a variable or parameter to the current (innermost) block. +func (a *analyzer) declareVar(name string, t schema.Type) { + if a.vars == nil { + // defensive: analyzeRoutineBody and walkBlock both pushVars before anything can + // declare into a.vars; never observed nil here. + a.pushVars() + } + if a.vars.vars == nil { + a.vars.vars = map[string]bodyVar{} + } + a.vars.vars[strings.ToLower(name)] = bodyVar{name: name, typ: t} +} + +// declareCursor adds a cursor to the current block. +func (a *analyzer) declareCursor(name string, cols []Column) { + if a.vars == nil { + // defensive: see declareVar's own note. + a.pushVars() + } + if a.vars.cursors == nil { + a.vars.cursors = map[string]*cursorInfo{} + } + a.vars.cursors[strings.ToLower(name)] = &cursorInfo{name: name, cols: cols} +} + +// lookupVar resolves a bare name against the block chain (innermost first): a local +// variable or parameter takes priority over a table column (MySQL's own rule), which is +// why analyze.go's column() calls this before falling back to the relations in scope. +func (a *analyzer) lookupVar(name string) (colRef, bool) { + for s := a.vars; s != nil; s = s.outer { + if v, ok := s.vars[strings.ToLower(name)]; ok { + return colRef{c: Column{Name: v.name, Type: v.typ, Known: v.typ.Name != "", Nullable: true}}, true + } + } + return colRef{}, false +} + +// lookupCursor resolves a declared cursor's name against the block chain. +func (a *analyzer) lookupCursor(name string) (*cursorInfo, bool) { + for s := a.vars; s != nil; s = s.outer { + if c, ok := s.cursors[strings.ToLower(name)]; ok { + return c, true + } + } + return nil, false +} + +// declareCondition adds a DECLARE ... CONDITION FOR to the current block. +func (a *analyzer) declareCondition(name string, ref condRef) { + if a.vars == nil { + // defensive: see declareVar's own note. + a.pushVars() + } + if a.vars.conds == nil { + a.vars.conds = map[string]condRef{} + } + a.vars.conds[strings.ToLower(name)] = ref +} + +// lookupCondition resolves a declared condition's name against the block chain. +func (a *analyzer) lookupCondition(name string) (condRef, bool) { + for s := a.vars; s != nil; s = s.outer { + if c, ok := s.conds[strings.ToLower(name)]; ok { + return c, true + } + } + return condRef{}, false +} + +// classifyMysqlerr reads a sp_condition_value's own `_mysqlerr` field (mysqlast folds it to +// a mysqlast.Number for a numeric literal, a Go string for a SQLSTATE literal read through +// field()'s Token.Value path, or a mysqlast.Const for the sp_condition_value::WARNING / +// NOT_FOUND / EXCEPTION constants read through the generic ActNew {Text: ...} builder +// (mysqlast.Builder.constant) -- measured against the generated shapes; a bug here (the +// Const case was once missing) had HANDLER FOR SQLWARNING / NOT FOUND / SQLEXCEPTION all +// silently resolve to the zero condRef (kind condSQLState, sqlstate ""), catching nothing a +// real Violation (which always carries a non-empty SQLState) could ever match). +// classifyMysqlerr's second return is the malformed SQLSTATE literal's own text ("" when it +// is well-formed, or the value was not a SQLSTATE literal at all): the server refuses +// CREATE for a SIGNAL/RESIGNAL/HANDLER FOR/DECLARE ... CONDITION naming a SQLSTATE that is +// not exactly 5 characters long (1407 "Bad SQLSTATE: ''", measured; a well-formed +// 5-character one that happens to be non-numeric, e.g. 'ABCDE', is accepted) -- +// resolveCondValue turns this into that Error, at the one call site position it has. +func classifyMysqlerr(v mysqlast.Value) (condRef, string) { + switch x := v.(type) { + case int: + // defensive: ulong_num's own reduction (sp_cond's Args{Child:1}, no Field applied) + // carries the mysqlparse-level value straight through, which is mysqlast.Number, + // not a bare Go int; kept in case that ever changes. + return condRef{kind: condNumber, number: x}, "" + case mysqlast.Number: + return condRef{kind: condNumber, number: int(x)}, "" + case string: + return classifyMysqlerrString(x) + case mysqlast.Const: + return classifyMysqlerrString(string(x)) + } + // defensive: sp_condition_value's own _mysqlerr is always one of the four cases above. + return condRef{}, "" +} + +// classifyMysqlerrString is classifyMysqlerr's own work once the value is a plain string +// (however it got there): one of the three keyword conditions, or a SQLSTATE literal (bad +// when it is not exactly 5 characters). +func classifyMysqlerrString(x string) (condRef, string) { + switch x { + case "sp_condition_value::WARNING": + return condRef{kind: condWarning}, "" + case "sp_condition_value::NOT_FOUND": + return condRef{kind: condNotFound}, "" + case "sp_condition_value::EXCEPTION": + return condRef{kind: condException}, "" + default: + if len(x) != 5 { + return condRef{}, x + } + return condRef{kind: condSQLState, sqlstate: strings.ToUpper(x)}, "" + } +} + +// resolveCondValue resolves one condition value of a SIGNAL, a RESIGNAL or a HANDLER FOR +// list: a bare sp_condition_value (a literal), or a sp_condition_name (a DECLARE ... +// CONDITION FOR, resolved against the block chain). err is non-nil (1407) only for a +// malformed SQLSTATE literal (classifyMysqlerr); every other caller only ever saw ok, so it +// keeps meaning exactly what it always has. +func (a *analyzer) resolveCondValue(v mysqlast.Value) (condRef, bool, error) { + n, ok := v.(*mysqlast.Node) + if !ok { + // defensive: every caller (a SIGNAL/RESIGNAL's own condition, a HANDLER FOR list's + // items) passes a sp_cond/sp_hcond value, always a *mysqlast.Node. + return condRef{}, false, nil + } + switch n.Class { + case "sp_condition_value": + ref, bad := classifyMysqlerr(n.Arg("_mysqlerr")) + if bad != "" { + return condRef{}, false, &Error{Message: fmt.Sprintf("Bad SQLSTATE: '%s'", bad), Code: 1407, Position: a.ph.Back(n.Start)} + } + return ref, true, nil + case "sp_condition_name": + ref, ok := a.lookupCondition(str(n.Arg("name"))) + return ref, ok, nil + } + // defensive: sp_cond/sp_hcond only ever build one of the two classes above. + return condRef{}, false, nil +} + +// resolveHandlerConditions resolves a HANDLER FOR's comma-separated condition list. +func (a *analyzer) resolveHandlerConditions(v mysqlast.Value) ([]condRef, error) { + list, _ := v.(mysqlast.List) + out := make([]condRef, 0, len(list)) + for _, c := range list { + ref, ok, err := a.resolveCondValue(c) + if err != nil { + return nil, err + } + if ok { + out = append(out, ref) + } + } + return out, nil +} + +// trigRowColumn resolves NEW.field / OLD.field inside a trigger body, applying the +// server's own rules (measured on mysqld 8.4): +// - referencing OLD in an INSERT trigger, or NEW in a DELETE trigger, is 1363 ("There is +// no OLD/NEW row in on INSERT/DELETE trigger") regardless of the column; +// - an unknown column is 1054 ("Unknown column 'x' in 'NEW'"/"'OLD'"), the row alias +// alone as the "in" part (not "NEW.x"); +// - write (an assignment target, SET NEW.x = ... / SET OLD.x = ...) additionally +// forbids OLD always (1362 "Updating of OLD row is not allowed in trigger") and NEW +// outside a BEFORE trigger (1362 "Updating of NEW row is not allowed in after +// trigger"); +// - NEW.col reads NULL-able in a BEFORE INSERT/UPDATE trigger regardless of the column's +// own NOT NULL (measured: the server's own NOT NULL check has not run yet at that +// point) -- EXCEPT a BEFORE INSERT's NEW.col for an omitted AUTO_INCREMENT column, or +// an omitted column with a non-NULL DEFAULT: the server has already substituted the +// value (the AUTO_INCREMENT placeholder 0, or the default) before the trigger body +// runs, so NEW.col is never actually NULL there (measured: `INSERT INTO orders (total) +// VALUES (...)`, omitting an AUTO_INCREMENT id, fires a BEFORE INSERT trigger that +// reads NEW.id = 0 and can insert it into another table's NOT NULL column without +// error). A BEFORE UPDATE's NEW.col stays conservatively nullable even for such a +// column: an UPDATE can name the column explicitly and assign it NULL, which the +// server's own NOT NULL check rejects with 1048 at the statement level, not inside the +// trigger body -- so from the trigger body's point of view NEW.col could still be NULL +// going in. An AFTER trigger's NEW.col, and OLD.col at any timing, follow the column's +// declared nullability (measured: an AFTER INSERT trigger on a table with an +// AUTO_INCREMENT key, a NOT NULL DEFAULT column and a plain NOT NULL column sees none +// of them NULL for an INSERT naming only the last, and an INSERT of an explicit NULL +// into a NOT NULL column is 1048 before any AFTER trigger runs -- the server's NOT +// NULL check sits between the BEFORE and the AFTER triggers). +func (a *analyzer) trigRowColumn(qualifier, field string, at int, write bool) (*schema.Column, bool, error) { + tg := a.trig + row := strings.ToUpper(qualifier) + event := strings.ToUpper(tg.Event) + timing := strings.ToUpper(tg.Timing) + if write && row == "OLD" { + // checked before the event: SET OLD.x is 1362 in an INSERT trigger too, not 1363 + // (measured on 8.4, found by the body probe) + return nil, false, &Error{Message: "Updating of OLD row is not allowed in trigger", Code: 1362, Position: a.ph.Back(at)} + } + if row == "OLD" && event == "INSERT" { + return nil, false, &Error{Message: "There is no OLD row in on INSERT trigger", Code: 1363, Position: a.ph.Back(at)} + } + if row == "NEW" && event == "DELETE" { + return nil, false, &Error{Message: "There is no NEW row in on DELETE trigger", Code: 1363, Position: a.ph.Back(at)} + } + col := a.trigTable.Column(field) + if col == nil { + return nil, false, &Error{Message: fmt.Sprintf("Unknown column '%s' in '%s'", field, row), Code: 1054, Position: a.ph.Back(at)} + } + if write && row == "NEW" && timing == "AFTER" { + return nil, false, &Error{Message: "Updating of NEW row is not allowed in after trigger", Code: 1362, Position: a.ph.Back(at)} + } + nullable := !col.NotNull + if row == "NEW" && timing == "BEFORE" { + // an AUTO_INCREMENT column holds the placeholder 0 in a BEFORE INSERT body whether + // the statement omitted it or wrote NULL (measured), so it is never NULL there. A + // DEFAULT does not help: an explicit NULL reaches the body as NULL (measured: the + // body's own INSERT of NEW.total into a NOT NULL column is 1048, on that column), + // and a body is analyzed once for every statement, so it stays nullable. + substituted := event == "INSERT" && col.AutoIncrement + if !substituted { + nullable = true + } + } + return col, nullable, nil +} + +// AnalyzeTrigger types tg's body against s: NEW / OLD resolve as tg's table (under the +// rules trigRowColumn documents), a bare name resolves against the block's DECLAREs first, +// then as a table column of whatever relation an embedded statement (INSERT/UPDATE/DELETE/ +// SELECT) brings into scope. The result is one Facts per DML statement the body contains, +// for dialect.Definitions; a construct the server itself refuses at CREATE time is an +// error instead (analyze.Error, the way Analyze's are). +func AnalyzeTrigger(s *schema.Schema, tg *schema.Trigger) (*BodyResult, error) { + if c, ok := bodyCache.Load(tg); ok { + // a cycle (this trigger's own analysis, further up the call stack, reaches itself + // because its body writes a table whose trigger writes back to this one) is broken + // right here: LoadOrStore below already stored the in-progress placeholder before + // analyzeTriggerBody was entered, so a recursive call sees it now, empty + // (result/err unset until the outer call finishes) -- measured (TestAnalyzeTrigger_Cycle). + cc := c.(*bodyCached) + return cc.result, cc.err + } + c := &bodyCached{} + actual, loaded := bodyCache.LoadOrStore(tg, c) + if loaded { + // defensive: only a genuine data race (two goroutines calling AnalyzeTrigger on the + // same *schema.Trigger concurrently) reaches this; single-goroutine recursion (the + // cycle above) always finds the entry through the plain Load check first. + cc := actual.(*bodyCached) + return cc.result, cc.err + } + br, err := analyzeTriggerBody(s, tg) + c.result, c.err = br, err + return br, err +} + +// analyzeTriggerBody is AnalyzeTrigger's own work, apart from the cache: cachedAnalyzeTrigger +// (violations.go) and AnalyzeTrigger itself both go through the cache instead. +func analyzeTriggerBody(s *schema.Schema, tg *schema.Trigger) (*BodyResult, error) { + t := s.Table(tg.Table) + if t == nil { + // defensive: schema.Load itself refuses a CREATE TRIGGER whose table it does not + // know (a Problem, not a loaded *schema.Trigger), so every *schema.Trigger + // AnalyzeTrigger is ever handed names a table the schema does have. + return nil, fmt.Errorf("analyze: trigger %s: its table %s is not in the schema", tg.Name, tg.Table) + } + _, ph := placeholder.Rewrite(tg.Definition) + a := &analyzer{s: s, text: tg.Definition, ph: ph, trig: tg, trigTable: t, raises: parseRaises(tg.Directives)} + br := &BodyResult{} + a.bodyResult = br + if err := a.walkOne(scope{}, tg.Body, br); err != nil { + return nil, err + } + br.Violations = dedupe(a.raised) + return br, nil +} + +// bodyCache caches a trigger's own body analysis (AnalyzeTrigger's schema.Trigger pointer +// is stable once the schema is loaded, the same assumption check/postgres/analyze/plpgsql.go's +// own plBodies cache makes), so a trigger fired by several statements, or reached again +// through its own recursive write, is walked once. The in-progress placeholder +// (LoadOrStore's "loaded" branch) is how a cycle breaks: see AnalyzeTrigger. +var bodyCache sync.Map // map[*schema.Trigger]*bodyCached + +type bodyCached struct { + result *BodyResult + err error +} + +// cachedAnalyzeTrigger is triggerViolations' own entry point (violations.go): the same +// cache AnalyzeTrigger uses, so neither walks a trigger's body twice. +func cachedAnalyzeTrigger(s *schema.Schema, tg *schema.Trigger) (*BodyResult, error) { + return AnalyzeTrigger(s, tg) +} + +// parseRaises reads a trigger's/routine's `-- sqlshape: error = ` directives +// (schema.ParseRaises; schema.go keeps them verbatim, validated, this is the "later +// stage" that parses them) into a map by key -- a MYSQL_ERRNO as decimal text, or a +// SQLSTATE. +func parseRaises(directives []string) map[string]string { + out := map[string]string{} + for _, r := range schema.ParseRaises(directives) { + out[r.Code] = r.Name + } + return out +} + +// AnalyzeRoutine types r's body against s: parameters are declared variables from the +// start (always nullable, like every routine variable), RETURN is typed against Returns +// for a FUNCTION and refused (1313) for a PROCEDURE, and a FUNCTION with no RETURN +// anywhere in its body is refused (1320) -- the server's own checks, not a reachability +// analysis. +func AnalyzeRoutine(s *schema.Schema, r *schema.Routine) (*BodyResult, error) { + if c, ok := routineCache.Load(r); ok { + // a cycle (a routine that reaches its own AnalyzeRoutine again while its first + // call is still on the stack, e.g. an UPDATE calling itself in the SET value) is + // broken here the same way AnalyzeTrigger's own Load check is -- measured + // (TestAnalyzeRoutine_Cycle). + cc := c.(*bodyCached) + return cc.result, cc.err + } + c := &bodyCached{} + actual, loaded := routineCache.LoadOrStore(r, c) + if loaded { + // defensive: see AnalyzeTrigger's own note -- only a concurrent race reaches this. + cc := actual.(*bodyCached) + return cc.result, cc.err + } + br, err := analyzeRoutineBody(s, r) + c.result, c.err = br, err + return br, err +} + +// routineCache is AnalyzeRoutine's own cache (bodyCache's counterpart): see AnalyzeTrigger. +var routineCache sync.Map // map[*schema.Routine]*bodyCached + +// AnalyzeEvent types an event's DO body against s, the way AnalyzeRoutine types a +// procedure's: no parameters, no NEW / OLD, a `RETURN` is 1313 (the server's own CREATE-time +// refusal, measured). Nothing a program runs reaches an event, so its Violations are computed +// but consumed by no call site; its Statements' facts are what dialect.Definitions judges +// (obligations), and an Error is the schema's own problem -- the server accepts a body +// naming a table that does not exist and fails it at every run (measured), the checker +// reports it once, here. +func AnalyzeEvent(s *schema.Schema, e *schema.Event) (*BodyResult, error) { + if c, ok := eventCache.Load(e); ok { + cc := c.(*bodyCached) + return cc.result, cc.err + } + c := &bodyCached{} + actual, loaded := eventCache.LoadOrStore(e, c) + if loaded { + // defensive: see AnalyzeTrigger's own note -- only a concurrent race reaches this. + cc := actual.(*bodyCached) + return cc.result, cc.err + } + _, ph := placeholder.Rewrite(e.Definition) + a := &analyzer{s: s, text: e.Definition, ph: ph} + br := &BodyResult{} + a.bodyResult = br + if err := a.walkOne(scope{}, e.Body, br); err != nil { + c.err = err + return nil, err + } + br.Violations = dedupe(a.raised) + c.result = br + return br, nil +} + +// eventCache is AnalyzeEvent's own cache: see AnalyzeTrigger. +var eventCache sync.Map // map[*schema.Event]*bodyCached + +func analyzeRoutineBody(s *schema.Schema, r *schema.Routine) (*BodyResult, error) { + _, ph := placeholder.Rewrite(r.Definition) + a := &analyzer{s: s, text: r.Definition, ph: ph, routine: r, raises: parseRaises(r.Directives)} + a.pushVars() + for _, p := range r.Params { + a.declareVar(p.Name, p.Type) + } + br := &BodyResult{} + a.bodyResult = br + err := a.walkOne(scope{}, r.Body, br) + a.popVars() + if err != nil { + return nil, err + } + if r.Kind == schema.Function && !a.sawReturn { + return nil, &Error{Message: fmt.Sprintf("No RETURN found in FUNCTION %s", r.Name), Code: 1320, Position: -1} + } + br.Violations = dedupe(a.raised) + return br, nil +} + +// resetStatement clears the per-statement bookkeeping Analyze's entry points (insert/ +// update/delete/selectStmt) fill in, so a fresh one can be typed independently of the +// last: the body's DML statements each get their own Facts, params, columns and uses. +func (a *analyzer) resetStatement() { + a.columns = nil + a.facts = nil + a.uses = nil + a.useIdx = nil + a.readUses = nil + a.assigning = false + a.write = nil + a.outerRefs = nil + a.blocks = nil + a.fdConst = nil + a.nullEq = nil + a.lists = nil + a.subFacts = nil + a.claimed = nil + a.paramSrc = nil + a.depth = 0 + a.setOp = 0 + a.refRels = nil + a.resetCalls() +} + +// resetCalls forgets the routines the statement just walked called: each body statement +// is its own invoking statement for the 1442 overlap check and folds in its own callees' +// failure modes (finishCalls), so a callee of an earlier statement must not count again. +func (a *analyzer) resetCalls() { + a.calledRoutines = nil + a.calledSeen = nil +} + +// finishCalls is what the routines a body statement called mean to the body, once the +// statement is walked: 1442 when one of them writes a table this statement references +// (or the trigger's own table, see checkCalledRoutineOverlap) -- an Error, the collision +// being certain from the bodies alone; their writes become the body's own (WriteTables is +// transitive, so a call site further out collides with what a nested call writes, as the +// server's prelocking does); and, unless the statement's violations() already did it +// (walkDML), their failure modes become the body's raised ones. +func (a *analyzer) finishCalls(br *BodyResult, foldViolations bool) error { + if len(a.calledRoutines) == 0 { + return nil + } + defer a.resetCalls() // folded once: a statement typed in several pieces (exprAt) does not fold twice + if err := a.checkCalledRoutineOverlap(); err != nil { + return err + } + for _, cr := range a.calledRoutines { + cbr, err := AnalyzeRoutine(a.s, cr.r) + if err != nil || cbr == nil { + continue + } + for _, wt := range cbr.WriteTables { + br.WriteTables = appendTableName(br.WriteTables, wt) + } + } + if foldViolations { + for _, v := range a.calledRoutineViolations() { + if v.SQLState == "" { + v.SQLState = constraintSQLState(v.Code) + } + a.raised = append(a.raised, v) + } + } + return nil +} + +// finishFacts is the post-processing Analyze does for a top-level statement (sorting the +// uses by position and hanging them off Facts), replayed here since the body walk calls +// insert/update/delete/selectStmt directly rather than through Analyze. +func (a *analyzer) finishFacts() { + sort.SliceStable(a.uses, func(i, j int) bool { return a.uses[i].Position < a.uses[j].Position }) + a.facts.Uses = a.uses +} + +// exprAt types an expression that is not part of a query block (a SET's right-hand side, +// an IF/WHILE/REPEAT condition, a RETURN, a SIGNAL information item): no relation is in +// scope, only the block's variables (and NEW/OLD in a trigger), through column()'s own +// lookupVar / trigRowColumn. +func (a *analyzer) exprAt(v mysqlast.Value, where string) error { + if v == nil { + return nil + } + if _, err := a.expr(scope{}, v, where); err != nil { + return err + } + if a.bodyResult != nil { + // a function the expression calls: its writes and failure modes are the body's, + // and it collides with the trigger's own table, or with a table a subquery of the + // expression names (finishCalls) + return a.finishCalls(a.bodyResult, true) + } + return nil +} + +// walkOne dispatches one body construct: a statement, a control-flow node, a list of +// statements, or the bare cursor name OPEN/CLOSE fold to (mysqlast cannot tell OPEN from +// CLOSE apart -- both need only the cursor to be declared, which is all this checks). +func (a *analyzer) walkOne(sc scope, v mysqlast.Value, br *BodyResult) error { + switch x := v.(type) { + case nil: + return nil + case mysqlast.List: + for _, s := range x { + if err := a.walkOne(sc, s, br); err != nil { + return err + } + } + return nil + case mysqlast.Token: + return a.walkCursorToken(str(x), x.Start) + case *mysqlast.Struct: + if cmd, ok := x.Fields["sql_command"].(mysqlast.Const); ok { + return a.commitCheck(string(cmd), 0) + } + // defensive: every generic Struct a body statement folds to (COMMIT, XA START, + // and the like -- anything without a dedicated AST hook) carries its own + // sql_command; none observed without one. + return nil + case *mysqlast.Node: + return a.walkNode(sc, x, br) + } + // defensive: a body construct is always nil, a List, a Token (a bare cursor name), a + // *Struct or a *Node -- mysqlast never folds one to another concrete Value type here. + return nil +} + +// walkCursorToken handles OPEN/CLOSE , both of which fold to the bare cursor name +// (1324 "Undefined CURSOR" when it was never declared, measured on mysqld). +func (a *analyzer) walkCursorToken(name string, at int) error { + if name == "" { + // defensive: OPEN/CLOSE's own grammar always names a cursor. + return nil + } + if _, ok := a.lookupCursor(name); !ok { + return &Error{Message: fmt.Sprintf("Undefined CURSOR: %s", name), Code: 1324, Position: a.ph.Back(at)} + } + return nil +} + +// commitCheck is 1422 for a transaction-control statement (COMMIT / START TRANSACTION / +// ROLLBACK / SAVEPOINT, folded to a {sql_command: SQLCOM_*} Struct) or a DDL statement +// (a PT_create_*/PT_drop_*/PT_alter_*/PT_rename_*/PT_truncate_* node) in a trigger or +// function body -- measured on mysqld: a procedure is exempt. Dynamic SQL (PREPARE / +// EXECUTE / DEALLOCATE PREPARE -- PREPARE and DEALLOCATE PREPARE fold to the same +// {sql_command: SQLCOM_*} Struct walkOne dispatches here directly; EXECUTE folds to a bare +// "execute" Node instead, whose own walkNode case calls this the same way) is 1336 in a +// trigger or function body, measured ("Dynamic SQL is not allowed in stored function or +// trigger"): a procedure is exempt from this too. +func (a *analyzer) commitCheck(class string, at int) error { + if a.trig == nil && (a.routine == nil || a.routine.Kind != schema.Function) { + return nil // a procedure: COMMIT, DDL and dynamic SQL are all allowed + } + if class == "SQLCOM_PREPARE" || class == "SQLCOM_DEALLOCATE_PREPARE" || class == "SQLCOM_EXECUTE" { + return &Error{Message: "Dynamic SQL is not allowed in stored function or trigger", Code: 1336, Position: a.ph.Back(at)} + } + if class == "SQLCOM_FLUSH" { + // measured: FLUSH TABLES inside a FUNCTION or a TRIGGER is refused at CREATE time + // every time (1336, the same family PREPARE/EXECUTE/DEALLOCATE PREPARE already + // carry); a PROCEDURE is exempt, like it is from those (the a.trig==nil && + // a.routine.Kind != Function guard above). + return &Error{Message: "FLUSH is not allowed in stored function or trigger", Code: 1336, Position: a.ph.Back(at)} + } + ddl := strings.HasPrefix(class, "PT_create_") || strings.HasPrefix(class, "PT_drop_") || + strings.HasPrefix(class, "PT_alter_") || strings.HasPrefix(class, "PT_rename_") || + strings.HasPrefix(class, "PT_truncate_") + txn := class == "SQLCOM_COMMIT" || class == "SQLCOM_BEGIN" || class == "SQLCOM_ROLLBACK" || + class == "SQLCOM_ROLLBACK_TO_SAVEPOINT" || class == "SQLCOM_SAVEPOINT" || class == "SQLCOM_RELEASE_SAVEPOINT" + if ddl || txn { + return &Error{Message: "Explicit or implicit commit is not allowed in stored function or trigger.", Code: 1422, Position: a.ph.Back(at)} + } + return nil +} + +// walkNode dispatches a *mysqlast.Node body construct. +func (a *analyzer) walkNode(sc scope, n *mysqlast.Node, br *BodyResult) error { + switch n.Class { + case "sp_block_content": + return a.walkBlock(sc, n, br) + case "sp_decl_var", "sp_decl_condition", "sp_decl_handler", "sp_decl_cursor": + return a.walkDecl(sc, n, br) + case "sp_if": + return a.walkIf(sc, n, br) + case "sp_labeled_block": + a.labels = append(a.labels, str(n.Arg("label"))) + err := a.walkOne(sc, n.Arg("body"), br) + a.labels = a.labels[:len(a.labels)-1] + return err + case "sp_labeled_control": + a.labels = append(a.labels, str(n.Arg("label"))) + err := a.walkOne(sc, n.Arg("body"), br) + a.labels = a.labels[:len(a.labels)-1] + return err + case "sp_unlabeled_control": + // WHILE (cond, body) / REPEAT (body, cond); a bare LOOP folds to its body List + // directly (no wrapper node), so it never reaches this case. + if _, ok := n.Args[0].(mysqlast.List); ok { + if err := a.walkOne(sc, n.Args[0], br); err != nil { + return err + } + return a.exprAt(n.Args[1], "repeat") + } + if err := a.exprAt(n.Args[0], "while"); err != nil { + return err + } + return a.walkOne(sc, n.Args[1], br) + case "simple_case_stmt": + if err := a.exprAt(n.Args[0], "case"); err != nil { + return err + } + for _, w := range flattenBinaryList(n.Args[1], "simple_when_clause_list") { + wn, _ := w.(*mysqlast.Node) + if wn == nil { + continue // defensive: a simple_when_clause_list item is always a Node + } + if err := a.exprAt(wn.Args[0], "when"); err != nil { + return err + } + if err := a.walkOne(sc, wn.Args[1], br); err != nil { + return err + } + } + if n.Args[2] == nil { + a.raiseCaseNotFound(n.Start) + } + return a.walkOne(sc, n.Args[2], br) + case "searched_case_stmt": + for _, w := range flattenBinaryList(n.Args[0], "searched_when_clause_list") { + wn, _ := w.(*mysqlast.Node) + if wn == nil { + continue // defensive: a searched_when_clause_list item is always a Node + } + if err := a.exprAt(wn.Args[0], "when"); err != nil { + return err + } + if err := a.walkOne(sc, wn.Args[1], br); err != nil { + return err + } + } + if n.Args[1] == nil { + a.raiseCaseNotFound(n.Start) + } + return a.walkOne(sc, n.Args[1], br) + case "sp_leave": + return a.checkLabel("LEAVE", str(n.Arg("label")), n.Start) + case "sp_iterate": + return a.checkLabel("ITERATE", str(n.Arg("label")), n.Start) + case "sp_return": + return a.walkReturn(n) + case "sp_signal", "sp_resignal": + return a.walkSignal(n) + case "PT_set": + return a.walkSet(sc, n) + case "PT_call": + return a.walkCall(n, br) + case "execute": + // EXECUTE : dynamic SQL the same way PREPARE / DEALLOCATE PREPARE are + // (commitCheck), but folded to a bare Node (no sql_command Struct of its own). + return a.commitCheck("SQLCOM_EXECUTE", n.Start) + case "flush": + // FLUSH TABLES (and every other FLUSH form): folded to a bare "flush" Node with no + // sql_command Struct of its own either (measured against the generated shapes, + // TestDumpFlush, scratchpad/adv3 -- the "flush" simple_statement rule is ActEmpty, + // and "FLUSH_SYM opt_no_write_to_binlog flush_options" builds a plain Node named + // after the rule, not a Struct), so commitCheck is reached the same way EXECUTE's + // own bare Node is, not through walkOne's generic *Struct case. + return a.commitCheck("SQLCOM_FLUSH", n.Start) + case "sp_proc_stmt_fetch": + return a.walkFetch(n) + case "lock_tables", "unlock", "PT_load_table", "Sql_cmd_alter_view": + // refused by the server's grammar inside any body (lex->sphead): 1314 " is not + // allowed in stored procedures", measured for a PROCEDURE, a FUNCTION and a + // TRIGGER alike (unlike commitCheck's 1422 / 1336, a PROCEDURE is not exempt) + return &Error{Message: badStatementName(n.Class) + " is not allowed in stored procedures", Code: 1314, Position: a.ph.Back(n.Start)} + case "PT_select_stmt": + return a.walkSelect(sc, n, br) + case "PT_insert": + return a.walkDML(n, facts.Insert, br, a.insert) + case "PT_update": + return a.walkDML(n, facts.Update, br, a.update) + case "PT_delete": + return a.walkDML(n, facts.Delete, br, a.delete) + } + if strings.HasPrefix(n.Class, "PT_create_") || strings.HasPrefix(n.Class, "PT_drop_") || + strings.HasPrefix(n.Class, "PT_alter_") || strings.HasPrefix(n.Class, "PT_rename_") || + strings.HasPrefix(n.Class, "PT_truncate_") { + return a.commitCheck(n.Class, n.Start) + } + return nil // a body statement this milestone does not walk (SHOW, GET DIAGNOSTICS...): skipped, not an error +} + +// flattenBinaryList unwraps a left-recursive when-clause list (a single clause folds to itself, +// with no wrapping node; two or more nest as wrapClass(list-so-far, clause)) into the +// clauses in source order. +func flattenBinaryList(v mysqlast.Value, wrapClass string) []mysqlast.Value { + if n, ok := v.(*mysqlast.Node); ok && n.Class == wrapClass { + return append(flattenBinaryList(n.Args[0], wrapClass), n.Args[1]) + } + return []mysqlast.Value{v} +} + +// pendingHandler is one DECLARE ... HANDLER of the block being walked, its body deferred +// until the block's own statements (and, so, its own failure modes) are known. +type pendingHandler struct { + conds []condRef + body mysqlast.Value +} + +// walkBlock walks BEGIN ... END: a fresh block of variables/conditions/cursors, then its +// statements, then the block closes (its DECLAREs go out of scope). A DECLARE ... HANDLER +// (which MySQL requires among the block's own declarations, ahead of its statements, the +// same position PL/pgSQL's EXCEPTION clause holds at the end of its block) is not walked +// there: it is collected, and its scope is exactly this block's own statements, so once +// they are walked its conditions filter what they raised (see block below) the way +// check/postgres/analyze/plpgsql.go's own block does for an EXCEPTION list. +func (a *analyzer) walkBlock(sc scope, n *mysqlast.Node, br *BodyResult) error { + a.pushVars() + defer a.popVars() + raisedAt := len(a.raised) + decls, _ := n.Args[0].(mysqlast.List) + var handlers []pendingHandler + for _, d := range decls { + if dn, ok := d.(*mysqlast.Node); ok && dn.Class == "sp_decl_handler" { + conds, err := a.resolveHandlerConditions(dn.Arg("conditions")) + if err != nil { + return err + } + h := pendingHandler{conds: conds, body: dn.Arg("body")} + // two handlers of one block naming the same condition value (the same + // SQLSTATE, error number or class, a named condition resolved to its value) + // are refused at CREATE time: 1413 "Duplicate handler declared in the same + // block" (measured on 8.4). Handlers whose sets merely overlap in what they + // would catch (SQLEXCEPTION next to SQLSTATE '45000') are accepted. + for _, prev := range handlers { + for _, pc := range prev.conds { + for _, c := range h.conds { + if pc == c { + return &Error{Message: "Duplicate handler declared in the same block", Code: 1413, Position: a.ph.Back(dn.Start)} + } + } + } + } + handlers = append(handlers, h) + continue + } + if err := a.walkOne(sc, d, br); err != nil { + return err + } + } + if err := a.walkOne(sc, n.Args[1], br); err != nil { + return err + } + if len(handlers) == 0 { + return nil + } + return a.absorb(sc, raisedAt, handlers, br) +} + +// absorb is walkBlock's own handler pass: the block's statements (from raisedAt on) may +// have raised failure modes a HANDLER here catches; those are removed from what propagates +// (a caught SIGNAL, or a caught constraint violation from the block's own writes, never +// reaches the caller), then each handler's own body is walked as ordinary code (so what it +// itself raises -- a fresh SIGNAL, a RESIGNAL -- joins back on top, subject to an +// enclosing block's own HANDLERs in turn). +func (a *analyzer) absorb(sc scope, raisedAt int, handlers []pendingHandler, br *BodyResult) error { + protected := append([]Violation(nil), a.raised[raisedAt:]...) + a.raised = a.raised[:raisedAt] + caughtBy := func(v Violation) bool { + for _, h := range handlers { + for _, c := range h.conds { + if c.catches(v) { + return true + } + } + } + return false + } + for _, v := range protected { + if !caughtBy(v) { + a.raised = append(a.raised, v) + } + } + for _, h := range handlers { + var caught []Violation + for _, v := range protected { + for _, c := range h.conds { + if c.catches(v) { + caught = append(caught, v) + break + } + } + } + saved := a.handlerRaise + a.handlerRaise = caught + a.inHandler++ + err := a.walkOne(sc, h.body, br) + a.inHandler-- + a.handlerRaise = saved + if err != nil { + return err + } + } + return nil +} + +// walkDecl processes one DECLARE: a variable (registered, typed from the declaration), a +// condition (registered against the block, for a later SIGNAL/HANDLER to resolve by name), +// a handler (dead code along walkBlock's own path, which intercepts sp_decl_handler before +// reaching here to defer its body -- kept as a defensive fallback), or a cursor (its query +// is typed once, for FETCH's column count). +func (a *analyzer) walkDecl(sc scope, n *mysqlast.Node, br *BodyResult) error { + switch n.Class { + case "sp_decl_var": + t, err := schema.TypeOf(n.Arg("type")) + if err != nil { + // defensive: sp_decl_var's own type shares CREATE TABLE's column-type grammar, + // every alternative of which schema.TypeOf recognizes. + return err + } + names, _ := n.Arg("names").(mysqlast.List) + for _, nm := range names { + name := str(nm) + if a.vars != nil { + if _, dup := a.vars.vars[strings.ToLower(name)]; dup { + // the server refuses two DECLAREs of the same name in the same block + // at CREATE time (1331 "Duplicate variable: x", measured); a nested + // block's own DECLARE shadowing an outer one is ordinary scoping, not + // a duplicate -- a.vars is that innermost block's own scope alone. + return &Error{Message: fmt.Sprintf("Duplicate variable: %s", name), Code: 1331, Position: a.ph.Back(n.Start)} + } + } + a.declareVar(name, t) + } + if def := n.Arg("default"); def != nil { + return a.exprAt(def, "default") + } + return nil + case "sp_decl_condition": + ref, ok, err := a.resolveCondValue(n.Arg("value")) + if err != nil { + return err + } + if ok { + name := str(n.Arg("name")) + if a.vars != nil { + if _, dup := a.vars.conds[strings.ToLower(name)]; dup { + // the CONDITION sibling of 1331: two DECLARE ... CONDITION FORs of the + // same name in the same block are refused at CREATE time (1332 + // "Duplicate condition: x", measured on 8.4) + return &Error{Message: fmt.Sprintf("Duplicate condition: %s", name), Code: 1332, Position: a.ph.Back(n.Start)} + } + } + a.declareCondition(name, ref) + } + // defensive when !ok: DECLARE ... CONDITION FOR's own value is always a bare + // sp_condition_value (sp_cond's grammar, not sp_condition_name), whose + // resolveCondValue case always answers ok. + return nil + case "sp_decl_handler": + return a.walkOne(sc, n.Arg("body"), br) + case "sp_decl_cursor": + if a.vars != nil { + if _, dup := a.vars.cursors[strings.ToLower(str(n.Arg("name")))]; dup { + // the CURSOR sibling of 1331 (1333 "Duplicate cursor: x", measured on 8.4). + // A variable and a cursor of one name live in different namespaces and + // are accepted (measured), as the two maps already say. + return &Error{Message: fmt.Sprintf("Duplicate cursor: %s", str(n.Arg("name"))), Code: 1333, Position: a.ph.Back(n.Start)} + } + } + a.resetStatement() + a.facts = &facts.Facts{Kind: facts.Select} + cols, err := a.queryExpression(queryExprOf(n.Arg("query")), nil) + if err != nil { + return err + } + a.columns = cols + a.appendStatement(br, n.Start) + a.declareCursor(str(n.Arg("name")), cols) + return nil + } + // defensive: walkNode only ever dispatches the four classes above to walkDecl. + return nil +} + +// queryExprOf digs a PT_select_stmt's own query expression out (the shape queryExpression +// itself expects), so a cursor's DECLARE ... FOR select_stmt can be typed the same way a +// top-level SELECT is. +func queryExprOf(v mysqlast.Value) mysqlast.Value { + n, ok := v.(*mysqlast.Node) + if !ok || n.Class != "PT_select_stmt" { + // defensive: a cursor's own query is always parsed as a select_stmt (a + // stand-alone SELECT, possibly a UNION, but the top production is always + // PT_select_stmt either way). + return v + } + return n.Arg("qe") +} + +// appendStatement finishes and records the current statement's Facts among the body's, at +// the line pos falls on (its own start, the way a view's body's position is dropped to -1 +// but a routine's/trigger's statement keeps a line: check/postgres's own convention). +func (a *analyzer) appendStatement(br *BodyResult, pos int) { + a.appendStatementCols(br, pos, nil) +} + +// appendStatementCols is appendStatement's own work, plus cols on the recorded +// BodyStatement (walkSelect's own PROCEDURE branch is the only caller that gives one). +func (a *analyzer) appendStatementCols(br *BodyResult, pos int, cols []Column) { + if a.facts == nil { + // defensive: every caller (walkDecl's cursor branch, walkDML, walkSelect) sets + // a.facts itself just before appending. + return + } + a.finishFacts() + br.Statements = append(br.Statements, BodyStatement{Facts: a.facts, Line: a.lineAt(a.ph.Back(pos)), Columns: cols}) +} + +// walkIf types the condition, walks the THEN statements, and continues into ELSEIF (a +// nested sp_if, unwrapped the same way sp_block_content's fold reaches it) or ELSE (a +// plain statement list), or neither. +func (a *analyzer) walkIf(sc scope, n *mysqlast.Node, br *BodyResult) error { + if err := a.exprAt(n.Args[0], "if"); err != nil { + return err + } + if err := a.walkOne(sc, n.Args[1], br); err != nil { + return err + } + switch e := n.Args[2].(type) { + case nil: + return nil + case *mysqlast.Node: + if e.Class == "sp_if" { + return a.walkIf(sc, e, br) + } + } + return a.walkOne(sc, n.Args[2], br) +} + +// checkLabel is 1308 ("LEAVE/ITERATE with no matching label") when label names no +// enclosing labeled block or loop. +func (a *analyzer) checkLabel(kw, label string, at int) error { + for _, l := range a.labels { + if strings.EqualFold(l, label) { + return nil + } + } + return &Error{Message: fmt.Sprintf("%s with no matching label: %s", kw, label), Code: 1308, Position: a.ph.Back(at)} +} + +// walkReturn is 1313 ("RETURN is only allowed in a FUNCTION") for a trigger or a +// procedure; for a function it types the expression against Returns and records that the +// body did have a RETURN (1320's check). +func (a *analyzer) walkReturn(n *mysqlast.Node) error { + if a.routine == nil || a.routine.Kind != schema.Function { + return &Error{Message: "RETURN is only allowed in a FUNCTION", Code: 1313, Position: a.ph.Back(n.Start)} + } + a.sawReturn = true + return a.exprAt(n.Args[0], "return") +} + +// walkSignal types SIGNAL/RESIGNAL's information items and records the failure mode it +// raises (block, above, is what absorbs it against an enclosing HANDLER). +// +// A SIGNAL's own condition (signal_value, sp_cond's grammar) is only ever a literal +// SQLSTATE or a literal MySQL error number, or a sp_condition_name naming a DECLARE ... +// CONDITION FOR one of those two (never SQLWARNING/NOT FOUND/SQLEXCEPTION, which sp_hcond +// alone accepts); a number, bare or through a named CONDITION, is 1646 at CREATE time +// (measured: "SIGNAL/RESIGNAL can only use a CONDITION defined with SQLSTATE"). A bare +// RESIGNAL (no condition given) reached outside any HANDLER (a.inHandler == 0) is 1645 +// ("RESIGNAL when handler not active"), certain every time (measured); reached inside one, +// it re-raises whatever the innermost enclosing HANDLER is itself handling (a.handlerRaise), +// unchanged, unless its own SET MYSQL_ERRNO overrides the number (measured: the caller sees +// the RESIGNAL's own overridden number, not the original SIGNAL's) -- RESIGNAL with a +// condition is an ordinary SIGNAL instead (measured: a fresh number). +// +// The key (Violation.Constraint here) is the SET MYSQL_ERRNO value as decimal text when +// the SIGNAL gives one, the SQLSTATE otherwise (mysql/errors.go's runtime uses the same +// rule to map an error back). SQLSTATE class "01" (SQLWARNING) is never a failure mode +// (measured: the statement succeeds); an unhandled class "02" (NOT FOUND) reports as 1643, +// anything else unhandled and without its own MYSQL_ERRNO as 1644 (both measured). +func (a *analyzer) walkSignal(n *mysqlast.Node) error { + items, _ := n.Arg("info").(mysqlast.List) + for _, it := range items { + itn, ok := it.(*mysqlast.Node) + if !ok { + continue // defensive: opt_set_signal_information's own items are always Nodes + } + if err := a.exprAt(itn.Arg("expr"), "signal"); err != nil { + return err + } + } + cond := n.Arg("condition") + if cond == nil { + if n.Class == "sp_resignal" { + a.raiseResignal(items) + } + return nil + } + ref, ok, err := a.resolveCondValue(cond) + if err != nil { + return err + } + if !ok { + return nil // an unresolved named condition: nothing to predict (defensive) + } + if ref.kind == condNumber { + return &Error{Message: "SIGNAL/RESIGNAL can only use a CONDITION defined with SQLSTATE", Code: 1646, Position: a.ph.Back(n.Start)} + } + class := "" + if len(ref.sqlstate) >= 2 { + class = ref.sqlstate[:2] + } + if class == "01" { + return nil // SQLWARNING: a warning, not a failure mode + } + errno, explicit := signalErrno(items) + if explicit && errno == 0 { + // an explicit `SET MYSQL_ERRNO = 0` is never the SQLSTATE's own default number: + // MYSQL_ERRNO must be in 1..65535, and the server raises 1231 ("Variable + // 'MYSQL_ERRNO' can't be set to the value of '0'") unconditionally instead, every + // time (measured, SQLSTATE 42000) -- signalErrno's own second return value (whether + // MYSQL_ERRNO was given at all) is what tells this apart from "no MYSQL_ERRNO + // given", for which 0 and code==0 below correctly default to 1643/1644. + v := Violation{Code: 1231, Constraint: itoa(1231), SQLState: "42000"} + if a.trig != nil { + v.Table, v.Trigger = a.trigTable.Name, a.trig.Name + } + a.raised = append(a.raised, v) + return nil + } + code, key := errno, ref.sqlstate + if code == 0 { + if class == "02" { + code = 1643 + } else { + code = 1644 + } + } else { + key = strconv.Itoa(errno) + } + v := Violation{Code: code, Constraint: key, SQLState: ref.sqlstate, Name: a.raises[key]} + if a.trig != nil { + v.Table, v.Trigger = a.trigTable.Name, a.trig.Name + } + a.raised = append(a.raised, v) + return nil +} + +// raiseResignal is a bare RESIGNAL's (no condition) own work: 1645 when reached outside any +// HANDLER (measured: "RESIGNAL when handler not active", certain every time this branch +// runs), otherwise a.handlerRaise re-raised, each overridden to the RESIGNAL's own SET +// MYSQL_ERRNO when it has one (measured: the caller sees the RESIGNAL's own number, not the +// original SIGNAL's -- the SQLSTATE and everything else about the violation stay the +// caught one's, only the number/key changes). +func (a *analyzer) raiseResignal(items mysqlast.List) { + if a.inHandler == 0 { + v := Violation{Code: 1645, Constraint: "1645", SQLState: "HY000"} + if a.trig != nil { + v.Table, v.Trigger = a.trigTable.Name, a.trig.Name + } + a.raised = append(a.raised, v) + return + } + errno, ok := signalErrno(items) + if !ok { + a.raised = append(a.raised, a.handlerRaise...) + return + } + key := strconv.Itoa(errno) + for _, v := range a.handlerRaise { + v.Code, v.Constraint, v.Name = errno, key, a.raises[key] + a.raised = append(a.raised, v) + } +} + +// raiseCaseNotFound is 1339 ("Case not found for CASE statement"): a stored routine's own +// CASE (simple or searched) with no ELSE may fail every time none of its WHENs match -- +// measured on mysqld -- the same "may" shape every other body-level failure mode here has +// (not certain, the way a SIGNAL naming a fixed condition is, since which branch a CASE +// takes depends on its own expression/predicates, not proven exhaustive here). +func (a *analyzer) raiseCaseNotFound(at int) { + v := Violation{Code: 1339, Constraint: "1339", SQLState: "20000"} + if a.trig != nil { + v.Table, v.Trigger = a.trigTable.Name, a.trig.Name + } + a.raised = append(a.raised, v) +} + +// signalErrno reads a SIGNAL/RESIGNAL's own `SET MYSQL_ERRNO = n` item, when there is one. +func signalErrno(items mysqlast.List) (int, bool) { + for _, it := range items { + itn, ok := it.(*mysqlast.Node) + if !ok || str(itn.Arg("name")) != "CIN_MYSQL_ERRNO" { + continue + } + if n, ok := itn.Arg("expr").(*mysqlast.Node); ok && n.Class == "Item_int" { + if tok, ok := n.Arg("i").(mysqlast.Token); ok { + if i, err := strconv.Atoi(tok.Value); err == nil { + return i, true + } + } + } + } + return 0, false +} + +// walkSet types SET's assignments: a local variable, NEW/OLD (a trigger's own rules, +// including the write-time 1362s), a `@user` variable (any name), or -- when the target +// resolves to none of those -- a system variable, silently skipped (m4's item 4: a body +// statement this milestone does not analyze is not an error). +func (a *analyzer) walkSet(sc scope, n *mysqlast.Node) error { + list, ok := n.Arg("list").(*mysqlast.Node) + if !ok { + // defensive: PT_set's own "list" is always a + // PT_start_option_value_list_no_type Node. + return nil + } + a.resetStatement() // its own invoking statement: an earlier statement's tables are not this one's + for _, item := range flattenSetList(list) { + in, ok := item.(*mysqlast.Node) + if !ok { + continue // defensive: flattenSetList's own head/value items are always Nodes + } + switch in.Class { + case "PT_set_variable": + if err := a.exprAt(in.Arg("opt_expr"), "set"); err != nil { + return err + } + qual, name := bipartite(in.Arg("name")) + if err := a.walkSetTarget(qual, name, in.Start); err != nil { + return err + } + case "PT_option_value_no_option_type_user_var": + if err := a.exprAt(in.Arg("expr"), "set"); err != nil { + return err + } + } + } + return nil +} + +// walkSetTarget resolves a `SET name = ...` / `SET qualifier.name = ...` target. +func (a *analyzer) walkSetTarget(qual, name string, at int) error { + if qual == "" { + if _, ok := a.lookupVar(name); ok { + return nil // a local variable or parameter: the assignment needs no further check + } + return nil // a system variable: not validated (item 4) + } + if strings.EqualFold(qual, "new") || strings.EqualFold(qual, "old") { + if a.trig == nil { + // outside a trigger the SET target NEW.x / OLD.x is read as a scoped system + // variable, and refused at CREATE time (1193 "Unknown system variable + // 'NEW.a'", measured on 8.4, found by the body probe); a read of NEW.x there is + // a column reference the server resolves only at run time, and passes + return &Error{Message: fmt.Sprintf("Unknown system variable '%s.%s'", qual, name), Code: 1193, Position: a.ph.Back(at)} + } + _, _, err := a.trigRowColumn(qual, name, at, true) + return err + } + return nil // an unrecognized qualifier: not validated (item 4) +} + +// flattenSetList walks a SET statement's `PT_start_option_value_list_no_type{head, tail}` +// into its assignments in order: head, then the PT_option_value_list_head chain's values. +func flattenSetList(list *mysqlast.Node) []mysqlast.Value { + out := []mysqlast.Value{list.Arg("head")} + tail := list.Arg("tail") + for tail != nil { + tn, ok := tail.(*mysqlast.Node) + if !ok { + break // defensive: a non-empty tail is always a PT_option_value_list_head Node + } + out = append(out, tn.Arg("value")) + tail = tn.Arg("tail") + } + return out +} + +// bipartite reads a PT_set_variable's `name` argument (a `.name` field access wrapping a +// Bipartite_name(qualifier, name) node) into its two parts; qualifier is "" when the name +// was not written `x.y`. +func bipartite(v mysqlast.Value) (qual, name string) { + wrap, ok := v.(*mysqlast.Node) + if !ok || len(wrap.Args) == 0 { + // defensive: PT_set_variable's own "name" is always the `.name` field access + // this comment describes. + return "", "" + } + bp, ok := wrap.Args[0].(*mysqlast.Node) + if !ok || bp.Class != "Bipartite_name" || len(bp.Args) != 2 { + // defensive: the field access always wraps a Bipartite_name(qualifier, name). + return "", "" + } + return str(bp.Args[0]), str(bp.Args[1]) +} + +// walkCall types CALL's arguments (m4's scope: walked and typed, not validated against the +// called routine's parameters, and its result is not modeled -- m6's concern). +func (a *analyzer) walkCall(n *mysqlast.Node, br *BodyResult) error { + a.resetStatement() // its own invoking statement: an earlier statement's tables are not this one's + _, name := spNameOf(n.Arg("proc_name")) + r := a.s.RoutineOf(schema.Procedure, name) + if r == nil { + // the server binds a body's CALL late (CREATE accepts it), but the schema is + // whole here, so the 1305 every execution would raise is certain + return &Error{Message: fmt.Sprintf("PROCEDURE %s does not exist", name), Code: 1305, Position: a.ph.Back(n.Start)} + } + args, _ := n.Arg("opt_expr_list").(mysqlast.List) + if len(args) != len(r.Params) { + return &Error{Message: fmt.Sprintf("Incorrect number of arguments for PROCEDURE %s; expected %d, got %d", r.Name, len(r.Params), len(args)), Code: 1318, Position: a.ph.Back(n.Start)} + } + for i, arg := range args { + p := r.Params[i] + if (p.Mode == "OUT" || p.Mode == "INOUT") && !a.isCallVariableTarget(arg) { + return &Error{Message: fmt.Sprintf("OUT or INOUT argument %d for routine %s is not a variable", i+1, r.Name), Code: 1414, Position: a.ph.Back(n.Start)} + } + if err := a.exprAt(arg, "call"); err != nil { + return err + } + } + a.noteCalledRoutine(r, n.Start) + if a.routine != nil && r == a.routine && a.s.Settings.MaxSPRecursionDepth == 0 { + // max_sp_recursion_depth defaults to 0: any actual recursive invocation -- even + // this first one -- is refused by the server at run time every time (1456 + // "Recursive limit ... was exceeded", measured). Detected here (this CALL's own + // callee is the very routine being walked) rather than through AnalyzeRoutine's own + // cycle-breaking cache (which exists only to stop the walk itself from looping, and + // answers "no writes, no error" for the in-progress call -- never a signal that the + // branch is certain to fail). Once the schema raises the setting above 0 (schema.go's + // SettingNames), how deep a call chain actually runs is not something this checker + // proves statically (a CALL can be conditioned on anything), so it predicts nothing + // here at all rather than a false 1456 (measured: TestAdv3BodyRecursionDepthSettingIgnoredBy1456 + // -- 5 levels of direct recursion under max_sp_recursion_depth=200 succeeds outright). + a.raised = append(a.raised, Violation{Code: 1456, Constraint: "1456", SQLState: "HY000"}) + } + if a.trig != nil || (a.routine != nil && a.routine.Kind == schema.Function) { + // a FUNCTION/TRIGGER cannot return a result set itself (walkSelect's own 1415 for + // its own INTO-less top-level SELECT); CALLing a PROCEDURE whose own body has one + // is certain to raise the very same 1415 at every execution too -- the server only + // catches it at run time, not at this CREATE (measured), but the schema is whole + // here, so resultColumnsOf's answer (the callee's own INTO-less top-level SELECTs) + // already settles it. + if cbr, err := AnalyzeRoutine(a.s, r); err == nil && cbr != nil { + if cols, ok := resultColumnsOf(cbr); ok && len(cols) > 0 { + what := "function" + if a.trig != nil { + what = "trigger" + } + return &Error{Message: fmt.Sprintf("Not allowed to return a result set from a %s", what), Code: 1415, Position: a.ph.Back(n.Start)} + } + } + } + // the callee's own result set propagates out through this CALL exactly as if it sat + // directly in this body (measured); a callee whose own shapes disagree contributes + // nothing here (resultColumnsOf reports that mismatch, if it matters, at whatever CALL + // site is the outermost one asking for it) + if cbr, err := AnalyzeRoutine(a.s, r); err == nil && cbr != nil { + if cols, ok := resultColumnsOf(cbr); ok && len(cols) > 0 { + br.NestedCallColumns = append(br.NestedCallColumns, cols) + } + } + // the callee's writes and failure modes are this body's (transitively, finishCalls); + // a CALL's own arguments reference no table, so it collides only with a trigger's own + // table (measured: `CALL p3(7)` alone never collides with what p3 writes) + return a.finishCalls(br, true) +} + +// walkFetch is FETCH cur INTO vars: the cursor must be declared (1324, walkCursorToken's +// own check, reused here since sp_proc_stmt_fetch's own shape carries the cursor's name +// directly rather than folding to the bare token); each target is a declared variable. +// The column-count mismatch the server only catches at CALL time (1328, measured) is +// nonetheless certain from the declarations alone, so it is reported here too. +func (a *analyzer) walkFetch(n *mysqlast.Node) error { + name := str(n.Args[1]) + cur, ok := a.lookupCursor(name) + if !ok { + return &Error{Message: fmt.Sprintf("Undefined CURSOR: %s", name), Code: 1324, Position: a.ph.Back(n.Start)} + } + targets := flattenBinaryList(n.Args[2], "sp_fetch_list") + if len(targets) != len(cur.cols) { + return &Error{Message: "Incorrect number of FETCH variables", Code: 1328, Position: a.ph.Back(n.Start)} + } + for _, t := range targets { + if _, err := a.selectIntoTarget(t); err != nil { + return err + } + } + return nil +} + +// selectIntoTarget resolves one SELECT ... INTO / FETCH ... INTO target: a declared +// variable (1327 "Undeclared variable" when it is not one, measured for FETCH; assumed the +// same for SELECT INTO, the same sp_pcontext lookup) or a `@user` variable (PT_select_var, +// always fine). +func (a *analyzer) selectIntoTarget(v mysqlast.Value) (bool, error) { + n, ok := v.(*mysqlast.Node) + if !ok { + if name := str(v); name != "" { + if _, ok := a.lookupVar(name); !ok { + return false, &Error{Message: fmt.Sprintf("Undeclared variable: %s", name), Code: 1327, Position: 0} + } + } + return true, nil + } + switch n.Class { + case "PT_select_var": + return true, nil // a `@user` variable: always fine + case "PT_select_sp_var": + name := str(n.Arg("name")) + if _, ok := a.lookupVar(name); !ok { + return false, &Error{Message: fmt.Sprintf("Undeclared variable: %s", name), Code: 1327, Position: a.ph.Back(n.Start)} + } + return true, nil + } + // defensive: an INTO/FETCH target is always a bare name (the !ok branch above), a + // PT_select_var or a PT_select_sp_var. + return true, nil +} + +// walkSelect types a SELECT (INTO or not): INTO-less in a trigger or function body is +// 1415 ("Not allowed to return a result set from a trigger/function", measured); its +// column count must match its INTO targets' (1222, measured as a CALL-time error but +// certain from the declarations, the same reasoning as walkFetch's 1328), and each +// variable target must be declared (1327). +func (a *analyzer) walkSelect(sc scope, n *mysqlast.Node, br *BodyResult) error { + a.resetStatement() + // a trailing FOR UPDATE / FOR SHARE / LOCK IN SHARE MODE (mysqlast folds the clauses + // beside the query): the read locks every table the query names, absent an "OF + // " narrowing it, which this does not read + locking := n.Arg("locking") != nil + if err := a.selectStmt(n); err != nil { + return err + } + if selectIntoFile(n) { + // INTO OUTFILE / DUMPFILE: no result set (a trigger or function may run one, + // measured) and no variable to match the columns against + if err := a.finishCalls(br, true); err != nil { + return err + } + a.appendStatement(br, n.Start) + if locking { + a.markLastLockedReads(br) + } + return nil + } + into := selectInto(n) + if len(into) == 0 { + if a.trig != nil { + return &Error{Message: "Not allowed to return a result set from a trigger", Code: 1415, Position: a.ph.Back(n.Start)} + } + if a.routine != nil && a.routine.Kind == schema.Function { + return &Error{Message: "Not allowed to return a result set from a function", Code: 1415, Position: a.ph.Back(n.Start)} + } + if err := a.finishCalls(br, true); err != nil { + return err + } + a.appendStatementCols(br, n.Start, a.columns) + if locking { + a.markLastLockedReads(br) + } + return nil + } + if len(into) != len(a.columns) { + return &Error{Message: "The used SELECT statements have a different number of columns", Code: 1222, Position: a.ph.Back(n.Start)} + } + for _, t := range into { + if _, err := a.selectIntoTarget(t); err != nil { + return err + } + } + // 1172 ("Result consisted of more than one row"): possible unless the query itself + // proves at most one row (x/cardinality's One argument, the same proof `LIMIT 1` + // satisfies); zero rows is NOT FOUND (1329, a warning, measured), never a failure. + if ok, _ := cardinality.AtMostOne(a.facts); !ok { + v := Violation{Code: code1172, Constraint: strconv.Itoa(code1172), SQLState: "42000"} + if a.trig != nil { + v.Table, v.Trigger = a.trigTable.Name, a.trig.Name + } + a.raised = append(a.raised, v) + } + if err := a.finishCalls(br, true); err != nil { + return err + } + a.appendStatement(br, n.Start) + if locking { + a.markLastLockedReads(br) + } + return nil +} + +// markLastLockedReads records a.refRels (the tables the just-appended locking SELECT +// references -- FOR UPDATE / FOR SHARE / LOCK IN SHARE MODE lock every table the query +// reads, absent an "OF " narrowing it, which walkSelect does not read) +// on the BodyStatement walkSelect just appended. +func (a *analyzer) markLastLockedReads(br *BodyResult) { + if len(br.Statements) == 0 { + return // defensive: only ever called right after appendStatement/appendStatementCols + } + var tables []string + for name := range a.refRels { + tables = append(tables, name) + } + br.Statements[len(br.Statements)-1].LockedReads = tables +} + +// badStatementName is the name the server's 1314 message gives a statement its grammar +// refuses inside a body. +func badStatementName(class string) string { + switch class { + case "lock_tables": + return "LOCK" + case "unlock": + return "UNLOCK" + case "PT_load_table": + return "LOAD DATA" + case "Sql_cmd_alter_view": + return "ALTER VIEW" + } + return class +} + +// selectInto digs a SELECT's own INTO target list out (nil when there is none). +func selectInto(n *mysqlast.Node) mysqlast.List { + if l, ok := n.Arg("into").(mysqlast.List); ok && len(l) > 0 { + // the trailing position: `SELECT ... FROM t LIMIT 1 INTO @v` (PT_select_stmt's own + // "into", a list of PT_select_var / PT_select_sp_var / names) + return l + } + qe, ok := n.Arg("qe").(*mysqlast.Node) + if !ok { + // defensive: PT_select_stmt's own "qe" is always its query expression Node. + return nil + } + body, ok := qe.Arg("body").(*mysqlast.Node) + if !ok || body.Class != "PT_query_specification" { + // a set operation (UNION and the like): its own top-level "qe" has no INTO of its + // own to report here (measured: TestSelectInto_UnionNoTopLevelInto). + return nil + } + l, _ := body.Arg("opt_into1").(mysqlast.List) + return l +} + +// walkDML types an embedded INSERT/UPDATE/DELETE with its own fresh per-statement state, +// records its Facts among the body's Definitions (position -1, the way a view's body's +// is), and folds what it may violate -- including, recursively, what firing its own +// table's triggers may raise (violations() already does, for any statement) -- into the +// body's own raised failure modes. A trigger writing its own table is instead 1442 +// ("Can't update table ... because it is already used by statement which invoked this +// stored function/trigger"), measured unconditionally true on mysqld 8.4 for every +// (timing, event, own-write kind) combination: not a "may", so an Error like the server's +// own CREATE-time refusals, not a Violation. +func (a *analyzer) walkDML(n *mysqlast.Node, kind facts.StmtKind, br *BodyResult, run func(*mysqlast.Node) error) error { + a.resetStatement() + if err := run(n); err != nil { + return err + } + if a.trig != nil && a.write != nil { + if own, name := a.ownTableWrite(a.write); own { + return &Error{Message: fmt.Sprintf("Can't update table '%s' in stored function/trigger because it is already used by statement which invoked this stored function/trigger.", name), Code: 1442, Position: a.ph.Back(n.Start)} + } + } + if a.write != nil { + if a.write.table != nil { + br.WriteTables = appendTableName(br.WriteTables, a.write.table.Name) + } + for _, m := range a.write.more { + if m.table != nil { + br.WriteTables = appendTableName(br.WriteTables, m.table.Name) + } + } + foldTriggerWriteTables(a.s, br, a.write) + } + if err := a.finishCalls(br, false); err != nil { + return err + } + for _, v := range a.violations() { + if v.SQLState == "" { + v.SQLState = constraintSQLState(v.Code) + } + a.raised = append(a.raised, v) + } + a.appendStatement(br, n.Start) + return nil +} + +// foldTriggerWriteTables adds, to br.WriteTables, the tables written by whatever trigger(s) +// w's own write fires (measured: a body's UPDATE of b_tbl, with b_tbl's own AFTER UPDATE +// trigger writing a_tbl, makes a_tbl every bit as much this body's own write as b_tbl is -- +// `SELECT f(...) FROM a_tbl` collides 1442 the same way it would if f wrote a_tbl directly). +// This is the same event mapping triggerFailureModes uses for violations, applied to +// WriteTables instead: REPLACE also fires the DELETE event on a displaced row, ON DUPLICATE +// KEY UPDATE also fires the UPDATE event on a collision. +func foldTriggerWriteTables(s *schema.Schema, br *BodyResult, w *write) { + switch w.kind { + case facts.Insert: + appendTriggerWriteTables(s, br, w.table, "INSERT") + if w.replace { + appendTriggerWriteTables(s, br, w.table, "DELETE") + } + if w.onDuplicate != nil { + appendTriggerWriteTables(s, br, w.table, "UPDATE") + } + case facts.Update: + appendTriggerWriteTables(s, br, w.table, "UPDATE") + for _, m := range w.more { + appendTriggerWriteTables(s, br, m.table, "UPDATE") + } + case facts.Delete: + appendTriggerWriteTables(s, br, w.table, "DELETE") + for _, m := range w.more { + appendTriggerWriteTables(s, br, m.table, "DELETE") + } + } +} + +// appendTriggerWriteTables folds t's own event triggers' WriteTables (each analyzed once +// and cached, cachedAnalyzeTrigger -- so a further cascade, a trigger whose own write fires +// another trigger in turn, folds in recursively through that trigger's own walkDML) into br. +func appendTriggerWriteTables(s *schema.Schema, br *BodyResult, t *schema.Table, event string) { + if t == nil { + return + } + for _, tg := range s.Triggers { + if !strings.EqualFold(tg.Table, t.Name) || !strings.EqualFold(tg.Event, event) { + continue + } + tbr, err := cachedAnalyzeTrigger(s, tg) + if err != nil || tbr == nil { + continue + } + for _, wt := range tbr.WriteTables { + br.WriteTables = appendTableName(br.WriteTables, wt) + } + } +} + +// appendTableName adds name to names, once (case-insensitively): BodyResult.WriteTables' +// own bookkeeping (walkDML), read back by call.go's checkCalledRoutineOverlap. +func appendTableName(names []string, name string) []string { + for _, n := range names { + if strings.EqualFold(n, name) { + return names + } + } + return append(names, name) +} + +// ownTableWrite reports whether w writes the trigger's own table (directly, or as one of a +// multi-table UPDATE/DELETE's further targets). +func (a *analyzer) ownTableWrite(w *write) (bool, string) { + if w.table == a.trigTable { + return true, a.trigTable.Name + } + for _, m := range w.more { + if m.table == a.trigTable { + return true, a.trigTable.Name + } + } + return false, "" +} diff --git a/check/mysql/internal/analyze/body_control_test.go b/check/mysql/internal/analyze/body_control_test.go new file mode 100644 index 0000000..6614e02 --- /dev/null +++ b/check/mysql/internal/analyze/body_control_test.go @@ -0,0 +1,324 @@ +package analyze + +import ( + "testing" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" +) + +// controlSchema exercises body.go's control-flow and DECLARE constructs that bodySchema and +// triggerViolationSchema do not: WHILE / REPEAT / a labeled LOOP with LEAVE and ITERATE, a +// labeled block, simple and searched CASE (with and without ELSE), a nested CALL inside a +// routine body, cursors driven through FETCH (with a NOT FOUND handler), and SELECT/FETCH +// INTO both a `@user` variable and a declared local one. +const controlSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE t ( + id INT NOT NULL PRIMARY KEY, + v INT NOT NULL, + note VARCHAR(50) +); + +CREATE PROCEDURE loop_while(IN n INT) +BEGIN + DECLARE i INT DEFAULT 0; + WHILE i < n DO + SET i = i + 1; + END WHILE; +END; + +CREATE PROCEDURE loop_repeat(IN n INT) +BEGIN + DECLARE i INT DEFAULT 0; + REPEAT + SET i = i + 1; + UNTIL i >= n END REPEAT; +END; + +CREATE PROCEDURE loop_labeled(IN n INT) +BEGIN + DECLARE i INT DEFAULT 0; + myloop: LOOP + SET i = i + 1; + IF i >= n THEN + LEAVE myloop; + END IF; + ITERATE myloop; + END LOOP myloop; +END; + +CREATE PROCEDURE labeled_block_test(IN n INT) +BEGIN + blk: BEGIN + IF n > 0 THEN + LEAVE blk; + END IF; + SET @x = 1; + END; +END; + +CREATE PROCEDURE case_simple(IN n INT) +BEGIN + DECLARE r INT DEFAULT 0; + CASE n + WHEN 1 THEN SET r = 10; + WHEN 2 THEN SET r = 20; + ELSE SET r = 0; + END CASE; +END; + +CREATE PROCEDURE case_searched_no_else(IN n INT) +BEGIN + DECLARE r INT DEFAULT 0; + CASE + WHEN n > 10 THEN SET r = 1; + WHEN n > 5 THEN SET r = 2; + END CASE; +END; + +CREATE PROCEDURE inner_proc(IN a INT) +BEGIN + SET @y = a; +END; + +CREATE PROCEDURE outer_caller(IN a INT) +BEGIN + CALL inner_proc(a + 1); +END; + +CREATE PROCEDURE cursor_fetch(IN cid INT) +BEGIN + DECLARE done INT DEFAULT 0; + DECLARE v_id INT; + DECLARE v_note VARCHAR(50); + DECLARE cur CURSOR FOR SELECT id, note FROM t WHERE id = cid; + DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1; + OPEN cur; + FETCH cur INTO v_id, v_note; + CLOSE cur; +END; + +CREATE PROCEDURE select_into_uservar(IN cid INT) +BEGIN + SELECT v INTO @out FROM t WHERE id = cid LIMIT 1; +END; + +CREATE PROCEDURE select_into_local(IN cid INT) +BEGIN + DECLARE v_out INT; + SELECT v INTO v_out FROM t WHERE id = cid LIMIT 1; +END; + +CREATE PROCEDURE warn_handler() +BEGIN + DECLARE CONTINUE HANDLER FOR SQLWARNING SET @w = 1; + SIGNAL SQLSTATE '01000'; +END; + +-- sqlshape: error 30001 = NotFoundSignal +CREATE PROCEDURE not_found_handler() +BEGIN + DECLARE CONTINUE HANDLER FOR NOT FOUND SET @nf = 1; + SIGNAL SQLSTATE '02000'; +END; + +-- sqlshape: error 30002 = GeneralSignal +CREATE PROCEDURE sqlexception_handler() +BEGIN + DECLARE EXIT HANDLER FOR SQLEXCEPTION SET @ex = 1; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'boom', MYSQL_ERRNO = 30002; +END; +` + +func loadControl(t *testing.T) *schema.Schema { + t.Helper() + s, err := schema.Load(controlSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + return s +} + +func analyzeProc(t *testing.T, s *schema.Schema, name string) *BodyResult { + t.Helper() + r := s.Routine(name) + if r == nil { + t.Fatalf("procedure %s not loaded", name) + } + br, err := AnalyzeRoutine(s, r) + if err != nil { + t.Fatalf("%s: unexpected error: %v", name, err) + } + return br +} + +func TestWalkNode_While(t *testing.T) { + s := loadControl(t) + analyzeProc(t, s, "loop_while") +} + +func TestWalkNode_Repeat(t *testing.T) { + s := loadControl(t) + analyzeProc(t, s, "loop_repeat") +} + +func TestWalkNode_LabeledLoopLeaveIterate(t *testing.T) { + s := loadControl(t) + analyzeProc(t, s, "loop_labeled") +} + +func TestWalkNode_LabeledBlock(t *testing.T) { + s := loadControl(t) + analyzeProc(t, s, "labeled_block_test") +} + +func TestWalkNode_SimpleCase(t *testing.T) { + s := loadControl(t) + analyzeProc(t, s, "case_simple") +} + +func TestWalkNode_SearchedCaseNoElse(t *testing.T) { + s := loadControl(t) + analyzeProc(t, s, "case_searched_no_else") +} + +func TestWalkCall_NestedInBody(t *testing.T) { + s := loadControl(t) + br := analyzeProc(t, s, "outer_caller") + if len(br.Statements) != 0 { + t.Errorf("got %d statements, want 0 (CALL alone writes no facts of its own in m4's body walk)", len(br.Statements)) + } +} + +func TestCursorFetch_NotFoundHandler(t *testing.T) { + s := loadControl(t) + br := analyzeProc(t, s, "cursor_fetch") + if len(br.Statements) != 1 { + t.Fatalf("got %d statements, want 1 (the cursor's own DECLARE ... FOR; FETCH itself records no Facts of its own)", len(br.Statements)) + } +} + +func TestSelectInto_UserVar(t *testing.T) { + s := loadControl(t) + analyzeProc(t, s, "select_into_uservar") +} + +func TestSelectInto_LocalVar(t *testing.T) { + s := loadControl(t) + analyzeProc(t, s, "select_into_local") +} + +func TestHandler_SQLWarning(t *testing.T) { + s := loadControl(t) + br := analyzeProc(t, s, "warn_handler") + // SQLSTATE '01000' is class 01 (a warning): no failure mode, so even though the handler + // is declared to catch nothing of it, no Violation is raised. + if len(br.Violations) != 0 { + t.Errorf("got %+v, want no Violations (SQLWARNING raises nothing to catch)", br.Violations) + } +} + +// TestHandler_NotFoundAbsorbs is a regression test for classifyMysqlerr's own Const case +// (once missing): a HANDLER FOR NOT FOUND must resolve to condNotFound, not the zero condRef +// (kind condSQLState, sqlstate ""), which would catch nothing (no real Violation ever has an +// empty SQLState) -- SIGNAL SQLSTATE '02000' (class "02", NOT FOUND) is absorbed. +func TestHandler_NotFoundAbsorbs(t *testing.T) { + s := loadControl(t) + br := analyzeProc(t, s, "not_found_handler") + if len(br.Violations) != 0 { + t.Errorf("got %+v, want no Violations (the NOT FOUND handler absorbs the '02000' SIGNAL)", br.Violations) + } +} + +// TestHandler_SQLExceptionAbsorbs is classifyMysqlerr's Const case for SQLEXCEPTION: it +// catches any class but "00"/"01"/"02", so a plain SIGNAL SQLSTATE '45000' is absorbed too. +func TestHandler_SQLExceptionAbsorbs(t *testing.T) { + s := loadControl(t) + br := analyzeProc(t, s, "sqlexception_handler") + if len(br.Violations) != 0 { + t.Errorf("got %+v, want no Violations (SQLEXCEPTION absorbs the '45000' SIGNAL)", br.Violations) + } +} + +// TestFetch_ColumnCountMismatch is 1328: FETCH's own target count must match the cursor's +// query column count. +func TestFetch_ColumnCountMismatch(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, v INT NOT NULL); +CREATE PROCEDURE bad_fetch() +BEGIN + DECLARE v_id INT; + DECLARE cur CURSOR FOR SELECT id, v FROM t; + OPEN cur; + FETCH cur INTO v_id; + CLOSE cur; +END; +` + s, err := schema.Load(text) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + r := s.Routine("bad_fetch") + _, err = AnalyzeRoutine(s, r) + ae, ok := err.(*Error) + if !ok || ae.Code != 1328 { + t.Fatalf("got %v, want a 1328 Error", err) + } +} + +// TestFetch_UndeclaredVariable is 1327. +func TestFetch_UndeclaredVariable(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY); +CREATE PROCEDURE bad_fetch2() +BEGIN + DECLARE cur CURSOR FOR SELECT id FROM t; + OPEN cur; + FETCH cur INTO v_missing; + CLOSE cur; +END; +` + s, err := schema.Load(text) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + r := s.Routine("bad_fetch2") + _, err = AnalyzeRoutine(s, r) + ae, ok := err.(*Error) + if !ok || ae.Code != 1327 { + t.Fatalf("got %v, want a 1327 Error", err) + } +} + +// TestSelectInto_UndeclaredLocalVariable is 1327 through selectIntoTarget's +// PT_select_sp_var case (as opposed to FETCH's, which selectIntoTarget also serves). +func TestSelectInto_UndeclaredLocalVariable(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, v INT NOT NULL); +CREATE PROCEDURE bad_select_into(IN cid INT) +BEGIN + SELECT v INTO v_missing FROM t WHERE id = cid LIMIT 1; +END; +` + s, err := schema.Load(text) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + r := s.Routine("bad_select_into") + _, err = AnalyzeRoutine(s, r) + ae, ok := err.(*Error) + if !ok || ae.Code != 1327 { + t.Fatalf("got %v, want a 1327 Error", err) + } +} diff --git a/check/mysql/internal/analyze/body_errors_test.go b/check/mysql/internal/analyze/body_errors_test.go new file mode 100644 index 0000000..051e1d8 --- /dev/null +++ b/check/mysql/internal/analyze/body_errors_test.go @@ -0,0 +1,597 @@ +package analyze + +import ( + "testing" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" +) + +// loadAndRoutine loads text (failing the test on a load error or a Problem) and returns the +// named routine, failing the test if it is not there. +func loadAndRoutine(t *testing.T, text, name string) (*schema.Schema, *schema.Routine) { + t.Helper() + s, err := schema.Load(text) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + r := s.Routine(name) + if r == nil { + t.Fatalf("routine %s not loaded", name) + } + return s, r +} + +// errPropCases each declare one procedure whose body fails to type for an ordinary reason +// (an unknown identifier, "nope"), reached through a different control-flow path each time: +// walkIf's condition and its THEN body, an ELSEIF chain, WHILE's condition and body, REPEAT's +// body and its UNTIL condition, both CASE forms' WHEN expressions and THEN bodies, and a +// SIGNAL information item's own expression. Each is a distinct "if err != nil { return err }" +// propagation site in body.go that a success-only body never reaches. +var errPropCases = []struct { + name string + body string +}{ + {"if_cond", `IF nope THEN SET @x = 1; END IF;`}, + {"if_then", `IF n = 1 THEN SET @x = nope; END IF;`}, + {"if_elseif_cond", `IF n = 1 THEN SET @x = 1; ELSEIF nope THEN SET @x = 2; END IF;`}, + {"if_elseif_then", `IF n = 1 THEN SET @x = 1; ELSEIF n = 2 THEN SET @x = nope; END IF;`}, + {"if_else", `IF n = 1 THEN SET @x = 1; ELSE SET @x = nope; END IF;`}, + {"while_cond", `WHILE nope > 0 DO SET @x = 1; END WHILE;`}, + {"while_body", `WHILE n > 0 DO SET @x = nope; END WHILE;`}, + {"repeat_body", `REPEAT SET @x = nope; UNTIL n > 0 END REPEAT;`}, + {"repeat_cond", `REPEAT SET @x = 1; UNTIL nope END REPEAT;`}, + {"case_simple_selector", `CASE nope WHEN 1 THEN SET @x = 1; END CASE;`}, + {"case_simple_when", `CASE n WHEN nope THEN SET @x = 1; END CASE;`}, + {"case_simple_then", `CASE n WHEN 1 THEN SET @x = nope; END CASE;`}, + {"case_searched_when", `CASE WHEN nope THEN SET @x = 1; END CASE;`}, + {"case_searched_then", `CASE WHEN n = 1 THEN SET @x = nope; END CASE;`}, + {"signal_item", `SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = nope;`}, + {"set_expr", `SET @x = nope;`}, +} + +func TestErrorPropagation(t *testing.T) { + for _, c := range errPropCases { + t.Run(c.name, func(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE PROCEDURE p(IN n INT) +BEGIN + ` + c.body + ` +END; +` + s, err := schema.Load(text) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + r := s.Routine("p") + if r == nil { + t.Fatal("routine p not loaded") + } + if _, err := AnalyzeRoutine(s, r); err == nil { + t.Fatalf("got no error, want one (nope is not a declared variable or column)") + } + }) + } +} + +// TestWalkSet_MultipleAssignments exercises flattenSetList's own tail chain (three +// assignments in one SET, not just one). +func TestWalkSet_MultipleAssignments(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE PROCEDURE p(IN n INT) +BEGIN + DECLARE a INT DEFAULT 0; + DECLARE b INT DEFAULT 0; + SET a = 1, b = 2, @c = 3; +END; +` + s, r := loadAndRoutine(t, text, "p") + if _, err := AnalyzeRoutine(s, r); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestWalkSetTarget_SystemVariable: an unqualified SET target that resolves to neither a +// local variable/parameter nor NEW/OLD is a system variable, silently accepted (m4's item 4). +func TestWalkSetTarget_SystemVariable(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE PROCEDURE p() +BEGIN + SET sort_buffer_size = 1000000; +END; +` + s, r := loadAndRoutine(t, text, "p") + if _, err := AnalyzeRoutine(s, r); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestWalkSetTarget_UnrecognizedQualifier: inside a trigger, a qualifier that is not NEW or +// OLD is not validated either (m4's item 4) -- unlike a bare name, this exercises +// walkSetTarget's own final fallthrough (as opposed to the qual == "" one above). +func TestWalkSetTarget_UnrecognizedQualifier(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE TABLE orders (id INT NOT NULL PRIMARY KEY, total INT NOT NULL); +CREATE TRIGGER t_bogus_qual BEFORE INSERT ON orders FOR EACH ROW +BEGIN + SET foo.bar = 1; +END; +` + s, err := schema.Load(text) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + tg := s.Trigger("t_bogus_qual") + if tg == nil { + t.Fatal("trigger not loaded") + } + if _, err := AnalyzeTrigger(s, tg); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestWalkSet_DefaultKeyword: `SET x = DEFAULT` carries no expression at all (PT_set_variable's +// own opt_expr is nil for it, unlike an ordinary assignment) -- exprAt's own nil guard. +func TestWalkSet_DefaultKeyword(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE PROCEDURE p() +BEGIN + SET sql_mode = DEFAULT; +END; +` + s, r := loadAndRoutine(t, text, "p") + if _, err := AnalyzeRoutine(s, r); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestSignal_UndeclaredNamedCondition: SIGNAL of an undeclared named condition resolves to +// nothing (resolveCondValue's own !ok path) -- not an error at this milestone (the server +// itself refuses it at CREATE time; schema.Load's grammar accepts naming any ident). +func TestSignal_UndeclaredNamedCondition(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE PROCEDURE p() +BEGIN + SIGNAL never_declared_cond; +END; +` + s, r := loadAndRoutine(t, text, "p") + br, err := AnalyzeRoutine(s, r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(br.Violations) != 0 { + t.Errorf("got %+v, want no Violations (nothing to predict for an unresolved condition)", br.Violations) + } +} + +// TestSignal_NamedConditionByNumber is 1646: SIGNAL/RESIGNAL of a named CONDITION that was +// declared FOR a MySQL error number (not a SQLSTATE) is refused at CREATE time (measured). +func TestSignal_NamedConditionByNumber(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE PROCEDURE p() +BEGIN + DECLARE my_err CONDITION FOR 1062; + SIGNAL my_err; +END; +` + s, r := loadAndRoutine(t, text, "p") + _, err := AnalyzeRoutine(s, r) + ae, ok := err.(*Error) + if !ok || ae.Code != 1646 { + t.Fatalf("got %v, want a 1646 Error", err) + } +} + +// TestLookupCondition_Undeclared: a HANDLER FOR an undeclared named condition resolves to +// nothing (lookupCondition's own "not found" path); resolveHandlerConditions then simply +// omits it (not an error -- schema.Load's own grammar accepts naming any ident here, the +// server itself refusing an unknown one at CREATE time, which this milestone does not +// re-check). +func TestLookupCondition_Undeclared(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE PROCEDURE p() +BEGIN + DECLARE CONTINUE HANDLER FOR never_declared SET @x = 1; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'boom'; +END; +` + s, r := loadAndRoutine(t, text, "p") + br, err := AnalyzeRoutine(s, r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // the undeclared condition catches nothing, so the SIGNAL still propagates. + if len(br.Violations) != 1 { + t.Errorf("got %+v, want the SIGNAL to propagate uncaught", br.Violations) + } +} + +// TestCommitCheck_AllowedInProcedure: COMMIT (and, by the same rule, DDL) is fine in a +// PROCEDURE -- commitCheck's own early exemption, as opposed to the 1422 tests, which are +// all triggers or functions. +func TestCommitCheck_AllowedInProcedure(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE PROCEDURE p() +BEGIN + COMMIT; +END; +` + s, r := loadAndRoutine(t, text, "p") + if _, err := AnalyzeRoutine(s, r); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestCommitCheck_OrdinaryStatementInTrigger: a statement neither DDL nor transaction +// control (here XA START, folded to a generic {sql_command: SQLCOM_XA_START} Struct, the +// same shape COMMIT/ROLLBACK take) is fine even inside a trigger -- commitCheck's own final +// fallthrough (as opposed to TestCommitCheck_DDLInFunction's 1422 and the +// "procedure: anything goes" branch other tests already cover). +func TestCommitCheck_OrdinaryStatementInTrigger(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY); +CREATE TRIGGER tg BEFORE INSERT ON t FOR EACH ROW +BEGIN + XA START 'x'; +END; +` + s, err := schema.Load(text) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + tg := s.Trigger("tg") + if tg == nil { + t.Fatal("trigger not loaded") + } + if _, err := AnalyzeTrigger(s, tg); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestCommitCheck_DDLInFunction is 1422 for a DDL statement (not just COMMIT/transaction +// control) reached inside a FUNCTION body. +func TestCommitCheck_DDLInFunction(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY); +CREATE FUNCTION f() RETURNS INT +BEGIN + TRUNCATE TABLE t; + RETURN 1; +END; +` + s, err := schema.Load(text) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + r := s.Routine("f") + if r == nil { + t.Fatal("function not loaded") + } + _, err = AnalyzeRoutine(s, r) + ae, ok := err.(*Error) + if !ok || ae.Code != 1422 { + t.Fatalf("got %v, want a 1422 Error", err) + } +} + +// TestWalkCall_NestedArgError: a nested CALL's own argument expression can fail to type +// (walkCall's own error propagation, as opposed to call.go's callStmt, which m4's body walk +// does not reach: a body's own CALL is walked, not validated against the callee's +// parameters -- see walkCall's doc). +func TestWalkCall_NestedArgError(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE PROCEDURE inner_p(IN a INT) +BEGIN + SET @y = a; +END; +CREATE PROCEDURE outer_p() +BEGIN + CALL inner_p(nope); +END; +` + s, r := loadAndRoutine(t, text, "outer_p") + if _, err := AnalyzeRoutine(s, r); err == nil { + t.Fatal("got no error, want one (nope is not declared)") + } +} + +// TestWalkFetch_UndeclaredCursor is 1324 through FETCH's own direct check (walkFetch), +// distinct from walkCursorToken's OPEN/CLOSE check of the same error. +func TestWalkFetch_UndeclaredCursor(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE PROCEDURE p() +BEGIN + DECLARE v INT; + FETCH never_declared INTO v; +END; +` + s, r := loadAndRoutine(t, text, "p") + _, err := AnalyzeRoutine(s, r) + ae, ok := err.(*Error) + if !ok || ae.Code != 1324 { + t.Fatalf("got %v, want a 1324 Error", err) + } +} + +// TestWalkSelect_StatementError: an ordinary error in the SELECT itself (an unknown column, +// not an INTO-related one) propagates through walkSelect's own selectStmt call. +func TestWalkSelect_StatementError(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY); +CREATE PROCEDURE p() +BEGIN + SELECT nope FROM t; +END; +` + s, r := loadAndRoutine(t, text, "p") + if _, err := AnalyzeRoutine(s, r); err == nil { + t.Fatal("got no error, want one (nope is not a column of t)") + } +} + +// TestSelectInto_UnionNoTopLevelInto: selectInto's own fallback when the query expression's +// body is not a plain PT_query_specification (here a UNION): no INTO is detected at the top +// level, so the statement is treated as INTO-less (a procedure's own possible result set), +// not as though the union somehow named INTO targets. +func TestSelectInto_UnionNoTopLevelInto(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE PROCEDURE p() +BEGIN + SELECT 1 AS x UNION SELECT 2; +END; +` + s, r := loadAndRoutine(t, text, "p") + br, err := AnalyzeRoutine(s, r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(br.Statements) != 1 || br.Statements[0].Columns == nil { + t.Errorf("got %+v, want one statement with its own result columns (no INTO)", br.Statements) + } +} + +// TestWalkDML_EmbeddedStatementError: an embedded INSERT/UPDATE/DELETE's own error (here an +// unknown column) propagates through walkDML's own run(n) call. +func TestWalkDML_EmbeddedStatementError(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY); +CREATE PROCEDURE p() +BEGIN + UPDATE t SET nope = 1 WHERE id = 1; +END; +` + s, r := loadAndRoutine(t, text, "p") + if _, err := AnalyzeRoutine(s, r); err == nil { + t.Fatal("got no error, want one (nope is not a column of t)") + } +} + +// TestAbsorb_HandlerBodyError: a HANDLER's own body can itself fail to type (here an unknown +// identifier) -- absorb's own error propagation out of walking the handler's body. +func TestAbsorb_HandlerBodyError(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE PROCEDURE p() +BEGIN + DECLARE EXIT HANDLER FOR SQLEXCEPTION SET @x = nope; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'boom'; +END; +` + s, r := loadAndRoutine(t, text, "p") + if _, err := AnalyzeRoutine(s, r); err == nil { + t.Fatal("got no error, want one (the handler body's own nope)") + } +} + +// TestWalkDecl_CursorQueryError: a cursor's own query can fail to type (here an unknown +// column) -- walkDecl's sp_decl_cursor error propagation. +func TestWalkDecl_CursorQueryError(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY); +CREATE PROCEDURE p() +BEGIN + DECLARE cur CURSOR FOR SELECT nope FROM t; + OPEN cur; + CLOSE cur; +END; +` + s, r := loadAndRoutine(t, text, "p") + if _, err := AnalyzeRoutine(s, r); err == nil { + t.Fatal("got no error, want one (nope is not a column of t)") + } +} + +// TestWalkSet_LocalVariableRHSError: `SET localvar = ...` (PT_set_variable, as opposed to +// set_expr's `@x = ...`, PT_option_value_no_option_type_user_var) with an erroring +// right-hand side. +func TestWalkSet_LocalVariableRHSError(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE PROCEDURE p() +BEGIN + DECLARE a INT DEFAULT 0; + SET a = nope; +END; +` + s, r := loadAndRoutine(t, text, "p") + if _, err := AnalyzeRoutine(s, r); err == nil { + t.Fatal("got no error, want one (nope is not declared)") + } +} + +// TestWalkNode_NotWalked: a body statement this milestone does not model (SHOW WARNINGS) is +// silently skipped, not an error -- walkNode's own final fallback. +func TestWalkNode_NotWalked(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE PROCEDURE p() +BEGIN + SHOW WARNINGS; +END; +` + s, r := loadAndRoutine(t, text, "p") + if _, err := AnalyzeRoutine(s, r); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestAnalyzeTrigger_Cycle: trigger A (on x) writes y, whose own trigger B writes back to x +// -- analyzing A's own body reaches triggerFailureModes for y's triggers, which analyzes B, +// which in turn reaches back to A while A's own AnalyzeTrigger call is still on the stack +// (the cache's "loaded" placeholder branch, AnalyzeTrigger's own doc comment). Neither +// recurses forever; the inner call sees the placeholder's zero BodyResult/error. +func TestAnalyzeTrigger_Cycle(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE TABLE x (id INT NOT NULL PRIMARY KEY); +CREATE TABLE y (id INT NOT NULL PRIMARY KEY); +CREATE TRIGGER trg_x AFTER INSERT ON x FOR EACH ROW +BEGIN + INSERT INTO y (id) VALUES (NEW.id); +END; +CREATE TRIGGER trg_y AFTER INSERT ON y FOR EACH ROW +BEGIN + INSERT INTO x (id) VALUES (NEW.id); +END; +` + s, err := schema.Load(text) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + tg := s.Trigger("trg_x") + if tg == nil { + t.Fatal("trigger not loaded") + } + if _, err := AnalyzeTrigger(s, tg); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestAnalyzeRoutine_Cycle: a FUNCTION whose own body writes a table through an UPDATE +// calling itself recursively in the SET value reaches AnalyzeRoutine on itself while its own +// first call is still on the stack (walkDML's own violations() -> calledRoutineViolations), +// the same cycle-breaking cache AnalyzeTrigger's own test exercises. +func TestAnalyzeRoutine_Cycle(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, v INT NOT NULL); +CREATE FUNCTION rec_f(x INT) RETURNS INT DETERMINISTIC +BEGIN + UPDATE t SET v = rec_f(x - 1) WHERE id = 1; + RETURN x; +END; +` + s, r := loadAndRoutine(t, text, "rec_f") + if _, err := AnalyzeRoutine(s, r); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestWalkSelect_1172InTrigger: a SELECT ... INTO inside a trigger body that cannot be +// proved to return at most one row is 1172, tagged with the trigger's own Table/Trigger (as +// opposed to triggerViolationSchema's own procedure-only 1172 test). +func TestWalkSelect_1172InTrigger(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE TABLE orders (id INT NOT NULL PRIMARY KEY, customer_id INT NOT NULL, total INT NOT NULL); +CREATE TABLE audit_log (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, total INT NOT NULL); +CREATE TRIGGER orders_ai AFTER INSERT ON orders FOR EACH ROW +BEGIN + DECLARE t INT; + SELECT total INTO t FROM orders WHERE customer_id = NEW.customer_id; + INSERT INTO audit_log (total) VALUES (t); +END; +` + s, err := schema.Load(text) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + tg := s.Trigger("orders_ai") + if tg == nil { + t.Fatal("trigger not loaded") + } + br, err := AnalyzeTrigger(s, tg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + found := false + for _, v := range br.Violations { + if v.Code == 1172 { + found = true + if v.Table != "orders" || v.Trigger != "orders_ai" { + t.Errorf("got Table=%q Trigger=%q, want orders/orders_ai", v.Table, v.Trigger) + } + } + } + if !found { + t.Errorf("got %+v, want a 1172 Violation", br.Violations) + } +} + +// TestWalkDML_MultiTableWriteTablesInProcedure: a multi-table DELETE inside a PROCEDURE (not +// a trigger, so ownTableWrite's own check does not apply) records every target table in +// WriteTables, including the "more" ones -- walkDML's own w.more loop, for call.go's +// checkCalledRoutineOverlap to read back. +func TestWalkDML_MultiTableWriteTablesInProcedure(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE TABLE a (id INT NOT NULL PRIMARY KEY); +CREATE TABLE b (id INT NOT NULL PRIMARY KEY); +CREATE PROCEDURE p() +BEGIN + DELETE a, b FROM a JOIN b ON a.id = b.id; +END; +` + s, r := loadAndRoutine(t, text, "p") + br, err := AnalyzeRoutine(s, r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(br.WriteTables) != 2 { + t.Fatalf("got %v, want both a and b", br.WriteTables) + } +} + +// TestOwnTableWrite_MultiTableSecondTarget: a trigger's own table written as a multi-table +// DELETE's second target (not its first) is still 1442 -- ownTableWrite's own w.more loop, as +// opposed to triggerViolationSchema's own single-table self-write test. +func TestOwnTableWrite_MultiTableSecondTarget(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE TABLE a (id INT NOT NULL PRIMARY KEY); +CREATE TABLE b (id INT NOT NULL PRIMARY KEY); +CREATE TRIGGER t_multi AFTER DELETE ON b FOR EACH ROW +BEGIN + DELETE a, b FROM a JOIN b ON a.id = b.id WHERE a.id = OLD.id; +END; +` + s, err := schema.Load(text) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + tg := s.Trigger("t_multi") + if tg == nil { + t.Fatal("trigger not loaded") + } + _, err = AnalyzeTrigger(s, tg) + ae, ok := err.(*Error) + if !ok || ae.Code != 1442 { + t.Fatalf("got %v, want a 1442 Error (b, the trigger's own table, is the DELETE's second target)", err) + } +} diff --git a/check/mysql/internal/analyze/body_test.go b/check/mysql/internal/analyze/body_test.go new file mode 100644 index 0000000..208d7a9 --- /dev/null +++ b/check/mysql/internal/analyze/body_test.go @@ -0,0 +1,243 @@ +package analyze + +import ( + "strings" + "testing" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" +) + +// bodySchema declares users/orders (as testSchema does) plus a handful of triggers and +// routines exercising the body walk's name resolution and control flow. +const bodySchema = `-- sqlshape: mysql 8.4 +CREATE TABLE users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(255), + balance INT NOT NULL DEFAULT 0 +); +CREATE TABLE orders ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + total DECIMAL(10,2) NOT NULL, + note TEXT, + FOREIGN KEY (user_id) REFERENCES users (id) +); +CREATE TABLE order_audit ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + order_id BIGINT UNSIGNED NOT NULL, + action VARCHAR(20) NOT NULL +); + +CREATE TRIGGER orders_before_insert BEFORE INSERT ON orders FOR EACH ROW +BEGIN + IF NEW.total > 1000000 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'too large', MYSQL_ERRNO = 30001; + END IF; + INSERT INTO order_audit (order_id, action) VALUES (NEW.id, 'insert'); +END; + +CREATE FUNCTION order_note_len(oid BIGINT UNSIGNED) RETURNS INT DETERMINISTIC +BEGIN + DECLARE n INT DEFAULT 0; + SELECT LENGTH(note) INTO n FROM orders WHERE id = oid; + RETURN n; +END; + +CREATE PROCEDURE bump_balance(IN uid BIGINT UNSIGNED, IN delta INT) +BEGIN + UPDATE users SET balance = balance + delta WHERE id = uid; +END; +` + +func loadBody(t *testing.T) *schema.Schema { + s, err := schema.Load(bodySchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + return s +} + +func TestAnalyzeTriggerBasic(t *testing.T) { + s := loadBody(t) + tg := s.Trigger("orders_before_insert") + if tg == nil { + t.Fatal("trigger not loaded") + } + br, err := AnalyzeTrigger(s, tg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(br.Statements) != 1 { + t.Fatalf("want 1 fact (the INSERT into order_audit), got %d", len(br.Statements)) + } + if br.Statements[0].Facts.Writes[0].Table != "order_audit" { + t.Errorf("want a write to order_audit, got %+v", br.Statements[0].Facts.Writes) + } +} + +func TestAnalyzeRoutineBasic(t *testing.T) { + s := loadBody(t) + fn := s.Routine("order_note_len") + if fn == nil { + t.Fatal("function not loaded") + } + br, err := AnalyzeRoutine(s, fn) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(br.Statements) != 1 { + t.Fatalf("want 1 fact (the SELECT INTO), got %d", len(br.Statements)) + } + + pr := s.Routine("bump_balance") + if pr == nil { + t.Fatal("procedure not loaded") + } + br2, err := AnalyzeRoutine(s, pr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(br2.Statements) != 1 || br2.Statements[0].Facts.Writes[0].Table != "users" { + t.Errorf("want 1 write to users, got %+v", br2.Statements) + } +} + +// bodyCase is one body that should fail to analyze with a specific MySQL error number. +type bodyCase struct { + name string + sql string + code int +} + +// triggerCases each declare their own trigger against the schema's `orders` (or `users`) +// table, appended one at a time so a failure in one does not stop the others from being +// checked (schema.Load tolerates a bad CREATE TRIGGER as a Problem, not a hard error). +var triggerCases = []bodyCase{ + {"old in insert trigger", `CREATE TRIGGER t_old_ins BEFORE INSERT ON orders FOR EACH ROW SET @x = OLD.total`, 1363}, + {"new in delete trigger", `CREATE TRIGGER t_new_del AFTER DELETE ON orders FOR EACH ROW SET @x = NEW.total`, 1363}, + {"set old", `CREATE TRIGGER t_set_old BEFORE UPDATE ON orders FOR EACH ROW SET OLD.total = 1`, 1362}, + {"set new after", `CREATE TRIGGER t_set_new_after AFTER INSERT ON orders FOR EACH ROW SET NEW.total = 1`, 1362}, + {"unknown new column", `CREATE TRIGGER t_unknown_new BEFORE INSERT ON orders FOR EACH ROW SET @x = NEW.nope`, 1054}, + {"select without into", `CREATE TRIGGER t_select BEFORE INSERT ON orders FOR EACH ROW BEGIN SELECT 1; END`, 1415}, + {"commit in trigger", `CREATE TRIGGER t_commit BEFORE INSERT ON orders FOR EACH ROW BEGIN COMMIT; END`, 1422}, + {"leave no label", `CREATE TRIGGER t_leave BEFORE INSERT ON orders FOR EACH ROW BEGIN LEAVE nowhere; END`, 1308}, +} + +func TestAnalyzeTriggerErrors(t *testing.T) { + for _, c := range triggerCases { + t.Run(c.name, func(t *testing.T) { + text := strings.Replace(bodySchema, "CREATE TRIGGER orders_before_insert", c.sql+";\nCREATE TRIGGER orders_before_insert", 1) + s, err := schema.Load(text) + if err != nil { + t.Fatal(err) + } + var name string + // the trigger name is the first identifier after CREATE TRIGGER in c.sql + fields := strings.Fields(c.sql) + for i, f := range fields { + if strings.EqualFold(f, "TRIGGER") && i+1 < len(fields) { + name = fields[i+1] + break + } + } + tg := s.Trigger(name) + if tg == nil { + t.Fatalf("trigger %s did not load (Problems: %v)", name, s.Problems) + } + _, err = AnalyzeTrigger(s, tg) + if err == nil { + t.Fatalf("want error %d, got none", c.code) + } + ae, ok := err.(*Error) + if !ok { + t.Fatalf("want *Error, got %T: %v", err, err) + } + if ae.Code != c.code { + t.Errorf("want code %d, got %d (%s)", c.code, ae.Code, ae.Message) + } + }) + } +} + +var routineCases = []bodyCase{ + {"function no return", `CREATE FUNCTION fn_noret() RETURNS INT BEGIN SET @x = 1; END`, 1320}, + {"procedure return", `CREATE PROCEDURE pr_ret() BEGIN RETURN 1; END`, 1313}, + {"iterate no label", `CREATE PROCEDURE pr_iter() BEGIN lp: LOOP ITERATE nope; END LOOP; END`, 1308}, + {"select without into in function", `CREATE FUNCTION fn_sel() RETURNS INT BEGIN SELECT 1; RETURN 1; END`, 1415}, + {"commit in function", `CREATE FUNCTION fn_commit() RETURNS INT BEGIN COMMIT; RETURN 1; END`, 1422}, + {"undeclared cursor", `CREATE PROCEDURE pr_cur() BEGIN OPEN nosuch; END`, 1324}, + {"fetch count mismatch", `CREATE PROCEDURE pr_fetch() BEGIN DECLARE x INT; DECLARE cur CURSOR FOR SELECT id, name FROM users; OPEN cur; FETCH cur INTO x; CLOSE cur; END`, 1328}, + {"select into count mismatch", `CREATE PROCEDURE pr_selinto() BEGIN DECLARE x INT; SELECT id, name INTO x FROM users; END`, 1222}, + {"fetch undeclared var", `CREATE PROCEDURE pr_fetchvar() BEGIN DECLARE cur CURSOR FOR SELECT id FROM users; OPEN cur; FETCH cur INTO nosuchvar; CLOSE cur; END`, 1327}, +} + +func TestAnalyzeRoutineErrors(t *testing.T) { + for _, c := range routineCases { + t.Run(c.name, func(t *testing.T) { + text := strings.Replace(bodySchema, "CREATE FUNCTION order_note_len", c.sql+";\nCREATE FUNCTION order_note_len", 1) + s, err := schema.Load(text) + if err != nil { + t.Fatal(err) + } + fields := strings.Fields(c.sql) + var name string + for i, f := range fields { + if (strings.EqualFold(f, "FUNCTION") || strings.EqualFold(f, "PROCEDURE")) && i+1 < len(fields) { + name = strings.SplitN(fields[i+1], "(", 2)[0] + break + } + } + r := s.Routine(name) + if r == nil { + t.Fatalf("routine %s did not load (Problems: %v)", name, s.Problems) + } + _, err = AnalyzeRoutine(s, r) + if err == nil { + t.Fatalf("want error %d, got none", c.code) + } + ae, ok := err.(*Error) + if !ok { + t.Fatalf("want *Error, got %T: %v", err, err) + } + if ae.Code != c.code { + t.Errorf("want code %d, got %d (%s)", c.code, ae.Code, ae.Message) + } + }) + } +} + +// TestVarShadowsColumn checks that a routine's local variable of the same name as a +// column resolves to the variable, not the column (MySQL's own rule). +func TestVarShadowsColumn(t *testing.T) { + text := bodySchema + ` +CREATE PROCEDURE pr_shadow(IN id BIGINT UNSIGNED) +BEGIN + DECLARE total INT DEFAULT 5; + SELECT total FROM users WHERE users.id = id LIMIT 1; +END; +` + s, err := schema.Load(text) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + r := s.Routine("pr_shadow") + if r == nil { + t.Fatal("routine not loaded") + } + // `total` is not a column of users: if the local variable did not shadow it (MySQL's + // own rule -- a variable/parameter resolves before a column), this would be 1054. + br, err := AnalyzeRoutine(s, r) + if err != nil { + t.Fatalf("unexpected error (the local variable should have shadowed the column): %v", err) + } + if len(br.Statements) != 1 { + t.Errorf("want 1 fact (the bodiless SELECT, allowed in a procedure), got %d", len(br.Statements)) + } +} diff --git a/check/mysql/internal/analyze/bodyprobe_test.go b/check/mysql/internal/analyze/bodyprobe_test.go new file mode 100644 index 0000000..9f7011d --- /dev/null +++ b/check/mysql/internal/analyze/bodyprobe_test.go @@ -0,0 +1,390 @@ +package analyze + +// The body probe: the server's CREATE-time verdict on trigger, procedure and function +// bodies against the analyzer's. Generated bodies mix the constructs the server refuses +// when the body is created (a duplicate DECLARE, a bad SQLSTATE literal, a LEAVE with no +// label, a RETURN in a procedure, a result set in a function, COMMIT / DDL / dynamic SQL / +// FLUSH in a function or trigger, LOCK TABLES / LOAD DATA / ALTER VIEW anywhere, NEW / OLD +// misuse, an undeclared variable or cursor) with ordinary ones; each is CREATEd on a +// running mysqld and DROPped again. Where the server refuses, the analyzer must raise the +// same number (AnalyzeRoutine / AnalyzeTrigger) or the loader the mirroring problem; where +// the server accepts, the loader must report no problem and the analyzer no CREATE-time +// code (a run-time certainty -- 1442, 1456 -- is allowed). Skipped without a mysqld on PATH +// (nix-shell -p mysql84). + +import ( + "context" + "errors" + "flag" + "fmt" + "math/rand" + "os" + "sort" + "strings" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +var ( + bodyN = flag.Int("body-probe-n", 300, "bodies the body probe judges") + bodySeed = flag.Int64("body-probe-seed", 1, "seed of the body probe's generator") + bodyReport = flag.String("body-probe-report", "", "write the body probe's findings here (default: the test log)") +) + +const bodyProbeBase = `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, a INT, b INT NOT NULL); +CREATE TABLE u (id INT NOT NULL PRIMARY KEY, a INT); +CREATE VIEW v AS SELECT id, a FROM u; +CREATE PROCEDURE other() BEGIN INSERT INTO u VALUES (99, 1); END; +` + +// createTimeCodes are the numbers the analyzer raises for what the server refuses at CREATE +// time; an analyzer error of another number against a body the server accepts is a +// run-time certainty (1442, 1456, 1424, 1231, ...) and not judged here. +var createTimeCodes = map[int]bool{1193: true, 1308: true, 1313: true, 1314: true, 1320: true, 1324: true, 1327: true, 1331: true, 1332: true, 1333: true, 1336: true, 1362: true, 1363: true, 1407: true, 1413: true, 1415: true, 1422: true} + +// bodyAlphabet lists the shapes the generator draws and the CREATE-time refusals the server +// can answer with. +var bodyAlphabet = []string{ + "procedure", "function", "trigger before insert", "trigger after insert", "trigger before update", "trigger after update", "trigger before delete", "trigger after delete", + "declare", "declare duplicate", "condition", "condition duplicate", "condition bad sqlstate", "cursor", "cursor duplicate", "handler", "handler duplicate", + "set", "select into", "select into undeclared", "select result set", "insert", "update own table", "commit", "prepare", "flush", "lock tables", "load data", "alter view", "create table", + "loop leave", "leave bad label", "return", "no return", "signal", "signal errno zero", "open fetch close", "open undeclared cursor", "set new", "set old", "read old", "read new", "set new unknown column", "read new unknown column", "call", "case no else", + "refused 1054", "refused 1193", "refused 1308", "refused 1313", "refused 1314", "refused 1320", "refused 1324", "refused 1327", "refused 1331", "refused 1332", "refused 1333", "refused 1336", "refused 1362", "refused 1363", "refused 1407", "refused 1413", "refused 1415", "refused 1422", "accepted", +} + +// bodyKnownUnreached names the alphabet entries the run does not reach, each with why. +var bodyKnownUnreached = map[string]string{} + +// genBody is one generated routine or trigger: its CREATE text, name, kind and shapes. +type genBody struct { + name string + kind string // "procedure", "function", "trigger" + create string + shapes []string +} + +func genRoutine(r *rand.Rand, i int) genBody { + g := genBody{name: fmt.Sprintf("bp%d", i)} + timing, event := "", "" + switch r.Intn(4) { + case 0: + g.kind = "procedure" + case 1: + g.kind = "function" + default: + g.kind = "trigger" + timing = []string{"BEFORE", "AFTER"}[r.Intn(2)] + event = []string{"INSERT", "UPDATE", "DELETE"}[r.Intn(3)] + g.shapes = append(g.shapes, "trigger "+strings.ToLower(timing)+" "+strings.ToLower(event)) + } + if g.kind != "trigger" { + g.shapes = append(g.shapes, g.kind) + } + var lines []string + add := func(shape, line string) { + g.shapes = append(g.shapes, shape) + lines = append(lines, " "+line) + } + // declarations first, as the grammar wants + vars := map[string]bool{} + if r.Intn(2) == 0 { + add("declare", "DECLARE v INT DEFAULT 0;") + vars["v"] = true + if r.Intn(5) == 0 { + add("declare duplicate", "DECLARE v INT;") + } + } + hasCond := false + if r.Intn(3) == 0 { + add("condition", "DECLARE c1 CONDITION FOR SQLSTATE '45001';") + hasCond = true + switch r.Intn(6) { + case 0: + add("condition duplicate", "DECLARE c1 CONDITION FOR SQLSTATE '45002';") + case 1: + add("condition bad sqlstate", "DECLARE c2 CONDITION FOR SQLSTATE '4500';") + } + } + hasCursor := false + if r.Intn(3) == 0 { + add("cursor", "DECLARE cur CURSOR FOR SELECT id FROM u;") + hasCursor = true + if r.Intn(5) == 0 { + add("cursor duplicate", "DECLARE cur CURSOR FOR SELECT a FROM u;") + } + } + if r.Intn(3) == 0 { + add("handler", "DECLARE CONTINUE HANDLER FOR SQLEXCEPTION SET @h = 1;") + switch r.Intn(6) { + case 0: + add("handler duplicate", "DECLARE CONTINUE HANDLER FOR SQLEXCEPTION SET @h = 2;") + case 1: + if hasCond { + add("handler", "DECLARE EXIT HANDLER FOR c1 SET @h = 3;") + } + } + } + // statements: ordinary ones, and at most one the server may refuse (two now and then, + // so the report also shows which of two refusals the server names first) + safe := func() { + switch r.Intn(7) { + case 0: + if vars["v"] { + add("set", "SET v = v + 1;") + } else { + add("set", "SET @x = 1;") + } + case 1: + if vars["v"] { + add("select into", "SELECT id INTO v FROM u LIMIT 1;") + } + case 2: + add("insert", "INSERT INTO u VALUES (5, 1);") + case 3: + add("loop leave", "lbl: LOOP LEAVE lbl; END LOOP;") + case 4: + add("signal", "SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'x';") + case 5: + add("call", "CALL other();") + default: + add("case no else", "CASE @x WHEN 1 THEN SET @y = 1; END CASE;") + } + } + risky := func() { + switch r.Intn(20) { + case 0: + add("select into undeclared", "SELECT id INTO w FROM u LIMIT 1;") + case 1: + add("select result set", "SELECT id FROM u;") + case 2: + add("update own table", "UPDATE t SET a = 1 WHERE id = 1;") + case 3: + add("commit", "COMMIT;") + case 4: + add("prepare", "PREPARE s FROM 'SELECT 1';") + case 5: + add("flush", "FLUSH TABLES;") + case 6: + add("lock tables", "LOCK TABLES u WRITE;") + case 7: + add("load data", "LOAD DATA INFILE '/tmp/x' INTO TABLE u;") + case 8: + add("alter view", "ALTER VIEW v AS SELECT id, a FROM u WHERE a = 1;") + case 9: + add("create table", "CREATE TABLE tmp_x (id INT);") + case 10: + add("leave bad label", "lbl2: LOOP LEAVE nope; END LOOP;") + case 11: + add("return", "RETURN 1;") + case 12: + add("signal errno zero", "SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 0;") + case 13: + if hasCursor && vars["v"] { + add("open fetch close", "OPEN cur; FETCH cur INTO v; CLOSE cur;") + } else { + add("open undeclared cursor", "OPEN nocur;") + } + case 14: + add("set new", "SET NEW.a = 1;") + case 15: + add("set old", "SET OLD.a = 1;") + case 16: + add("read old", "SET @o = OLD.a;") + case 17: + add("read new", "SET @n = NEW.a;") + case 18: + add("set new unknown column", "SET NEW.nosuch = 1;") + default: + add("read new unknown column", "SET @o = NEW.nosuch;") + } + } + for k := 0; k < r.Intn(3); k++ { + safe() + } + switch r.Intn(10) { + case 0, 1: + case 2: + risky() + risky() + default: + risky() + } + for k := 0; k < r.Intn(2); k++ { + safe() + } + body := "BEGIN\n" + strings.Join(lines, "\n") + "\n" + switch g.kind { + case "procedure": + g.create = "CREATE PROCEDURE " + g.name + "(IN n INT)\n" + body + "END" + case "function": + if r.Intn(4) == 0 { + g.shapes = append(g.shapes, "no return") + g.create = "CREATE FUNCTION " + g.name + "(n INT) RETURNS INT DETERMINISTIC\n" + body + "END" + } else { + g.create = "CREATE FUNCTION " + g.name + "(n INT) RETURNS INT DETERMINISTIC\n" + body + " RETURN 1;\nEND" + } + default: + g.create = "CREATE TRIGGER " + g.name + " " + timing + " " + event + " ON t FOR EACH ROW\n" + body + "END" + } + return g +} + +func TestBodyProbe(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, bodyProbeBase) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + r := rand.New(rand.NewSource(*bodySeed)) + counts := map[string]int{} + hit := map[string]bool{} + type finding struct{ kind, detail, create string } + var findings []finding + seen := map[string]bool{} + for i := 0; i < *bodyN; i++ { + g := genRoutine(r, i) + for _, s := range g.shapes { + hit[s] = true + } + // the server + _, serr := db.Conn().ExecContext(ctx, g.create) + code := 0 + var me *driver.MySQLError + if errors.As(serr, &me) { + code = int(me.Number) + } else if serr != nil { + t.Fatalf("%s: %v", g.create, serr) + } + if code == 0 { + drop := map[string]string{"procedure": "DROP PROCEDURE ", "function": "DROP FUNCTION ", "trigger": "DROP TRIGGER "}[g.kind] + if _, err := db.Conn().ExecContext(ctx, drop+g.name); err != nil { + t.Fatalf("drop %s: %v", g.name, err) + } + hit["accepted"] = true + } else { + hit[fmt.Sprintf("refused %d", code)] = true + } + // the analyzer + s, lerr := schema.Load(bodyProbeBase + g.create + ";\n") + if lerr != nil { + t.Fatalf("%s: schema.Load: %v", g.create, lerr) + } + var problems []string + for _, p := range s.Problems { + if strings.Contains(p.Message, g.name) { + problems = append(problems, p.Message) + } + } + var aerr error + switch g.kind { + case "procedure": + if rt := s.RoutineOf(schema.Procedure, g.name); rt != nil { + _, aerr = AnalyzeRoutine(s, rt) + } + case "function": + if rt := s.RoutineOf(schema.Function, g.name); rt != nil { + _, aerr = AnalyzeRoutine(s, rt) + } + default: + if tg := s.Trigger(g.name); tg != nil { + _, aerr = AnalyzeTrigger(s, tg) + } + } + acode := errCode(aerr) + var kind, detail string + switch { + case code != 0 && acode == code: + counts["pass (refused, same code)"]++ + case code != 0 && problemMentions(problems, me.Message): + counts["pass (refused, loader problem)"]++ + case code != 0 && (createTimeCodes[acode] || len(problems) > 0): + // both refuse, naming different constructs: the server checks in its own + // order (grammar-time refusals first), the analyzer in walk order + counts["pass (refused, other code)"]++ + if counts["pass (refused, other code)"] <= 5 { + t.Logf("refused with another code: server %v, analyzer %v\n%s", serr, aerr, g.create) + } + case code != 0: + kind, detail = "server refuses, analyzer does not", fmt.Sprintf("server: %v\nanalyzer: %v\nloader problems: %v", serr, aerr, problems) + case len(problems) > 0: + kind, detail = "loader problem for a body the server accepts", fmt.Sprintf("loader problems: %v\nanalyzer: %v", problems, aerr) + case acode != 0 && createTimeCodes[acode]: + kind, detail = "analyzer refuses what the server accepts", fmt.Sprintf("analyzer: %v", aerr) + default: + counts["pass (accepted)"]++ + } + if kind != "" { + counts["finding"]++ + key := kind + "\n" + detail + if !seen[key] { + seen[key] = true + findings = append(findings, finding{kind, detail, g.create}) + } + } + } + var report strings.Builder + fmt.Fprintf(&report, "# body probe (mysql), seed %d, %d bodies\n\n", *bodySeed, *bodyN) + var keys []string + for k := range counts { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(&report, "%s: %d\n", k, counts[k]) + } + for i, f := range findings { + fmt.Fprintf(&report, "\n## %d. %s\n\n%s\n\n```sql\n%s\n```\n", i+1, f.kind, f.detail, f.create) + } + var missed, stale []string + fmt.Fprintf(&report, "\n## alphabet coverage\n\n") + for _, e := range bodyAlphabet { + mark := " " + if hit[e] { + mark = "x" + if _, known := bodyKnownUnreached[e]; known { + stale = append(stale, e) + } + } else if _, known := bodyKnownUnreached[e]; !known { + missed = append(missed, e) + } + fmt.Fprintf(&report, "- [%s] %s\n", mark, e) + } + if *bodyReport != "" { + if err := os.WriteFile(*bodyReport, []byte(report.String()), 0o644); err != nil { + t.Fatal(err) + } + t.Logf("report: %s", *bodyReport) + } else { + t.Log(report.String()) + } + if len(findings) > 0 { + t.Errorf("%d finding(s), see the report", len(findings)) + } + if len(missed) > 0 { + t.Errorf("alphabet entries never reached: %s", strings.Join(missed, ", ")) + } + if len(stale) > 0 { + t.Errorf("known-unreached entries that were reached: %s", strings.Join(stale, ", ")) + } +} + +// problemMentions: a loader problem carries the server's own message (bodyRefusalProblem). +func problemMentions(problems []string, msg string) bool { + for _, p := range problems { + if strings.Contains(p, msg) { + return true + } + } + return false +} diff --git a/check/mysql/internal/analyze/call.go b/check/mysql/internal/analyze/call.go new file mode 100644 index 0000000..b8b08e2 --- /dev/null +++ b/check/mysql/internal/analyze/call.go @@ -0,0 +1,256 @@ +package analyze + +import ( + "fmt" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlast" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/facts" +) + +// calledRoutine is one schema-declared routine a statement's analysis met: a FUNCTION +// resolved at a call site inside an expression (expr.go's storedFuncCall), or the +// PROCEDURE a CALL statement itself runs (callStmt, below). pos is the call's own start +// (a.ph.Back applied where it is read), for the 1442 overlap check's position. +type calledRoutine struct { + r *schema.Routine + pos int +} + +// hasCode reports whether vs carries a Violation of code (checkCalledRoutineOverlap's own +// use: whether a called routine's own analysis already found a certain-shaped direct +// recursion, 1456). +func hasCode(vs []Violation, code int) bool { + for _, v := range vs { + if v.Code == code { + return true + } + } + return false +} + +// noteCalledRoutine records r as called, once (a statement calling the same function twice, +// or through two argument positions, does not fold in its failure modes twice): violations() +// and checkCalledRoutineOverlap both read a.calledRoutines. +func (a *analyzer) noteCalledRoutine(r *schema.Routine, pos int) { + if a.calledSeen == nil { + a.calledSeen = map[*schema.Routine]bool{} + } + if a.calledSeen[r] { + return + } + a.calledSeen[r] = true + a.calledRoutines = append(a.calledRoutines, calledRoutine{r: r, pos: pos}) +} + +// checkCalledRoutineOverlap is 1442 ("Can't update table '%s' in stored function/trigger +// because it is already used by statement which invoked this stored function/trigger"), +// measured unconditionally true on mysqld 8.4 whenever a called routine's own body writes a +// table the invoking statement itself references -- reading it is enough (measured: `SELECT +// writer(v) FROM t` fails the same way an UPDATE of t calling writer() does), not only +// writing it; a routine's body that only reads the table (never a "may", the write is +// certain from the body alone) does not collide. A CALL whose own arguments reference no +// table (the ordinary case: constants, placeholders, `@vars`) never collides through the +// PROCEDURE it runs alone, matching the measurement (`CALL p3(7)`, p3's body writing t, +// raised nothing: the statement itself never mentions t). +// +// Inside a body the same rule holds per embedded statement (measured: a procedure whose +// `UPDATE t SET v = writer(v)` calls a function writing t is 1442 when CALLed, while +// `SELECT COUNT(*) INTO n FROM t; SET @x = writer(1)` as two statements is not), and a +// trigger's own table counts as referenced by every statement of its body, since the +// statement that fired the trigger is on that table (measured: a trigger on `other` +// whose body runs `SET @y = writer_other(1)` is 1442; the same trigger on t fired by +// `INSERT INTO t SELECT ... FROM other` is not -- only the trigger's table collides). +// The writes a routine reaches through its own nested calls count as its (WriteTables +// is transitive, body.go's finishCalls; measured: `SELECT f(1) FROM t` is 1442 both when +// f CALLs a procedure writing t and when f RETURNs a function writing t). +func (a *analyzer) checkCalledRoutineOverlap() error { + if len(a.calledRoutines) == 0 { + return nil + } + refTables := map[string]bool{} + if a.trig != nil { + refTables[strings.ToLower(a.trigTable.Name)] = true + } + for name := range a.refRels { + refTables[name] = true + } + for _, u := range a.uses { + if u.Table != "" { + refTables[strings.ToLower(u.Table)] = true + } + } + if w := a.write; w != nil { + if w.table != nil { + refTables[strings.ToLower(w.table.Name)] = true + } + for _, m := range w.more { + if m.table != nil { + refTables[strings.ToLower(m.table.Name)] = true + } + } + } + for _, cr := range a.calledRoutines { + br, err := AnalyzeRoutine(a.s, cr.r) + if err != nil || br == nil { + continue + } + if hasCode(br.Violations, 1456) { + // cr.r calls itself, unconditionally reached before any of its own further + // statements (max_sp_recursion_depth defaults to 0, so the very first actual + // recursive invocation always fails): the write this overlap would otherwise + // name never runs, so mysqld raises cr.r's own 1456 instead, not this one + // (measured: TestAdv3ChainCallRecursionAnd1442BothSwallowed -- a trigger CALLing + // a directly-recursive procedure that also writes the trigger's own table fails + // with 1456 every time, never 1442). cr.r's own Violations (including that 1456) + // are folded in the ordinary way instead, by calledRoutineViolations. + continue + } + for _, wt := range br.WriteTables { + if refTables[strings.ToLower(wt)] { + return &Error{ + Message: fmt.Sprintf("Can't update table '%s' in stored function/trigger because it is already used by statement which invoked this stored function/trigger.", wt), + Code: 1442, + Position: a.ph.Back(cr.pos), + } + } + } + } + return nil +} + +// spNameOf reads a sp_name node's own db/name (db is "" when the call was not qualified); +// unqualified is the only form this milestone resolves against (db is ignored, the way a +// table's db qualifier already is in target(): sqlshape loads a single schema). +func spNameOf(v mysqlast.Value) (db, name string) { + n, ok := v.(*mysqlast.Node) + if !ok || n.Class != "sp_name" { + // defensive: the sp_name production (hooks_sp.go) is the only builder for + // PT_call's proc_name, so this is never actually reached. + return "", str(v) + } + return str(n.Arg("db")), str(n.Arg("name")) +} + +// isCallVariableTarget reports whether v is something an OUT/INOUT argument can write back +// into: a placeholder (a prepared statement's own bind slot always qualifies -- measured: +// `CALL p(?, ?)` with any two bound values raises nothing even for the OUT position, since +// the placeholder acts as an anonymous variable slot the same way `@out` does), a `@user` +// variable, -- inside a trigger's/routine's own body -- a declared local variable or +// parameter, or NEW.col inside a BEFORE trigger's own body (measured: `CALL p_out(NEW.v)` +// inside a BEFORE INSERT trigger raises nothing; the server's own 1414 message names this +// exception outright -- "... is not a variable or NEW pseudo-variable in BEFORE trigger"). +// OLD.col never qualifies (OLD is never writable, in any trigger timing) and neither does +// NEW.col outside a BEFORE trigger. Anything else (a literal, an expression) is 1414 +// (measured: `CALL p4(5)`, p4's sole parameter INOUT, a bare literal in that position). +func (a *analyzer) isCallVariableTarget(v mysqlast.Value) bool { + if isParam(v) { + return true + } + n, ok := v.(*mysqlast.Node) + if !ok { + // defensive: every CALL argument the grammar builds is a *mysqlast.Node (an + // expression or a literal), never a bare Go value. + return false + } + switch n.Class { + case "PTI_user_variable": + return true + case "PTI_simple_ident_ident", "PTI_simple_ident_nospvar_ident": + _, ok := a.lookupVar(str(n.Arg("ident"))) + return ok + case "PTI_simple_ident_q_2d": + return a.trig != nil && strings.EqualFold(a.trig.Timing, "BEFORE") && + strings.EqualFold(str(n.Arg("table")), "new") + } + return false +} + +// callStmt analyzes CALL name(args): the procedure resolves by name alone (db ignored, see +// spNameOf), its argument count must match (1318, the message measured the same shape a +// function's own mismatch takes), an OUT/INOUT argument must be a variable (1414), an IN or +// INOUT placeholder is typed from the parameter's declared type. The statement's own facts +// are Kind Call (x/facts' own doc: "no scope of its own" -- the body's reads/writes are the +// routine's, not this statement's); AtMostOne follows from there being at most one result +// row (a CALL is never part of an aggregate/GROUP BY question). Its result columns are the +// body's own INTO-less SELECT(s) (resultColumnsOf); its failure modes are the body's own +// (violations(), through calledRoutineViolations), and checkCalledRoutineOverlap covers the +// 1442 a body write can raise the same way a function call's does. +func (a *analyzer) callStmt(n *mysqlast.Node) error { + _, name := spNameOf(n.Arg("proc_name")) + r := a.s.RoutineOf(schema.Procedure, name) + if r == nil { + return &Error{Message: fmt.Sprintf("PROCEDURE %s does not exist", name), Code: 1305, Position: a.ph.Back(n.Start)} + } + args, _ := n.Arg("opt_expr_list").(mysqlast.List) + if len(args) != len(r.Params) { + return &Error{Message: fmt.Sprintf("Incorrect number of arguments for PROCEDURE %s; expected %d, got %d", r.Name, len(r.Params), len(args)), Code: 1318, Position: a.ph.Back(n.Start)} + } + a.facts = &facts.Facts{Kind: facts.Call, AtMostOne: true} + for i, arg := range args { + p := r.Params[i] + if (p.Mode == "OUT" || p.Mode == "INOUT") && !a.isCallVariableTarget(arg) { + return &Error{Message: fmt.Sprintf("OUT or INOUT argument %d for routine %s is not a variable", i+1, r.Name), Code: 1414, Position: a.ph.Back(n.Start)} + } + if _, err := a.expr(scope{}, arg, "call"); err != nil { + return err + } + if isParam(arg) { + a.setParam(arg, p.Type) + } + } + a.noteCalledRoutine(r, n.Start) + br, err := AnalyzeRoutine(a.s, r) + if err != nil { + return err + } + cols, ok := resultColumnsOf(br) + if !ok { + return &Error{Message: fmt.Sprintf("PROCEDURE %s returns result sets of more than one shape", r.Name), Code: 0, Position: a.ph.Back(n.Start)} + } + a.columns = cols + return nil +} + +// resultColumnsOf collects a routine's own INTO-less top-level SELECTs (walkSelect marks +// each such statement's own Columns; every other body statement's is nil, including a +// SELECT ... INTO or a cursor's DECLARE ... FOR, which consume their columns rather than +// returning them): with none, a CALL has no result columns; with one, its columns are the +// CALL's; with more than one, only when their shapes agree exactly (the same count, the +// same names in order, the same Known-ness and, when Known, the same type name -- the +// nullability instead ORs across them, the same way a schema constraint's does not have to +// match) do they still agree on one column list. A mismatch is not something mysqld itself +// refuses (it only ever returns whichever result set the execution path taken produced, at +// runtime): ok is false, and callStmt reports it as its own error, not a MySQL one (Code 0). +func resultColumnsOf(br *BodyResult) ([]Column, bool) { + var sets [][]Column + for _, st := range br.Statements { + if len(st.Columns) > 0 { + sets = append(sets, st.Columns) + } + } + for _, cols := range br.NestedCallColumns { + if len(cols) > 0 { + sets = append(sets, cols) + } + } + if len(sets) == 0 { + return nil, true + } + out := append([]Column(nil), sets[0]...) + for _, cols := range sets[1:] { + if len(cols) != len(out) { + return nil, false + } + for i, c := range cols { + if !strings.EqualFold(c.Name, out[i].Name) || c.Known != out[i].Known || (c.Known && c.Type.Name != out[i].Type.Name) { + return nil, false + } + if c.Nullable { + out[i].Nullable = true + } + } + } + return out, true +} diff --git a/check/mysql/internal/analyze/call_test.go b/check/mysql/internal/analyze/call_test.go new file mode 100644 index 0000000..26dad6d --- /dev/null +++ b/check/mysql/internal/analyze/call_test.go @@ -0,0 +1,477 @@ +package analyze + +import ( + "strings" + "testing" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/facts" +) + +// callSchema exercises m6's own additions: a stored FUNCTION's call site (resolved, +// argument-checked, its own failure modes and the table it writes folded into the caller), +// and CALL of a PROCEDURE (argument-checked, OUT/INOUT validated, its own result columns +// and failure modes). +const callSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE widgets ( + id INT NOT NULL PRIMARY KEY, + v INT NOT NULL +); + +-- sqlshape: error 30001 = TooBig +CREATE FUNCTION next_total(x DECIMAL(10,2)) RETURNS DECIMAL(10,2) DETERMINISTIC +BEGIN + IF x > 1000000 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'too big', MYSQL_ERRNO = 30001; + END IF; + RETURN x + 1; +END; + +-- sqlshape: not null +CREATE FUNCTION next_total_strict(x DECIMAL(10,2)) RETURNS DECIMAL(10,2) DETERMINISTIC +BEGIN + RETURN x + 1; +END; + +CREATE FUNCTION bump_widget(x INT) RETURNS INT +BEGIN + UPDATE widgets SET v = x WHERE id = 1; + RETURN x; +END; + +-- sqlshape: error 30002 = TooBig2 +CREATE PROCEDURE do_signal(IN a INT, OUT b INT) +BEGIN + SET b = a + 1; + IF a > 100 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'too big', MYSQL_ERRNO = 30002; + END IF; +END; + +CREATE PROCEDURE report(IN a INT) +BEGIN + SELECT a AS x, a * 2 AS y; +END; + +CREATE PROCEDURE writes_widgets(IN a INT) +BEGIN + UPDATE widgets SET v = a WHERE id = 1; +END; + +-- two branches, the same shape (one nullable, one not): the CALL's own result column is +-- nullable (the OR across the branches, the way a schema constraint's own equality does not +-- have to match between them either) +CREATE PROCEDURE report_branch(IN a INT) +BEGIN + IF a > 0 THEN + SELECT id AS x FROM widgets WHERE id = a; + ELSE + SELECT a AS x; + END IF; +END; + +-- two branches of a different shape: not decidable statically (mysqld only refuses this at +-- runtime, with no error number of its own) +CREATE PROCEDURE report_mismatched(IN a INT) +BEGIN + IF a > 0 THEN + SELECT a AS x; + ELSE + SELECT a AS x, a * 2 AS y; + END IF; +END; + +-- two branches of the same column count but a different name in one position: still not +-- decidable statically, resultColumnsOf's own per-column disagreement (as opposed to +-- report_mismatched's disagreement in count). +CREATE PROCEDURE report_mismatched_name(IN a INT) +BEGIN + IF a > 0 THEN + SELECT a AS x; + ELSE + SELECT a AS y; + END IF; +END; +` + +func loadCallSchema(t *testing.T) *schema.Schema { + s, err := schema.Load(callSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + return s +} + +// TestStoredFunctionCallResolves: a SELECT calling a schema FUNCTION types its result from +// RETURNS (always nullable) and folds the function's own SIGNAL into the statement's +// Violations, tagged with the function's name. +func TestStoredFunctionCallResolves(t *testing.T) { + s := loadCallSchema(t) + r, err := Analyze(s, "SELECT next_total($1)") + if err != nil { + t.Fatal(err) + } + if len(r.Columns) != 1 || !r.Columns[0].Known || r.Columns[0].Type.Name != "decimal" || !r.Columns[0].Nullable { + t.Errorf("got columns %+v, want one known nullable decimal", r.Columns) + } + if len(r.Params) != 1 || !r.Params[0].Known || r.Params[0].Type.Name != "decimal" { + t.Errorf("got params %+v, want $1 typed decimal from next_total's own parameter", r.Params) + } + if got := violationKeys(r.Violations); got != "30001 30001=TooBig" { + t.Errorf("got %s, want the function's own SIGNAL (30001=TooBig)", got) + } + if len(r.Violations) != 1 || r.Violations[0].Function != "next_total" { + t.Errorf("got %+v, want Function=next_total", r.Violations) + } +} + +// TestStoredFunctionNotNullDirective: the same override postgres/schema.go's createFunction +// reads (`-- sqlshape: not null` above a CREATE FUNCTION) makes a MySQL stored function's +// call site non-nullable too, rather than the always-nullable default +// TestStoredFunctionCallResolves exercises. +func TestStoredFunctionNotNullDirective(t *testing.T) { + s := loadCallSchema(t) + r, err := Analyze(s, "SELECT next_total_strict($1)") + if err != nil { + t.Fatal(err) + } + if len(r.Columns) != 1 || !r.Columns[0].Known || r.Columns[0].Type.Name != "decimal" || r.Columns[0].Nullable { + t.Errorf("got columns %+v, want one known not-null decimal", r.Columns) + } +} + +// TestStoredFunctionQualified: `db.f(...)` resolves the same stored function as an +// unqualified call (the db qualifier is not validated against any particular schema name: +// m6's own scope, matching how a table's db qualifier is already ignored in target()). +func TestStoredFunctionQualified(t *testing.T) { + s := loadCallSchema(t) + r, err := Analyze(s, "SELECT anydb.next_total($1)") + if err != nil { + t.Fatal(err) + } + if len(r.Columns) != 1 || r.Columns[0].Type.Name != "decimal" { + t.Errorf("got columns %+v, want a decimal (qualified call resolves the same function)", r.Columns) + } +} + +// TestStoredFunctionArgCountMismatch is 1318, the message shape measured on mysqld 8.4. +func TestStoredFunctionArgCountMismatch(t *testing.T) { + s := loadCallSchema(t) + _, err := Analyze(s, "SELECT next_total($1, $2)") + ae, ok := err.(*Error) + if !ok || ae.Code != 1318 { + t.Fatalf("got %v, want a 1318 Error", err) + } +} + +// TestStoredFunctionUnknown is 1305, the same code an unknown native function already gets. +func TestStoredFunctionUnknown(t *testing.T) { + s := loadCallSchema(t) + _, err := Analyze(s, "SELECT no_such_function($1)") + ae, ok := err.(*Error) + if !ok || ae.Code != 1305 { + t.Fatalf("got %v, want a 1305 Error", err) + } +} + +// TestFunctionWritingReferencedTableIs1442: a function whose body writes a table the +// invoking statement itself references (reading it is enough, measured on mysqld 8.4) is +// 1442, an Error (the collision is certain, not a "may"). +func TestFunctionWritingReferencedTableIs1442(t *testing.T) { + s := loadCallSchema(t) + _, err := Analyze(s, "SELECT v, bump_widget(v) FROM widgets WHERE id = $1") + ae, ok := err.(*Error) + if !ok || ae.Code != 1442 { + t.Fatalf("got %v, want a 1442 Error", err) + } +} + +// TestFunctionWritingUnreferencedTableOK: the same function, called where the statement +// never references widgets at all, collides with nothing. +func TestFunctionWritingUnreferencedTableOK(t *testing.T) { + s := loadCallSchema(t) + r, err := Analyze(s, "SELECT bump_widget($1)") + if err != nil { + t.Fatal(err) + } + if len(r.Columns) != 1 { + t.Errorf("got columns %+v", r.Columns) + } +} + +// TestCallStmtBasic: CALL types its arguments against the procedure's own parameters, its +// facts are Kind Call, and its own SIGNAL is a Violation of the CALL statement. +func TestCallStmtBasic(t *testing.T) { + s := loadCallSchema(t) + r, err := Analyze(s, "CALL do_signal($1, @out)") + if err != nil { + t.Fatal(err) + } + if r.Facts == nil || r.Facts.Kind != facts.Call { + t.Fatalf("got Facts %+v, want Kind Call", r.Facts) + } + if !r.Facts.AtMostOne { + t.Errorf("want AtMostOne for a CALL") + } + if len(r.Params) != 1 || !r.Params[0].Known || r.Params[0].Type.Name != "int" { + t.Errorf("got params %+v, want $1 typed int from do_signal's IN parameter", r.Params) + } + if got := violationKeys(r.Violations); got != "30002 30002=TooBig2" { + t.Errorf("got %s, want the procedure's own SIGNAL (30002=TooBig2)", got) + } +} + +// TestCallStmtArgCountMismatch is 1318, the same shape a function call's mismatch is. +func TestCallStmtArgCountMismatch(t *testing.T) { + s := loadCallSchema(t) + _, err := Analyze(s, "CALL do_signal($1)") + ae, ok := err.(*Error) + if !ok || ae.Code != 1318 { + t.Fatalf("got %v, want a 1318 Error", err) + } +} + +// TestCallStmtUnknownProcedure is 1305. +func TestCallStmtUnknownProcedure(t *testing.T) { + s := loadCallSchema(t) + _, err := Analyze(s, "CALL no_such_proc($1)") + ae, ok := err.(*Error) + if !ok || ae.Code != 1305 { + t.Fatalf("got %v, want a 1305 Error", err) + } +} + +// TestCallStmtOutArgNotVariable is 1414 (measured on mysqld 8.4: a literal in an OUT/INOUT +// position, as opposed to a placeholder or a `@var`, which the server always accepts). +func TestCallStmtOutArgNotVariable(t *testing.T) { + s := loadCallSchema(t) + _, err := Analyze(s, "CALL do_signal($1, 5)") + ae, ok := err.(*Error) + if !ok || ae.Code != 1414 { + t.Fatalf("got %v, want a 1414 Error", err) + } +} + +// TestCallStmtOutArgPlaceholderOK: a placeholder in the OUT position is always accepted +// (measured: it is a prepared statement's own bind slot, an anonymous variable). +func TestCallStmtOutArgPlaceholderOK(t *testing.T) { + s := loadCallSchema(t) + if _, err := Analyze(s, "CALL do_signal($1, $2)"); err != nil { + t.Fatalf("got %v, want no error (a placeholder OUT argument is always fine)", err) + } +} + +// TestCallStmtResultColumns: a procedure whose body is a single INTO-less SELECT gives the +// CALL statement that SELECT's own columns. +func TestCallStmtResultColumns(t *testing.T) { + s := loadCallSchema(t) + r, err := Analyze(s, "CALL report($1)") + if err != nil { + t.Fatal(err) + } + if len(r.Columns) != 2 || r.Columns[0].Name != "x" || r.Columns[1].Name != "y" { + t.Fatalf("got columns %+v, want x, y", r.Columns) + } +} + +// TestCallStmtNoResultColumns: a procedure whose body writes but never SELECTs has no +// result columns. +func TestCallStmtNoResultColumns(t *testing.T) { + s := loadCallSchema(t) + r, err := Analyze(s, "CALL writes_widgets($1)") + if err != nil { + t.Fatal(err) + } + if len(r.Columns) != 0 { + t.Errorf("got columns %+v, want none", r.Columns) + } +} + +// TestCallStmtResultColumnsMergeNullability: two same-shaped INTO-less SELECT branches +// agree on one column list, with the nullability ORed across them. +func TestCallStmtResultColumnsMergeNullability(t *testing.T) { + s := loadCallSchema(t) + r, err := Analyze(s, "CALL report_branch($1)") + if err != nil { + t.Fatal(err) + } + if len(r.Columns) != 1 || r.Columns[0].Name != "x" || !r.Columns[0].Nullable { + t.Fatalf("got columns %+v, want one nullable column x (ORed across the branches)", r.Columns) + } +} + +// TestCallStmtResultColumnsMismatchedShapes: two INTO-less SELECT branches of a different +// shape are not decidable statically -- the checker's own error (Code 0), not a MySQL one +// (mysqld itself only refuses this at runtime, whichever branch actually runs). +func TestCallStmtResultColumnsMismatchedShapes(t *testing.T) { + s := loadCallSchema(t) + _, err := Analyze(s, "CALL report_mismatched($1)") + ae, ok := err.(*Error) + if !ok || ae.Code != 0 { + t.Fatalf("got %v, want the checker's own Error (Code 0)", err) + } +} + +// TestCallStmtAloneDoesNotCollide: a CALL whose own arguments reference no table never +// collides with what the procedure's body writes (measured on mysqld 8.4: `CALL p3(7)` +// where p3 writes t raised nothing -- the statement itself never mentions t). +func TestCallStmtAloneDoesNotCollide(t *testing.T) { + s := loadCallSchema(t) + if _, err := Analyze(s, "CALL writes_widgets($1)"); err != nil { + t.Fatalf("got %v, want no error", err) + } +} + +// TestCallStmtOutArgNotVariable_Identifier is 1414 through isCallVariableTarget's other +// path: a bare identifier (not a `@var`, not a placeholder) resolves through lookupVar the +// same way a routine body's own local variable would, but there is none in scope at a +// top-level CALL (schema.Load bodies push their own vars only around a trigger's/routine's +// own analysis), so it is rejected the same as any other non-variable expression. +func TestCallStmtOutArgNotVariable_Identifier(t *testing.T) { + s := loadCallSchema(t) + _, err := Analyze(s, "CALL do_signal($1, v)") + ae, ok := err.(*Error) + if !ok || ae.Code != 1414 { + t.Fatalf("got %v, want a 1414 Error", err) + } +} + +// TestNoteCalledRoutine_DedupesSameCallTwice: calling the same FUNCTION twice in one +// statement folds its own SIGNAL into Violations once, not twice (noteCalledRoutine's own +// doc: a.calledSeen). +func TestNoteCalledRoutine_DedupesSameCallTwice(t *testing.T) { + s := loadCallSchema(t) + r, err := Analyze(s, "SELECT next_total($1) + next_total($2)") + if err != nil { + t.Fatal(err) + } + if len(r.Violations) != 1 { + t.Fatalf("got %d violations, want 1 (the same routine called twice folds once): %+v", len(r.Violations), r.Violations) + } +} + +// TestCheckCalledRoutineOverlap_UpdateWriteTarget: the invoking statement's own write +// target (not merely something it reads, a.uses) also collides -- an UPDATE whose SET +// value calls a function that writes the very table being updated is 1442, exercising +// checkCalledRoutineOverlap's a.write.table branch (TestFunctionWritingReferencedTableIs1442 +// above only exercises the a.uses branch, through a SELECT). +func TestCheckCalledRoutineOverlap_UpdateWriteTarget(t *testing.T) { + s := loadCallSchema(t) + _, err := Analyze(s, "UPDATE widgets SET v = bump_widget(v) WHERE id = $1") + ae, ok := err.(*Error) + if !ok || ae.Code != 1442 { + t.Fatalf("got %v, want a 1442 Error", err) + } +} + +// TestCheckCalledRoutineOverlap_MultiTableWriteTarget: a multi-table UPDATE's second write +// target (w.more, not w.table -- the first one assigned) also collides, when a called +// function writes it. +func TestCheckCalledRoutineOverlap_MultiTableWriteTarget(t *testing.T) { + s := loadCallSchema(t) + text := callSchema + ` +CREATE TABLE gadgets ( + id INT NOT NULL PRIMARY KEY, + v INT NOT NULL +); +` + s2, err := schema.Load(text) + if err != nil { + t.Fatal(err) + } + if len(s2.Problems) > 0 { + t.Fatalf("schema problems: %v", s2.Problems) + } + _, err = Analyze(s2, "UPDATE gadgets g, widgets w SET g.v = $1, w.v = bump_widget(w.v) WHERE g.id = w.id") + ae, ok := err.(*Error) + if !ok || ae.Code != 1442 { + t.Fatalf("got %v, want a 1442 Error (bump_widget writes widgets, gadgets' second write target)", err) + } + _ = s // loadCallSchema's own schema is unused here; s2 carries the extra table +} + +// TestCallStmtArgExprError: an IN argument's own expression can fail to type (here an +// unknown FUNCTION call), and callStmt returns that error as-is (a.expr's own, not +// wrapped or replaced). +func TestCallStmtArgExprError(t *testing.T) { + s := loadCallSchema(t) + _, err := Analyze(s, "CALL do_signal(no_such_function(), @out)") + ae, ok := err.(*Error) + if !ok || ae.Code != 1305 { + t.Fatalf("got %v, want the argument expression's own 1305 Error", err) + } +} + +// TestCallStmtBrokenBody: CALL of a procedure whose own body fails to analyze (here LEAVE +// with no enclosing label, 1308) surfaces that error directly -- unlike +// checkCalledRoutineOverlap's own best-effort skip (TestCheckCalledRoutineOverlap_BrokenBodySwallowsError), +// callStmt itself needs the body's result columns, so it cannot proceed without one. This +// procedure is not part of callSchema (loaded against a real mysqld by +// call_violations_server_test.go), since the server itself refuses LEAVE with no matching +// label at CREATE time -- schema.Load, which never runs a body through mysqld, tolerates it +// as a Problem-free load, deferring the check to AnalyzeRoutine. +func TestCallStmtBrokenBody(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE PROCEDURE broken_proc(IN x INT) +BEGIN + LEAVE nowhere; +END; +` + s, err := schema.Load(text) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + _, err = Analyze(s, "CALL broken_proc($1)") + ae, ok := err.(*Error) + if !ok || ae.Code != 1308 { + t.Fatalf("got %v, want the body's own 1308 Error", err) + } +} + +// TestCallStmtResultColumnsMismatchedNames: same column count in both branches, but a +// different name in one position -- resultColumnsOf's own per-column disagreement, distinct +// from TestCallStmtResultColumnsMismatchedShapes's count disagreement. +func TestCallStmtResultColumnsMismatchedNames(t *testing.T) { + s := loadCallSchema(t) + _, err := Analyze(s, "CALL report_mismatched_name($1)") + ae, ok := err.(*Error) + if !ok || ae.Code != 0 { + t.Fatalf("got %v, want the checker's own Error (Code 0)", err) + } +} + +// TestCheckCalledRoutineOverlap_BrokenBodySwallowsError: a called function whose own body +// fails to analyze (a construct the server refuses at CREATE time, here an unterminated +// FUNCTION with no RETURN) cannot be asked whether it writes an overlapping table -- +// checkCalledRoutineOverlap skips it (the body's own error is reported separately, through +// dialect.Schema.Definitions, not duplicated here). +func TestCheckCalledRoutineOverlap_BrokenBodySwallowsError(t *testing.T) { + text := `-- sqlshape: mysql 8.4 +CREATE TABLE widgets ( + id INT NOT NULL PRIMARY KEY, + v INT NOT NULL +); +CREATE FUNCTION broken_fn(x INT) RETURNS INT +BEGIN + DECLARE y INT DEFAULT 0; +END; +` + s, err := schema.Load(text) + if err != nil { + t.Fatal(err) + } + // the loader mirrors the server's own CREATE-time refusal (1320, measured) as a problem + // and still loads the function + if len(s.Problems) != 1 || !strings.Contains(s.Problems[0].Message, "No RETURN found in FUNCTION broken_fn") { + t.Fatalf("schema problems: %v, want the one 1320 problem", s.Problems) + } + if _, err := Analyze(s, "SELECT v, broken_fn(v) FROM widgets WHERE id = $1"); err != nil { + t.Fatalf("got %v, want no error (the broken body is skipped, not propagated as a 1442 false positive)", err) + } +} diff --git a/check/mysql/internal/analyze/call_violations_server_test.go b/check/mysql/internal/analyze/call_violations_server_test.go new file mode 100644 index 0000000..a162e18 --- /dev/null +++ b/check/mysql/internal/analyze/call_violations_server_test.go @@ -0,0 +1,132 @@ +package analyze + +import ( + "context" + "errors" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// TestFunctionCallViolationsServer checks the checker's predicted failure modes for a +// statement calling a schema FUNCTION -- its own SIGNAL, and its own embedded write's +// schema violation -- against what mysqld actually raises, and that a function writing a +// table the statement itself references (1442) is refused the same way at execution. +func TestFunctionCallViolationsServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, callSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + s, err := schema.Load(callSchema) + if err != nil { + t.Fatal(err) + } + conn := db.Conn() + if _, err := conn.ExecContext(ctx, "INSERT INTO widgets VALUES (1, 1)"); err != nil { + t.Fatal(err) + } + + // the function's own SIGNAL + r, err := Analyze(s, "SELECT next_total($1)") + if err != nil { + t.Fatal(err) + } + if got := violationKeys(r.Violations); got != "30001 30001=TooBig" { + t.Fatalf("predicted %s, want the function's own SIGNAL", got) + } + var x float64 + execErr := conn.QueryRowContext(ctx, "SELECT next_total(?)", 2000000).Scan(&x) + var me *driver.MySQLError + if !errors.As(execErr, &me) || me.Number != 30001 || string(me.SQLState[:]) != "45000" { + t.Errorf("SELECT next_total(2000000): got %v, want 30001 (45000)", execErr) + } + if err := conn.QueryRowContext(ctx, "SELECT next_total(?)", 5).Scan(&x); err != nil { + t.Errorf("SELECT next_total(5): got %v, want no error", err) + } + + // a function writing the table the statement itself references: 1442, predicted as an + // Error rather than a Violation (the collision is certain, not a "may") + if _, err := Analyze(s, "SELECT v, bump_widget(v) FROM widgets WHERE id = $1"); err == nil { + t.Fatal("want a 1442 Error, got none") + } else if ae, ok := err.(*Error); !ok || ae.Code != 1442 { + t.Fatalf("got %v, want a 1442 Error", err) + } + rows, execErr := conn.QueryContext(ctx, "SELECT v, bump_widget(v) FROM widgets WHERE id = ?", 1) + if execErr == nil { + for rows.Next() { + } + execErr = rows.Err() + } + if !errors.As(execErr, &me) || me.Number != 1442 { + t.Errorf("SELECT v, bump_widget(v) FROM widgets: got %v, want 1442", execErr) + } +} + +// TestCallStmtViolationsServer checks a CALL's own predicted SIGNAL and result columns +// against what mysqld actually raises and returns. +func TestCallStmtViolationsServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, callSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + s, err := schema.Load(callSchema) + if err != nil { + t.Fatal(err) + } + conn := db.Conn() + + r, err := Analyze(s, "CALL do_signal($1, @out)") + if err != nil { + t.Fatal(err) + } + if got := violationKeys(r.Violations); got != "30002 30002=TooBig2" { + t.Fatalf("predicted %s, want the procedure's own SIGNAL", got) + } + _, execErr := conn.ExecContext(ctx, "CALL do_signal(?, @out)", 200) + var me *driver.MySQLError + if !errors.As(execErr, &me) || me.Number != 30002 || string(me.SQLState[:]) != "45000" { + t.Errorf("CALL do_signal(200, @out): got %v, want 30002 (45000)", execErr) + } + if _, err := conn.ExecContext(ctx, "CALL do_signal(?, @out)", 5); err != nil { + t.Errorf("CALL do_signal(5, @out): got %v, want no error", err) + } + + // the CALL's own predicted result columns match what the server actually returns + r, err = Analyze(s, "CALL report($1)") + if err != nil { + t.Fatal(err) + } + rows, err := conn.QueryContext(ctx, "CALL report(?)", 5) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + cts, err := rows.ColumnTypes() + if err != nil { + t.Fatal(err) + } + if len(cts) != len(r.Columns) { + t.Fatalf("server returns %d columns, predicted %d", len(cts), len(r.Columns)) + } + for i, ct := range cts { + if ct.Name() != r.Columns[i].Name { + t.Errorf("column %d: server name %q, predicted %q", i, ct.Name(), r.Columns[i].Name) + } + } +} diff --git a/check/mysql/internal/analyze/checkoption_test.go b/check/mysql/internal/analyze/checkoption_test.go new file mode 100644 index 0000000..92f772f --- /dev/null +++ b/check/mysql/internal/analyze/checkoption_test.go @@ -0,0 +1,93 @@ +package analyze + +import ( + "context" + "errors" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +const checkOptionSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE t ( + id INT NOT NULL PRIMARY KEY, + tenant_id INT NOT NULL, + v INT NOT NULL +); +CREATE VIEW v1 AS SELECT id, tenant_id, v FROM t WHERE tenant_id = 1; +CREATE VIEW v2 AS SELECT id, tenant_id, v FROM v1 WHERE v > 0 WITH CASCADED CHECK OPTION; +CREATE VIEW v2local AS SELECT id, tenant_id, v FROM v1 WHERE v > 0 WITH LOCAL CHECK OPTION; +CREATE VIEW v2plain AS SELECT id, tenant_id, v FROM v1 WHERE v > 0; +` + +// TestCheckOptionViolations1369 predicts and then measures, against a real mysqld, the +// 1369 (ER_VIEW_CHECK_FAILED) an UPDATE through a WITH CHECK OPTION view may hit: present +// for CASCADED and LOCAL (each can fail its own WHERE, v > 0), absent for a plain view with +// no CHECK OPTION at all (v2plain lets the row go invisible silently -- MySQL's own +// documented behavior for a view without the clause). +func TestCheckOptionViolations1369(t *testing.T) { + s, err := schema.Load(checkOptionSchema) + if err != nil { + t.Fatal(err) + } + cases := []struct { + sql string + wantCode int + }{ + {`UPDATE v2 SET v = $1 WHERE id = $2`, 1369}, + {`UPDATE v2local SET v = $1 WHERE id = $2`, 1369}, + {`UPDATE v2plain SET v = $1 WHERE id = $2`, 0}, + } + for _, c := range cases { + r, aerr := Analyze(s, c.sql) + if aerr != nil { + t.Fatalf("%s: %v", c.sql, aerr) + } + found := false + for _, v := range r.Violations { + if v.Code == 1369 { + found = true + } + } + if found != (c.wantCode == 1369) { + t.Errorf("%s: predicted 1369 = %v, want %v (violations: %+v)", c.sql, found, c.wantCode == 1369, r.Violations) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, checkOptionSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + if _, err := conn.ExecContext(ctx, `INSERT INTO t (id, tenant_id, v) VALUES (1, 1, 5)`); err != nil { + t.Fatal(err) + } + // v > 0 is the CHECK OPTION'd predicate on both v2 and v2local; setting v to -1 fails + // it either way (a LOCAL check option still re-checks its own WHERE, only not an + // underlying view's). + for _, sql := range []string{ + `UPDATE v2 SET v = -1 WHERE id = 1`, + `UPDATE v2local SET v = -1 WHERE id = 1`, + } { + _, execErr := conn.ExecContext(ctx, sql) + var me *driver.MySQLError + if !errors.As(execErr, &me) || me.Number != 1369 { + t.Errorf("%s: want 1369 (ER_VIEW_CHECK_FAILED), got %v", sql, execErr) + } + } + // v2plain has no CHECK OPTION: the server lets the row go invisible through it + // (documented MySQL behavior), no error + if _, err := conn.ExecContext(ctx, `UPDATE v2plain SET v = -1 WHERE id = 1`); err != nil { + t.Errorf("UPDATE v2plain SET v = -1: want no error (no CHECK OPTION declared), got %v", err) + } +} diff --git a/check/mysql/internal/analyze/convert.go b/check/mysql/internal/analyze/convert.go new file mode 100644 index 0000000..a38b754 --- /dev/null +++ b/check/mysql/internal/analyze/convert.go @@ -0,0 +1,202 @@ +package analyze + +// A comparison of a DATE / DATETIME / TIMESTAMP value with a constant string converts the +// string when the comparator is set up (Arg_comparator::set_cmp_func caching the constant, +// get_date_from_string): a string that does not parse as a datetime under the session's +// sql_mode fails the statement wherever the comparison sits -- a WHERE, a select item, a +// JOIN's ON, a HAVING, an ORDER BY or GROUP BY item, a derived table's or CTE's query, a +// branch a constant decides against -- whatever the statement kind and however the tables +// look (measured: SELECT 1 FROM tt WHERE dt = 'abc' fails over an empty, unindexed table). +// The error is 1525 "Incorrect DATETIME value", or, in a strict write against a plain +// column, the store-shaped 1292 "Incorrect datetime value: ... for column ... at row 1". +// A string that parses but carries a time zone displacement over a zero month or day fails +// the displacement conversion instead, whatever the mode: 1292 "Truncated incorrect +// temporal value", the datetime respelt without its fraction and the displacement's hour +// unpadded (measured: '2020-00-01 00:00:00.123456+00:00' -> '2020-00-01 00:00:00+0:00'). +// BETWEEN and IN do not convert eagerly (measured: both run over 'abc'). +// +// A comparison, BETWEEN or IN of a numeric column with a constant string that holds no +// number ('' aside, which is 0 silently) is the warning 1292 "Truncated incorrect DOUBLE +// value", an error only in a strict write, eagerly too (measured: UPDATE ... WHERE i = +// '1invalid' fails over an empty table; the SELECT runs; '1e1', '1.5', ' 1' all pass). +// +// The same escalation reads a constant string operand of a bit operator as a whole +// integer: junk or '' is 1292 "Truncated incorrect INTEGER value" (measured: UPDATE ... +// WHERE (c IS NULL) >> ('') fails over an empty table). +// +// Not read: a scalar subquery's constant (SELECT 'abc') as the compared string (the server +// folds it; this file does not), a TIME or YEAR column's own per-row string conversion +// (TIME parses per row; YEAR compares as a number and falls under the numeric rule), a +// numeric expression that is not a plain column, and anything inside a trigger's or +// routine's body. + +import ( + "fmt" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlast" +) + +// compareConstString judges the operands of one comparison (kind "cmp"), a BETWEEN or an +// IN: the numeric rule alone applies to the latter two, and a BETWEEN's conversion runs +// per row (measured: over an empty table it passes; over a row it fails), so its hit is +// the violation 1292, where a comparison's and an IN's are the statement's error. +func (a *analyzer) compareConstString(sc scope, args []mysqlast.Value, ts []typed, kind string) *Error { + if a.routine != nil || a.trig != nil { + return nil + } + for i, t := range ts { + if !t.known { + continue + } + switch t.typ.Name { + case "date", "datetime", "timestamp": + if kind != "cmp" { + continue + } + for j, arg := range args { + if j == i { + continue + } + if e := a.temporalCompared(sc, args[i], t.typ.Name, arg); e != nil { + return e + } + } + case "tinyint", "smallint", "mediumint", "int", "bigint", "decimal", "double", "float", "year": + if !a.strictWriteStmt() { + continue + } + if _, ok := a.comparedColumn(sc, args[i]); !ok { + continue + } + for j, arg := range args { + if j == i { + continue + } + s, at, ok := constString(arg) + if !ok || s == "" { + continue + } + if _, tail, good := numericString(s); !good || tail { + if kind == "between" { + table := "" + if a.write != nil && a.write.table != nil { + table = a.write.table.Name + } + a.storeRaised = append(a.storeRaised, Violation{Code: code1292, Constraint: itoa(code1292), Table: table, SQLState: constraintSQLState(code1292)}) + return nil + } + return &Error{Message: fmt.Sprintf("Truncated incorrect DOUBLE value: '%s'", s), Code: code1292, Position: a.ph.Back(at)} + } + } + } + } + return nil +} + +// temporalCompared judges one constant string compared with a date / datetime / timestamp +// value of type typName. +func (a *analyzer) temporalCompared(sc scope, side mysqlast.Value, typName string, arg mysqlast.Value) *Error { + s, at, ok := constString(arg) + if !ok { + return nil + } + var tm mysqlTime + var warn int + if !strToDatetime(s, a.dateFlags("date"), &tm, &warn) || warn&(timeWarnTruncated|timeWarnOutOfRange|timeWarnZeroDate|timeWarnZeroInDate) != 0 { + if a.strictWriteStmt() { + if name, ok := a.comparedColumn(sc, side); ok { + return &Error{Message: fmt.Sprintf("Incorrect %s value: '%s' for column '%s' at row 1", temporalWord(typName), s, name), Code: code1292, Position: a.ph.Back(at)} + } + } + return &Error{Message: fmt.Sprintf("Incorrect %s value: '%s'", strings.ToUpper(typName), s), Code: 1525, Position: a.ph.Back(at)} + } + if tm.hasTZ && (tm.month == 0 || tm.day == 0) { + return &Error{Message: fmt.Sprintf("Truncated incorrect temporal value: '%s'", displacedSpelling(tm)), Code: code1292, Position: a.ph.Back(at)} + } + return nil +} + +// displacedSpelling respells a displaced datetime the way the conversion's error message +// does: no fraction, the displacement's hour unpadded. +func displacedSpelling(tm mysqlTime) string { + off := tm.tzOffset + sign := "+" + if off < 0 { + sign = "-" + off = -off + } + return fmt.Sprintf("%04d-%02d-%02d %02d:%02d:%02d%s%d:%02d", + tm.year, tm.month, tm.day, tm.hour, tm.minute, tm.second, sign, off/3600, off%3600/60) +} + +// bitOpStringOperand judges the constant string operands of a bit operator, landing the +// failure the way constCheck does: the statement's error in a condition or an INSERT's +// VALUES, the violation 1292 where the expression runs per row. +func (a *analyzer) bitOpStringOperand(sc scope, n *mysqlast.Node, where string) *Error { + if !a.strictWriteStmt() || a.noFold > 0 { + return nil + } + for _, arg := range exprArgs(n) { + s, at, ok := constString(arg) + if !ok { + continue + } + if _, good := integerString(s); good { + continue + } + perRow := a.foldPerRow && !condContext(where) + if perRow { + table := "" + if a.write != nil && a.write.table != nil { + table = a.write.table.Name + } + a.storeRaised = append(a.storeRaised, Violation{Code: code1292, Constraint: itoa(code1292), Table: table, SQLState: constraintSQLState(code1292)}) + return nil + } + return &Error{Message: fmt.Sprintf("Truncated incorrect INTEGER value: '%s'", s), Code: code1292, Position: a.ph.Back(at)} + } + return nil +} + +// comparedColumn resolves a comparison operand to its plain column's name, unwrapping the +// expression markers. +func (a *analyzer) comparedColumn(sc scope, v mysqlast.Value) (string, bool) { + n, ok := unwrapExpr(v) + if !ok || !isColumnRef(n) { + return "", false + } + ref, ok := a.plainColumn(sc, n) + if !ok || ref.col == nil { + return "", false + } + return ref.col.Name, true +} + +// constString is a constant string literal's text, unwrapping PTI_udf_expr and COLLATE. +func constString(v mysqlast.Value) (s string, at int, ok bool) { + n, good := unwrapExpr(v) + if !good { + return "", 0, false + } + s, good = stringLiteral(n) + return s, n.Start, good +} + +// unwrapExpr strips PTI_udf_expr and Item_func_set_collation wrappers. +func unwrapExpr(v mysqlast.Value) (*mysqlast.Node, bool) { + for { + n, ok := v.(*mysqlast.Node) + if !ok { + return nil, false + } + switch n.Class { + case "PTI_udf_expr": + v = n.Arg("expr") + case "Item_func_set_collation": + v = n.Arg("a") + default: + return n, true + } + } +} diff --git a/check/mysql/internal/analyze/convert_server_test.go b/check/mysql/internal/analyze/convert_server_test.go new file mode 100644 index 0000000..4b888f9 --- /dev/null +++ b/check/mysql/internal/analyze/convert_server_test.go @@ -0,0 +1,545 @@ +package analyze + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// The schema the conversion cases run against, on the server and in the checker alike. +const convertSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE tt ( + i INT NOT NULL, + d DATE, + dt DATETIME, + tm TIME, + ts TIMESTAMP NULL, + ts6 TIMESTAMP(6) NULL, + y YEAR, + c3 CHAR(3), + vc VARCHAR(10), + dbl DOUBLE +); +CREATE TABLE ttk ( + i INT NOT NULL PRIMARY KEY, + ts TIMESTAMP NULL, + KEY (ts) +); +` + +// convertCases are pinned against mysqld over empty tables under the default (strict) +// mode: want is the server's error number, 0 when the statement runs. For a 1292 or a 1525 +// the checker must raise the same code and message. +var convertCases = []struct { + sql string + want int +}{ + // a constant CAST to DATE / DATETIME the value does not survive fails a strict write + // and runs anywhere else + {"INSERT INTO tt (d) VALUES (CAST('2004-10-0' AS DATE))", 1292}, + {"INSERT INTO tt (d) VALUES (CONVERT('2004-10-0', DATE))", 1292}, + {"INSERT INTO tt (vc) VALUES (CAST('2004-10-0' AS DATE))", 1292}, + {"INSERT INTO tt (i, d) VALUES (1, CAST('2004-10-01' AS DATE))", 0}, + {"INSERT INTO tt (i, dt) VALUES (1, CAST('2004-10-01 15:30' AS DATETIME))", 0}, + {"INSERT INTO tt (d) VALUES (CAST('0000-00-00' AS DATE))", 1292}, + {"INSERT INTO tt (i, dt) VALUES (1, CAST('0000-10-31 15:30' AS DATETIME))", 0}, + {"INSERT INTO tt (dt) VALUES (CAST('2004-0-10 15:30' AS DATETIME))", 1292}, + {"INSERT INTO tt (i, d) VALUES (1, CAST('2020-00-01' AS DATE))", 1292}, + {"INSERT INTO tt (i, dt) VALUES (1, CAST('0000-01-01' AS DATETIME))", 0}, + {"INSERT INTO tt (dt) VALUES (CAST(65 AS DATETIME))", 1292}, + {"INSERT INTO tt (d) VALUES (CAST(20041000 AS DATE))", 1292}, + {"INSERT INTO tt (i) VALUES (CAST('2004-10-0' AS DATE) IS NULL)", 1292}, + {"INSERT INTO tt (i) VALUES (IF(1, 1, CAST('2004-10-0' AS DATE)))", 0}, + {"INSERT INTO tt (i) VALUES (IF(0, 1, CAST('2004-10-0' AS DATE)))", 1292}, + {"SELECT CAST('2004-10-0' AS DATE)", 0}, + {"SELECT 1 FROM tt WHERE CAST('2004-10-0' AS DATE)", 0}, + {"DELETE FROM tt WHERE CAST('2004-10-0' AS DATE)", 1292}, + // TIME and YEAR + {"INSERT INTO tt (tm) VALUES (CAST('abc' AS TIME))", 1292}, + {"INSERT INTO tt (tm) VALUES (CAST('839:00:00' AS TIME))", 1292}, + {"INSERT INTO tt (tm) VALUES (CAST(8385960 AS TIME))", 1292}, + {"INSERT INTO tt (i, tm) VALUES (1, CAST('10:00:00' AS TIME))", 0}, + {"SELECT CAST('abc' AS TIME)", 0}, + {"INSERT INTO tt (y) VALUES (CAST('2020extra' AS YEAR))", 1292}, + {"INSERT INTO tt (y) VALUES (CAST(20201 AS YEAR))", 1292}, + {"INSERT INTO tt (i, y) VALUES (1, CAST(2155 AS YEAR))", 0}, + {"INSERT INTO tt (y) VALUES (CAST(2156 AS YEAR))", 1292}, + {"INSERT INTO tt (y) VALUES (CAST(-1 AS YEAR))", 1292}, + {"INSERT INTO tt (y) VALUES (CAST(100 AS YEAR))", 1292}, + {"INSERT INTO tt (y) VALUES (CAST(1900 AS YEAR))", 1292}, + {"INSERT INTO tt (i, y) VALUES (1, CAST(1901 AS YEAR))", 0}, + {"INSERT INTO tt (i, y) VALUES (1, CAST(0 AS YEAR))", 0}, + {"INSERT INTO tt (i, y) VALUES (1, CAST(99 AS YEAR))", 0}, + {"INSERT INTO tt (i, y) VALUES (1, CAST('abc' AS YEAR))", 0}, + {"INSERT INTO tt (i, y) VALUES (1, CAST('' AS YEAR))", 0}, + {"INSERT INTO tt (y) VALUES (CAST('2020.5' AS YEAR))", 1292}, + {"INSERT INTO tt (i, y) VALUES (1, CAST(2020.6 AS YEAR))", 0}, + {"INSERT INTO tt (i, y) VALUES (1, CAST(' 2020' AS YEAR))", 0}, + // CHAR(n) + {"INSERT INTO tt (c3) VALUES (CAST(1000 AS CHAR(3)))", 1292}, + {"INSERT INTO tt (vc) VALUES (CAST(1000 AS CHAR(3)))", 1292}, + {"INSERT INTO tt (c3) VALUES (CAST(1000.0 AS CHAR(3)))", 1292}, + {"INSERT INTO tt (c3) VALUES (CAST(1000E+0 AS CHAR(3)))", 1292}, + {"INSERT INTO tt (c3) VALUES (CAST('1000' AS CHAR(3)))", 1292}, + {"INSERT INTO tt (i, c3) VALUES (1, CAST('abcd' AS CHAR(3)))", 1292}, + {"INSERT INTO tt (i, c3) VALUES (1, CAST(100 AS CHAR(3)))", 0}, + {"INSERT INTO tt (i, c3) VALUES (1, CAST(NULL AS CHAR(3)))", 0}, + {"SELECT CAST(1000 AS CHAR(3))", 0}, + // SIGNED / UNSIGNED / DOUBLE / DECIMAL from a string + {"INSERT INTO tt (i) VALUES (CAST('abc' AS SIGNED))", 1292}, + {"INSERT INTO tt (i) VALUES (CAST('10a' AS UNSIGNED))", 1292}, + {"INSERT INTO tt (i) VALUES (CAST('' AS SIGNED))", 1292}, + {"INSERT INTO tt (i) VALUES (CAST(' 10' AS SIGNED))", 0}, + {"INSERT INTO tt (i) VALUES (CAST('10 ' AS SIGNED))", 0}, + {"INSERT INTO tt (i) VALUES (CAST('1.5' AS SIGNED))", 1292}, + {"INSERT INTO tt (i) VALUES (CAST('1e2' AS SIGNED))", 1292}, + {"INSERT INTO tt (i) VALUES (CAST('99999999999999999999' AS SIGNED))", 1292}, + {"INSERT INTO tt (i) VALUES (CAST('18446744073709551616' AS UNSIGNED))", 1292}, + {"INSERT INTO tt (i) VALUES (CAST(99999999999999999999.0 AS SIGNED))", 1292}, + {"SELECT CAST('abc' AS SIGNED)", 0}, + {"INSERT INTO tt (dbl) VALUES (CAST('abc' AS DOUBLE))", 1292}, + {"INSERT INTO tt (dbl) VALUES (CAST('1e999' AS DOUBLE))", 1292}, + {"INSERT INTO tt (dbl) VALUES (CAST('abc' AS DECIMAL(5,2)))", 1292}, + // a string operand of an arithmetic or bit operator + {"INSERT INTO tt (dbl) VALUES (10E+0 + 'a')", 1292}, + {"INSERT INTO tt (dbl) VALUES (10 + '1a')", 1292}, + {"INSERT INTO tt (i, dbl) VALUES (1, 10 + '1')", 0}, + {"INSERT INTO tt (i, dbl) VALUES (1, 10 + ' 1')", 0}, + {"INSERT INTO tt (i, dbl) VALUES (1, 10 + '1 ')", 0}, + {"SELECT 10E+0 + 'a'", 0}, + {"INSERT INTO tt (i) VALUES (1 >> '')", 1292}, + {"INSERT INTO tt (i) VALUES (1 >> 'a')", 1292}, + {"UPDATE tt SET i = 1 WHERE (i IS NULL) >> ('')", 1292}, + {"UPDATE tt SET i = 1 WHERE (i IS NULL) >> ('' COLLATE utf8mb4_0900_ai_ci)", 1292}, + // a numeric column compared with a constant string holding no number + {"UPDATE tt SET i = 1 WHERE i = '1invalid'", 1292}, + {"UPDATE tt SET i = 1 WHERE i = 'abc'", 1292}, + {"UPDATE tt SET i = 1 WHERE dbl > '1invalid'", 1292}, + {"UPDATE tt SET i = 1 WHERE y = 'abc'", 1292}, + {"DELETE FROM tt WHERE i = '1invalid'", 1292}, + {"INSERT INTO ttk (i) SELECT i FROM tt WHERE i = '1invalid'", 1292}, + {"UPDATE tt SET i = 1 WHERE i = ''", 0}, + {"UPDATE tt SET i = 1 WHERE i = ' 1'", 0}, + {"UPDATE tt SET i = 1 WHERE i = '1e1'", 0}, + {"UPDATE tt SET i = 1 WHERE i = '1.5'", 0}, + {"UPDATE tt SET i = 1 WHERE vc = '1invalid'", 0}, + {"UPDATE tt SET i = 1 WHERE i IN ('abc', '1')", 1292}, + {"SELECT 1 FROM tt WHERE i = '1invalid'", 0}, + // a DATE / DATETIME / TIMESTAMP value compared with a constant string that is no + // datetime: the statement's error wherever the comparison sits, whatever the statement + {"SELECT 1 FROM tt WHERE dt = 'abc'", 1525}, + {"SELECT 1 FROM tt WHERE d = '2020-00-01'", 1525}, + {"SELECT 1 FROM tt WHERE d = '0000-00-00'", 1525}, + {"SELECT 1 FROM tt WHERE dt > 'abc'", 1525}, + {"SELECT 1 FROM tt WHERE dt BETWEEN 'abc' AND '2020-01-01'", 0}, + {"SELECT 1 FROM tt WHERE dt IN ('abc', '2020-01-01')", 0}, + {"SELECT 1 FROM tt WHERE tm = 'abc'", 0}, + {"SELECT 1 FROM tt WHERE ts = '2020-01-01 00:00:00+99:00'", 1525}, + {"SELECT 1 FROM tt WHERE dt = '2020-01-32'", 1525}, + {"SELECT 1 FROM tt WHERE dt = 65", 0}, + {"SELECT 1 FROM tt WHERE 1 = 0 AND dt = 'abc'", 1525}, + {"SELECT 1 FROM tt WHERE 'abc' = dt", 1525}, + {"SELECT 1 FROM tt WHERE dt <=> 'abc'", 1525}, + {"SELECT 1 FROM tt WHERE dt != 'abc'", 1525}, + {"SELECT dt = 'abc' FROM tt", 1525}, + {"SELECT 1 FROM tt HAVING MAX(dt) = 'abc'", 1525}, + {"SELECT 1 FROM tt a JOIN ttk b ON a.dt = 'abc'", 1525}, + {"SELECT 1 FROM (SELECT 1 AS x FROM tt WHERE dt = 'abc') q", 1525}, + {"WITH q AS (SELECT 1 AS x FROM tt WHERE dt = 'abc') SELECT 1 FROM q", 1525}, + {"SELECT 1 FROM tt WHERE NOT (dt = 'abc')", 1525}, + {"SELECT 1 FROM tt WHERE dt = 'abc' OR 1 = 1", 1525}, + {"SELECT 1 FROM tt WHERE CASE WHEN 1 = 0 THEN dt = 'abc' ELSE 0 END", 1525}, + {"SELECT 1 FROM tt ORDER BY dt = 'abc'", 1525}, + {"SELECT 1 FROM tt GROUP BY dt = 'abc'", 1525}, + {"SELECT 1 FROM tt WHERE EXISTS (SELECT 1 FROM ttk WHERE tt.dt = 'abc')", 1525}, + {"SELECT 1 FROM tt WHERE d >= 'abc'", 1525}, + {"SELECT 1 FROM tt WHERE ts = 'abc'", 1525}, + {"SELECT 1 FROM tt WHERE ts = '2038-01-19 03:14:08+00:00'", 0}, + // in a strict write the same failure wears the store's message + {"UPDATE tt SET i = 1 WHERE dt = 'abc'", 1292}, + {"UPDATE tt SET i = 1 WHERE d = '2020-00-01'", 1292}, + {"UPDATE tt SET i = 1 WHERE d = 'abc'", 1292}, + {"UPDATE tt SET i = 1 WHERE i = 1 AND dt = 'abc'", 1292}, + {"INSERT INTO ttk (i) SELECT i FROM tt WHERE dt = 'abc'", 1292}, + // a year 0 over a real month and day is out of the TIMESTAMP range + {"INSERT INTO tt (i, ts) VALUES (1, '0000-10-31 15:30:00')", 1292}, + {"INSERT INTO tt (i, dt) VALUES (1, '0000-10-31 15:30:00')", 0}, + // TIMESTAMP() of a constant that is no datetime + {"INSERT INTO tt (i, dt) VALUES (1, TIMESTAMP('0000-00-00 10:00:00'))", 1292}, + {"INSERT INTO tt (i, dt) VALUES (1, TIMESTAMP('2019-01-01 00:00:71'))", 1292}, + {"INSERT INTO tt (i, dt) VALUES (1, TIMESTAMP('abc'))", 1292}, + {"INSERT INTO tt (i, dt) VALUES (1, TIMESTAMP(65))", 1292}, + {"INSERT INTO tt (i, dt) VALUES (1, TIMESTAMP('2020-01-01'))", 0}, + {"INSERT INTO tt (i, dt) VALUES (1, TIMESTAMP('2020-01-01', '25:00:00'))", 0}, + {"SELECT TIMESTAMP('18:16:35.025453')", 0}, + {"DELETE FROM tt WHERE TIMESTAMP('18:16:35.025453') IS NULL", 1292}, +} + +// convertCodeOnly are pinned by code alone: a TIMESTAMP literal out of the column's UTC +// range, whose server message spells the value converted to the session's time zone, which +// the checker does not know. +var convertCodeOnly = []struct { + sql string + want int +}{ + {"INSERT INTO tt (i, ts) VALUES (1, TIMESTAMP'2038-01-19 03:14:08+00:00')", 1292}, + {"INSERT INTO tt (i, ts) VALUES (1, TIMESTAMP'2038-01-19 03:14:07+00:00')", 0}, + {"INSERT INTO tt (i, ts) VALUES (1, TIMESTAMP'1970-01-01 00:00:00+00:00')", 1292}, + {"INSERT INTO tt (i, ts) VALUES (1, TIMESTAMP'1970-01-01 00:00:01+00:00')", 0}, + {"INSERT INTO tt (i, ts6) VALUES (1, TIMESTAMP'2038-01-19 03:14:07.999999+00:00')", 0}, + // the fraction rounds to the column's precision before the range check + {"INSERT INTO tt (i, ts) VALUES (1, TIMESTAMP'1970-01-01 00:00:00.999999+00:00')", 0}, + {"INSERT INTO tt (i, ts) VALUES (1, TIMESTAMP'1970-01-01 00:00:00.000001+00:00')", 1292}, + {"INSERT INTO tt (i, ts) VALUES (1, TIMESTAMP'2038-01-19 03:14:07.999999+00:00')", 1292}, + {"INSERT INTO tt (i, ts6) VALUES (1, TIMESTAMP'1970-01-01 00:00:00.999999+00:00')", 1292}, + {"INSERT INTO tt (i, ts) VALUES (1, TIMESTAMP'2038-01-19 12:14:07+09:00')", 0}, + {"INSERT INTO tt (i, ts) VALUES (1, TIMESTAMP'2040-01-01 00:00:00')", 1292}, + {"INSERT INTO tt (i, ts) VALUES (1, DATE'2020-01-01')", 0}, + {"INSERT INTO tt (i, dt) VALUES (1, TIMESTAMP'9999-12-31 23:59:59+00:00')", 0}, + {"INSERT INTO tt (i, ts) VALUES (1, TIMESTAMP'0000-10-31 15:30:00')", 1292}, +} + +// convertTZEdge is the one displaced literal whose error number depends on the session's +// time zone (measured on 8.4.11): rounding .999999 to TIMESTAMP(0) carries past the +// DATETIME maximum. A session at exactly +00:00 overflows in the rounding itself (1441 +// "Datetime function: datetime field overflow"); at any other offset the literal converts +// first -- an eastern session wraps past year 9999 to year 0, a western one lands inside +// the range -- and the store then fails the TIMESTAMP range (1292). The checker does not +// know the session time zone and keeps the range verdict, 1292. +var convertTZEdge = []string{ + "INSERT INTO tt (i, ts) VALUES (1, TIMESTAMP'9999-12-31 23:59:59.999999+00:00')", +} + +// convertInvalidDatesCases run under STRICT_ALL_TABLES,ALLOW_INVALID_DATES: a DATETIME +// takes an invalid calendar date, a TIMESTAMP still refuses it (a real moment), and a +// year 0 over a real month and day is out of the TIMESTAMP range. +var convertInvalidDatesCases = []struct { + sql string + want int +}{ + {"INSERT INTO tt (i, ts) VALUES (1, '2004-02-30 15:30:04')", 1292}, + {"INSERT INTO tt (i, dt) VALUES (1, '2004-02-30 15:30:04')", 0}, + {"INSERT INTO tt (i, d) VALUES (1, '2004-02-30')", 0}, + {"INSERT INTO tt (i, ts) VALUES (1, '2004-0-31 15:30:00')", 1292}, + {"INSERT INTO tt (i, ts) VALUES (1, '0000-10-31 15:30:00')", 1292}, + {"INSERT INTO tt (i, dt) VALUES (1, '0000-10-31 15:30:00')", 0}, +} + +const invalidDatesHeader = "-- sqlshape: mysql 8.4\n-- sqlshape: server sql_mode = 'STRICT_ALL_TABLES,ALLOW_INVALID_DATES'\n" + +// convertPerRow run their failing expression per row: the server (over empty tables) +// accepts them, the checker lists the violation 1292. +var convertPerRow = []string{ + "UPDATE tt SET d = CAST('2004-10-0' AS DATE)", + "UPDATE tt SET vc = CAST('2004-10-0' AS DATE)", + "UPDATE tt SET c3 = CAST(1000 AS CHAR(3))", + "UPDATE tt SET i = CAST('abc' AS SIGNED)", + "DELETE FROM tt WHERE d = CAST('2004-10-0' AS DATE)", + "UPDATE tt SET i = 1 WHERE d = CAST('2004-10-0' AS DATE)", + "INSERT INTO tt (i) SELECT CAST('abc' AS SIGNED) FROM tt", + "INSERT INTO ttk (i) VALUES (1) ON DUPLICATE KEY UPDATE ts = CAST('2004-10-0' AS DATE)", + "UPDATE tt SET ts = TIMESTAMP'2038-01-19 03:14:08+00:00'", + "UPDATE tt SET i = i >> ''", + "UPDATE tt SET i = 1 WHERE i BETWEEN 'abc' AND '9'", +} + +// convertUnclaimed fail on the server but the checker stays silent: documented ceilings +// (a scalar subquery's constant, a TIME column's per-row conversion misses the eager +// escalation this file models). +var convertUnclaimed = []struct { + sql string + want int +}{ + {"SELECT 1 FROM tt WHERE dt = (SELECT 'abc')", 1525}, +} + +// convertNonStrictCases run under sql_mode = 'NO_ENGINE_SUBSTITUTION' (no strict mode): +// the write escalations vanish, the comparison conversion stays. +var convertNonStrictCases = []struct { + sql string + want int +}{ + {"INSERT INTO tt (d) VALUES (CAST('2004-10-0' AS DATE))", 0}, + {"INSERT INTO tt (c3) VALUES (CAST(1000 AS CHAR(3)))", 0}, + {"INSERT INTO tt (i) VALUES (CAST('abc' AS SIGNED))", 0}, + {"INSERT INTO tt (dbl) VALUES (10E+0 + 'a')", 0}, + {"UPDATE tt SET i = 1 WHERE i = '1invalid'", 0}, + {"INSERT INTO tt (ts) VALUES (TIMESTAMP'2038-01-19 03:14:08+00:00')", 0}, + {"INSERT INTO tt (y) VALUES (CAST(20201 AS YEAR))", 0}, + {"SELECT 1 FROM tt WHERE dt = 'abc'", 1525}, + {"UPDATE tt SET i = 1 WHERE dt = 'abc'", 1525}, + {"SELECT TIMESTAMP '2020-00-01 08:00:00.123456+00:00'", 1292}, + {"SELECT 1 FROM tt WHERE ts = '2020-00-01 00:00:00.123456+00:00'", 1292}, + {"SELECT DATE '2020-00-01'", 0}, + {"SELECT TIMESTAMP('0000-00-00 10:00:00')", 0}, + {"INSERT INTO tt (i, dt) VALUES (1, TIMESTAMP('0000-00-00 10:00:00'))", 0}, +} + +// convertZeroInDateCases run under the default mode minus NO_ZERO_IN_DATE (strict, zero +// month / day allowed): the displacement conversion still refuses a zero month or day. +var convertZeroInDateCases = []struct { + sql string + want int +}{ + {"SELECT TIMESTAMP '2020-00-01 08:00:00.123456+00:00'", 1292}, + {"SELECT TIMESTAMP '2020-01-00 08:00:00.123456+00:00'", 1292}, + {"INSERT INTO tt (i, dt) VALUES (1, '2020-00-01 00:00:00.123456+00:00')", 1292}, + {"INSERT INTO tt (i, dt) VALUES (1, '2020-01-00 00:00:00.123456+00:00')", 1292}, + {"SELECT 1 FROM tt WHERE dt = '2020-00-01 00:00:00.123456+00:00'", 1292}, + {"SELECT 1 FROM tt WHERE dt = '2020-01-00 00:00:00.123456+00:00'", 1292}, + {"INSERT INTO tt (i, d) VALUES (1, CAST('2020-00-01' AS DATE))", 0}, + {"SELECT DATE '2020-00-01'", 0}, +} + +const nonStrictHeader = "-- sqlshape: mysql 8.4\n-- sqlshape: server sql_mode = 'NO_ENGINE_SUBSTITUTION'\n" +const zeroInDateHeader = "-- sqlshape: mysql 8.4\n-- sqlshape: server sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'\n" + +func TestConvertServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, convertSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + run := func(sql string) (int, string) { + _, err := conn.ExecContext(ctx, sql) + conn.ExecContext(ctx, "DELETE FROM tt") + conn.ExecContext(ctx, "DELETE FROM ttk") + if err == nil { + return 0, "" + } + var me *driver.MySQLError + if !errors.As(err, &me) { + t.Errorf("%s: %v", sql, err) + return -1, "" + } + return int(me.Number), me.Message + } + s := loadConvert(t, convertSchema) + for _, c := range convertCases { + got, msg := run(c.sql) + if got != c.want && got >= 0 { + t.Errorf("%s: the server says %d %s, the checker %d", c.sql, got, msg, c.want) + } + if c.want != 1292 && c.want != 1525 { + continue + } + _, err := Analyze(s, c.sql) + var e *Error + if !errors.As(err, &e) || e.Code != c.want { + t.Errorf("%s: analyzer %v, want %d", c.sql, err, c.want) + continue + } + if e.Message != msg { + t.Errorf("%s: analyzer says %q, the server %q", c.sql, e.Message, msg) + } + } + for _, c := range convertCodeOnly { + got, msg := run(c.sql) + if got != c.want && got >= 0 { + t.Errorf("%s: the server says %d %s, the checker %d", c.sql, got, msg, c.want) + } + _, err := Analyze(s, c.sql) + if c.want == 0 { + if err != nil { + t.Errorf("%s: analyzer %v, want none", c.sql, err) + } + continue + } + var e *Error + if !errors.As(err, &e) || e.Code != c.want { + t.Errorf("%s: analyzer %v, want %d", c.sql, err, c.want) + } + } + for _, sql := range convertTZEdge { + got, msg := run(sql) + if got != 1292 && got != 1441 && got >= 0 { + t.Errorf("%s: the server says %d %s, want 1292 or 1441", sql, got, msg) + } + _, err := Analyze(s, sql) + var e *Error + if !errors.As(err, &e) || e.Code != 1292 { + t.Errorf("%s: analyzer %v, want 1292", sql, err) + } + } + for _, sql := range convertPerRow { + if got, msg := run(sql); got != 0 && got >= 0 { + t.Errorf("%s over no rows: the server says %d %s, want 0", sql, got, msg) + } + } + for _, c := range convertUnclaimed { + if got, msg := run(c.sql); got != c.want && got >= 0 { + t.Errorf("%s: the server says %d %s, the note claims %d", c.sql, got, msg, c.want) + } + } + // with a row, the per-row constants fail as the violations say + if _, err := conn.ExecContext(ctx, "INSERT INTO tt (i, d, dt, tm, ts, y, c3, vc, dbl) VALUES (1, '2020-01-01', '2020-01-01 00:00:00', '10:00:00', '2020-01-01 00:00:00', 2020, 'ab', 'x', 1.5)"); err != nil { + t.Fatal(err) + } + for _, sql := range []string{ + "UPDATE tt SET d = CAST('2004-10-0' AS DATE)", + "UPDATE tt SET c3 = CAST(1000 AS CHAR(3))", + "UPDATE tt SET i = CAST('abc' AS SIGNED)", + "UPDATE tt SET ts = TIMESTAMP'2038-01-19 03:14:08+00:00'", + "UPDATE tt SET i = i >> ''", + "UPDATE tt SET i = 1 WHERE i BETWEEN 'abc' AND '9'", + } { + if _, err := conn.ExecContext(ctx, sql); err == nil { + t.Errorf("%s over a row: the server accepts, want 1292", sql) + } else { + var me *driver.MySQLError + if errors.As(err, &me) && me.Number != 1292 { + t.Errorf("%s over a row: the server says %d, want 1292", sql, me.Number) + } + } + } + if _, err := conn.ExecContext(ctx, "DELETE FROM tt"); err != nil { + t.Fatal(err) + } + // the non-strict mode drops the write escalations + if _, err := conn.ExecContext(ctx, "SET sql_mode = 'NO_ENGINE_SUBSTITUTION'"); err != nil { + t.Fatal(err) + } + ns := loadConvert(t, strings.Replace(convertSchema, "-- sqlshape: mysql 8.4\n", nonStrictHeader, 1)) + for _, c := range convertNonStrictCases { + got, msg := run(c.sql) + if got != c.want && got >= 0 { + t.Errorf("non-strict %s: the server says %d %s, the checker %d", c.sql, got, msg, c.want) + } + _, err := Analyze(ns, c.sql) + if c.want == 0 { + if err != nil { + t.Errorf("non-strict %s: analyzer %v, want none", c.sql, err) + } + continue + } + var e *Error + if !errors.As(err, &e) || e.Code != c.want { + t.Errorf("non-strict %s: analyzer %v, want %d", c.sql, err, c.want) + continue + } + if e.Message != msg { + t.Errorf("non-strict %s: analyzer says %q, the server %q", c.sql, e.Message, msg) + } + } + // strict with ALLOW_INVALID_DATES: a TIMESTAMP ignores it + if _, err := conn.ExecContext(ctx, "SET sql_mode = 'STRICT_ALL_TABLES,ALLOW_INVALID_DATES'"); err != nil { + t.Fatal(err) + } + aid := loadConvert(t, strings.Replace(convertSchema, "-- sqlshape: mysql 8.4\n", invalidDatesHeader, 1)) + for _, c := range convertInvalidDatesCases { + got, msg := run(c.sql) + if got != c.want && got >= 0 { + t.Errorf("invalid-dates %s: the server says %d %s, the checker %d", c.sql, got, msg, c.want) + } + _, err := Analyze(aid, c.sql) + if c.want == 0 { + if err != nil { + t.Errorf("invalid-dates %s: analyzer %v, want none", c.sql, err) + } + continue + } + var e *Error + if !errors.As(err, &e) || e.Code != c.want { + t.Errorf("invalid-dates %s: analyzer %v, want %d", c.sql, err, c.want) + continue + } + if e.Message != msg { + t.Errorf("invalid-dates %s: analyzer says %q, the server %q", c.sql, e.Message, msg) + } + } + // strict with NO_ZERO_IN_DATE off: the displacement conversion still refuses zero parts + if _, err := conn.ExecContext(ctx, "SET sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'"); err != nil { + t.Fatal(err) + } + zid := loadConvert(t, strings.Replace(convertSchema, "-- sqlshape: mysql 8.4\n", zeroInDateHeader, 1)) + for _, c := range convertZeroInDateCases { + got, msg := run(c.sql) + if got != c.want && got >= 0 { + t.Errorf("zero-in-date %s: the server says %d %s, the checker %d", c.sql, got, msg, c.want) + } + _, err := Analyze(zid, c.sql) + if c.want == 0 { + if err != nil { + t.Errorf("zero-in-date %s: analyzer %v, want none", c.sql, err) + } + continue + } + var e *Error + if !errors.As(err, &e) || e.Code != c.want { + t.Errorf("zero-in-date %s: analyzer %v, want %d", c.sql, err, c.want) + continue + } + if e.Message != msg { + t.Errorf("zero-in-date %s: analyzer says %q, the server %q", c.sql, e.Message, msg) + } + } +} + +// TestConvert is the checker's side of the conversion cases without a server. +func TestConvert(t *testing.T) { + s := loadConvert(t, convertSchema) + for _, c := range convertCases { + _, err := Analyze(s, c.sql) + code := 0 + if err != nil { + e, ok := err.(*Error) + if !ok { + t.Errorf("%s: %v", c.sql, err) + continue + } + code = e.Code + } + if code != c.want { + t.Errorf("%s: got %d, want %d", c.sql, code, c.want) + } + } + for _, sql := range convertPerRow { + res, err := Analyze(s, sql) + if err != nil { + t.Errorf("%s: %v", sql, err) + continue + } + if !hasKeyedViolation(res.Violations, code1292) { + t.Errorf("%s: no violation 1292 listed", sql) + } + } + for _, c := range convertUnclaimed { + if _, err := Analyze(s, c.sql); err != nil { + t.Errorf("%s: analyzer %v, want silence (a documented ceiling)", c.sql, err) + } + } +} + +func loadConvert(t *testing.T, text string) *schema.Schema { + t.Helper() + s, err := schema.Load(text) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + return s +} + +func hasKeyedViolation(vs []Violation, code int) bool { + for _, v := range vs { + if v.Code == code && v.Key() == itoa(code) { + return true + } + } + return false +} diff --git a/check/mysql/internal/analyze/corpus_replay_test.go b/check/mysql/internal/analyze/corpus_replay_test.go new file mode 100644 index 0000000..1919851 --- /dev/null +++ b/check/mysql/internal/analyze/corpus_replay_test.go @@ -0,0 +1,921 @@ +package analyze + +// The corpus probe replays MySQL's own test corpus (mysql-test/t, the server source the +// function catalog is generated from, checked out under the sqlshape cache directory) +// against a running mysqld and the analyzer side by side, and reports every statement on +// which they disagree -- the MySQL counterpart of check/postgres/analyze's regress probe. +// Each .test file runs on a mysqld of its own (mysqltest.StartOwn, over a second per file: the +// corpus creates and drops databases by name, changes global variables and accounts, so +// files cannot share a server), connected to a fresh `test` database the way mysqltest +// connects, over one connection (so SET and USE hold); after a statement that may have +// changed the schema, the analyzer's +// schema is rebuilt from the server's own SHOW CREATE output (dump.Read) under the +// session's sql_mode, so the analyzer judges what the server holds. A SELECT's columns are +// compared by name, type family and nullability; an error by number; a statement the +// analyzer does not read (DDL, SHOW, SET ...) is tallied, not judged. +// +// The known disagreements are listed in testdata/corpus_baseline.txt; the test fails on a +// statement that joins or leaves that list (-corpus-update rewrites it after reading the +// report). Skipped without the corpus or a mysqld on PATH (nix-shell -p mysql84). +// +// go test ./check/mysql/internal/analyze -run TestCorpusReplay -corpus-run [-corpus-files select,join] \ +// [-corpus-report /path/report.md] [-corpus-update] [-corpus-jobs 8] +// +// It is not part of a plain `go test` (five minutes on eight cores, a mysqld per file): +// -corpus-run or $SQLSHAPE_CORPUS=1 turns it on. + +import ( + "context" + "crypto/sha1" + "database/sql" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "runtime/debug" + "sort" + "strings" + "sync" + "testing" + "time" + "unicode/utf8" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/dump" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/parsegen" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +var ( + corpusFiles = flag.String("corpus-files", "", "comma-separated .test names (without extension) to replay (default: every file; the baseline is not checked)") + corpusReport = flag.String("corpus-report", "", "write the per-statement report here (default: the test log summary only)") + corpusUpdate = flag.Bool("corpus-update", false, "rewrite testdata/corpus_baseline.txt from this run's hits") + corpusJobs = flag.Int("corpus-jobs", 0, "files replayed at once (default: NumCPU, at most 8)") + corpusLimit = flag.Int("corpus-limit", 0, "replay only the first N files (0: all; the baseline is not checked)") + corpusRun = flag.Bool("corpus-run", false, "run the corpus probe (five minutes on eight cores; also $SQLSHAPE_CORPUS=1)") +) + +const corpusBaseline = "testdata/corpus_baseline.txt" + +// corpusHit is one disagreement. +type corpusHit struct { + file string + line int + class string // LENIENT / STRICT / CODE / DIFF / RUNTIME / SCHEMA + key string + sql string + detail string +} + +func (h corpusHit) id() string { + sum := sha1.Sum([]byte(h.sql)) + return fmt.Sprintf("%s\t%s %s\t%x", h.file, h.class, h.key, sum[:6]) +} + +func corpusDir() string { + home, _ := os.UserHomeDir() + dir := filepath.Join(home, ".cache", "sqlshape", "mysql-server", "mysql-test", "t") + if _, err := os.Stat(dir); err != nil { + return "" + } + return dir +} + +func TestCorpusReplay(t *testing.T) { + if !*corpusRun && os.Getenv("SQLSHAPE_CORPUS") == "" && *corpusFiles == "" && *corpusLimit == 0 { + t.Skip("the corpus probe runs with -corpus-run (or $SQLSHAPE_CORPUS=1): five minutes on eight cores") + } + dir := corpusDir() + if dir == "" { + t.Skip("no MySQL source under the cache directory") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour) + defer cancel() + probeServer, err := mysqltest.Start(ctx, "-- sqlshape: mysql 8.4\n") + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + probeServer.Close() + stmts, _, err := parsegen.SplitTestDir(dir) + if err != nil { + t.Fatal(err) + } + byFile := map[string][]parsegen.Statement{} + var files []string + for _, s := range stmts { + if _, ok := byFile[s.File]; !ok { + files = append(files, s.File) + } + byFile[s.File] = append(byFile[s.File], s) + } + partial := false + if *corpusFiles != "" { + want := map[string]bool{} + for _, f := range strings.Split(*corpusFiles, ",") { + want[strings.TrimSuffix(strings.TrimSpace(f), ".test")+".test"] = true + } + var keep []string + for _, f := range files { + if want[f] { + keep = append(keep, f) + } + } + files, partial = keep, true + } + if *corpusLimit > 0 && *corpusLimit < len(files) { + files, partial = files[:*corpusLimit], true + } + jobs := *corpusJobs + if jobs <= 0 { + jobs = min(runtime.NumCPU(), 8) + } + p := &corpusProbe{t: t, ctx: ctx, counts: map[string]int{}, unsupported: map[string]int{}} + var ( + mu sync.Mutex + hits []corpusHit + wg sync.WaitGroup + next = make(chan int) + started = time.Now() + ) + for w := 0; w < jobs; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := range next { + srv, err := mysqltest.StartOwn(ctx, "-- sqlshape: mysql 8.4\nCREATE DATABASE test;\n") + if err != nil { + t.Errorf("%s: %v", files[i], err) + continue + } + fh := p.runFile(srv, files[i], byFile[files[i]]) + srv.Close() + mu.Lock() + hits = append(hits, fh...) + mu.Unlock() + } + }() + } + for i := range files { + next <- i + } + close(next) + wg.Wait() + sort.Slice(hits, func(i, j int) bool { + if hits[i].file != hits[j].file { + return hits[i].file < hits[j].file + } + return hits[i].line < hits[j].line + }) + + // summary + var b strings.Builder + fmt.Fprintf(&b, "# corpus probe (mysql): %d files, %.0fs\n\n", len(files), time.Since(started).Seconds()) + var keys []string + for k := range p.counts { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(&b, "%s: %d\n", k, p.counts[k]) + } + byClass := map[string]int{} + byKey := map[string]int{} + for _, h := range hits { + byClass[h.class]++ + byKey[h.class+" "+h.key]++ + } + fmt.Fprintf(&b, "\nhits: %d\n", len(hits)) + for _, c := range []string{"LENIENT", "STRICT", "CODE", "DIFF", "RUNTIME", "SCHEMA", "PANIC"} { + if byClass[c] > 0 { + fmt.Fprintf(&b, " %s: %d\n", c, byClass[c]) + } + } + type kv struct { + k string + n int + } + var top []kv + for k, n := range byKey { + top = append(top, kv{k, n}) + } + sort.Slice(top, func(i, j int) bool { return top[i].n > top[j].n || top[i].n == top[j].n && top[i].k < top[j].k }) + fmt.Fprintf(&b, "\ntop keys:\n") + for i, kv := range top { + if i >= 40 { + break + } + fmt.Fprintf(&b, " %5d %s\n", kv.n, kv.k) + } + var un []kv + for k, n := range p.unsupported { + un = append(un, kv{k, n}) + } + sort.Slice(un, func(i, j int) bool { return un[i].n > un[j].n }) + fmt.Fprintf(&b, "\nnot read by the analyzer (top):\n") + for i, kv := range un { + if i >= 15 { + break + } + fmt.Fprintf(&b, " %5d %s\n", kv.n, kv.k) + } + t.Log(b.String()) + if *corpusReport != "" { + for _, h := range hits { + fmt.Fprintf(&b, "\n## %s:%d %s %s\n\n```sql\n%s\n```\n\n%s\n", h.file, h.line, h.class, h.key, h.sql, h.detail) + } + if err := os.WriteFile(*corpusReport, []byte(b.String()), 0o644); err != nil { + t.Fatal(err) + } + t.Logf("report: %s", *corpusReport) + } + + // the baseline + if partial { + return + } + if *corpusUpdate { + var lines []string + lines = append(lines, "# corpus probe: the statements on which the analyzer and MySQL knowingly disagree.", "# file\tclass key\tsha1(sql)\tfirst line. Rewrite with: go test ./check/mysql/internal/analyze -run TestCorpusReplay -corpus-update") + seen := map[string]bool{} + for _, h := range hits { + id := h.id() + if seen[id] { + continue + } + seen[id] = true + first := strings.SplitN(strings.TrimSpace(h.sql), "\n", 2)[0] + if len(first) > 100 { + first = first[:100] + } + lines = append(lines, id+"\t"+first) + } + if err := os.WriteFile(corpusBaseline, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Logf("baseline rewritten: %d hits", len(seen)) + return + } + known := map[string]bool{} + if data, err := os.ReadFile(corpusBaseline); err == nil { + for _, l := range strings.Split(string(data), "\n") { + if l == "" || strings.HasPrefix(l, "#") { + continue + } + if f := strings.Split(l, "\t"); len(f) >= 3 { + known[strings.Join(f[:3], "\t")] = true + } + } + } else if !os.IsNotExist(err) { + t.Fatal(err) + } + current := map[string]bool{} + var joined []string + for _, h := range hits { + id := h.id() + current[id] = true + if !known[id] { + joined = append(joined, id) + } + } + var left []string + for id := range known { + if !current[id] { + left = append(left, id) + } + } + sort.Strings(joined) + sort.Strings(left) + if len(joined) > 0 { + t.Errorf("%d statement(s) newly disagree (not in %s):\n%s", len(joined), corpusBaseline, strings.Join(joined, "\n")) + } + if len(left) > 0 { + t.Errorf("%d baseline statement(s) no longer disagree (drop them, or -corpus-update):\n%s", len(left), strings.Join(left, "\n")) + } +} + +type corpusProbe struct { + t *testing.T + ctx context.Context + mu sync.Mutex + // counts tallies statements by outcome; unsupported the analyzer's own limits by message + counts map[string]int + unsupported map[string]int +} + +func (p *corpusProbe) count(key string, n int) { + p.mu.Lock() + p.counts[key] += n + p.mu.Unlock() +} + +var ( + reCreateTemp = regexp.MustCompile("(?i)^\\s*CREATE\\s+TEMPORARY\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?`?(\\w+)`?") + reSystemSchema = regexp.MustCompile("(?i)\\b(information_schema|performance_schema|mysql|sys)\\s*\\.\\s*`?\\w") +) + +// mentionsAny: the statement names one of the tables (as a bare word). +func mentionsAny(sql string, names map[string]bool) bool { + if len(names) == 0 { + return false + } + for _, w := range regexp.MustCompile("`?(\\w+)`?").FindAllStringSubmatch(sql, -1) { + if names[strings.ToLower(w[1])] { + return true + } + } + return false +} + +var reFirstWord = regexp.MustCompile(`(?is)^(?:\s|/\*.*?\*/|--[^\n]*\n|#[^\n]*\n)*\(*\s*(\w+)`) + +// firstWord is a statement's leading keyword, upper-cased. +func firstWord(sql string) string { + m := reFirstWord.FindStringSubmatch(sql) + if m == nil { + return "" + } + return strings.ToUpper(m[1]) +} + +// schemaChanging: after this statement the analyzer's schema is rebuilt from the server. +func schemaChanging(word string) bool { + switch word { + case "CREATE", "ALTER", "DROP", "RENAME", "SET", "USE", "TRUNCATE", "FLUSH", "IMPORT": + return true + } + return false +} + +// notUnsupported is the analyzer's own limit on a statement kind, not a verdict. +func notUnsupported(err error) (string, bool) { + if err == nil { + return "", false + } + if _, ok := err.(*Error); ok { + return "", false + } + msg := err.Error() + if i := strings.Index(msg, ":"); i > 0 && strings.HasPrefix(msg, "analyze") { + msg = strings.TrimSpace(msg[i+1:]) + } + if len(msg) > 60 { + msg = msg[:60] + } + return msg, true +} + +// runFile replays one .test file on its own srv, in the `test` database. +func (p *corpusProbe) runFile(srv *mysqltest.DB, file string, stmts []parsegen.Statement) []corpusHit { + ctx := p.ctx + name := strings.TrimSuffix(file, ".test") + cfg, err := driver.ParseDSN(srv.DSN()) + if err != nil { + p.t.Fatal(err) + } + cfg.DBName = "test" + cfg.MultiStatements = false + db, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + p.t.Fatal(err) + } + defer db.Close() + db.SetMaxOpenConns(1) + conn, err := db.Conn(ctx) + if err != nil { + p.t.Logf("%s: connect: %v", file, err) + return nil + } + defer conn.Close() + // the dump reads SHOW CREATE on a connection of its own (the file's connection may hold + // LOCK TABLES, under which SHOW CREATE of another table is refused), in whatever + // database the file's connection is in + dumpDB, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + p.t.Fatal(err) + } + defer dumpDB.Close() + reconnect := func() { + conn.Close() + conn, err = db.Conn(ctx) + if err != nil { + p.t.Logf("%s: reconnect: %v", file, err) + } + } + start := time.Now() + var hits []corpusHit + var s *schema.Schema // nil: rebuild before the next judged statement + schemaBroken := "" + rebuild := func() { + s = nil + schemaBroken = "" + var current sql.NullString + var mode string + if err := conn.QueryRowContext(ctx, "SELECT DATABASE(), @@SESSION.sql_mode").Scan(¤t, &mode); err != nil { + schemaBroken = "session: " + err.Error() + return + } + if !current.Valid { + schemaBroken = "no database selected" + return + } + // the file's connection may hold a metadata lock (an open transaction, LOCK + // TABLES, HANDLER OPEN) that SHOW CREATE on another connection waits for: bound + // the wait, and give up on the schema for now + dctx, dcancel := context.WithTimeout(ctx, 8*time.Second) + defer dcancel() + dc, err := dumpDB.Conn(dctx) + if err != nil { + schemaBroken = "dump connection: " + err.Error() + return + } + defer dc.Close() + if _, err := dc.ExecContext(dctx, "SET SESSION lock_wait_timeout = 3, SESSION autocommit = 1"); err != nil { + schemaBroken = "dump session: " + err.Error() + return + } + if _, err := dc.ExecContext(dctx, "COMMIT"); err != nil { + // the file may have set autocommit off globally (locking_readonly_db): a pooled + // connection would then read the dictionary from a transaction's stale snapshot + schemaBroken = "dump commit: " + err.Error() + return + } + if _, err := dc.ExecContext(dctx, "USE `"+current.String+"`"); err != nil { + schemaBroken = "dump USE: " + err.Error() + return + } + text, err := dump.Read(dctx, dc) + if err != nil { + schemaBroken = "dump: " + err.Error() + return + } + // the session's sql_mode, an empty one included (SET sql_mode = '' is the corpus's + // way out of strict mode; without the line the schema would assume the default) + header := "-- sqlshape: mysql 8.4\n-- sqlshape: server sql_mode = '" + mode + "'\n" + sc, err := schema.Load(header + text) + if err != nil { + schemaBroken = "load: " + err.Error() + return + } + if len(sc.Problems) > 0 { + var ps []string + for _, pr := range sc.Problems { + ps = append(ps, pr.Message) + } + schemaBroken = "problems: " + strings.Join(ps, "; ") + return + } + s = sc + } + dirty := true + locked := false // LOCK TABLES held by the file's connection + judged, total := 0, 0 + temps := map[string]bool{} // CREATE TEMPORARY TABLE names: not in SHOW CREATE, so not the analyzer's + const fileBudget = 3 * time.Minute + for _, st := range stmts { + total++ + if time.Since(start) > fileBudget { + p.count("skipped (file budget)", 1) + continue + } + sql := strings.TrimSpace(st.SQL) + word := firstWord(sql) + if m := reCreateTemp.FindStringSubmatch(sql); m != nil { + temps[strings.ToLower(m[1])] = true + } + switch word { + case "SHUTDOWN", "RESTART", "KILL": + p.count("skipped (server control)", 1) + continue + } + if !utf8.ValidString(sql) { + // a file in a legacy encoding (gb18030, sjis, koi8r ...): the harness sends the + // bytes as utf8mb4 and reads SHOW CREATE back as utf8mb4, so the names it + // compares are not the file's -- the file's own character set, not a verdict + p.count("not judged (encoding)", 1) + continue + } + // the server + sctx, cancel := context.WithTimeout(ctx, 10*time.Second) + var desc *description + var serr error + if isQueryWord(word) { + desc, serr = describeOn(sctx, conn, sql) + } else { + _, serr = conn.ExecContext(sctx, sql) + } + cancel() + if serr != nil && (errors.Is(serr, context.DeadlineExceeded) || strings.Contains(serr.Error(), "bad connection") || strings.Contains(serr.Error(), "invalid connection")) { + p.count("skipped (timeout / connection)", 1) + reconnect() + dirty = true + continue + } + var me *driver.MySQLError + scode := 0 + if serr != nil { + if !errors.As(serr, &me) { + p.count("skipped (driver error)", 1) + continue + } + scode = int(me.Number) + } + if serr == nil && schemaChanging(word) { + dirty = true + } + if serr == nil { + switch word { + case "LOCK": + locked = true + case "UNLOCK", "START", "BEGIN": // UNLOCK TABLES; a transaction start releases LOCK TABLES too + if locked { + dirty = true // a DDL under the lock is visible only now + } + locked = false + } + } + if reSystemSchema.MatchString(sql) { + p.count("not judged (system schema)", 1) + continue + } + if mentionsAny(sql, temps) { + p.count("not judged (temporary table)", 1) + continue + } + if scode == 1046 || scode == 1049 { + // no database selected, or a database the file expects and this server lacks: + // the file's environment, not the statement's meaning (a MySQL schema is one + // database to the analyzer) + p.count("not judged (no database)", 1) + continue + } + // the analyzer: only the statement kinds it reads + switch word { + case "SELECT", "INSERT", "UPDATE", "DELETE", "REPLACE", "CALL", "WITH", "TABLE", "VALUES", "LOCK", "UNLOCK", "LOAD": + default: + p.count("not judged (statement kind)", 1) + continue + } + if dirty && locked { + // a DDL ran under LOCK TABLES: SHOW CREATE from the dump's connection waits on + // (or does not yet see) what the locking connection did -- ALTER TABLE ... + // RENAME under LOCK TABLES leaves the new name locked exclusively (measured: + // the dump read an inventory without it); judged again after UNLOCK + p.count("not judged (locked)", 1) + continue + } + if dirty { + rebuild() + dirty = false + } + if s == nil { + p.count("not judged (schema)", 1) + if schemaBroken != "" { + // a one-shot event the scheduler ran and dropped between the statement and + // the dump is timing, not a disagreement + if !strings.Contains(schemaBroken, "Unknown event") { + hits = append(hits, corpusHit{file: name, line: st.Line, class: "SCHEMA", key: schemaKey(schemaBroken), sql: sql, detail: schemaBroken}) + } + schemaBroken = "" // once per rebuild + } + continue + } + if word == "CALL" { + body := calledBody(s, sql) + if serr == nil && reBodyDDL.MatchString(body) { + dirty = true // the body ran DDL: the schema is not what the CALL was judged on + } + if body != "" && (reSystemSchema.MatchString(body) || mentionsAny(body, temps) || reCreateTempAny.MatchString(body)) { + // the routine's body reads information_schema / performance_schema (the + // corpus's own p_verify_reprepare_count) or a TEMPORARY table, or creates + // one: the same exclusions as for a top-level statement, one level down + p.count("not judged (routine body)", 1) + continue + } + } + if scode == 3024 || scode == 1317 { + // ER_QUERY_TIMEOUT / ER_QUERY_INTERRUPTED: whether the server raises these is + // the machine's speed, not the statement's shape -- the same corpus flapped + // max_statement_time's rows in and out of the baseline run to run + p.count("not judged (timing)", 1) + continue + } + res, aerr := analyzeSafe(s, sql) + if aerr != nil && strings.HasPrefix(aerr.Error(), "panic:") { + hits = append(hits, corpusHit{file: name, line: st.Line, class: "PANIC", key: panicKey(aerr.Error()), sql: sql, detail: aerr.Error()}) + continue + } + if msg, ok := notUnsupported(aerr); ok { + p.mu.Lock() + p.unsupported[msg]++ + p.mu.Unlock() + p.count("not judged (analyzer limit)", 1) + continue + } + if acode := errCode(aerr); acode == 1146 && otherDatabase(sql, aerr.Error()) || acode == 1305 && reQualifiedCall.MatchString(sql) { + // the table the analyzer misses is named with a database qualifier (or the + // routine it misses is called as db.routine): another database's, which the + // analyzer (one database) does not model + p.count("not judged (other database)", 1) + continue + } + judged++ + acode := errCode(aerr) + switch { + case scode != 0 && acode != 0: + if scode == acode { + p.count("agree (error)", 1) + } else { + hits = append(hits, corpusHit{file: name, line: st.Line, class: "CODE", key: fmt.Sprintf("%d->%d", scode, acode), sql: sql, detail: fmt.Sprintf("server: %v\nanalyzer: %v", serr, aerr)}) + } + case scode != 0 && runtimeCode(scode): + // a failure the data decides (a constraint, a conversion, a subquery's row + // count): the analyzer's verdict on it is a predicted violation, not an error + if predictsCode(res, scode) { + p.count("agree (predicted failure)", 1) + } else { + hits = append(hits, corpusHit{file: name, line: st.Line, class: "RUNTIME", key: fmt.Sprint(scode), sql: sql, detail: fmt.Sprintf("server: %v\nanalyzer accepts and predicts %v", serr, predictedCodes(res))}) + } + case scode != 0: + hits = append(hits, corpusHit{file: name, line: st.Line, class: "LENIENT", key: fmt.Sprint(scode), sql: sql, detail: fmt.Sprintf("server: %v\nanalyzer accepts", serr)}) + case acode != 0: + hits = append(hits, corpusHit{file: name, line: st.Line, class: "STRICT", key: fmt.Sprint(acode), sql: sql, detail: fmt.Sprintf("analyzer: %v\nserver accepts", aerr)}) + default: + if desc == nil { + p.count("agree (write)", 1) + break + } + if key, detail := compareColumns(res, desc); key != "" { + hits = append(hits, corpusHit{file: name, line: st.Line, class: "DIFF", key: key, sql: sql, detail: detail}) + } else { + p.count("agree (columns)", 1) + } + } + } + p.t.Logf("%-40s %5d stmts %4d judged %6.1fs", name, total, judged, time.Since(start).Seconds()) + return hits +} + +// runtimeCode: an error the data decides at run time, which the analyzer predicts as a +// violation (an expect line's key) rather than raises: constraints (1048 / 1062 / 1451 / +// 1452 / 3819 / 1369 / 1364 / 1423 / 1263), conversions and ranges (1264 / 1265 / 1292 / 1366 / +// 1406 / 3854 / 1690 / 1441), a scalar subquery's or SELECT INTO's row count (1242 / 1172), +// SIGNALs (1644 / 1643), a division by zero (1365), a duplicate under a locking read. +func runtimeCode(code int) bool { + switch code { + case 1048, 1062, 1451, 1452, 3819, 1369, 1364, 1423, 1263, 1264, 1265, 1292, 1366, 1406, 3854, 1690, 1441, 1242, 1172, 1644, 1643, 1365, 1329, 1213, 1205, 1105, 3105, 1216, 1217, 1586, 1416: + return true + } + return false +} + +// predictsCode: the analyzer lists a violation of the server's error number. +func predictsCode(r *Result, code int) bool { + if r == nil { + return false + } + for _, v := range r.Violations { + if v.Code == code { + return true + } + } + return false +} + +func predictedCodes(r *Result) []int { + if r == nil { + return nil + } + var out []int + for _, v := range r.Violations { + out = append(out, v.Code) + } + sort.Ints(out) + return out +} + +var ( + reBodyDDL = regexp.MustCompile(`(?i)\b(CREATE|DROP|ALTER|RENAME)\s+(TABLE|VIEW|TEMPORARY)\b`) + reCreateTempAny = regexp.MustCompile(`(?i)\bCREATE\s+TEMPORARY\s+TABLE\b`) + reMissingTable = regexp.MustCompile(`Table '([^']+)' doesn't exist`) + reQualifiedCall = regexp.MustCompile("(?i)`?\\w+`?\\s*\\.\\s*`?\\w+`?\\s*\\(") + reCallName = regexp.MustCompile("(?is)^CALL\\s+`?([\\w$]+)`?") +) + +// calledBody is the CREATE text of the routine a CALL statement names, "" when the schema +// has no such routine. +func calledBody(s *schema.Schema, sql string) string { + m := reCallName.FindStringSubmatch(sql) + if m == nil { + return "" + } + if r := s.RoutineOf(schema.Procedure, m[1]); r != nil { + return r.Definition + } + return "" +} + +// otherDatabase: the table a 1146 names appears database-qualified in the statement (the +// name is matched as bytes: the corpus is read as latin-1, and a name need not be UTF-8). +func otherDatabase(sql, msg string) bool { + m := reMissingTable.FindStringSubmatch(msg) + if m == nil { + return false + } + name := m[1] + lower := strings.ToLower(sql) + for i := 0; i < len(lower); { + j := strings.Index(lower[i:], strings.ToLower(name)) + if j < 0 { + return false + } + k := i + j + // walk back over an optional backquote, spaces, a dot, spaces, and a word + b := k - 1 + if b >= 0 && lower[b] == '`' { + b-- + } + for b >= 0 && (lower[b] == ' ' || lower[b] == '\t') { + b-- + } + if b >= 0 && lower[b] == '.' { + b-- + for b >= 0 && (lower[b] == ' ' || lower[b] == '\t') { + b-- + } + if b >= 0 && (lower[b] == '_' || lower[b] == '`' || lower[b] >= 'a' && lower[b] <= 'z' || lower[b] >= '0' && lower[b] <= '9') { + return true + } + } + i = k + len(name) + } + return false +} + +// analyzeSafe is Analyze with a panic turned into an error, so one crashing statement +// does not end the run (and is reported as a hit of its own). +func analyzeSafe(s *schema.Schema, sql string) (r *Result, err error) { + defer func() { + if e := recover(); e != nil { + var trace []string + for _, l := range strings.Split(string(debug.Stack()), "\n") { + if strings.Contains(l, "/check/mysql/") && !strings.Contains(l, "_test.go") { + trace = append(trace, strings.TrimSpace(l)) + } + } + r, err = nil, fmt.Errorf("panic: %v\n%s", e, strings.Join(trace, "\n")) + } + }() + return Analyze(s, sql) +} + +// panicKey names a panic by its message and the analyzer frame it came from. +func panicKey(msg string) string { + lines := strings.Split(msg, "\n") + key := strings.TrimPrefix(lines[0], "panic: ") + if len(key) > 50 { + key = key[:50] + } + if len(lines) > 1 { + if i := strings.LastIndex(lines[1], "/"); i >= 0 { + key += " @ " + lines[1][i+1:] + } + } + return key +} + +func schemaKey(msg string) string { + i := strings.Index(msg, ":") + if i < 0 { + return msg + } + rest := strings.TrimSpace(msg[i+1:]) + if j := strings.IndexAny(rest, ";\n"); j >= 0 { + rest = rest[:j] + } + // strip the object's name: the class of problem is the key + rest = regexp.MustCompile("`[^`]*`|'[^']*'|\"[^\"]*\"").ReplaceAllString(rest, "?") + rest = regexp.MustCompile(`\d+`).ReplaceAllString(rest, "N") + if len(rest) > 60 { + rest = rest[:60] + } + return msg[:i] + ": " + rest +} + +func isQueryWord(word string) bool { + switch word { + case "SELECT", "TABLE", "VALUES", "WITH": + return true + } + return false +} + +// description is the server's result-set metadata for a query. +type description struct { + cols []struct { + name, typ string + nullable bool + } +} + +// describeOn runs a query and reads its column metadata (rows are drained, since the +// statement is the corpus's own and its side effects are wanted). +func describeOn(ctx context.Context, conn *sql.Conn, sql string) (*description, error) { + rows, err := conn.QueryContext(ctx, sql) + if err != nil { + return nil, err + } + defer rows.Close() + cts, err := rows.ColumnTypes() + if err != nil { + return nil, err + } + d := &description{} + for _, ct := range cts { + n, _ := ct.Nullable() + d.cols = append(d.cols, struct { + name, typ string + nullable bool + }{ct.Name(), ct.DatabaseTypeName(), n}) + } + for rows.Next() { + } + return d, rows.Err() +} + +// compareColumns judges the analyzer's columns against the server's metadata: count, +// names, type families (typeKey / oracleKey's grouping), nullability. +func compareColumns(r *Result, d *description) (key, detail string) { + if len(r.Columns) != len(d.cols) { + return "column count", fmt.Sprintf("analyzer %d columns, server %d", len(r.Columns), len(d.cols)) + } + for i, c := range r.Columns { + oc := d.cols[i] + if !sameColumnName(c.Name, oc.name) { + return "column name", fmt.Sprintf("column %d: analyzer %q, server %q", i+1, c.Name, oc.name) + } + if !c.Known { + continue + } + got, want := typeKey(c.Type), familyOf(oc.typ) + if got != want { + return "column type " + got + "->" + want, fmt.Sprintf("column %q: analyzer %s, server %s", c.Name, got, want) + } + if c.Nullable != oc.nullable { + return fmt.Sprintf("nullable %v->%v", c.Nullable, oc.nullable), fmt.Sprintf("column %q: analyzer nullable=%v, server %v", c.Name, c.Nullable, oc.nullable) + } + } + return "", "" +} + +// sameColumnName compares a result column's name with the server's, allowing for what the +// connection's character set did to the server's: under a file's SET NAMES utf8mb3 a +// character outside the BMP in the name comes back as '?', and under a single-byte SET +// NAMES (koi8r, latin1) the name is not UTF-8 at all -- the file's connection settings, +// not the analyzer's naming. +func sameColumnName(analyzer, server string) bool { + if analyzer == server { + return true + } + if !utf8.ValidString(server) { + return true + } + if strings.ContainsRune(server, '?') { + folded := strings.Map(func(r rune) rune { + if r > 0xFFFF { + return '?' + } + return r + }, analyzer) + return folded == server + } + return false +} + +// familyOf is oracleKey over the driver's DatabaseTypeName. +func familyOf(t string) string { + name := strings.ToLower(t) + if name == "geometry" { + return "geometry" + } + + unsigned := strings.HasPrefix(name, "unsigned ") + name = strings.TrimPrefix(name, "unsigned ") + switch name { + case "char", "varchar", "tinytext", "text", "mediumtext", "longtext", "enum", "set": + name = "string" + case "binary", "varbinary", "tinyblob", "blob", "mediumblob", "longblob": + name = "binary" + } + if unsigned { + return name + " unsigned" + } + return name +} diff --git a/check/mysql/internal/analyze/event_test.go b/check/mysql/internal/analyze/event_test.go new file mode 100644 index 0000000..fc54719 --- /dev/null +++ b/check/mysql/internal/analyze/event_test.go @@ -0,0 +1,39 @@ +package analyze + +import ( + "testing" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" +) + +// TestAnalyzeEvent: an event's body is walked like a routine's -- its statements' facts are +// recorded, a table it names that the schema does not have is an Error (the server accepts +// it at CREATE time and fails at every run, measured), and a RETURN is 1313 (the server's own +// CREATE-time refusal, measured). +func TestAnalyzeEvent(t *testing.T) { + s, err := schema.Load(`-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, v INT NOT NULL); +CREATE EVENT ok ON SCHEDULE EVERY 1 DAY DO BEGIN DELETE FROM t WHERE id < 0; UPDATE t SET v = v + 1 WHERE id = 1; END; +CREATE EVENT no_table ON SCHEDULE EVERY 1 DAY DO DELETE FROM nope WHERE id < 0; +CREATE EVENT returns ON SCHEDULE EVERY 1 DAY DO BEGIN DECLARE n INT; SELECT COUNT(*) INTO n FROM t; RETURN n; END; +`) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("problems: %v", s.Problems) + } + br, err := AnalyzeEvent(s, s.Event("ok")) + if err != nil { + t.Fatal(err) + } + if len(br.Statements) != 2 || br.Statements[0].Facts == nil || br.Statements[1].Facts == nil { + t.Errorf("ok: want the facts of both statements, got %+v", br.Statements) + } + if _, err := AnalyzeEvent(s, s.Event("no_table")); errCode(err) != 1146 { + t.Errorf("no_table: got %v, want 1146", err) + } + if _, err := AnalyzeEvent(s, s.Event("returns")); errCode(err) != 1313 { + t.Errorf("returns: got %v, want 1313", err) + } +} diff --git a/check/mysql/internal/analyze/expr.go b/check/mysql/internal/analyze/expr.go new file mode 100644 index 0000000..e6ceb90 --- /dev/null +++ b/check/mysql/internal/analyze/expr.go @@ -0,0 +1,1394 @@ +package analyze + +import ( + "fmt" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/catalog" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlast" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/sqlmode" +) + +func isParam(v mysqlast.Value) bool { + n, ok := v.(*mysqlast.Node) + return ok && n.Class == "Item_param" +} + +// setParam records the type context gives a placeholder. +func (a *analyzer) setParam(v mysqlast.Value, t schema.Type) { + n, ok := v.(*mysqlast.Node) + if !ok || n.Class != "Item_param" { + return + } + i := a.ph.Number(n.Start) - 1 + if i < 0 || i >= len(a.params) { + return + } + // the first context wins: the same $n in two places keeps its first type + if !a.params[i].Known { + a.params[i] = Param{Type: t, Known: true} + } +} + +// noteParamSource records the table column a placeholder met (the first one wins). +func (a *analyzer) noteParamSource(v mysqlast.Value, table *schema.Table, col *schema.Column, assigned bool) { + n, ok := v.(*mysqlast.Node) + if !ok || n.Class != "Item_param" || table == nil || col == nil { + return + } + num := a.ph.Number(n.Start) + if a.paramSrc == nil { + a.paramSrc = map[int]*ParamSource{} + } + if _, done := a.paramSrc[num]; !done { + a.paramSrc[num] = &ParamSource{Table: table.Name, Column: col.Name, NotNull: col.NotNull, Assigned: assigned} + } +} + +// paramSources records, for the placeholders among the operands of a comparison (=, IN, +// BETWEEN, LIKE ...), the one table column among the other operands: a parameter compared +// with a column stands for that column. +func (a *analyzer) paramSources(sc scope, args []mysqlast.Value) { + var table *schema.Table + var col *schema.Column + for _, v := range args { + if isParam(v) { + continue + } + if ref, ok := a.plainColumn(sc, v); ok && ref.c.base != nil && ref.c.baseTable != nil { + if col != nil && (col != ref.c.base || table != ref.c.baseTable) { + return // two columns: the parameter stands for neither + } + table, col = ref.c.baseTable, ref.c.base + } + } + if col == nil { + return + } + for _, v := range args { + a.noteParamSource(v, table, col, false) + } +} + +// setParamField types a placeholder by an enum_field_types name. +func (a *analyzer) setParamField(v mysqlast.Value, ft string) { + if t, ok := fromFieldType(ft, false); ok { + a.setParam(v, t) + } +} + +// expr types an expression: column references, literals and placeholders directly; the +// operators by the server's rules (types.go); function calls by their Item class in the +// catalog. Every construct is walked for its errors and its placeholders; what has no +// rule yet comes back untyped. +func (a *analyzer) expr(sc scope, v mysqlast.Value, where string) (typed, error) { + switch x := v.(type) { + case nil: + return unknown, nil + case mysqlast.List: + for _, e := range x { + if _, err := a.expr(sc, e, where); err != nil { + return unknown, err + } + } + return unknown, nil + case *mysqlast.Struct: + for _, k := range x.Order { + if _, err := a.expr(sc, x.Fields[k], where); err != nil { + return unknown, err + } + } + return unknown, nil + case *mysqlast.Node: + a.parents = append(a.parents, x) + t, err := a.node(sc, x, where) + if err == nil { + if e := a.constCheck(sc, x, where); e != nil { + err = e + t = unknown + } + } + a.parents = a.parents[:len(a.parents)-1] + return t, err + } + return unknown, nil +} + +// exprs types a list of expressions. +func (a *analyzer) exprs(sc scope, vs []mysqlast.Value, where string) ([]typed, error) { + out := make([]typed, len(vs)) + for i, v := range vs { + t, err := a.expr(sc, v, where) + if err != nil { + return nil, err + } + out[i] = t + } + return out, nil +} + +func (a *analyzer) node(sc scope, n *mysqlast.Node, where string) (typed, error) { + if e := a.wrongArguments(sc, n, where); e != nil { + return unknown, e + } + switch n.Class { + case "PTI_simple_ident_ident", "PTI_simple_ident_nospvar_ident", "PTI_simple_ident_q_2d", "PTI_simple_ident_q_3d": + ref, err := a.column(sc, n, where) + if err != nil { + return unknown, err + } + return typeOfColumn(ref.c), nil + case "Item_param", "PTI_user_variable": + return unknown, nil + case "PTI_get_system_variable": + // @@scope.name: an explicitly scoped read must match the variable's own scope + // (1238, measured: a statement error wherever the read sits, dead branches and + // subqueries included). The generated catalog.SysVars carries every stock + // server variable's scope; a name outside it (a plugin's or component's + // variable, and any `@@prefix.name` component read) is not judged, and an + // unqualified @@name never scope-fails. + if qual, name := bipartite(n.Arg("name")); qual == "" && name != "" { + scope, _ := n.Arg("scope").(mysqlast.Const) + switch catalog.SysVars[strings.ToLower(name)] { + case 'g': + if scope == "OPT_SESSION" { + return unknown, &Error{Message: fmt.Sprintf("Variable '%s' is a GLOBAL variable", strings.ToLower(name)), Code: 1238, Position: a.ph.Back(n.Start)} + } + case 's': + if scope == "OPT_GLOBAL" { + return unknown, &Error{Message: fmt.Sprintf("Variable '%s' is a SESSION variable", strings.ToLower(name)), Code: 1238, Position: a.ph.Back(n.Start)} + } + } + } + return unknown, nil + + // literals + case "Item_int": + t := known("bigint", false) + t.typ.Length = len(strings.TrimLeft(str(n.Arg("i")), "-")) + return t, nil + case "Item_uint": + t := known("bigint", false) + t.typ.Unsigned = true + return t, nil + case "Item_decimal": + t := known("decimal", false) + s := str(n.Arg("str")) + if i := strings.IndexByte(s, '.'); i >= 0 { + t.typ.Dec = len(s) - i - 1 + t.typ.Length = len(strings.TrimLeft(s, "-")) - 1 + } else { + t.typ.Dec = 0 + t.typ.Length = len(strings.TrimLeft(s, "-")) + } + return t, nil + case "Item_float": + return known("double", false), nil + case "PTI_text_literal_text_string", "PTI_text_literal_nchar_string", "PTI_text_literal_underscore_charset", "PTI_text_literal_concat": + t := known("varchar", false) + if tok, ok := n.Arg("literal").(mysqlast.Token); ok { + t.typ.Length = len([]rune(tok.Value)) + } + return t, nil + case "Item_hex_string", "Item_bin_string", "PTI_literal_underscore_charset_hex_num", "PTI_literal_underscore_charset_bin_num": + return known("varbinary", false), nil + case "PTI_temporal_literal": + // DATE'...' / TIME'...' / TIMESTAMP'...' (Item_date_literal / Item_time_literal / + // Item_datetime_literal): the named type, never NULL; the fractional seconds are + // the literal's own digits. The value must parse as exactly that type with no + // warning (create_temporal_literal: str_to_datetime under the sql_mode's zero-date + // flags, a DATE with no time part, a DATETIME with one, str_to_time for a TIME), + // else the statement is 1525 "Incorrect DATE / TIME / DATETIME value" (measured) + ft := strings.TrimPrefix(str(n.Arg("field_type")), "MYSQL_TYPE_") + typ, ok := fromFieldType(ft, false) + if !ok { + return unknown, nil + } + if tok, ok := n.Arg("literal").(mysqlast.Token); ok { + if err := a.temporalLiteral(typ.Name, tok.Value, n.Start); err != nil { + return unknown, err + } + if typ.Name == "time" || typ.Name == "datetime" { + typ.Dec = 0 + if i := strings.LastIndexByte(tok.Value, '.'); i >= 0 { + typ.Dec = min(len(tok.Value)-i-1, 6) + } + } + } + return typed{typ: typ, known: true}, nil + case "Item_null": + return known("null", true), nil + case "Item_func_true", "Item_func_false": + return boolean(false), nil + + // comparisons and logic: a bigint(1), NULL when an operand is + case "PTI_comp_op": + ts, err := a.exprs(sc, []mysqlast.Value{n.Arg("left"), n.Arg("right")}, where) + if err != nil { + return unknown, err + } + a.paramsFromOthers([]mysqlast.Value{n.Arg("left"), n.Arg("right")}, ts, "") + a.paramSources(sc, []mysqlast.Value{n.Arg("left"), n.Arg("right")}) + if e := a.compareConstString(sc, []mysqlast.Value{n.Arg("left"), n.Arg("right")}, ts, "cmp"); e != nil { + return unknown, e + } + if op, _ := n.Arg("boolfunc2creator").(mysqlast.Op); op == "<=>" { + // Item_func_equal::resolve_type's set_nullable(false): NULL <=> NULL is 1 + return boolean(false), nil + } + return boolean(ts[0].nullable || ts[1].nullable), nil + case "Item_func_in": + list, _ := n.Arg("list").(mysqlast.List) + ts, err := a.exprsDead(sc, list, where, a.inMatch(list)) + if err != nil { + return unknown, err + } + a.paramsFromOthers(list, ts, "") + a.paramSources(sc, list) + if e := a.compareConstString(sc, list, ts, "in"); e != nil { + return unknown, e + } + return boolean(anyNullable(ts)), nil + case "Item_func_between", "Item_func_like", "Item_func_strcmp": + args := exprArgs(n) + ts, err := a.exprs(sc, args, where) + if err != nil { + return unknown, err + } + if n.Class == "Item_func_between" && geometryOperand(ts) { + return unknown, a.geometryRejected("between", n.Start) + } + a.paramsFromOthers(args, ts, "") + a.paramSources(sc, args) + if n.Class == "Item_func_between" { + if e := a.compareConstString(sc, args, ts, "between"); e != nil { + return unknown, e + } + } + return boolean(anyNullable(ts)), nil + case "Item_cond_and", "Item_cond_or", "Item_func_xor": + ts, err := a.condArgs(sc, n, where) + if err != nil { + return unknown, err + } + return boolean(anyNullable(ts)), nil + case "Item_func_isnull", "Item_func_isnotnull": + if n.Class == "Item_func_isnull" { + // Item_func_isnull::fix_fields: over a never-NULL argument the predicate is + // the constant false, and the argument is never evaluated + if err := a.isNullArg(sc, n.Arg("a"), where); err != nil { + return unknown, err + } + return boolean(false), nil + } + if _, err := a.expr(sc, n.Arg("a"), where); err != nil { + return unknown, err + } + return boolean(false), nil + case "PTI_truth_transform": + t, err := a.expr(sc, n.Arg("expr"), where) + if err != nil { + return unknown, err + } + if str(n.Arg("truth_test")) == "Item::BOOL_NEGATED" { // NOT x is NULL for NULL x; IS [NOT] TRUE / FALSE never is + return boolean(t.nullable), nil + } + return boolean(false), nil + // subqueries: entered with this scope as the enclosing one, for correlated references + case "Item_insert_value": + // VALUES(c) of ON DUPLICATE KEY UPDATE: c is a column of the INSERT's target, + // whatever the SELECT's tables have (measured: `... SELECT x, z FROM t2 ON DUPLICATE + // KEY UPDATE x = VALUES(z)` is 1054 when the target has no z) + if a.insertTarget == nil { + // outside ON DUPLICATE KEY UPDATE the function is NULL (measured, the corpus's + // `SELECT VALUES(x) FROM t`), its argument still resolved + if _, err := a.expr(sc, n.Arg("a"), where); err != nil { + return unknown, err + } + return known("null", true), nil + } + sc = scope{rels: []relation{*a.insertTarget}} + return a.expr(sc, n.Arg("a"), where) + case "PTI_exists_subselect": + a.existsList = true // the subquery's select list is never evaluated (fold.go) + _, err := a.subquery(n.Arg("subselect"), &sc) + a.existsList = false + if err != nil { + return unknown, err + } + return boolean(false), nil + case "Item_in_subselect", "PTI_comp_op_all": + // x IN (SELECT c ...), x = ANY (SELECT c ...): a placeholder x takes c's type + left := n.Arg("left_expr") + if n.Class == "PTI_comp_op_all" { + left = n.Arg("left") + } + sub := n.Arg("pt_subquery") + if n.Class == "PTI_comp_op_all" { + sub = n.Arg("subselect") + } + lt, err := a.expr(sc, left, where) + if err != nil { + return unknown, err + } + cols, err := a.subquery(sub, &sc) + if err != nil { + return unknown, err + } + row, isRow := left.(*mysqlast.Node) + if isRow && row.Class == "Item_row" { + if len(cols) != 1+len(exprArgsTail(row)) { + return unknown, &Error{Message: fmt.Sprintf("Operand should contain %d column(s)", 1+len(exprArgsTail(row))), Code: 1241, Position: a.ph.Back(n.Start)} + } + } else if len(cols) != 1 { + return unknown, &Error{Message: "Operand should contain 1 column(s)", Code: 1241, Position: a.ph.Back(n.Start)} + } + _ = lt + if isParam(left) && len(cols) == 1 && cols[0].Known { + a.setParam(left, cols[0].Type) + if cols[0].base != nil && cols[0].baseTable != nil { + a.noteParamSource(left, cols[0].baseTable, cols[0].base, false) + } + } + return boolean(true), nil // the server marks every IN / ANY / ALL over a subquery nullable + case "PTI_singlerow_subselect": + return a.scalarSubquery(n.Arg("subselect"), sc, n.Start) + + // arithmetic + case "Item_func_plus", "Item_func_minus", "Item_func_mul", "Item_func_mod": + ts, err := a.exprs(sc, []mysqlast.Value{n.Arg("a"), n.Arg("b")}, where) + if err != nil { + return unknown, err + } + if geometryOperand(ts) { + return unknown, a.geometryRejected(operatorName(n.Class), n.Start) + } + a.paramsFromOthers([]mysqlast.Value{n.Arg("a"), n.Arg("b")}, ts, "") + t := numOp(ts[0], ts[1], n.Class == "Item_func_mod") + if n.Class == "Item_func_minus" && a.s.Settings.SQLMode.Has(sqlmode.NoUnsignedSubtraction) { + t.typ.Unsigned = false // Item_func_minus::result_precision under NO_UNSIGNED_SUBTRACTION + } + if n.Class == "Item_func_mod" { + t.nullable = true // x % 0 is NULL + } + return t, nil + case "Item_func_div": + ts, err := a.exprs(sc, []mysqlast.Value{n.Arg("a"), n.Arg("b")}, where) + if err != nil { + return unknown, err + } + if geometryOperand(ts) { + return unknown, a.geometryRejected("/", n.Start) + } + a.paramsFromOthers([]mysqlast.Value{n.Arg("a"), n.Arg("b")}, ts, "") + t := numOp(ts[0], ts[1], false) + if t.typ.Name == "bigint" { // an integer division is exact: decimal + t.typ = schema.Type{Name: "decimal", Length: -1, Dec: -1} + } + t.nullable = true // division by zero + return t, nil + case "Item_func_div_int": + ts, err := a.exprs(sc, []mysqlast.Value{n.Arg("a"), n.Arg("b")}, where) + if err != nil { + return unknown, err + } + if geometryOperand(ts) { + return unknown, a.geometryRejected("DIV", n.Start) + } + a.paramsFromOthers([]mysqlast.Value{n.Arg("a"), n.Arg("b")}, ts, "LONGLONG") + t := known("bigint", true) + t.typ.Unsigned = ts[0].typ.Unsigned || ts[1].typ.Unsigned + return t, nil + case "Item_func_neg": + t, err := a.expr(sc, n.Arg("a"), where) + if err != nil { + return unknown, err + } + if geometryOperand([]typed{t}) { + return unknown, a.geometryRejected("-", n.Start) + } + out := num1(t, false) + out.typ.Unsigned = false + return out, nil + case "Item_func_bit_and", "Item_func_bit_or", "Item_func_bit_xor", "Item_func_shift_left", "Item_func_shift_right", "Item_func_bit_neg": + ts, err := a.exprs(sc, exprArgs(n), where) + if err != nil { + return unknown, err + } + if geometryOperand(ts) { + return unknown, a.geometryRejected(operatorName(n.Class), n.Start) + } + a.paramsFromOthers(exprArgs(n), ts, "LONGLONG") + if e := a.bitOpStringOperand(sc, n, where); e != nil { + return unknown, e + } + t := known("bigint", anyNullable(ts)) + t.typ.Unsigned = true + return t, nil + + // branches: aggregate_type over the values a branch can produce + case "Item_func_if": + args := []mysqlast.Value{n.Arg("a"), n.Arg("b"), n.Arg("c")} + dead := -1 // the branch a constant condition never takes + if b, null, ok := a.constBool(args[0]); ok { + dead = 1 + if b && !null { + dead = 2 + } + } + ts, err := a.exprsOff(sc, args, where, func(i int) bool { return i == dead }) + if err != nil { + return unknown, err + } + a.setParamField(args[0], "LONGLONG") + a.paramsFromOthers(args[1:], ts[1:], "") + t := aggregate(ts[1:]) + t.nullable = ts[1].nullable || ts[2].nullable + return t, nil + case "Item_func_case": + list, _ := n.Arg("list").(mysqlast.List) + var whens, thens []mysqlast.Value + for i, v := range list { + if i%2 == 0 { + whens = append(whens, v) + } else { + thens = append(thens, v) + } + } + if first := n.Arg("first_expr"); first != nil { + whens = append(whens, first) + } + var results []mysqlast.Value + results = append(results, thens...) + if e := n.Arg("else_expr"); e != nil { + results = append(results, e) + } + wt, rt, err := a.caseArgs(sc, n.Arg("first_expr"), whens, thens, results, where) + if err != nil { + return unknown, err + } + a.paramsFromOthers(whens, wt, "") + a.paramsFromOthers(results, rt, "") + t := aggregate(rt) + t.nullable = n.Arg("else_expr") == nil || anyNullable(rt) + return t, nil + case "Item_func_coalesce", "Item_func_ifnull", "Item_func_any_value": + args := exprArgs(n) + ts, err := a.exprsDead(sc, args, where, a.firstNonNull(args)) + if err != nil { + return unknown, err + } + a.paramsFromOthers(args, ts, "") + t := aggregate(ts) + t.nullable = true + for _, x := range ts { + if !x.nullable { + t.nullable = false // a non-nullable argument guarantees a value + } + } + return t, nil + case "Item_func_nullif": + ts, err := a.exprs(sc, exprArgs(n), where) + if err != nil { + return unknown, err + } + a.paramsFromOthers(exprArgs(n), ts, "") + t := ts[0] + t.nullable = true + return t, nil + + // the spatial constructors: POINT(x, y) is a point; LINESTRING / POLYGON / MULTI* / + // GEOMETRYCOLLECTION judge their arguments (geom.go) + case "Item_func_point": + ts, err := a.exprs(sc, exprArgs(n), where) + if err != nil { + return unknown, err + } + for _, arg := range exprArgs(n) { + a.setParamField(arg, "DOUBLE") + } + return geometryType(wkbPoint, anyNullable(ts)), nil + case "Item_func_spatial_collection": + return a.spatialCollection(sc, n, where) + + // casts + case "create_func_cast": + return a.cast(sc, n, where) + case "Item_func_set_collation", "Item_func_conv_charset": + t, err := a.expr(sc, n.Arg("a"), where) + if err != nil { + return unknown, err + } + if t.known && kindOf(t.typ) != "STRING_RESULT" { + t = known("varchar", true) + } + t.nullable = true // an Item_str_func: nullable in strict mode (Item_str_func::fix_fields) + return t, nil + + // the registry: a function named in the statement + case "PTI_function_call_generic_ident_sys", "PTI_function_call_generic_2d": + return a.call(sc, n, where) + } + + // any other Item class the grammar builds directly: judged by its class in the catalog + if strings.HasPrefix(n.Class, "Item_") || strings.HasPrefix(n.Class, "PTI_function_call_nonkeyword_") || n.Class == "PTI_count_sym" { + class := n.Class + switch { + case strings.HasPrefix(class, "PTI_function_call_nonkeyword_now"): + class = "Item_func_now" + case strings.HasPrefix(class, "PTI_function_call_nonkeyword_sysdate"): + class = "Item_func_now" + case class == "PTI_count_sym": // COUNT(*) + class = "Item_sum_count" + } + args := exprArgs(n) + ts, err := a.exprs(sc, args, where) + if err != nil { + return unknown, err + } + if _, ok := catalog.Items[class]; ok { + if e := a.rejectsGeometry(class, operatorName(class), ts, n.Start); e != nil { + return unknown, e + } + return a.classType(class, args, ts, isWindowFunction(n)), nil + } + return unknown, nil + } + // anything else: walk it for its errors and parameters, and leave it untyped + if _, err := a.expr(sc, mysqlast.List(n.Args), where); err != nil { + return unknown, err + } + return unknown, nil +} + +// funcCallParts reads a generic function call's own name and argument list, from either +// shape the grammar builds: PTI_function_call_generic_ident_sys ("f(args)", each argument +// wrapped in a PTI_udf_expr for a possible alias) or PTI_function_call_generic_2d +// ("db.f(args)", a plain expr_list). db is ignored the way a table's own db qualifier +// already is in target(): sqlshape loads a single schema, and a stored routine resolves by +// name alone regardless of how the call qualifies it (measured on mysqld 8.4: `SELECT +// db.f2(1)` and `SELECT f2(1)` both reach the same stored function). +func funcCallParts(n *mysqlast.Node) (string, []mysqlast.Value) { + name, args, _ := funcCallPartsAlias(n) + return name, args +} + +// funcCallPartsAlias also reports whether any argument carries an alias (`f(x AS a)`, +// the loadable function syntax): a native function refuses one with 1583, anything +// else -- a stored function, even one that does not exist -- with 1584, both measured. +func funcCallPartsAlias(n *mysqlast.Node) (string, []mysqlast.Value, bool) { + if n.Class == "PTI_function_call_generic_2d" { + list, _ := n.Arg("opt_expr_list").(mysqlast.List) + return str(n.Arg("func")), []mysqlast.Value(list), false + } + name := str(n.Arg("ident")) + var args []mysqlast.Value + aliased := false + list, _ := n.Arg("opt_udf_expr_list").(mysqlast.List) + for _, e := range list { + if u, ok := e.(*mysqlast.Node); ok && u.Class == "PTI_udf_expr" { + if u.Arg("select_alias") != nil { + aliased = true + } + args = append(args, u.Arg("expr")) + } else { + args = append(args, e) + } + } + return name, args, aliased +} + +// call types a function of the native registry, or -- when no native function has that +// name -- a schema-declared FUNCTION (storedFuncCall): measured on mysqld 8.4, an +// unqualified call always reaches a native function of the same name first (a schema is +// free to declare a FUNCTION named like a builtin, `abs` say; `SELECT abs(-1)` is still +// 1), and a qualified call, `db.name(...)`, only ever names a stored function (measured: +// `SELECT db.abs(-1)` runs the schema's own, and `db.nope(1)` is 1305 "FUNCTION db.nope +// does not exist", never a native function). The db itself is not compared, the way a +// table's qualifier is not (sqlshape loads a single schema). +func (a *analyzer) call(sc scope, n *mysqlast.Node, where string) (typed, error) { + name, args, aliased := funcCallPartsAlias(n) + if n.Class == "PTI_function_call_generic_2d" && str(n.Arg("db")) != "" { + if r := a.s.RoutineOf(schema.Function, name); r != nil { + return a.storedFuncCall(sc, r, args, n.Start, where) + } + return unknown, &Error{Message: fmt.Sprintf("FUNCTION %s does not exist", name), Code: 1305, Position: a.ph.Back(n.Start)} + } + f := catalog.Lookup(name) + if f == nil { + if aliased { + // `f(x AS a)` outside the native registry is refused before the function + // is even looked up (measured: a nonexistent name gets 1584, not 1305) + return unknown, &Error{Message: fmt.Sprintf("Incorrect parameters in the call to stored function `%s`", name), Code: 1584, Position: a.ph.Back(n.Start)} + } + if r := a.s.RoutineOf(schema.Function, name); r != nil { + return a.storedFuncCall(sc, r, args, n.Start, where) + } + return unknown, &Error{Message: fmt.Sprintf("FUNCTION %s does not exist", name), Code: 1305, Position: a.ph.Back(n.Start)} + } + if f.Internal { + // the data dictionary's own functions (INTERNAL_TABLE_ROWS and friends) are + // refused whenever a statement names them, before the argument count and the + // alias check alike (measured), the name spelt as written + return unknown, &Error{Message: fmt.Sprintf("Access to native function '%s' is rejected.", name), Code: 3566, Position: a.ph.Back(n.Start)} + } + if f.Min >= 0 && !f.Accepts(len(args)) { + // the count check comes first (measured: `abs(1 AS x, 2)` is 1582, not 1583), + // and its message spells the name as written where 1583's lowercases it + return unknown, &Error{Message: fmt.Sprintf("Incorrect parameter count in the call to native function '%s'", name), Code: 1582, Position: a.ph.Back(n.Start)} + } + if aliased { + return unknown, &Error{Message: fmt.Sprintf("Incorrect parameters in the call to native function '%s'", strings.ToLower(name)), Code: 1583, Position: a.ph.Back(n.Start)} + } + if strings.EqualFold(name, "NAME_CONST") { + if e := a.nameConstArgs(args, n.Start); e != nil { + return unknown, e + } + } + ts, err := a.callArgs(sc, f.Class, args, where) + if err != nil { + return unknown, err + } + if f.Class == "Item_func_geometry_from_text" || f.Class == "Item_func_geometry_from_wkb" { + return a.geometryReader(name, args, ts, n.Start) + } + if e := a.rejectsGeometry(f.Class, strings.ToLower(name), ts, n.Start); e != nil { + return unknown, e + } + switch f.Factory { + case "Datediff_instantiator": // TO_DAYS(a) - TO_DAYS(b): a bigint, NULL for an invalid date + a.setParamField(args[0], "DATETIME") + a.setParamField(args[1], "DATETIME") + return known("bigint", true), nil + case "X_instantiator", "Y_instantiator", "Latitude_instantiator", "Longitude_instantiator": + if len(args) == 1 { // the observer reads a coordinate; with two arguments the mutator returns the geometry + a.setParamField(args[0], "GEOMETRY") + return known("double", anyNullable(ts)), nil + } + case "Srid_instantiator": + if len(args) == 1 { + a.setParamField(args[0], "GEOMETRY") + return known("bigint", anyNullable(ts)), nil + } + case "From_unixtime_instantiator": + if len(args) == 1 { // Item_func_from_unixtime; with a format it is DATE_FORMAT + a.setParamField(args[0], "NEWDECIMAL") + return known("datetime", true), nil + } + } + return a.classType(f.Class, args, ts, isWindowFunction(n)), nil +} + +// storedFuncCall types a call to a schema-declared FUNCTION: its argument count must match +// (1318, message measured: "Incorrect number of arguments for FUNCTION db.f; expected N, +// got M" -- this checker's own message drops the db qualifier, the way its 1305 already +// does), each argument is typed and, when it is a bare placeholder, takes the parameter's +// own declared type (a stored function's parameters are always IN: schema.Param's own +// doc). The result is Returns, nullable unless the function's own `-- sqlshape: not null` +// directive says otherwise (r.NotNull, schema.go's spDirectives): a stored function's +// RETURN can otherwise produce NULL regardless of the declared type, and there is no +// static proof otherwise -- the same reasoning a routine variable's own bodyVar.nullable +// always being true follows, and the same override postgres/analyze's function call typing +// reads from schema.Function.NotNull. The call site is noted (noteCalledRoutine, call.go) +// for its own failure modes (violations(), through calledRoutineViolations) and the +// table-overlap check (1442, checkCalledRoutineOverlap). +func (a *analyzer) storedFuncCall(sc scope, r *schema.Routine, args []mysqlast.Value, at int, where string) (typed, error) { + if len(args) != len(r.Params) { + return unknown, &Error{Message: fmt.Sprintf("Incorrect number of arguments for FUNCTION %s; expected %d, got %d", r.Name, len(r.Params), len(args)), Code: 1318, Position: a.ph.Back(at)} + } + for i, arg := range args { + if _, err := a.expr(sc, arg, where); err != nil { + return unknown, err + } + if isParam(arg) { + a.setParam(arg, r.Params[i].Type) + } + } + a.noteCalledRoutine(r, at) + if a.routine != nil && r == a.routine { + // unlike a PROCEDURE's own direct recursion (walkCall's own self-CALL check, + // body.go -- 1456, and only certain once max_sp_recursion_depth is exceeded), a + // FUNCTION can never recurse at all: CREATE FUNCTION accepts a body that RETURNs + // the result of calling itself (measured), but every single execution fails with + // 1424 ("Recursive stored functions and triggers are not allowed"), regardless of + // max_sp_recursion_depth. + a.raised = append(a.raised, Violation{Code: 1424, Constraint: itoa(1424), SQLState: "HY000"}) + } + return typed{typ: r.Returns, known: r.Returns.Name != "", nullable: !r.NotNull}, nil +} + +// classType is what an Item class returns over typed arguments, from the catalog: the +// family fixes the result kind, the facts refine the type, the nullability and the +// placeholders' types; the hybrid families compute from the arguments. windowed says the +// call carries an OVER clause: its value then reaches the client through the window's +// temporary table, which widens the narrow integers (tmpTableInt). +func (a *analyzer) classType(class string, args []mysqlast.Value, ts []typed, windowed bool) typed { + fs := classFacts(class) + // placeholders: param_type_is_default gives a type by position, param_type_uses_non_param the others' type + for _, f := range fs { + if from, to, ft, ok := paramDefault(f); ok { + if to < 0 || to > len(args) { + to = len(args) + } + for i := from; i < to && i < len(args); i++ { + a.setParamField(args[i], ft) + } + } + } + if ft, ok := paramNonParam(fs); ok { + a.paramsFromOthers(args, ts, ft) + } + + nullable := anyNullable(ts) + if nb, ok := factNullable(fs); ok { + nullable = nb + } + fam := catalog.FamilyOf(class) + switch { + case fixNullable(class): + nullable = anyNullable(ts) // its fix_fields decides, last + case strictNullable(class) && a.s.Settings.Strict(): + nullable = true // Item_str_func::fix_fields: nullable in strict mode (the default) + case fam == "Item_json_func": + nullable = true // Item_json_func's constructor + case strings.HasPrefix(class, "Item_typecast_"): + nullable = true // a value the cast cannot convert becomes NULL + } + var t typed + switch { + // the hybrids: the arguments decide + case isA(class, "Item_func_int_val"): + if len(ts) > 0 { + t = num1(ts[0], true) + } + case isA(class, "Item_func_num1"): + if len(ts) > 0 { + t = num1(ts[0], false) + if class == "Item_func_round" && len(ts) == 1 && t.known && t.typ.Name == "decimal" { + t.typ.Dec = 0 // ROUND(x) rounds to an integer + t.typ.Length = ts[0].typ.Length + } + } + case isA(class, "Item_num_op"): + if len(ts) == 2 { + t = numOp(ts[0], ts[1], false) + } + case isA(class, "Item_func_min_max"): + t = aggregate(ts) + if t.known && t.typ.Name == "json" { + t = known("varchar", t.nullable) // GREATEST / LEAST compare JSON as strings + } + case fam == "Item_temporal_hybrid_func": + // ADDTIME / SUBTIME / TIMESTAMP(): a TIME stays TIME, any other temporal first argument + // makes a DATETIME, a string stays a string. STR_TO_DATE: a DATETIME unless the + // format is a constant (not read). DATE_ADD is built by the grammar, not here. + name := "VARCHAR" + switch { + case class == "Item_func_str_to_date": + name = strToDateType(args) + case len(ts) > 0 && ts[0].known && ts[0].typ.Name == "time": + name = "TIME" + case len(ts) > 0 && ts[0].known && isTemporal(ts[0].typ): + name = "DATETIME" + } + if typ, ok := fromFieldType(name, false); ok { + t = typed{typ: typ, known: true} + } + case isA(class, "Item_func_coalesce"): + t = aggregate(ts) + nullable = true + for _, x := range ts { + if !x.nullable { + nullable = false // a non-nullable argument guarantees a value + } + } + case isA(class, "Item_func_nullif"): + if len(ts) > 0 { + t = ts[0] + if t.known && kindOf(t.typ) == "STRING_RESULT" && t.typ.Name != "json" { // set_data_type_string: temporal values included + typ, _ := fromFieldType("VARCHAR", isBinary(t.typ)) + typ.Length = t.typ.Length + t.typ = typ + } + nullable = true + } + case isA(class, "Item_sum_hybrid"): // MIN / MAX + if len(ts) > 0 { + t = ts[0] + } + case class == "Item_first_last_value", class == "Item_nth_value": // FIRST_VALUE/LAST_VALUE/NTH_VALUE OVER (...): set_data_type_from_item(args[0]) + if len(ts) > 0 { + t = typed{typ: ts[0].typ, known: ts[0].known} + } + case class == "Item_lead_lag": // LAG/LEAD OVER (...): resolve_type aggregates the value + // expr (ts[0]) with the default value (ts[2]) when both a default and an offset are + // given (exprArgs then carries three arguments: value, offset, default); nullable + // follows either of those two when a default is present, and is unconditionally + // true otherwise (there is no value for the missing previous/next/offset row, + // measured on mysqld 8.4). + if len(ts) > 0 { + agg := ts[:1] + if len(ts) >= 3 { + agg = []typed{ts[0], ts[2]} + } + t = aggregate(agg) + if len(ts) >= 3 { + nullable = ts[0].nullable || ts[2].nullable + } else { + nullable = true + } + } + case isA(class, "Item_sum_sum"): // SUM, AVG + if len(ts) > 0 && ts[0].known { + switch numericContext(ts[0]) { + case "REAL_RESULT": + t = known("double", true) + default: + t = known("decimal", true) + } + } + case class == "Item_func_unix_timestamp": + // a bigint, or a decimal with the fractional seconds of the argument (a string or a + // number converts to DATETIME(6) first) + t = known("bigint", false) + if len(ts) > 0 && ts[0].known { + switch { + case isTemporal(ts[0].typ) && ts[0].typ.Dec <= 0, kindOf(ts[0].typ) == "INT_RESULT": + default: + t = known("decimal", false) + } + } else if len(ts) > 0 { + t = known("decimal", false) + } + case isA(class, "Item_sum_count"): + t = known("bigint", false) + case isA(class, "Item_sum_bit"): + t = known("bigint", false) + t.typ.Unsigned = true + case isA(class, "Item_func_group_concat"): + t = known("text", true) + case isA(class, "Item_sum_json_array") || isA(class, "Item_sum_json_object"): + t = known("json", true) + case class == "Item_row_number" || class == "Item_rank" || class == "Item_dense_rank" || class == "Item_ntile": + t = known("bigint", false) + case class == "Item_cume_dist" || class == "Item_percent_rank": + t = known("double", false) + case fam == "Item_bool_func", class == "Item_func_regexp_like": + t = boolean(nullable) // set_data_type_bool: a bigint(1) + default: + name := factType(fs) + if name == "" { + switch fam { + case "Item_int_func", "Item_sum_int": + name = "LONGLONG" + case "Item_str_func", "Item_str_ascii_func", "Item_static_string_func": + name = "VARCHAR" + case "Item_real_func", "Item_dec_func", "Item_sum_num": + name = "DOUBLE" + case "Item_json_func": + name = "JSON" + case "Item_geometry_func": + name = "GEOMETRY" + case "Item_datetime_func": + name = "DATETIME" + case "Item_date_func": + name = "DATE" + case "Item_time_func": + name = "TIME" + } + } + if name != "" { + // the result collation: binary when the facts say so, or when an Item_str_func + // sets none (Item's default collation is binary); the arguments' when the facts + // aggregate them, which a binary argument makes binary + binary := factBinary(fs) + switch { + case binary: + case isA(class, "Item_func_sysconst"), fam == "Item_static_string_func": + // USER() / CURRENT_USER() / DATABASE() / SCHEMA() / VERSION() / CURRENT_ROLE() + // ...: the constructor's collation.set(system_charset_info) makes them + // utf8mb3 strings, which classFacts (the class's own resolve_type facts) + // does not see + binary = false + case class == "Item_func_quote": + binary = false // a binary argument's quotes take the connection's collation + case strings.Contains(strings.Join(fs, ";"), "args[0]->collation"): + binary = len(ts) > 0 && ts[0].known && isBinary(ts[0].typ) + case aggregatesCharset(fs): + for _, x := range ts { + if x.known && isBinary(x.typ) { + binary = true + } + } + case fam == "Item_str_func" && !explicitCharset(fs): + binary = true + } + if typ, ok := fromFieldType(name, binary); ok { + t = typed{typ: typ, known: true} + } + } + } + if !t.known { + return unknown + } + if factUnsigned(fs) { + t.typ.Unsigned = true + } + switch { + case class == "Item_lead_lag": + // unlike every other Item_non_framing_wf, LAG/LEAD really can be NULL (the + // preceding/following row does not exist, or an explicit NULL default): computed + // above, not forced false the way ROW_NUMBER/RANK/NTILE/... are. + t.nullable = nullable + case isA(class, "Item_sum_count"), isA(class, "Item_sum_bit"), isA(class, "Item_non_framing_wf"): + // Item_sum::resolve_type says nullable for every aggregate; these never are + t.nullable = false + case isA(class, "Item_sum"): + t.nullable = true // an aggregate over no rows is NULL + case class == "Item_func_regexp_replace": + // set_data_type_string(MAX_BLOB_WIDTH) in the arguments' character set: over a + // character string the 16M characters exceed max_allowed_packet (64M bytes at + // utf8mb4), which Item_str_func::fix_fields turns into nullable; binary and + // numeric arguments stay within it + t.nullable = nullable + for _, x := range ts { + if x.known && kindOf(x.typ) == "STRING_RESULT" && !isBinary(x.typ) && !isTemporal(x.typ) && x.typ.Name != "json" { + t.nullable = true + } + } + default: + t.nullable = nullable + } + if windowed { + t.typ = tmpTableInt(t.typ) + } + return t +} + +// cast types CAST / CONVERT: the target type as written. Casts to temporal types are +// nullable (an unparsable value becomes NULL); the others follow their argument. +func (a *analyzer) cast(sc scope, n *mysqlast.Node, where string) (typed, error) { + arg, err := a.expr(sc, n.Arg("arg"), where) + if err != nil { + return unknown, err + } + target, length, dec := "", -1, -1 + binary := false + switch x := n.Arg("type").(type) { + case *mysqlast.Struct: + target = str(x.Fields["target"]) + length = intOr(x.Fields["length"], -1) + dec = intOr(x.Fields["dec"], -1) + binary = strings.TrimPrefix(str(x.Fields["charset"]), "&") == "my_charset_bin" || isTrue(x.Fields["binary"]) + if strings.Contains(target, "?ITEM_CAST_DOUBLE:ITEM_CAST_FLOAT") { // the action's ($1 == DOUBLE) ? ... : ... + target = "ITEM_CAST_FLOAT" + if up := strings.ToUpper(a.text[n.Start:n.End]); strings.Contains(up, "DOUBLE") || strings.Contains(up, "REAL") { + target = "ITEM_CAST_DOUBLE" + } + } + default: + target = str(x) // BINARY x: the cast to CHAR with the binary charset in as_array's slot + binary = str(n.Arg("as_array")) == "my_charset_bin" + } + t := typed{typ: schema.Type{Length: length, Dec: dec}, known: true, nullable: arg.nullable} + switch strings.TrimPrefix(target, "ITEM_CAST_") { + case "SIGNED_INT": + t.typ.Name = "bigint" + case "UNSIGNED_INT": + t.typ.Name, t.typ.Unsigned = "bigint", true + case "CHAR": + t.typ.Name, t.nullable = "varchar", true // an Item_str_func: nullable in strict mode + if binary { + t.typ.Name = "varbinary" + } + if length < 0 && arg.known && arg.typ.Length >= 0 { + t.typ.Length = arg.typ.Length + } + case "NCHAR": + t.typ.Name, t.typ.Charset, t.nullable = "varchar", "utf8mb3", true + case "DECIMAL": + t.typ.Name = "decimal" + if length < 0 { + t.typ.Length, t.typ.Dec = 10, 0 + } else if dec < 0 { + t.typ.Dec = 0 + } + case "FLOAT": + t.typ.Name = "float" + case "DOUBLE": + t.typ.Name = "double" + case "DATE": + t.typ.Name, t.typ.Length, t.nullable = "date", -1, true + case "TIME": + t.typ.Name, t.typ.Length, t.nullable = "time", -1, true + case "DATETIME": + t.typ.Name, t.typ.Length, t.nullable = "datetime", -1, true + case "YEAR": + t.typ.Name, t.nullable = "year", true + case "JSON": + t.typ.Name, t.nullable = "json", true // Item_json_func: nullable by construction + case "POINT", "LINESTRING", "POLYGON", "MULTIPOINT", "MULTILINESTRING", "MULTIPOLYGON", "GEOMETRYCOLLECTION": + t.typ.Name = strings.ToLower(strings.TrimPrefix(target, "ITEM_CAST_")) + default: + return unknown, nil + } + return t, nil +} + +// paramsFromOthers types the placeholders among args by the other arguments: the type +// ft when given (param_type_uses_non_param(thd, TYPE)), else the aggregate of the typed +// arguments (a placeholder compared with a column takes the column's type). +func (a *analyzer) paramsFromOthers(args []mysqlast.Value, ts []typed, ft string) { + var t schema.Type + ok := false + if ft != "" { + t, ok = fromFieldType(ft, false) + } else { + var others []typed + for i, v := range args { + if !isParam(v) && i < len(ts) && ts[i].known && ts[i].typ.Name != "null" { + others = append(others, ts[i]) + } + } + if len(others) == 1 { + t, ok = others[0].typ, true + } else if len(others) > 1 { + agg := aggregate(others) + t, ok = agg.typ, agg.known + } + } + if !ok { + return + } + if t.IsInteger() { + t.Length = -1 // a display width says nothing about a value + } + for _, v := range args { + if isParam(v) { + a.setParam(v, t) + } + } +} + +// exprArgs are the expression arguments of a grammar-built Item node: its Node / List +// arguments, through the PTI_in_sum_expr / PTI_udf_expr wrappers, skipping the +// constants (operator kinds, interval units, flags). +// exprArgsTail is an Item_row's tail (its elements after the head). +func exprArgsTail(row *mysqlast.Node) mysqlast.List { + l, _ := row.Arg("tail").(mysqlast.List) + return l +} + +func exprArgs(n *mysqlast.Node) []mysqlast.Value { + var out []mysqlast.Value + var add func(v mysqlast.Value) + add = func(v mysqlast.Value) { + switch x := v.(type) { + case *mysqlast.Node: + switch x.Class { + case "PTI_in_sum_expr", "PTI_udf_expr": + add(x.Arg("expr")) + case "PT_window", "String": + // a window specification, a separator: not an argument + default: + out = append(out, x) + } + case mysqlast.List: + for _, e := range x { + add(e) + } + } + } + for _, v := range n.Args { + add(v) + } + return out +} + +func anyNullable(ts []typed) bool { + for _, t := range ts { + if t.nullable { + return true + } + } + return false +} + +// intOr reads a number or a numeric token; def when v is neither. +func intOr(v mysqlast.Value, def int) int { + switch x := v.(type) { + case mysqlast.Number: + return int(x) + case mysqlast.Token, string, mysqlast.Const: + if n, ok := atoi(strings.Trim(str(x), "\"")); ok { + return n + } + } + return def +} + +func isTrue(v mysqlast.Value) bool { + return str(v) == "true" +} + +// strToDateType is the type STR_TO_DATE gives from a literal format +// (Item_func_str_to_date::fix_from_format): a TIME when the format has only time parts, a +// DATE when only date parts, a DATETIME when both (or when the format is not a literal). +func strToDateType(args []mysqlast.Value) string { + if len(args) < 2 { + return "DATETIME" + } + n, ok := args[1].(*mysqlast.Node) + if !ok || n.Class != "PTI_text_literal_text_string" { + return "DATETIME" + } + tok, ok := n.Arg("literal").(mysqlast.Token) + if !ok { + return "DATETIME" + } + format := tok.Value + date, time := false, false + for i := 0; i+1 < len(format); i++ { + if format[i] != '%' { + continue + } + i++ + switch { + case format[i] == 'f', strings.IndexByte("HISThiklrs", format[i]) >= 0: + time = true + case strings.IndexByte("MVUXYWabcjmvuxyw", format[i]) >= 0: + date = true + } + } + switch { + case time && date: + return "DATETIME" + case time: + return "TIME" + } + return "DATE" +} + +// condArgs types an AND / OR / XOR's operands. AND and OR stop at the first constant that +// decides them (measured: `1 = 0 AND x` never evaluates x, `x AND 1 = 0` does): the +// operands after it are typed with folding off. A NULL decides an AND only where the +// optimizer folds the condition (WHERE / HAVING / ON: remove_eq_conds), not in a value +// (Item_cond_and::val_int evaluates on through a NULL); and in a condition a nested +// AND / OR is still simplified on its own, decided or not. +func (a *analyzer) condArgs(sc scope, n *mysqlast.Node, where string) ([]typed, error) { + args := exprArgs(n) + if n.Class == "Item_func_xor" { + return a.exprs(sc, args, where) + } + and := n.Class == "Item_cond_and" + cond := condContext(where) + ts := make([]typed, len(args)) + decided := false + for i, arg := range args { + skip := decided && !(cond && (isCondNode(arg) || hasSubquery(arg))) + if skip { + a.noFold++ + } + t, err := a.expr(sc, arg, where) + if skip { + a.noFold-- + } + if err != nil { + return nil, err + } + ts[i] = t + if !decided { + if b, null, ok := a.constBool(arg); ok { + switch { + case and && !null && !b, and && null && cond, !and && !null && b: + decided = true + } + } + } + } + return ts, nil +} + +// exprsDead types args, those from index dead on (dead >= 0) with folding off: the +// entries a constant settles the server never reaches (COALESCE, IN). +func (a *analyzer) exprsDead(sc scope, args []mysqlast.Value, where string, dead int) ([]typed, error) { + return a.exprsOff(sc, args, where, func(i int) bool { return dead >= 0 && i >= dead }) +} + +// exprsOff types args, the ones off says with folding off. +func (a *analyzer) exprsOff(sc scope, args []mysqlast.Value, where string, off func(int) bool) ([]typed, error) { + out := make([]typed, len(args)) + for i, v := range args { + if off(i) { + a.noFold++ + } + t, err := a.expr(sc, v, where) + if off(i) { + a.noFold-- + } + if err != nil { + return nil, err + } + out[i] = t + } + return out, nil +} + +// caseArgs types a CASE's WHEN and THEN / ELSE expressions in evaluation order: a +// constant WHEN (or a constant operand equal to a constant WHEN) picks its THEN and turns +// folding off for what the server never evaluates (the later WHENs and THENs, the ELSE), +// a constant false WHEN turns it off for its own THEN. +func (a *analyzer) caseArgs(sc scope, first mysqlast.Value, whens, thens, results []mysqlast.Value, where string) ([]typed, []typed, error) { + wt := make([]typed, len(whens)) + rt := make([]typed, len(results)) + typeAt := func(v mysqlast.Value, off bool) (typed, error) { + if off { + a.noFold++ + defer func() { a.noFold-- }() + } + return a.expr(sc, v, where) + } + decided := false + deadThen := map[int]bool{} + nWhens := len(thens) // whens may carry the operand as its last entry + for i := 0; i < nWhens; i++ { + t, err := typeAt(whens[i], decided) + if err != nil { + return nil, nil, err + } + wt[i] = t + if decided { + deadThen[i] = true + continue + } + if first == nil { + if b, null, ok := a.constBool(whens[i]); ok { + if b && !null { + decided = true + } else { + deadThen[i] = true + } + } + } else if eq, ok := a.constEqual(first, whens[i]); ok { + if eq { + decided = true + } else { + deadThen[i] = true + } + } + } + if first != nil && len(whens) > nWhens { + t, err := typeAt(whens[nWhens], false) + if err != nil { + return nil, nil, err + } + wt[nWhens] = t + } + for i, v := range results { + off := deadThen[i] || (i >= len(thens) && decided) + t, err := typeAt(v, off) + if err != nil { + return nil, nil, err + } + rt[i] = t + } + return wt, rt, nil +} + +// firstNonNull is the index after the first constant non-NULL argument of a COALESCE / +// IFNULL (the arguments the server never reaches), -1 when no constant settles it. +func (a *analyzer) firstNonNull(args []mysqlast.Value) int { + for i, arg := range args { + c, ok, fail := a.fold(arg) + if !ok || fail != nil { + return -1 + } + if !c.isNull() { + return i + 1 + } + } + return -1 +} + +// inMatch is the index after the constant list entry a constant `x IN (...)` operand first +// equals (the entries never compared), -1 when the operand is not a constant or nothing +// constant matches. +func (a *analyzer) inMatch(list mysqlast.List) int { + if len(list) < 2 { + return -1 + } + if _, ok, fail := a.fold(list[0]); !ok || fail != nil { + return -1 + } + for i := 1; i < len(list); i++ { + eq, ok := a.constEqual(list[0], list[i]) + if !ok { + return -1 + } + if eq { + return i + 1 + } + } + return -1 +} + +// isNullArg types the argument of IS NULL / ISNULL: folding off, then judged only when the +// argument can be NULL (a never-NULL argument makes the predicate a constant false the +// server never evaluates the argument for). +func (a *analyzer) isNullArg(sc scope, v mysqlast.Value, where string) error { + a.noFold++ + t, err := a.expr(sc, v, where) + a.noFold-- + if err != nil { + return err + } + if n, ok := v.(*mysqlast.Node); ok && (t.nullable || !t.known) { + a.parents = append(a.parents, n) + e := a.constCheck(sc, n, where) + a.parents = a.parents[:len(a.parents)-1] + if e != nil { + return e + } + } + return nil +} + +// callArgs types a native function's arguments, with the laziness of IFNULL (its second +// argument is never evaluated when the first is a constant non-NULL) and ISNULL (isNullArg). +func (a *analyzer) callArgs(sc scope, class string, args []mysqlast.Value, where string) ([]typed, error) { + switch class { + case "Item_func_ifnull": + return a.exprsDead(sc, args, where, a.firstNonNull(args)) + case "Item_func_isnull": + if len(args) == 1 { + if err := a.isNullArg(sc, args[0], where); err != nil { + return nil, err + } + return []typed{boolean(false)}, nil + } + } + return a.exprs(sc, args, where) +} + +// hasSubquery reports a PT_subquery anywhere under v. +func hasSubquery(v mysqlast.Value) bool { + switch x := v.(type) { + case mysqlast.List: + for _, e := range x { + if hasSubquery(e) { + return true + } + } + case *mysqlast.Node: + if x.Class == "PT_subquery" { + return true + } + for _, arg := range x.Args { + if hasSubquery(arg) { + return true + } + } + } + return false +} diff --git a/check/mysql/internal/analyze/facts.go b/check/mysql/internal/analyze/facts.go new file mode 100644 index 0000000..3391dd0 --- /dev/null +++ b/check/mysql/internal/analyze/facts.go @@ -0,0 +1,1133 @@ +package analyze + +import ( + "sort" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlast" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/facts" +) + +// The statement's facts (x/facts): what one block provably does, for the contracts that are +// written on facts rather than on a dialect's tree — the One proof (x/cardinality) and the +// obligations. Everything recorded holds for every surviving row: a conjunct of WHERE or of +// an inner join's ON, and for an outer join only what its ON says about the nullable side; a +// disjunction contributes nothing, a function call is opaque. + +// block records one SELECT block (or the target list of a write) after its clauses are typed. +func (a *analyzer) block(sc *scope, body *mysqlast.Node) *facts.Scope { + fs := &facts.Scope{At: -1} + for _, r := range sc.rels { + fs.Leaves = append(fs.Leaves, a.leafFacts(r)) + } + a.checkOptionFacts(sc, fs) + if w := body.Arg("opt_where_clause"); w != nil { + if n, ok := w.(*mysqlast.Node); ok { + fs.At = int32(a.ph.Back(n.Start)) + if n.Class == "PTI_where" { + w = n.Arg("expr") + } + } + for _, c := range conjuncts(w) { + if constTrue(c) { + continue // the server removes it (WHERE 1 = 1) + } + a.predFacts(sc, fs, c, nil) + a.nullRejecting(sc, c, nil) + } + } + for _, j := range sc.joins { + var restrict []int // nil: the conjunct holds for every row of the block + switch { + case strings.Contains(j.kind, "LEFT"): + restrict = j.right + case strings.Contains(j.kind, "RIGHT"): + restrict = j.left + } + if j.on != nil { + for _, c := range conjuncts(j.on) { + a.predFacts(sc, fs, c, restrict) + a.nullRejecting(sc, c, restrict) + } + if restrict != nil && !deterministic(j.on) { + sc.ndJoins = append(sc.ndJoins, restrict) + } + } + for _, name := range j.using { + l, okl := a.colIn(sc, j.left, name) + r, okr := a.colIn(sc, j.right, name) + if okl && okr { + a.equalityFacts(fs, l, r, restrict) + sc.nnMarks = append(sc.nnMarks, nnMark{l, restrict}, nnMark{r, restrict}) + } + } + } + closeFixed(fs) + // fdClosure's cutoffs, before HAVING's own preds and nnMarks are added + sc.wherePreds = len(fs.Preds) + sc.whereNN = len(sc.nnMarks) + if sc.kids != nil { + for _, k := range *sc.kids { + if !a.claimed[k] { // a subquery body hangs off its EXISTS / IN predicate instead + fs.Children = append(fs.Children, k) + } + } + } + a.shape(sc, fs, body) + a.havingFacts(sc, fs, body) + return fs +} + +// checkOptionFacts: a write through a view declared WITH CHECK OPTION is pinned by +// whatever the view's own WHERE (and, unless the view says LOCAL -- MySQL's own default, +// a plain WITH CHECK OPTION, is CASCADED) an underlying view's WHERE fixes. The server +// refuses any row that would not satisfy it (1369 ER_VIEW_CHECK_FAILED, always naming the +// view written through, whichever level's WHERE actually failed -- measured), the +// write-side counterpart of a view carrying its WHERE into a reading statement's +// obligations. Also records the view as a failure mode of the write's base table +// (violations.go's checkOptionViolations), by the same reasoning insertViolations / +// updateViolations already use for the schema's own constraints. +func (a *analyzer) checkOptionFacts(sc *scope, fs *facts.Scope) { + for i, r := range sc.rels { + if !r.target || r.view == "" { + continue + } + v := a.s.View(r.view) + if v == nil || !a.chainHasCheckOption(v, nil) { + // an underlying view's own option is enforced whatever the target view says, + // and the 1369 still names the target (measured: an INSERT through a plain + // view over a WITH CHECK OPTION one fails naming the outer view) + continue + } + for _, pr := range facts.LiftThroughView(fs.Leaves[i], i) { + if pr.Op == facts.Eq { + fs.Preds = append(fs.Preds, pr) + } + } + if base := baseTableOf(r.cols); base != nil { + if a.checkOptionViews == nil { + a.checkOptionViews = map[*schema.Table][]string{} + } + a.checkOptionViews[base] = append(a.checkOptionViews[base], r.view) + } + } +} + +// havingFacts folds a HAVING conjunct into fs exactly like a WHERE conjunct, but only when +// doing so is sound: mysqld runs HAVING after grouping, so in general a HAVING predicate +// says something about a group's aggregate, not about one row. It is safe -- and measured +// identical to the same predicate written in WHERE -- for exactly the conjuncts that read +// no aggregate and mention only columns that are themselves GROUP BY expressions: within a +// surviving group every row shares that column's one value (that is what GROUP BY means), +// so filtering groups by it is the same partition of rows WHERE would have made before +// grouping. A conjunct that reads an aggregate, or a non-grouped column MySQL's own +// extension allows into HAVING, is left as untyped noise -- shape() and this function +// together are what fullgroup.go's stricter ONLY_FULL_GROUP_BY validation does not need to +// answer, so neither borrows the other's classification. +func (a *analyzer) havingFacts(sc *scope, fs *facts.Scope, body *mysqlast.Node) { + hv := havingExpr(body.Arg("opt_having_clause")) + if hv == nil { + return + } + groupCols := map[facts.ColRef]bool{} + for _, g := range fs.Groups { + if g.Kind == facts.Column { + groupCols[g.Col] = true + } + } + changed := false + for _, c := range conjuncts(hv) { + if constTrue(c) { + continue + } + n, ok := c.(*mysqlast.Node) + if !ok || containsAggregate(c) { + continue + } + if _, cols := a.opaqueText(sc, n); !allIn(cols, groupCols) { + continue // says something about the group, not about one grouped-by column + } + a.predFacts(sc, fs, c, nil) + a.nullRejecting(sc, c, nil) + changed = true + } + if changed { + closeFixed(fs) + } +} + +// allIn reports whether every column of cols is a key of set. +func allIn(cols []facts.ColRef, set map[facts.ColRef]bool) bool { + for _, c := range cols { + if !set[c] { + return false + } + } + return true +} + +// shape writes down what a SELECT block says about its own row count: its GROUP BY +// expressions (each a leaf column, a known value, or an expression; ROLLUP is one row per +// set). An aggregate without GROUP BY makes the block one row too, but which aggregates +// are the block's own is groupCheck's to say, so querySpecification sets Single after it. +func (a *analyzer) shape(sc *scope, fs *facts.Scope, body *mysqlast.Node) { + if body.Class != "PT_query_specification" { + return + } + items, _ := body.Arg("item_list").(mysqlast.List) + g, ok := body.Arg("opt_group_clause").(*mysqlast.Node) + if !ok { + return + } + fs.Groups = []facts.Term{} + fs.GroupingSets = str(g.Arg("olap")) != "" && str(g.Arg("olap")) != "UNSPECIFIED_OLAP_TYPE" + list, _ := g.Arg("group_list").(mysqlast.List) + for _, it := range list { + item := it + if oe, ok := it.(*mysqlast.Node); ok && oe.Class == "PT_order_expr" { + item = oe.Arg("item") + } + fs.Groups = append(fs.Groups, a.groupTerm(sc, items, item)) + } +} + +// groupTerm classifies one GROUP BY item the way the server resolves it: an integer is a +// select-list position, a bare name a select-list alias first, then a column. +func (a *analyzer) groupTerm(sc *scope, items mysqlast.List, item mysqlast.Value) facts.Term { + if n, ok := item.(*mysqlast.Node); ok { + switch n.Class { + case "Item_int", "Item_uint": + i := intOr(n.Arg("i"), intOr(n.Arg("str"), 0)) - 1 + if i >= 0 && i < len(items) { + if ewa, ok := items[i].(*mysqlast.Node); ok && ewa.Class == "PTI_expr_with_alias" { + return a.groupTerm(sc, nil, ewa.Arg("expr")) + } + } + case "PTI_simple_ident_ident", "PTI_simple_ident_nospvar_ident": + name := str(n.Arg("ident")) + for _, it := range items { + if ewa, ok := it.(*mysqlast.Node); ok && ewa.Class == "PTI_expr_with_alias" && strings.EqualFold(str(ewa.Arg("alias")), name) { + if _, isCol := a.colFact(sc, n); !isCol { + return a.groupTerm(sc, nil, ewa.Arg("expr")) + } + } + } + } + } + if col, ok := a.colFact(sc, item); ok { + return facts.Term{Kind: facts.Column, Col: col} + } + if t, ok := a.termFacts(sc, item); ok { + return t + } + text := "" + if n, ok := item.(*mysqlast.Node); ok { + text = a.textOf(n) + } + return facts.Term{Kind: facts.Expr, Text: text} +} + +// leafFacts describes one relation of the block: its table and the unique keys the proof +// may fix a row by (the primary key and the UNIQUE keys over whole columns). +func (a *analyzer) leafFacts(r relation) facts.Leaf { + lf := facts.Leaf{Alias: r.alias, Kind: facts.Derived, Role: facts.Read, Position: int32(a.ph.Back(r.pos))} + if r.target { + lf.Role = facts.Target + } + if r.cte { + lf.Kind = facts.CTE + } + if r.table != nil { + lf.Table, lf.Kind = r.table.Name, facts.Table + for _, k := range r.table.Keys { + if k.Kind != schema.Primary && k.Kind != schema.Unique || len(k.Parts) == 0 { + continue + } + cols := make([]string, 0, len(k.Parts)) + for _, p := range k.Parts { + if p.Expr != nil || p.Length != 0 || p.Column == "" { + cols = nil + break + } + cols = append(cols, p.Column) + } + if cols != nil { + lf.Keys = append(lf.Keys, facts.Key{Columns: cols}) + } + } + } else if r.target && r.view != "" && r.updatable { + // a write through an updatable view lands on the base table (WITH CHECK OPTION + // pins it further, in checkOptionFacts); the rows written are the view's, proved + // through its body. r.updatable being true here (the column resolution that made + // this a write target already required it, or the statement would have failed + // with 1288 before facts were built) means every plain column of r.cols carries + // its ultimate base table, however many views it passed through. + if base := baseTableOf(r.cols); base != nil { + lf.Table, lf.Kind = base.Name, facts.Table + } else { + lf.Table, lf.Kind = r.view, facts.View + } + } else if r.view != "" { + lf.Table, lf.Kind = r.view, facts.View + } + if r.view != "" { + if v := a.s.View(r.view); v != nil { + switch v.CheckOption { + case "LOCAL": + lf.CheckOption = facts.LocalCheckOption + case "CASCADED": + lf.CheckOption = facts.CascadedCheckOption + } + } + } + if r.table == nil && r.body != nil { + lf.Body = r.body + for _, c := range r.cols { + if c.leaf1 > 0 { + lf.Outputs = append(lf.Outputs, facts.Output{Name: c.Name, Col: facts.ColRef{Leaf: c.leaf1 - 1, Column: c.leafCol}}) + } + } + } + if lf.Table != "" { + // the opt-outs: the statement's, or inside a view's body the view's own directives + waived := a.waived + if len(a.viewWaived) > 0 { + waived = a.viewWaived[len(a.viewWaived)-1] + } + lf.Waived = append([]string{}, waived[lf.Table]...) + } + return lf +} + +// baseTableOf is the base table a merged view's plain columns ultimately came from, +// however many views the reference passed through (Column.base / baseTable already name +// the root, set once where a query resolves a plain column of a real table and carried +// along unchanged through every further view that just selects it on); nil for a view +// whose every output is computed (nothing a write could land on). +func baseTableOf(cols []Column) *schema.Table { + for _, c := range cols { + if c.baseTable != nil { + return c.baseTable + } + } + return nil +} + +// conjuncts flattens the ANDs of a condition. +func conjuncts(v mysqlast.Value) []mysqlast.Value { + n, ok := v.(*mysqlast.Node) + if !ok { + return nil + } + if n.Class == "Item_cond_and" { + var out []mysqlast.Value + for _, arg := range n.Args { + out = append(out, conjuncts(arg)...) + } + return out + } + return []mysqlast.Value{v} +} + +// predFacts records one conjunct. restrict, when not nil, lists the leaves the conjunct is +// allowed to say something about (the nullable side of an outer join). +func (a *analyzer) predFacts(sc *scope, fs *facts.Scope, c mysqlast.Value, restrict []int) { + n, ok := c.(*mysqlast.Node) + if !ok { + return + } + text, cols := a.opaqueText(sc, n) + pr := facts.Pred{Op: facts.Opaque, Text: text, Cols: cols, Origin: facts.FromStatement} + if restrict != nil { + pr.Restricts = append([]int{}, restrict...) + } + switch n.Class { + case "PTI_comp_op": + if op, _ := n.Arg("boolfunc2creator").(mysqlast.Op); op == "=" { + left, right := n.Arg("left"), n.Arg("right") + if lr, rr := rowElements(left), rowElements(right); lr != nil && rr != nil && len(lr) == len(rr) { + // (a, b) = (c, d) is a = c AND b = d + for i := range lr { + if !a.eqFacts(sc, fs, lr[i], rr[i], restrict, pr) { + fs.Preds = append(fs.Preds, pr) + return + } + } + return + } + if a.eqFacts(sc, fs, left, right, restrict, pr) { + return + } + } + case "PTI_exists_subselect": + if body := a.subBody(n.Arg("subselect")); body != nil { + a.claimed[body] = true + pr.Op, pr.Sub = facts.Exists, body + fs.Preds = append(fs.Preds, pr) + return + } + case "Item_in_subselect": + // x IN (SELECT y ...): a witness row of the body with y = x + if body := a.subBody(n.Arg("pt_subquery")); body != nil { + if info := a.blocks[body]; info != nil && len(info.sc.items) == 1 && info.sc.items[0].leaf1 > 0 { + out := facts.ColRef{Leaf: info.sc.items[0].leaf1 - 1, Column: info.sc.items[0].leafCol} + var term facts.Term + ok := false + if col, isCol := a.colFact(sc, n.Arg("left_expr")); isCol { + term, ok = facts.Term{Kind: facts.Outer, Col: col}, true + } else { + term, ok = a.termFacts(sc, n.Arg("left_expr")) + } + if ok { + with := *body + with.Preds = append(append([]facts.Pred{}, body.Preds...), facts.Pred{Op: facts.Eq, Col: out, Term: term, Origin: facts.FromStatement}) + a.claimed[body] = true + pr.Op, pr.Sub = facts.Exists, &with + fs.Preds = append(fs.Preds, pr) + return + } + } + } + case "Item_func_isnull", "Item_func_isnotnull": + if col, ok := a.colFact(sc, n.Arg("a")); ok && allowed(col, restrict) { + pr.Op, pr.Col = facts.IsNull, col + if n.Class == "Item_func_isnotnull" { + pr.Op = facts.IsNotNull + fs.NotNull = append(fs.NotNull, col) + } + fs.Preds = append(fs.Preds, pr) + return + } + case "PTI_handle_sql2003_note184_exception": + // `expr1 IN (expr2)`, exactly one alternative: the grammar's own rule for a + // one-item IN list (bit_expr IN_SYM '(' expr ')') never builds an Item_func_in -- + // only the two-or-more-item list rule does -- so this is the only node a + // single-element IN ever parses to (measured: mysqlparse/shapes.go's `predicate` + // production). It is exactly col = x, so pinned / single-row proofs may treat it + // as Eq. <=> is not part of this path (a NULL-safe IN does not exist). + if !isTrue(n.Arg("is_negation")) { + left, right := n.Arg("left"), n.Arg("right") + if lr, rr := rowElements(left), rowElements(right); lr != nil && rr != nil && len(lr) == len(rr) { + // (a, b) IN ((c, d)) is (a, b) = (c, d), the same row-constructor + // decomposition the PTI_comp_op "=" case above does -- this grammar path + // hands eqFacts an Item_row on either side unless unpacked here first, and + // eqFacts itself never calls rowElements. Measured: `(a, b) IN ((5, 6))` + // selects exactly the row with a = 5 AND b = 6 on mysqld 8.4. + for i := range lr { + if !a.eqFacts(sc, fs, lr[i], rr[i], restrict, pr) { + fs.Preds = append(fs.Preds, pr) + return + } + } + return + } + if a.eqFacts(sc, fs, left, right, restrict, pr) { + return + } + } + case "Item_func_in": + list, _ := n.Arg("list").(mysqlast.List) + if !isTrue(n.Arg("is_negation")) && len(list) > 1 { + if col, ok := a.colFact(sc, list[0]); ok && allowed(col, restrict) { + terms := make([]facts.Term, 0, len(list)-1) + for _, v := range list[1:] { + t, ok := a.termFacts(sc, v) + if !ok { + terms = nil + break + } + terms = append(terms, t) + } + if terms != nil { + pr.Op, pr.Col, pr.Terms = facts.In, col, terms + fs.Preds = append(fs.Preds, pr) + return + } + } + } + } + fs.Preds = append(fs.Preds, pr) +} + +// subBody is the facts of a subquery analyzed earlier in this statement. +func (a *analyzer) subBody(v mysqlast.Value) *facts.Scope { + n, ok := v.(*mysqlast.Node) + if !ok || n.Class != "PT_subquery" { + return nil + } + body := a.subFacts[n] + if body == nil { + return nil + } + if a.claimed == nil { + a.claimed = map[*facts.Scope]bool{} + } + return body +} + +// constTrue reports a condition the server folds away: a non-zero integer literal, TRUE, +// a comparison of two equal literals, a conjunction of such (nil is no condition). +func constTrue(v mysqlast.Value) bool { + if v == nil { + return true + } + n, ok := v.(*mysqlast.Node) + if !ok { + return false + } + switch n.Class { + case "Item_func_true": + return true + case "Item_int", "Item_uint": + return intOr(n.Arg("i"), intOr(n.Arg("str"), 0)) != 0 + case "Item_cond_and": + for _, c := range n.Args { + if !constTrue(c) { + return false + } + } + return true + case "PTI_comp_op": + if op, _ := n.Arg("boolfunc2creator").(mysqlast.Op); op == "=" || op == "<=>" { + l, lok := n.Arg("left").(*mysqlast.Node) + r, rok := n.Arg("right").(*mysqlast.Node) + return lok && rok && isLiteral(l) && isLiteral(r) && mysqlast.Sprint(l) == mysqlast.Sprint(r) + } + } + return false +} + +// whereExpr is the WHERE condition under its PTI_where wrapper (nil for none). +func whereExpr(v mysqlast.Value) mysqlast.Value { + n, ok := v.(*mysqlast.Node) + if !ok { + return nil + } + if n.Class == "PTI_where" { + return n.Arg("expr") + } + return n +} + +// isLiteral reports a literal node. +func isLiteral(n *mysqlast.Node) bool { + switch n.Class { + case "Item_int", "Item_uint", "Item_decimal", "Item_float", "PTI_text_literal_text_string", "PTI_text_literal_nchar_string", + "PTI_text_literal_underscore_charset", "Item_hex_string", "Item_bin_string", "Item_func_true", "Item_func_false": + return true + } + return false +} + +// clearPositions drops the offsets of a view body's facts: they index the view's +// definition, not the statement. +func clearPositions(fs *facts.Scope) { + if fs == nil { + return + } + fs.At = -1 + for i := range fs.Leaves { + fs.Leaves[i].Position = -1 + if fs.Leaves[i].Body != nil && fs.Leaves[i].Kind != facts.View { + clearPositions(fs.Leaves[i].Body) + } + } + for _, p := range fs.Preds { + if p.Op == facts.Exists && p.Sub != nil { + clearPositions(p.Sub) + } + } + for _, c := range fs.Children { + clearPositions(c) + } +} + +// eqFacts records one scalar equality left = right when a side is a column of the block: +// col = col as an equality with its edges, col = term as a fixing predicate. It reports +// whether the equality was recorded (false: the caller keeps the conjunct opaque). +func (a *analyzer) eqFacts(sc *scope, fs *facts.Scope, left, right mysqlast.Value, restrict []int, pr facts.Pred) bool { + l, okl := a.colFact(sc, left) + r, okr := a.colFact(sc, right) + switch { + case okl && okr: + a.equalityFacts(fs, l, r, restrict) + return true + case okl || okr: + col, other := l, right + if okr { + col, other = r, left + } + if isNullLiteral(other) && allowed(col, restrict) { + if a.nullEq == nil { + a.nullEq = map[*facts.Scope][]facts.ColRef{} + } + a.nullEq[fs] = append(a.nullEq[fs], col) + return false + } + if term, ok := a.termFacts(sc, other); ok && allowed(col, restrict) { + if a.stringNumberCoercion(sc, col, other) { + // col = against a string-typed column: mysqld + // converts the column's stored value to a number for the + // comparison (Type Conversion in Expression Evaluation, measured: + // a VARCHAR PRIMARY KEY holding both '5' and '05' both satisfy + // `id = 5`), so the equality does not fix the column to one value + // -- neither pinned nor the One proof may treat it as Eq. The + // reverse (a numeric column, a string literal) is not excluded: + // mysqld converts both sides to a float there, and a numeric + // column already has one canonical value per row, so no two rows + // of it can share a converted value the way two spellings of the + // same number can share a string column's value. <=> is not this + // path (eqFacts only ever sees plain =). + return false + } + fs.Preds = append(fs.Preds, facts.Pred{Op: facts.Eq, Col: col, Term: term, Text: pr.Text, Restricts: pr.Restricts, Origin: facts.FromStatement}) + fs.Fixed = append(fs.Fixed, col) + if term.Kind == facts.Known { + // what the server counts as a constant (Item::const_item, or an outer + // reference), for the functional dependencies; not a parameter + if a.fdConst == nil { + a.fdConst = map[string]bool{} + } + a.fdConst[pr.Text+"\x00"+term.Text] = isColumnRef(other) || constItem(other) + } + return true + } + } + return false +} + +// rowElements lists the elements of an Item_row (nil for any other value). +func rowElements(v mysqlast.Value) []mysqlast.Value { + n, ok := v.(*mysqlast.Node) + if !ok || n.Class != "Item_row" { + return nil + } + return append([]mysqlast.Value{n.Arg("head")}, exprArgsTail(n)...) +} + +// nullRejecting marks the block's columns a conjunct rejects NULL for: the direct +// arguments of a comparison (not <=>), LIKE, BETWEEN, IN or IS NOT NULL, through NOT and +// IS TRUE / FALSE (Item_func::not_null_tables as Group_check::analyze_conjunct reads it). +// restrict is the nullable side when the conjunct is an outer join's ON. +func (a *analyzer) nullRejecting(sc *scope, c mysqlast.Value, restrict []int) { + n, ok := c.(*mysqlast.Node) + if !ok { + return + } + if n.Class == "PTI_truth_transform" { + n, ok = n.Arg("expr").(*mysqlast.Node) + if !ok { + return + } + } + var args []mysqlast.Value + switch n.Class { + case "PTI_comp_op": + if op, _ := n.Arg("boolfunc2creator").(mysqlast.Op); op == "<=>" { + return + } + for _, side := range []mysqlast.Value{n.Arg("left"), n.Arg("right")} { + if els := rowElements(side); els != nil { + args = append(args, els...) + } else { + args = append(args, side) + } + } + case "Item_func_like": + args = []mysqlast.Value{n.Arg("a"), n.Arg("b")} + case "Item_func_between": + args = []mysqlast.Value{n.Arg("a"), n.Arg("b"), n.Arg("c")} + case "Item_func_in": + args, _ = n.Arg("list").(mysqlast.List) + case "PTI_handle_sql2003_note184_exception": + args = []mysqlast.Value{n.Arg("left"), n.Arg("right")} + case "Item_func_isnotnull": + args = []mysqlast.Value{n.Arg("a")} + default: + return + } + for _, v := range args { + if col, ok := a.colFact(sc, v); ok { + sc.nnMarks = append(sc.nnMarks, nnMark{col, restrict}) + } + } +} + +// opaqueText renders a conjunct the language does not decompose, with every column +// reference that resolves to a leaf of the block reduced to its bare column name (so the +// same predicate reads the same whatever the alias), and lists those columns. +func (a *analyzer) opaqueText(sc *scope, n *mysqlast.Node) (string, []facts.ColRef) { + type cut struct { + start, end int + name string + } + var cuts []cut + var cols []facts.ColRef + seen := map[facts.ColRef]bool{} + var walk func(v mysqlast.Value) + walk = func(v mysqlast.Value) { + switch x := v.(type) { + case *mysqlast.Node: + switch x.Class { + case "PTI_simple_ident_ident", "PTI_simple_ident_nospvar_ident", "PTI_simple_ident_q_2d", "PTI_simple_ident_q_3d": + if ref, ok := a.colFact(sc, x); ok { + if !seen[ref] { + seen[ref] = true + cols = append(cols, ref) + } + if x.Class == "PTI_simple_ident_q_2d" || x.Class == "PTI_simple_ident_q_3d" { + cuts = append(cuts, cut{x.Start, x.End, ref.Column}) + } + } + return + } + for _, arg := range x.Args { + walk(arg) + } + case mysqlast.List: + for _, e := range x { + walk(e) + } + case *mysqlast.Struct: + for _, k := range x.Order { + walk(x.Fields[k]) + } + } + } + walk(n) + text := a.textOf(n) + if len(cuts) > 0 && n.Start >= 0 && n.End <= len(a.text) { + sort.Slice(cuts, func(i, j int) bool { return cuts[i].start < cuts[j].start }) + var b strings.Builder + pos := n.Start + for _, c := range cuts { + if c.start < pos || c.end > n.End { + continue + } + b.WriteString(a.text[pos:c.start]) + b.WriteString(c.name) + pos = c.end + } + b.WriteString(a.text[pos:n.End]) + text = b.String() + } + sort.Slice(cols, func(i, j int) bool { + if cols[i].Leaf != cols[j].Leaf { + return cols[i].Leaf < cols[j].Leaf + } + return cols[i].Column < cols[j].Column + }) + return text, cols +} + +// equalityFacts records col = col: a predicate, and the edges knowledge travels along — +// both ways for an inner join or WHERE, only into the nullable side for an outer join's ON. +func (a *analyzer) equalityFacts(fs *facts.Scope, l, r facts.ColRef, restrict []int) { + fs.Preds = append(fs.Preds, facts.Pred{Op: facts.Eq, Col: l, Term: facts.Term{Kind: facts.Column, Col: r}, Origin: facts.FromStatement, Restricts: append([]int(nil), restrict...)}) + if restrict == nil || allowed(r, restrict) { + fs.Edges = append(fs.Edges, facts.Edge{From: l, To: r}) + } + if restrict == nil || allowed(l, restrict) { + fs.Edges = append(fs.Edges, facts.Edge{From: r, To: l}) + } +} + +// allowed reports whether a conjunct may speak about the column's leaf. +func allowed(c facts.ColRef, restrict []int) bool { + if restrict == nil { + return true + } + for _, i := range restrict { + if i == c.Leaf { + return true + } + } + return false +} + +// colFact resolves a column reference to a leaf of this block (an outer query's column is +// not a fact of this block; it is a Known term instead, see termFacts). +func (a *analyzer) colFact(sc *scope, v mysqlast.Value) (facts.ColRef, bool) { + n, ok := v.(*mysqlast.Node) + if !ok { + return facts.ColRef{}, false + } + switch n.Class { + case "PTI_simple_ident_ident", "PTI_simple_ident_nospvar_ident", "PTI_simple_ident_q_2d", "PTI_simple_ident_q_3d": + default: + return facts.ColRef{}, false + } + // merged: sc.merged, so an unqualified name coalesced by USING / NATURAL resolves to + // the left leaf (lookup's mergedLeaves check) instead of failing ambiguous (1052) -- + // measured: `items JOIN orders USING (tenant_id) ... WHERE tenant_id = ?` only ever + // touches the named tenant's items row on mysqld 8.4, so the coalesced column must be + // recognized as a fact of both leaves (the existing USING equality edge, block()'s + // j.using loop, then carries Fixed to the right leaf). + own := scope{rels: sc.rels, merged: sc.merged} // this level only + ref, err := a.column(own, n, "where clause") + if err != nil || ref.rel == nil { + return facts.ColRef{}, false + } + for i := range sc.rels { + if &sc.rels[i] == ref.rel || sc.rels[i].alias == ref.rel.alias { + return facts.ColRef{Leaf: i, Column: ref.c.Name}, true + } + } + return facts.ColRef{}, false +} + +// colIn resolves a USING column within the given leaves. +func (a *analyzer) colIn(sc *scope, leaves []int, name string) (facts.ColRef, bool) { + for _, i := range leaves { + if _, ok := sc.rels[i].column(name); ok { + return facts.ColRef{Leaf: i, Column: name}, true + } + } + return facts.ColRef{}, false +} + +// termFacts classifies the known side of an equality: a parameter, a literal, or an +// expression that reads no column of this block (an outer reference, a function of +// constants). An expression reading this block's columns is not a term. +func (a *analyzer) termFacts(sc *scope, v mysqlast.Value) (facts.Term, bool) { + n, ok := v.(*mysqlast.Node) + if !ok { + return facts.Term{}, false + } + switch n.Class { + case "Item_param": + return facts.Term{Kind: facts.Param, Param: int32(a.ph.Number(n.Start))}, true + } + if literalClass(n.Class) { + return facts.Term{Kind: facts.Const, Const: constText(n)}, true + } + if a.readsBlock(sc, v) || !deterministic(v) { + return facts.Term{}, false // a value the row decides, or one the execution decides (RAND()), is not known + } + if isColumnRef(n) && sc.outer != nil { + // a column of the enclosing block: the link a correlated subquery witnesses through + if col, ok := a.colFact(sc.outer, n); ok { + return facts.Term{Kind: facts.Outer, Col: col}, true + } + } + if _, isSub := v.(*mysqlast.Node); isSub && containsClass(v, "PT_subquery") && !constItem(v) { + return facts.Term{}, false // a subquery's value is not known before the statement runs (unless it reads no table) + } + return facts.Term{Kind: facts.Known, Text: a.textOf(n)}, true +} + +// literalClass reports whether class is a literal's (a Const term). +func literalClass(class string) bool { + switch class { + case "Item_int", "Item_uint", "Item_decimal", "Item_float", "PTI_text_literal_text_string", "PTI_text_literal_nchar_string", + "PTI_text_literal_underscore_charset", "Item_hex_string", "Item_bin_string", "Item_null", "Item_func_true", "Item_func_false": + return true + } + return false +} + +// constText renders a literal in x/obligation's spelling: a one-letter type tag followed +// by the literal's bare value, matching check/postgres/analyze/card.go's constText so a +// Const term compares equal across dialects and so x/obligation/check.go's stateOf (which +// strips exactly one of "ifsbx" off the front) can recover a MySQL state name. Built from +// the AST node's own token value (quotes/escapes already resolved by the lexer, e.g. +// PTI_text_literal_text_string's "literal" token), not textOf's raw source span, because +// textOf would keep the source's own quoting instead of the tag ("'submitted'" can never +// equal a declaration's bare "submitted" -- the transitions obligation's finding). "" for +// a class literalClass does not recognize (never reached: the two switches list the same +// classes). +func constText(n *mysqlast.Node) string { + switch n.Class { + case "Item_int": + return "i" + str(n.Arg("i")) + case "Item_uint": + return "i" + str(n.Arg("str")) + case "Item_decimal", "Item_float": + return "f" + str(n.Arg("str")) + case "PTI_text_literal_text_string", "PTI_text_literal_nchar_string", "PTI_text_literal_underscore_charset": + return "s" + str(n.Arg("literal")) + case "Item_hex_string", "Item_bin_string": + return "x" + str(n.Arg("literal")) + case "Item_null": + return "NULL" + case "Item_func_true": + return "btrue" + case "Item_func_false": + return "bfalse" + } + return "" +} + +// numericLiteralClass reports whether class is a numeric literal's (Item_int, Item_uint, +// Item_decimal, Item_float): the constant classes for which mysqld's own comparison rules +// (Type Conversion in Expression Evaluation) convert a string column's value to a number +// rather than the other way around. A quoted numeral (a text literal that happens to look +// like a number, e.g. '5') is not one of these classes and is compared as a string when the +// column is a string type, so it is not part of the hazard eqFacts guards against. +func numericLiteralClass(class string) bool { + switch class { + case "Item_int", "Item_uint", "Item_decimal", "Item_float": + return true + } + return false +} + +// stringColumnType reports whether t is one of the character string types (CHAR, VARCHAR, +// the TEXT family): the ones mysqld's own comparison rules convert to a number, rather than +// converting the other operand, when compared with a numeric literal. BINARY/VARBINARY and +// the BLOB family are not included: they hold bytes, not a server-parsed character string, +// and are not measured here. +func stringColumnType(t schema.Type) bool { + switch t.Name { + case "char", "varchar", "tinytext", "text", "mediumtext", "longtext": + return true + } + return false +} + +// castNumericTarget reports a CAST(... AS type) (or CONVERT(x, type)) node whose own +// target type is one of the numeric SQL types -- SIGNED, UNSIGNED, DECIMAL, FLOAT, DOUBLE +// or REAL -- read the way analyze.go's cast (expr.go) itself reads the "type" argument, +// without typing the CAST's own argument (its type plays no part in the CAST's result +// type). Measured on mysqld 8.4: `c = CAST(1 AS SIGNED)` against a VARCHAR UNIQUE column +// holding '1' and '01' matches both rows -- the column is converted to a number for the +// comparison exactly as it is for a bare numeric literal, so a computed CAST is the same +// hazard as numericLiteralClass, just not a literal. +func castNumericTarget(n *mysqlast.Node) bool { + if n.Class != "create_func_cast" { + return false + } + target := "" + if s, ok := n.Arg("type").(*mysqlast.Struct); ok { + target = str(s.Fields["target"]) + } else { + target = str(n.Arg("type")) // BINARY x / CAST(x AS CHAR ...): never numeric + } + if strings.Contains(target, "?ITEM_CAST_DOUBLE:ITEM_CAST_FLOAT") { + return true // the grammar action's (dec == NOT_FIXED_DEC) ? DOUBLE : FLOAT: both numeric + } + switch strings.TrimPrefix(target, "ITEM_CAST_") { + case "SIGNED_INT", "UNSIGNED_INT", "DECIMAL", "FLOAT", "DOUBLE": + return true + } + return false +} + +// stringNumberCoercion reports whether col = other is exactly the hazard measured on mysqld +// 8.4: a string-typed column compared with an operand of a numeric result type. mysqld +// converts the *column's* stored value to a number for such a comparison (not the other +// operand to a string), so distinct column values that convert to the same number ('5' and +// '05', say) both satisfy the equality -- an equality sqlshape must not use to fix the +// column to one value, whether for `require single` (x/cardinality's One proof) or for +// `require pinned` (both read Fixed / Eq facts as "this column has exactly one value +// here"). The hazard is not literal-shaped: a bare numeric literal (numericLiteralClass), +// TRUE / FALSE (Item_func_true/false, which compare as the integers 1 and 0 -- measured: +// `c = TRUE` matches both '1' and '01'), and a computed CAST/CONVERT to a numeric type +// (castNumericTarget) all trigger it the same way. +// +// The reverse -- a numeric column compared with a text literal -- is not excluded: mysqld +// converts both sides to floating point there, and a numeric column already stores one +// canonical value per row, so no two rows of it can convert to the same float the way two +// spellings of a number can convert to the same float from a string column. A parameter +// ($n) is bound with its Go-side type already fixed by the driver, not textual: its class +// (Item_param) is none of the ones checked here, so it is never flagged, whatever its +// eventual bound type turns out to be. `<=>` does not go through eqFacts either +// (nullRejecting and eqFacts's caller only route plain = comparisons here). +func (a *analyzer) stringNumberCoercion(sc *scope, col facts.ColRef, other mysqlast.Value) bool { + n, ok := other.(*mysqlast.Node) + if !ok || !(numericLiteralClass(n.Class) || n.Class == "Item_func_true" || n.Class == "Item_func_false" || castNumericTarget(n)) { + return false + } + if col.Leaf < 0 || col.Leaf >= len(sc.rels) { + return false + } + ref, ok := sc.rels[col.Leaf].column(col.Column) + if !ok { + return false + } + return stringColumnType(ref.c.Type) +} + +// isNullLiteral reports whether v is the literal NULL: `col = NULL` is never true and fixes +// nothing. +func isNullLiteral(v mysqlast.Value) bool { + n, ok := v.(*mysqlast.Node) + return ok && n.Class == "Item_null" +} + +// readsBlock reports whether v references a column of this block. +func (a *analyzer) readsBlock(sc *scope, v mysqlast.Value) bool { + switch x := v.(type) { + case *mysqlast.Node: + if _, ok := a.colFact(sc, x); ok { + return true + } + for _, arg := range x.Args { + if a.readsBlock(sc, arg) { + return true + } + } + case mysqlast.List: + for _, e := range x { + if a.readsBlock(sc, e) { + return true + } + } + case *mysqlast.Struct: + for _, e := range x.Fields { + if a.readsBlock(sc, e) { + return true + } + } + } + return false +} + +// textOf is the source text of a node, in the statement's own spelling ($n). +func (a *analyzer) textOf(n *mysqlast.Node) string { + if n.Start < 0 || n.End > len(a.text) || n.Start >= n.End { + return "" + } + return a.text[n.Start:n.End] +} + +// closeFixed closes Fixed over Edges (From fixed implies To fixed) and sorts it. +func closeFixed(fs *facts.Scope) { + known := map[facts.ColRef]bool{} + for _, c := range fs.Fixed { + known[c] = true + } + for changed := true; changed; { + changed = false + for _, e := range fs.Edges { + if known[e.From] && !known[e.To] { + known[e.To] = true + changed = true + } + } + } + fs.Fixed = fs.Fixed[:0] + for c := range known { + fs.Fixed = append(fs.Fixed, c) + } + sort.Slice(fs.Fixed, func(i, j int) bool { + if fs.Fixed[i].Leaf != fs.Fixed[j].Leaf { + return fs.Fixed[i].Leaf < fs.Fixed[j].Leaf + } + return fs.Fixed[i].Column < fs.Fixed[j].Column + }) +} + +// writeFacts is the record of a write: its target, the columns it assigns and the terms +// stored in them (parallel to assigned; the first row's for a multi-row INSERT). +func (a *analyzer) writeFacts(kind facts.StmtKind, rel *relation, table *schema.Table, assigned []*schema.Column, values []facts.Term) facts.Write { + if table == nil { + table = rel.table + } + if table == nil { + table = baseTableOf(rel.cols) // a write through a view lands on its base table + } + w := facts.Write{Table: table.Name, Kind: kind, Position: int32(a.ph.Back(rel.pos))} + for i, c := range assigned { + dup := false + for _, name := range w.Assigned { + dup = dup || name == c.Name + } + if dup { + continue + } + w.Assigned = append(w.Assigned, c.Name) + v := facts.Term{Kind: facts.Known, Text: "?"} + if i < len(values) { + v = values[i] + } + w.Values = append(w.Values, v) + } + return w +} + +// limitOne reports a literal LIMIT 1 (or 0). +// limitIsZero reports a literal LIMIT 0. +func limitIsZero(v mysqlast.Value) bool { + n, ok := v.(*mysqlast.Node) + if !ok { + return false + } + lim := mysqlast.Value(n) + if opts, ok := n.Arg("limit_options").(*mysqlast.Struct); ok { + lim = opts.Fields["limit"] + } + ln, ok := lim.(*mysqlast.Node) + if !ok { + return false + } + switch ln.Class { + case "Item_int": + return str(ln.Arg("i")) == "0" + case "Item_uint": // the grammar's limit_option builds an Item_uint + return str(ln.Arg("str")) == "0" + } + return false +} + +func limitOne(v mysqlast.Value) bool { + n, ok := v.(*mysqlast.Node) + if !ok { + return false + } + lim := mysqlast.Value(n) + if opts, ok := n.Arg("limit_options").(*mysqlast.Struct); ok { + lim = opts.Fields["limit"] + } + ln, ok := lim.(*mysqlast.Node) + if !ok { + return false + } + switch ln.Class { + case "Item_int": + return intOr(ln.Arg("i"), -1) <= 1 && intOr(ln.Arg("i"), -1) >= 0 + case "Item_uint": + return intOr(ln.Arg("str"), -1) <= 1 && intOr(ln.Arg("str"), -1) >= 0 + } + return false +} + +// containsAggregate reports an aggregate or window function anywhere in v. +func containsAggregate(v mysqlast.Value) bool { + switch x := v.(type) { + case *mysqlast.Node: + if isAggregateLike(x) || isWindowFunction(x) { + return true + } + for _, arg := range x.Args { + if containsAggregate(arg) { + return true + } + } + case mysqlast.List: + for _, e := range x { + if containsAggregate(e) { + return true + } + } + } + return false +} + +// isAggregate reports an aggregate function call (COUNT, SUM, MIN, MAX, AVG, GROUP_CONCAT, +// BIT_*, STD, ...): the Item_sum classes and COUNT(*)'s own node. A window function (an +// aggregate with OVER) is not one. +func isAggregate(v mysqlast.Value) bool { + n, ok := v.(*mysqlast.Node) + if !ok { + return false + } + if n.Class == "PTI_count_sym" { + return n.Arg("w") == nil + } + if !isA(n.Class, "Item_sum") { + return false + } + return n.Arg("w") == nil && n.Arg("window") == nil // with OVER it is a window function +} diff --git a/check/mysql/internal/analyze/fold.go b/check/mysql/internal/analyze/fold.go new file mode 100644 index 0000000..0a49a78 --- /dev/null +++ b/check/mysql/internal/analyze/fold.go @@ -0,0 +1,1226 @@ +package analyze + +import ( + "fmt" + "math" + "math/big" + "strconv" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlast" + "github.com/kr9ly/sqlshape/v2/x/sqlmode" +) + +// Constant arithmetic the server cannot compute: ER_DATA_OUT_OF_RANGE (1690). +// +// The server evaluates a constant expression once, before or as the statement runs, and +// an integer result that does not fit the operator's BIGINT / BIGINT UNSIGNED, a DOUBLE +// that is infinite, a function's DOUBLE cast to an integer it does not fit, or a +// RANDOM_BYTES length outside 1 .. 1024 fails the statement with 1690. This file evaluates +// what the corpus probe found the server evaluating (item_func.cc's int_op / real_op and +// check_integer_overflow / check_float_overflow, measured in TestConstantFoldServer): +// +// - the literals (Item_int / Item_uint / Item_decimal / Item_float, TRUE / FALSE, a quoted +// string, NULL), unary minus, + - * / DIV %, ABS, EXP, POW / POWER, COT, DEGREES, +// ROUND with a negative number of places over an integer, RANDOM_BYTES, and CAST to +// SIGNED / UNSIGNED / FLOAT / DOUBLE. Anything else is not a constant to this file, +// and an expression it does not model is left alone; +// - an integer operator's result is unsigned when either operand is (subtraction not +// under NO_UNSIGNED_SUBTRACTION); the exact result must fit that type. A string +// operand makes the operator a DOUBLE one (my_strtod: 'abc' is 0), a decimal one a +// DECIMAL one (never judged: my_decimal's overflow rule is not modeled); +// - DIV over non-integers divides as decimals and the truncated quotient must fit +// (either operand unsigned: BIGINT UNSIGNED); +// - CAST(f AS SIGNED / UNSIGNED) of a DOUBLE a function or operator computed fails when +// it does not fit (llrint_with_overflow_check), naming the inner expression; a DOUBLE +// literal is clamped instead (Item_float::val_int), as a decimal or a string is; +// - the message spells the expression as Item::print does: `(a + b)`, `-(x)`, +// `pow(2,1024)`, `cast(x as unsigned)`. +// +// Where the failure lands follows how the server evaluates (measured): +// +// - in a WHERE / HAVING / ON, the optimizer folds the constant before any row is read: +// the statement fails whatever the data, and so does a constant select item of a +// query without a FROM (or FROM DUAL), a derived table's or CTE's, an INSERT ... +// VALUES value, a SET. The checker raises the error; +// - in a select item of a query with a FROM, an UPDATE's SET, an ON DUPLICATE KEY UPDATE +// assignment, the expression runs per row: no row, no failure. The checker lists the +// violation 1690 (keyed "1690" the way 1416 is) instead of failing the statement; +// - AND / OR stop at the first constant that decides them (a false or, in a condition, +// a NULL for AND; a true for OR): the constants after it are never evaluated, though +// a nested AND / OR inside a condition still is. IF / CASE / COALESCE / IFNULL evaluate +// only the branch a constant condition picks, `c IN (list)` with a constant c stops at +// the first match, `x IS NULL` over a never-NULL x is folded to false without +// evaluating x (Item_func_isnull::fix_fields). GROUP BY and ORDER BY drop constant +// items unevaluated; so does EXISTS with its select list; +// - a trigger's or routine's body is not judged (a body's run-time failures are its +// raised violations, and the body walk has no row context). +// +// This file also evaluates the constant conversions whose warning a strict write escalates +// to the error 1292 ER_TRUNCATED_WRONG_VALUE (measured in TestConvertServer): a CAST / +// CONVERT to DATE / DATETIME / TIME / YEAR / CHAR(n) / SIGNED / UNSIGNED / DOUBLE / FLOAT / +// DECIMAL the value does not survive, TIMESTAMP(x) of a constant that is no datetime, and +// a string operand of + - * / holding no full number. These fail only in a strict +// INSERT / REPLACE / UPDATE / DELETE outside IGNORE (strictWriteStmt); a SELECT, a SET, a +// DO or a non-strict write runs them with a warning, rows present or not. Where the +// failure lands follows the same rules as the 1690, except that a comparison's (or IN's / +// LIKE's / BETWEEN's) constant operand always runs per row (measured: UPDATE ... WHERE d = +// CAST('2004-10-0' AS DATE) runs over an empty table), so it is the violation 1292. +// convert.go carries the sibling rules for a column compared with a constant string. +// +// Ceilings: a comparison whose other side is an aggregate over no rows (NULL) skips the +// constant; `IN (SELECT c FROM t)` folds c into the rewritten condition and fails even over +// an empty t; a hex or bit literal operand is not folded (0x1 is a string to +, b'1' an +// integer); a user variable's value is not known; an UPDATE's ORDER BY constant is +// evaluated by the server but dropped here with the other ORDER BY items; a temporal cast's +// value is not carried into the store checks (CAST('0000-10-31 15:30' AS DATETIME) stored +// into a TIMESTAMP column fails on the server, unjudged); STR_TO_DATE and the other +// datetime functions are not evaluated. + +type ckind int + +const ( + cNull ckind = iota + cInt + cUint + cDec + cReal + cStr +) + +// cval is a constant's value while folding. +type cval struct { + kind ckind + i *big.Int // cInt / cUint + d *big.Rat // cDec + r float64 // cReal + s string // cStr + fn bool // computed by a function or operator, not written as a literal +} + +// foldFail is a 1690 or a 1292 the folding met: the type word of the message ("BIGINT", +// "BIGINT UNSIGNED", "DOUBLE"), the expression printed, and where it starts. code is 0 for +// the 1690 message form; a 1292 carries its whole message in raw and its code here. +type foldFail struct { + kind string + print string + at int + raw string // a whole message, when kind / print do not apply (RANDOM_BYTES, 1292) + code int // 0: 1690 +} + +func (f *foldFail) message() string { + if f.raw != "" { + return f.raw + } + return fmt.Sprintf("%s value is out of range in '%s'", f.kind, f.print) +} + +func (f *foldFail) errCode() int { + if f.code != 0 { + return f.code + } + return code1690 +} + +// strictWriteStmt: the statement writes rows under strict mode, outside IGNORE and a body. +// This is the context where the server escalates an evaluation warning -- a constant CAST +// the value does not survive, a string operand holding no number -- to the error 1292 +// (Truncated incorrect value); a SELECT, a SET, a DO or a non-strict write only warns +// (measured: the same CAST('2004-10-0' AS DATE) errors in INSERT ... VALUES and runs in +// SELECT, with rows present too). +func (a *analyzer) strictWriteStmt() bool { + if a.write == nil || a.write.ignore || a.routine != nil || a.trig != nil { + return false + } + mode := a.s.Settings.SQLMode + return mode.StrictAll() || mode.StrictTransOnly() +} + +func fail1292(msg string, at int) *foldFail { + return &foldFail{raw: msg, at: at, code: 1292} +} + +var ( + int64Min = new(big.Int).Neg(new(big.Int).Lsh(big.NewInt(1), 63)) + int64Max = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 63), big.NewInt(1)) + uint64Max = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 64), big.NewInt(1)) + two63 = new(big.Int).Lsh(big.NewInt(1), 63) + two63f = math.Ldexp(1, 63) +) + +func (c cval) isNull() bool { return c.kind == cNull } + +// asReal is the value in a DOUBLE context (my_strtod for a string: the numeric prefix, +// 0 when there is none). +func (c cval) asReal() float64 { + switch c.kind { + case cInt, cUint: + f, _ := new(big.Float).SetInt(c.i).Float64() + return f + case cDec: + f, _ := c.d.Float64() + return f + case cReal: + return c.r + case cStr: + r, _, ok := numericString(c.s) + if !ok { + return 0 + } + f, _ := r.Float64() + return f + } + return 0 +} + +// asDec is the value in a DECIMAL context. +func (c cval) asDec() *big.Rat { + switch c.kind { + case cInt, cUint: + return new(big.Rat).SetInt(c.i) + case cDec: + return c.d + case cReal: + r := new(big.Rat) + if r.SetFloat64(c.r) == nil { + return new(big.Rat) + } + return r + case cStr: + r, _, ok := numericString(c.s) + if !ok { + return new(big.Rat) + } + return r + } + return new(big.Rat) +} + +// truth is the value as a condition: false for NULL too (null says which). +func (c cval) truth() (b, null bool) { + switch c.kind { + case cNull: + return false, true + case cInt, cUint: + return c.i.Sign() != 0, false + case cDec: + return c.d.Sign() != 0, false + case cReal: + return c.r != 0, false + case cStr: + return c.asReal() != 0, false + } + return false, false +} + +func intVal(i *big.Int, unsigned bool) cval { + if unsigned { + return cval{kind: cUint, i: i, fn: true} + } + return cval{kind: cInt, i: i, fn: true} +} + +// fitsInt reports whether i fits the operator's result type. +func fitsInt(i *big.Int, unsigned bool) bool { + if unsigned { + return i.Sign() >= 0 && i.Cmp(uint64Max) <= 0 + } + return i.Cmp(int64Min) >= 0 && i.Cmp(int64Max) <= 0 +} + +func intKind(unsigned bool) string { + if unsigned { + return "BIGINT UNSIGNED" + } + return "BIGINT" +} + +// foldable says whether n is a node fold judges at its own level (its arguments are judged +// where the walk met them). +func foldable(n *mysqlast.Node) bool { + switch n.Class { + case "Item_func_neg", "Item_func_plus", "Item_func_minus", "Item_func_mul", "Item_func_div", "Item_func_div_int", "create_func_cast": + return true + case "PTI_function_call_generic_ident_sys": + switch strings.ToUpper(str(n.Arg("ident"))) { + case "ABS", "EXP", "POW", "POWER", "COT", "DEGREES", "ROUND", "RANDOM_BYTES", + "INET_ATON", "INET6_ATON", "UNHEX", "STR_TO_DATE", "UUID_TO_BIN", "BIN_TO_UUID", "PERIOD_ADD", "PERIOD_DIFF": + return true + } + case "Item_typecast_datetime": // TIMESTAMP(x) + return true + } + return false +} + +// fold evaluates a constant expression. ok is false when v is not a constant this file +// models (its value is unknown); fail is the 1690 the evaluation met, ok or not. +func (a *analyzer) fold(v mysqlast.Value) (c cval, ok bool, fail *foldFail) { + n, isNode := v.(*mysqlast.Node) + if !isNode { + return cval{}, false, nil + } + switch n.Class { + case "Item_int": + i, good := new(big.Int).SetString(str(n.Arg("i")), 10) + return cval{kind: cInt, i: i}, good, nil + case "Item_uint": + i, good := new(big.Int).SetString(str(n.Arg("str")), 10) + return cval{kind: cUint, i: i}, good, nil + case "Item_decimal": + d, good := new(big.Rat).SetString(str(n.Arg("str"))) + return cval{kind: cDec, d: d}, good, nil + case "Item_float": + f, err := strconv.ParseFloat(str(n.Arg("str")), 64) + if err != nil || math.IsInf(f, 0) { + return cval{}, false, nil + } + return cval{kind: cReal, r: f}, true, nil + case "Item_null": + return cval{kind: cNull}, true, nil + case "Item_func_true": + return cval{kind: cInt, i: big.NewInt(1)}, true, nil + case "Item_func_false": + return cval{kind: cInt, i: big.NewInt(0)}, true, nil + case "PTI_text_literal_text_string", "PTI_text_literal_nchar_string", "PTI_text_literal_underscore_charset": + s, good := stringLiteral(n) + return cval{kind: cStr, s: s}, good, nil + case "PTI_udf_expr": + return a.fold(n.Arg("expr")) + case "Item_func_neg": + x, ok, fail := a.fold(n.Arg("a")) + if !ok || fail != nil { + return cval{}, false, fail + } + switch x.kind { + case cNull: + return x, true, nil + case cInt: + return intVal(new(big.Int).Neg(x.i), false), true, nil + case cUint: + // Item_func_neg::fix_num_length_and_dec: -9223372036854775808 is the BIGINT + // minimum; a larger unsigned turns decimal + if x.i.Cmp(two63) <= 0 { + return intVal(new(big.Int).Neg(x.i), false), true, nil + } + return cval{kind: cDec, d: new(big.Rat).Neg(new(big.Rat).SetInt(x.i)), fn: true}, true, nil + case cDec: + return cval{kind: cDec, d: new(big.Rat).Neg(x.d), fn: true}, true, nil + default: + return cval{kind: cReal, r: -x.asReal(), fn: true}, true, nil + } + case "Item_func_plus", "Item_func_minus", "Item_func_mul", "Item_func_div", "Item_func_div_int", "Item_func_mod": + return a.foldBinary(n) + case "create_func_cast": + return a.foldCast(n) + case "Item_typecast_datetime": + // TIMESTAMP(x) of a constant that is no datetime (a time-only string, a bad number, + // a zero date the mode forbids) is the warning 1292, an error in a strict write + // (measured: INSERT VALUES (TIMESTAMP('0000-00-00 10:00:00')) fails, the SELECT runs) + if !a.strictWriteStmt() { + return cval{}, false, nil + } + x, ok, fail := a.fold(n.Arg("a")) + if !ok || fail != nil { + return cval{}, false, fail + } + if x.isNull() { + return cval{kind: cNull}, true, nil + } + if fail := a.datetimeValueFail(x, n.Arg("a")); fail != nil { + return cval{}, false, fail + } + return cval{}, false, nil + case "PTI_function_call_generic_ident_sys": + return a.foldCall(n) + case "PTI_comp_op": + return a.foldCompare(n) + case "Item_func_not": + x, ok, fail := a.fold(n.Arg("a")) + if !ok || fail != nil { + return cval{}, false, fail + } + if x.isNull() { + return x, true, nil + } + b, _ := x.truth() + if b { + return cval{kind: cInt, i: big.NewInt(0), fn: true}, true, nil + } + return cval{kind: cInt, i: big.NewInt(1), fn: true}, true, nil + } + return cval{}, false, nil +} + +// foldCompare is a comparison of two constants (for the conditions AND / OR / IF decide +// on): numbers as numbers, two strings as text, a string against a number as a number. +func (a *analyzer) foldCompare(n *mysqlast.Node) (cval, bool, *foldFail) { + x, okx, fail := a.fold(n.Arg("left")) + if fail != nil { + return cval{}, false, fail + } + y, oky, fail := a.fold(n.Arg("right")) + if fail != nil { + return cval{}, false, fail + } + if !okx || !oky { + return cval{}, false, nil + } + op, _ := n.Arg("boolfunc2creator").(mysqlast.Op) + boolVal := func(b bool) (cval, bool, *foldFail) { + if b { + return cval{kind: cInt, i: big.NewInt(1), fn: true}, true, nil + } + return cval{kind: cInt, i: big.NewInt(0), fn: true}, true, nil + } + if op == "<=>" { + if x.isNull() || y.isNull() { + return boolVal(x.isNull() && y.isNull()) + } + } else if x.isNull() || y.isNull() { + return cval{kind: cNull}, true, nil + } + var cmp int + switch { + case x.kind == cStr && y.kind == cStr: + cmp = strings.Compare(strings.ToLower(x.s), strings.ToLower(y.s)) + case x.kind == cReal || y.kind == cReal || x.kind == cStr || y.kind == cStr: + xr, yr := x.asReal(), y.asReal() + switch { + case xr < yr: + cmp = -1 + case xr > yr: + cmp = 1 + } + default: + cmp = x.asDec().Cmp(y.asDec()) + } + switch op { + case "=", "<=>": + return boolVal(cmp == 0) + case "<>", "!=": + return boolVal(cmp != 0) + case "<": + return boolVal(cmp < 0) + case "<=": + return boolVal(cmp <= 0) + case ">": + return boolVal(cmp > 0) + case ">=": + return boolVal(cmp >= 0) + } + return cval{}, false, nil +} + +// foldBinary is + - * / DIV %. +func (a *analyzer) foldBinary(n *mysqlast.Node) (cval, bool, *foldFail) { + x, okx, fail := a.fold(n.Arg("a")) + if fail != nil { + return cval{}, false, fail + } + y, oky, fail := a.fold(n.Arg("b")) + if fail != nil { + return cval{}, false, fail + } + if !okx || !oky { + return cval{}, false, nil + } + if x.isNull() || y.isNull() { + return cval{kind: cNull}, true, nil + } + real := x.kind == cReal || x.kind == cStr || y.kind == cReal || y.kind == cStr + dec := x.kind == cDec || y.kind == cDec + unsigned := x.kind == cUint || y.kind == cUint + // a string operand of + - * / holds no full number: my_strtod's warning 1292, an error + // in a strict write (measured: 10E+0 + 'a' fails INSERT ... VALUES, runs in SELECT and + // in a non-strict write; '' is 0 without the warning) + if real && a.strictWriteStmt() { + switch n.Class { + case "Item_func_plus", "Item_func_minus", "Item_func_mul", "Item_func_div": + for _, op := range []cval{x, y} { + if op.kind != cStr || op.s == "" { + continue + } + if _, tail, good := numericString(op.s); !good || tail { + return cval{}, false, fail1292(fmt.Sprintf("Truncated incorrect DOUBLE value: '%s'", op.s), n.Start) + } + } + } + } + failWith := func(kind string) (cval, bool, *foldFail) { + return cval{}, false, &foldFail{kind: kind, print: a.printItem(n), at: n.Start} + } + switch n.Class { + case "Item_func_plus", "Item_func_minus", "Item_func_mul": + if real { + var r float64 + switch n.Class { + case "Item_func_plus": + r = x.asReal() + y.asReal() + case "Item_func_minus": + r = x.asReal() - y.asReal() + default: + r = x.asReal() * y.asReal() + } + if math.IsInf(r, 0) { + return failWith("DOUBLE") + } + return cval{kind: cReal, r: r, fn: true}, true, nil + } + if dec { + d := new(big.Rat) + switch n.Class { + case "Item_func_plus": + d.Add(x.asDec(), y.asDec()) + case "Item_func_minus": + d.Sub(x.asDec(), y.asDec()) + default: + d.Mul(x.asDec(), y.asDec()) + } + return cval{kind: cDec, d: d, fn: true}, true, nil + } + i := new(big.Int) + switch n.Class { + case "Item_func_plus": + i.Add(x.i, y.i) + case "Item_func_minus": + i.Sub(x.i, y.i) + if a.s.Settings.SQLMode.Has(sqlmode.NoUnsignedSubtraction) { + unsigned = false + } + default: + i.Mul(x.i, y.i) + } + if !fitsInt(i, unsigned) { + return failWith(intKind(unsigned)) + } + return intVal(i, unsigned), true, nil + case "Item_func_div": + if real { + if y.asReal() == 0 { + return cval{kind: cNull}, true, nil + } + r := x.asReal() / y.asReal() + if math.IsInf(r, 0) { + return failWith("DOUBLE") + } + return cval{kind: cReal, r: r, fn: true}, true, nil + } + if y.asDec().Sign() == 0 { + return cval{kind: cNull}, true, nil + } + return cval{kind: cDec, d: new(big.Rat).Quo(x.asDec(), y.asDec()), fn: true}, true, nil + case "Item_func_div_int": + var q *big.Int + if !real && !dec { + if y.i.Sign() == 0 { + return cval{kind: cNull}, true, nil + } + q = new(big.Int).Quo(x.i, y.i) + if !unsigned && q.Cmp(int64Min) == 0 { + return failWith("BIGINT") // Item_func_int_div::val_int divides magnitudes: 2^63 does not fit + } + } else { + // Item_func_int_div::val_int over non-integers: a decimal division, truncated + d := y.asDec() + if d.Sign() == 0 { + return cval{kind: cNull}, true, nil + } + r := new(big.Rat).Quo(x.asDec(), d) + q = new(big.Int).Quo(r.Num(), r.Denom()) + } + if !fitsInt(q, unsigned) { + return failWith(intKind(unsigned)) + } + return intVal(q, unsigned), true, nil + case "Item_func_mod": + if real || dec { + return cval{}, false, nil + } + if y.i.Sign() == 0 { + return cval{kind: cNull}, true, nil + } + return intVal(new(big.Int).Rem(x.i, y.i), x.kind == cUint), true, nil + } + return cval{}, false, nil +} + +// foldCast is CAST(x AS SIGNED / UNSIGNED / FLOAT / DOUBLE). +func (a *analyzer) foldCast(n *mysqlast.Node) (cval, bool, *foldFail) { + target := castTarget(a, n) + switch target { + case "SIGNED_INT", "UNSIGNED_INT", "FLOAT", "DOUBLE": + case "DATE", "DATETIME", "TIME", "YEAR", "CHAR", "DECIMAL": + return a.foldCastValue(n, target) + default: + return cval{}, false, nil + } + x, ok, fail := a.fold(n.Arg("arg")) + if !ok || fail != nil { + return cval{}, false, fail + } + if x.isNull() { + return x, true, nil + } + switch target { + case "FLOAT", "DOUBLE": + // a string the double parse does not consume whole (junk, or an exponent past the + // double range: my_strtod stops) is the warning 1292, an error in a strict write + if x.kind == cStr && a.strictWriteStmt() { + _, tail, good := numericString(x.s) + if !good || tail || math.IsInf(x.asReal(), 0) { + return cval{}, false, fail1292(fmt.Sprintf("Truncated incorrect DOUBLE value: '%s'", x.s), nodeStart(n.Arg("arg"))) + } + } + if target == "DOUBLE" { + return cval{kind: cReal, r: x.asReal(), fn: true}, true, nil + } + r := x.asReal() + if math.Abs(r) > math.MaxFloat32 { + return cval{}, false, &foldFail{kind: "DOUBLE", print: a.printItem(n), at: n.Start} + } + return cval{kind: cReal, r: float64(float32(r)), fn: true}, true, nil + } + unsigned := target == "UNSIGNED_INT" + // Item_typecast_signed / _unsigned parse a whole string (my_strtoll10): junk, a + // fractional part or a magnitude past BIGINT UNSIGNED is the warning 1292, an error in + // a strict write (measured: CAST('abc' AS SIGNED), CAST('1.5' AS SIGNED), + // CAST('99999999999999999999' AS SIGNED) all fail INSERT ... VALUES and run in SELECT, + // while CAST('-1' AS UNSIGNED) wraps silently); a decimal outside the range is the same + // warning spelt DECIMAL. Elsewhere the value is clamped as below. + if a.strictWriteStmt() { + switch x.kind { + case cStr: + if i, good := integerString(x.s); !good || new(big.Int).Abs(i).Cmp(uint64Max) > 0 { + return cval{}, false, fail1292(fmt.Sprintf("Truncated incorrect INTEGER value: '%s'", x.s), nodeStart(n.Arg("arg"))) + } + case cDec: + if !fitsInt(roundHalfAway(x.d).Num(), unsigned) { + return cval{}, false, fail1292(fmt.Sprintf("Truncated incorrect DECIMAL value: '%s'", a.printItem(n.Arg("arg"))), nodeStart(n.Arg("arg"))) + } + } + } + var i *big.Int + switch x.kind { + case cInt, cUint: + i = new(big.Int).Set(x.i) + case cReal: + if x.fn { + // llrint_with_overflow_check: a function's DOUBLE must fit; the message names it + if x.r < -two63f || x.r >= two63f { // the longlong range, for UNSIGNED too (measured) + return cval{}, false, &foldFail{kind: "BIGINT", print: a.printItem(n.Arg("arg")), at: nodeStart(n.Arg("arg"))} + } + } + i = roundHalfAway(x.asDec()).Num() + default: + i = roundHalfAway(x.asDec()).Num() + } + // a value the type cannot hold is clamped (a literal's val_int, my_decimal2int) or + // wraps (a signed integer read as unsigned) + switch { + case unsigned && i.Sign() < 0 && (x.kind == cInt || x.kind == cUint): + i.Add(i, new(big.Int).Add(uint64Max, big.NewInt(1))) + case unsigned && i.Sign() < 0: + i.SetInt64(0) + case unsigned && i.Cmp(uint64Max) > 0: + i.Set(uint64Max) + case !unsigned && i.Cmp(int64Max) > 0: + if x.kind == cUint { + i.Sub(i, new(big.Int).Add(uint64Max, big.NewInt(1))) + } else { + i.Set(int64Max) + } + case !unsigned && i.Cmp(int64Min) < 0: + i.Set(int64Min) + } + return intVal(i, unsigned), true, nil +} + +// foldCastValue is CAST(x AS DATE / DATETIME / TIME / YEAR / CHAR(n) / DECIMAL) of a +// constant, judged only for the failure 1292 in a strict write: the cast's warning is +// escalated there, while a SELECT, a SET or a non-strict write stores NULL or a clamp with +// a warning (measured: TestCastValueServer). The value itself is not modeled (ok stays +// false); an argument this file cannot fold is left alone. +func (a *analyzer) foldCastValue(n *mysqlast.Node, target string) (cval, bool, *foldFail) { + if !a.strictWriteStmt() { + return cval{}, false, nil + } + x, ok, fail := a.fold(n.Arg("arg")) + if !ok || fail != nil { + return cval{}, false, fail + } + if x.isNull() { + return cval{kind: cNull}, true, nil + } + at := nodeStart(n.Arg("arg")) + spell := a.printItem(n.Arg("arg")) + if x.kind == cStr { + spell = x.s + } + switch target { + case "DATE", "DATETIME": + if fail := a.datetimeValueFail(x, n.Arg("arg")); fail != nil { + return cval{}, false, fail + } + case "TIME": + var tm mysqlTime + var warn int + bad := false + if x.kind == cStr { + bad = !strToTime(x.s, &tm, &warn) || warn != 0 + } else { + ip := new(big.Int).Quo(x.asDec().Num(), x.asDec().Denom()) + bad = !ip.IsInt64() || !numberToTime(ip.Int64(), &tm, &warn) + } + if bad { + return cval{}, false, fail1292(fmt.Sprintf("Truncated incorrect time value: '%s'", spell), at) + } + case "YEAR": + // Item_typecast_year: a string's leading integer is the year and junk after it or + // a fractional part fails, but a string with no digits at all is 0 with no warning + // (measured: CAST('abc' AS YEAR) runs, CAST('2020extra' AS YEAR) fails); the value + // must be 0-99 (two-digit form) or 1901-2155 + var v *big.Int + bad := false + if x.kind == cStr { + s := strings.TrimSpace(x.s) + i := 0 + if i < len(s) && (s[i] == '+' || s[i] == '-') { + i++ + } + j := i + for j < len(s) && isDigit(s[j]) { + j++ + } + if j == i { + return cval{}, false, nil // no digits: 0, no warning + } + if j != len(s) { + bad = true + } else { + v, _ = new(big.Int).SetString(s[:j], 10) + } + } else { + v = roundHalfAway(x.asDec()).Num() + spell = v.String() // the message spells the value, not the expression + } + if !bad { + bad = v.Sign() < 0 || v.Cmp(big.NewInt(2155)) > 0 || (v.Cmp(big.NewInt(100)) >= 0 && v.Cmp(big.NewInt(1901)) < 0) + } + if bad { + return cval{}, false, fail1292(fmt.Sprintf("Truncated incorrect YEAR value: '%s'", spell), at) + } + case "CHAR": + // a value whose string form is longer than the CAST's length (measured: CAST(1000 + // AS CHAR(3))); a CHAR with no length never cuts (the store's 1406 judges the column) + length, okLen := castCharLength(a, n) + if !okLen { + return cval{}, false, nil + } + form, okForm := castCharForm(a, n, x) + if !okForm { + return cval{}, false, nil + } + if len([]rune(form)) > length { + return cval{}, false, fail1292(fmt.Sprintf("Truncated incorrect CHAR(%d) value: '%s'", length, form), at) + } + case "DECIMAL": + if x.kind == cStr { + if _, tail, good := numericString(x.s); !good || tail { + return cval{}, false, fail1292(fmt.Sprintf("Truncated incorrect DECIMAL value: '%s'", x.s), at) + } + } + } + return cval{}, false, nil +} + +// datetimeValueFail judges a constant given to CAST(... AS DATE / DATETIME) or +// TIMESTAMP(...): str_to_datetime / number_to_datetime under the session's zero-date +// flags; both targets spell the message "datetime" (measured: CAST('2004-10-0' AS DATE)). +func (a *analyzer) datetimeValueFail(x cval, arg mysqlast.Value) *foldFail { + spell := a.printItem(arg) + if x.kind == cStr { + spell = x.s + } + var tm mysqlTime + var warn int + bad := false + if x.kind == cStr { + bad = !strToDatetime(x.s, a.dateFlags("date"), &tm, &warn) || warn&(timeWarnTruncated|timeWarnOutOfRange|timeWarnZeroDate|timeWarnZeroInDate) != 0 + } else { + ip := new(big.Int).Quo(x.asDec().Num(), x.asDec().Denom()) + bad = !ip.IsInt64() || ip.Sign() < 0 || !numberToDatetime(ip.Int64(), a.dateFlags("date"), &tm, &warn) + } + if bad { + return fail1292(fmt.Sprintf("Incorrect datetime value: '%s'", spell), nodeStart(arg)) + } + return nil +} + +// integerString parses a whole string as an integer the way my_strtoll10 reads it for an +// integer cast: optional surrounding spaces and sign, digits, nothing else. +func integerString(s string) (*big.Int, bool) { + s = strings.Trim(s, " \t") + if s == "" { + return nil, false + } + i := 0 + if s[i] == '+' || s[i] == '-' { + i++ + } + if i == len(s) { + return nil, false + } + for j := i; j < len(s); j++ { + if !isDigit(s[j]) { + return nil, false + } + } + v, ok := new(big.Int).SetString(s, 10) + return v, ok +} + +// castCharLength is the CAST(x AS CHAR(n))'s length, absent for a plain CHAR and for +// BINARY (whose message is not measured). +func castCharLength(a *analyzer, n *mysqlast.Node) (int, bool) { + st, ok := n.Arg("type").(*mysqlast.Struct) + if !ok { + return 0, false + } + if strings.Contains(str(st.Fields["charset"]), "my_charset_bin") { + return 0, false + } + v, err := strconv.Atoi(strings.Trim(str(st.Fields["length"]), "\"")) + if err != nil { + return 0, false + } + return v, true +} + +// castCharForm is the string the server would produce for the cast's value: the string +// itself, an integer's digits, a decimal literal's own text, a double via the shortest +// round-trip form. +func castCharForm(a *analyzer, n *mysqlast.Node, x cval) (string, bool) { + switch x.kind { + case cStr: + return x.s, true + case cInt, cUint: + return x.i.String(), true + case cReal: + return strconv.FormatFloat(x.r, 'g', -1, 64), true + case cDec: + if arg, ok := n.Arg("arg").(*mysqlast.Node); ok && arg.Class == "Item_decimal" { + return str(arg.Arg("str")), true + } + } + return "", false +} + +// castTarget is the cast's type word ("SIGNED_INT", "FLOAT", ...), as cast() reads it. +func castTarget(a *analyzer, n *mysqlast.Node) string { + target := "" + switch x := n.Arg("type").(type) { + case *mysqlast.Struct: + target = str(x.Fields["target"]) + if strings.Contains(target, "?ITEM_CAST_DOUBLE:ITEM_CAST_FLOAT") { + target = "ITEM_CAST_FLOAT" + if up := strings.ToUpper(a.text[n.Start:n.End]); strings.Contains(up, "DOUBLE") || strings.Contains(up, "REAL") { + target = "ITEM_CAST_DOUBLE" + } + } + default: + target = str(x) + } + return strings.TrimPrefix(target, "ITEM_CAST_") +} + +// foldCall is a native function call this file models. +func (a *analyzer) foldCall(n *mysqlast.Node) (cval, bool, *foldFail) { + name, args := funcCallParts(n) + name = strings.ToUpper(name) + if handled, c, valued, fail := a.foldFuncVal(name, n, args); handled { + // a function judged by a constant argument's value (funcval.go): only UNHEX + // carries its result on, for BIN_TO_UUID(UNHEX(...)) + return c, valued, fail + } + vals := make([]cval, len(args)) + for i, arg := range args { + v, ok, fail := a.fold(arg) + if fail != nil { + return cval{}, false, fail + } + if !ok { + return cval{}, false, nil + } + vals[i] = v + } + realFail := func() (cval, bool, *foldFail) { + return cval{}, false, &foldFail{kind: "DOUBLE", print: a.printItem(n), at: n.Start} + } + switch name { + case "ABS": + if len(vals) != 1 { + return cval{}, false, nil + } + x := vals[0] + switch x.kind { + case cNull: + return x, true, nil + case cInt: + if x.i.Cmp(int64Min) == 0 { // Item_func_abs::int_op + return cval{}, false, &foldFail{kind: "BIGINT", print: a.printItem(n), at: n.Start} + } + return intVal(new(big.Int).Abs(x.i), false), true, nil + case cUint: + return intVal(x.i, true), true, nil + case cDec: + return cval{kind: cDec, d: new(big.Rat).Abs(x.d), fn: true}, true, nil + default: + return cval{kind: cReal, r: math.Abs(x.asReal()), fn: true}, true, nil + } + case "EXP", "COT", "DEGREES": + if len(vals) != 1 { + return cval{}, false, nil + } + x := vals[0] + if x.isNull() { + return x, true, nil + } + var r float64 + switch name { + case "EXP": + r = math.Exp(x.asReal()) + case "COT": + t := math.Tan(x.asReal()) + if t == 0 { // Item_func_cot::val_real + return realFail() + } + r = 1 / t + default: + r = x.asReal() * (180 / math.Pi) // Item_func_units: value * mul + } + if math.IsInf(r, 0) { + return realFail() + } + return cval{kind: cReal, r: r, fn: true}, true, nil + case "POW", "POWER": + if len(vals) != 2 { + return cval{}, false, nil + } + if vals[0].isNull() || vals[1].isNull() { + return cval{kind: cNull}, true, nil + } + r := math.Pow(vals[0].asReal(), vals[1].asReal()) + if math.IsInf(r, 0) || math.IsNaN(r) { + return realFail() + } + return cval{kind: cReal, r: r, fn: true}, true, nil + case "ROUND": + if len(vals) == 0 || len(vals) > 2 { + return cval{}, false, nil + } + x := vals[0] + if x.isNull() { + return x, true, nil + } + if x.kind != cInt && x.kind != cUint { + return cval{}, false, nil // a decimal or a double rounds without an integer's range + } + places := int64(0) + if len(vals) == 2 { + if vals[1].isNull() { + return cval{kind: cNull}, true, nil + } + places = roundHalfAway(vals[1].asDec()).Num().Int64() + } + if places >= 0 { + return intVal(x.i, x.kind == cUint), true, nil + } + if places < -40 { + return intVal(big.NewInt(0), x.kind == cUint), true, nil + } + scale := new(big.Int).Exp(big.NewInt(10), big.NewInt(-places), nil) + q := roundHalfAway(new(big.Rat).SetFrac(x.i, scale)).Num() + q.Mul(q, scale) + if !fitsInt(q, x.kind == cUint) { + return cval{}, false, &foldFail{kind: intKind(x.kind == cUint), print: a.printItem(n), at: n.Start} + } + return intVal(q, x.kind == cUint), true, nil + case "RANDOM_BYTES": + if len(vals) != 1 { + return cval{}, false, nil + } + x := vals[0] + if x.isNull() { + return x, true, nil + } + length := roundHalfAway(x.asDec()).Num() + if length.Sign() < 1 || length.Cmp(big.NewInt(1024)) > 0 { + return cval{}, false, &foldFail{raw: "length value is out of range in 'random_bytes'", at: n.Start} + } + return cval{}, false, nil + } + return cval{}, false, nil +} + +// printItem spells a folded expression the way Item::print does in the server's message. +func (a *analyzer) printItem(v mysqlast.Value) string { + n, ok := v.(*mysqlast.Node) + if !ok { + return "" + } + switch n.Class { + case "Item_int": + return str(n.Arg("i")) + case "Item_uint", "Item_decimal", "Item_float": + return str(n.Arg("str")) + case "Item_null": + return "NULL" + case "Item_func_true": + return "true" + case "Item_func_false": + return "false" + case "PTI_text_literal_text_string", "PTI_text_literal_nchar_string", "PTI_text_literal_underscore_charset": + s, _ := stringLiteral(n) + return "'" + strings.ReplaceAll(s, "'", "\\'") + "'" + case "PTI_udf_expr": + return a.printItem(n.Arg("expr")) + case "Item_func_neg": + return "-(" + a.printItem(n.Arg("a")) + ")" + case "Item_func_plus", "Item_func_minus", "Item_func_mul", "Item_func_div", "Item_func_mod": + return "(" + a.printItem(n.Arg("a")) + " " + operatorName(n.Class) + " " + a.printItem(n.Arg("b")) + ")" + case "Item_func_div_int": + return "(" + a.printItem(n.Arg("a")) + " DIV " + a.printItem(n.Arg("b")) + ")" + case "create_func_cast": + word := strings.ToLower(strings.TrimSuffix(castTarget(a, n), "_INT")) + return "cast(" + a.printItem(n.Arg("arg")) + " as " + word + ")" + case "PTI_function_call_generic_ident_sys": + name, args := funcCallParts(n) + name = strings.ToLower(name) + if name == "power" { + name = "pow" + } + parts := make([]string, len(args)) + for i, arg := range args { + parts[i] = a.printItem(arg) + } + return name + "(" + strings.Join(parts, ",") + ")" + } + return a.textOf(n) +} + +// constCheck judges a typed node for a constant 1690: the statement's error where the +// server evaluates the constant before reading rows, the violation 1690 where it runs per +// row (a.foldPerRow), nothing while folding is off (a.noFold: a branch a constant decides +// against, an ORDER BY / GROUP BY item) or inside a body. +func (a *analyzer) constCheck(sc scope, n *mysqlast.Node, where string) *Error { + if a.noFold > 0 || a.routine != nil || a.trig != nil || !foldable(n) { + return nil + } + _, _, fail := a.fold(n) + if fail == nil { + return nil + } + code := fail.errCode() + perRow := a.foldPerRow + if condContext(where) { + // a 1292 constant that is a comparison's (or IN's / LIKE's) operand is not folded + // with the range optimizer's eagerness: it runs per row (measured: UPDATE ... WHERE + // d = CAST('2004-10-0' AS DATE) runs over an empty table and fails over a row) + perRow = !a.isConstant(n) || !a.optimizeTime(sc, where, code == 1292) + } + if perRow { + table := "" + if a.write != nil && a.write.table != nil { + table = a.write.table.Name + } else { + for _, rel := range sc.rels { + if rel.table != nil { + table = rel.table.Name + break + } + } + } + a.storeRaised = append(a.storeRaised, Violation{Code: code, Constraint: itoa(code), Table: table, SQLState: constraintSQLState(code)}) + return nil + } + return &Error{Message: fail.message(), Code: code, Position: a.ph.Back(fail.at)} +} + +// constBool is v as a constant condition: ok when v folds, decided when it is not NULL. +func (a *analyzer) constBool(v mysqlast.Value) (b, null, ok bool) { + c, ok, fail := a.fold(v) + if !ok || fail != nil { + return false, false, false + } + b, null = c.truth() + return b, null, true +} + +// constEqual reports whether two constants compare equal (a CASE's operand against a WHEN): +// numerics as numbers, strings as text; false and !ok when they are not comparable here. +func (a *analyzer) constEqual(x, y mysqlast.Value) (eq, ok bool) { + cx, okx, fx := a.fold(x) + cy, oky, fy := a.fold(y) + if !okx || !oky || fx != nil || fy != nil || cx.isNull() || cy.isNull() { + return false, false + } + if cx.kind == cStr || cy.kind == cStr { + if cx.kind == cStr && cy.kind == cStr { + return strings.EqualFold(cx.s, cy.s), true + } + return false, false + } + if cx.kind == cReal || cy.kind == cReal { + return cx.asReal() == cy.asReal(), true + } + return cx.asDec().Cmp(cy.asDec()) == 0, true +} + +// isCondNode: an AND / OR, which a condition's optimizer simplifies on its own even when +// an enclosing AND / OR is already decided. +func isCondNode(v mysqlast.Value) bool { + n, ok := v.(*mysqlast.Node) + return ok && (n.Class == "Item_cond_and" || n.Class == "Item_cond_or") +} + +// condContext says the clause is one the optimizer folds before reading rows. +func condContext(where string) bool { + return where == "where clause" || where == "having clause" || where == "on clause" +} + +// optimizeTime says the constant at the top of a.parents is one the optimizer evaluates +// before any row is read: the largest constant it is part of is a condition's own term, +// reached from the condition's root through AND / OR / NOT and the predicates the +// optimizer evaluates constant operands of -- IN and LIKE always (Item_func_in's constant +// array, the pattern's analysis), a comparison or BETWEEN when the other operand is a +// column some key of its table covers (the range optimizer builds the range; measured: +// `WHERE id = 9223372036854775807 + 1` fails over no rows, `WHERE name = 1e308 * 10` over +// an unindexed name runs). A constant nested under anything else (an IF's branch, a +// function's argument beside a column) runs per row. In a HAVING, a predicate over an +// aggregate runs per group and is not such a path (measured: `HAVING MAX(id) > +// ` over no rows never evaluates the constant). +func (a *analyzer) optimizeTime(sc scope, where string, noCompare bool) bool { + base := 0 + if len(a.condBases) > 0 { + base = a.condBases[len(a.condBases)-1] + } + for i := len(a.parents) - 2; i >= base; i-- { + p := a.parents[i] + if a.isConstant(p) { + continue // still inside the constant + } + switch p.Class { + case "Item_cond_and", "Item_cond_or", "Item_func_xor", "Item_func_not", "PTI_truth_transform", "PTI_udf_expr": + continue + case "Item_func_isnull", "Item_func_isnotnull": + if where == "having clause" && containsAggregate(p) { + return false + } + continue + case "Item_func_in", "Item_func_like": + if noCompare || (where == "having clause" && containsAggregate(p)) { + return false + } + continue + case "PTI_comp_op", "PTI_handle_sql2003_note184_exception", "Item_func_between": + if noCompare || (where == "having clause" && containsAggregate(p)) { + return false + } + if !a.keyedOperand(sc, p) { + return false + } + continue + } + return false + } + return true +} + +// isConstant says v is an expression over constants alone -- a const_item() to the server, +// evaluated once wherever the optimizer meets it -- whatever its value (it may be the very +// overflow being placed) and whether fold models it (a CONCAT of constants is one too). +func (a *analyzer) isConstant(v mysqlast.Value) bool { + switch x := v.(type) { + case mysqlast.List: + for _, e := range x { + if !a.isConstant(e) { + return false + } + } + return true + case *mysqlast.Node: + switch x.Class { + case "Item_int", "Item_uint", "Item_decimal", "Item_float", "Item_null", "Item_func_true", "Item_func_false", + "PTI_text_literal_text_string", "PTI_text_literal_nchar_string", "PTI_text_literal_underscore_charset": + return true + case "PTI_udf_expr": + return a.isConstant(x.Arg("expr")) + case "Item_func_neg", "Item_func_not", "Item_typecast_datetime": + return a.isConstant(x.Arg("a")) + case "Item_func_plus", "Item_func_minus", "Item_func_mul", "Item_func_div", "Item_func_div_int", "Item_func_mod": + return a.isConstant(x.Arg("a")) && a.isConstant(x.Arg("b")) + case "PTI_comp_op": + return a.isConstant(x.Arg("left")) && a.isConstant(x.Arg("right")) + case "create_func_cast": + return a.isConstant(x.Arg("arg")) + case "PTI_function_call_generic_ident_sys": + name, args := funcCallParts(x) + if strings.EqualFold(name, "RANDOM_BYTES") || strings.EqualFold(name, "RAND") || strings.EqualFold(name, "UUID") { + return false // not a const_item(): evaluated per row, never by the optimizer + } + return a.isConstant(mysqlast.List(args)) + case "Item_func_if", "Item_func_case", "Item_func_coalesce", "Item_func_in", "PTI_handle_sql2003_note184_exception", + "Item_func_between", "Item_func_like", "Item_cond_and", "Item_cond_or", "Item_func_xor", "Item_func_isnull", "Item_func_isnotnull", "Item_row": + for _, arg := range x.Args { + if arg != nil && !a.isConstant(arg) { + return false + } + } + return true + } + } + return false +} + +// keyedOperand reports a predicate whose non-constant operand is a column some key of its +// table covers: the range optimizer then evaluates the predicate's constants. +func (a *analyzer) keyedOperand(sc scope, p *mysqlast.Node) bool { + for _, arg := range p.Args { + if a.isConstant(arg) { + continue + } + if list, ok := arg.(mysqlast.List); ok { // IN's list, BETWEEN's bounds + for _, e := range list { + if !a.isConstant(e) && a.keyedColumn(sc, e) { + return true + } + } + continue + } + if a.keyedColumn(sc, arg) { + return true + } + } + return false +} + +func (a *analyzer) keyedColumn(sc scope, v mysqlast.Value) bool { + n, ok := v.(*mysqlast.Node) + if !ok || !isColumnRef(n) { + return false + } + ref, ok := a.plainColumn(sc, n) + if !ok || ref.col == nil || ref.c.baseTable == nil { + return false + } + for _, k := range ref.c.baseTable.Keys { + for _, part := range k.Parts { + if strings.EqualFold(part.Column, ref.col.Name) { + return true + } + } + } + return false +} diff --git a/check/mysql/internal/analyze/fold_server_test.go b/check/mysql/internal/analyze/fold_server_test.go new file mode 100644 index 0000000..14b2461 --- /dev/null +++ b/check/mysql/internal/analyze/fold_server_test.go @@ -0,0 +1,421 @@ +package analyze + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// foldCases are constant expressions fold.go judges, each pinned against mysqld: want is +// the error number the server raises before reading a row, 0 when the statement runs. A +// constant that runs per row (a select item over a FROM, an UPDATE's SET, a comparison +// against an unindexed column) does not fail over an empty table: those are +// foldPerRowCases, run first while `users` is empty, where the checker lists the violation +// 1690 instead. +var foldCases = []struct { + sql string + want int +}{ + // integer operators: the exact result must fit BIGINT, or BIGINT UNSIGNED when an + // operand is unsigned + {"SELECT 9223372036854775807 * 2", 1690}, + {"SELECT 9223372036854775807 + 1", 1690}, + {"SELECT 9223372036854775807 + 1.0", 0}, + {"SELECT 9223372036854775807 + 1e0", 0}, + {"SELECT 9223372036854775807 + TRUE", 1690}, + {"SELECT 9223372036854775807 + NULL", 0}, + {"SELECT '9223372036854775807' + 1", 0}, + {"SELECT '1e308' + '1e308'", 1690}, + {"SELECT 'abc' + 9223372036854775807", 0}, + {"SELECT -9223372036854775808 - 1", 1690}, + {"SELECT 9223372036854775807 - -1", 1690}, + {"SELECT 4294967296 * 4294967296", 1690}, + {"SELECT -9223372036854775808 * -1", 1690}, + {"SELECT 9223372036854775809 * -1", 1690}, + {"SELECT 18446744073709551615 + 1", 1690}, + {"SELECT 1 + 18446744073709551615", 1690}, + {"SELECT 9223372036854775807 + CAST(1 AS UNSIGNED)", 0}, + {"SELECT CAST(1 AS UNSIGNED) - 2", 1690}, + {"SELECT CAST(1 AS UNSIGNED) + -1", 0}, + {"SELECT CAST(1 AS UNSIGNED) + -2", 1690}, + {"SELECT -1 + CAST(1 AS UNSIGNED)", 0}, + {"SELECT -1 - CAST(1 AS UNSIGNED)", 1690}, + {"SELECT 1 - CAST(2 AS UNSIGNED)", 1690}, + {"SELECT 2 - 3 + CAST(1 AS UNSIGNED)", 0}, + {"SELECT -1 * CAST(1 AS UNSIGNED)", 1690}, + {"SELECT CAST(0 AS UNSIGNED) * -1", 0}, + {"SELECT 18446744073709551615 - (-1)", 1690}, + {"SELECT 9223372036854775807 * 1 + 1", 1690}, + {"SELECT (9223372036854775807 + 1) * 0", 1690}, + {"SELECT 1 + 1 + 9223372036854775807", 1690}, + {"SELECT 9223372036854775807 % 2 + 1", 0}, + {"SELECT 9223372036854775807 MOD 0", 0}, + {"SELECT -(-9223372036854775808)", 0}, + {"SELECT -9223372036854775809", 0}, + {"SELECT -(CAST(9223372036854775809 AS UNSIGNED))", 0}, + // DIV: the truncated quotient must fit; magnitudes are divided, so 2^63 never does + {"SELECT -9223372036854775808 DIV -1", 1690}, + {"SELECT -9223372036854775808 DIV 1", 1690}, + {"SELECT 9223372036854775807 DIV -1", 0}, + {"SELECT 18446744073709551615 DIV -1", 1690}, + {"SELECT 18446744073709551615 DIV 1", 0}, + {"SELECT 18446744073709551615 DIV 2", 0}, + {"SELECT 18446744073709551615 DIV 1.0", 0}, + {"SELECT 18446744073709551615 DIV -0.5", 1690}, + {"SELECT -1 DIV CAST(1 AS UNSIGNED)", 1690}, + {"SELECT 10 DIV 0", 0}, + {"SELECT 1 DIV 0.5", 0}, + {"SELECT 1.5 DIV 1", 0}, + {"SELECT 9223372036854775807 DIV 0.5", 1690}, + {"SELECT 9223372036854775807 DIV 1e-1", 1690}, + {"SELECT -9223372036854775808 DIV -1.0", 1690}, + {"SELECT 123456789012345678901234567890 DIV 1", 1690}, + {"SELECT '18446744073709551616' DIV 1", 1690}, + {"SELECT 10 / 0", 0}, + {"SELECT 1e300 / 1e-300", 1690}, + // DOUBLE: an infinite result + {"SELECT 1e308 + 1e308", 1690}, + {"SELECT -1e308 - 1e308", 1690}, + {"SELECT 1e300 * 1e300", 1690}, + {"SELECT 1e308 * 10", 1690}, + {"SELECT 1e308 * 1e0", 0}, + {"SELECT 3.4e38 * 10", 0}, + {"SELECT EXP(709)", 0}, + {"SELECT EXP(710)", 1690}, + {"SELECT EXP('710')", 1690}, + {"SELECT POW(2, 1023)", 0}, + {"SELECT POW(2, 1024)", 1690}, + {"SELECT POWER(2, 1024)", 1690}, + {"SELECT POW(0, -1)", 1690}, + {"SELECT POW(-1, 0.5)", 1690}, + {"SELECT COT(0)", 1690}, + {"SELECT COT(0.0)", 1690}, + {"SELECT COT('v')", 1690}, + {"SELECT COT(3.141592653589793)", 0}, + {"SELECT DEGREES(1e306)", 0}, + {"SELECT DEGREES(1e307)", 1690}, + {"SELECT RADIANS(1e308)", 0}, + {"SELECT LN(0)", 0}, + {"SELECT SQRT(-1)", 0}, + {"SELECT TAN(1e308)", 0}, + // ABS / ROUND over integers + {"SELECT ABS(-9223372036854775808)", 1690}, + {"SELECT ABS(-9223372036854775808 + 0)", 1690}, + {"SELECT ABS(CAST(-1 AS SIGNED))", 0}, + {"SELECT ABS(-(1e19))", 0}, + {"SELECT ROUND(9223372036854775807, -1)", 1690}, + {"SELECT ROUND(9223372036854775805, -1)", 1690}, + {"SELECT ROUND(9223372036854775804, -1)", 0}, + {"SELECT ROUND(9223372036854775807, -18)", 0}, + {"SELECT ROUND(9223372036854775807, -19)", 1690}, + {"SELECT ROUND(9223372036854775807, -1.5)", 0}, + {"SELECT ROUND(9223372036854775807, '-1')", 1690}, + {"SELECT ROUND(9223372036854775807.0, -1)", 0}, + {"SELECT ROUND(-9223372036854775808, -1)", 1690}, + {"SELECT ROUND(-9223372036854775808, -4)", 1690}, + {"SELECT ROUND(18446744073709551615, -1)", 1690}, + {"SELECT ROUND(18446744073709551615, 0)", 0}, + {"SELECT ROUND(15, -1)", 0}, + {"SELECT TRUNCATE(9223372036854775807, -1)", 0}, + {"SELECT FLOOR(1e308)", 0}, + // CAST + {"SELECT CAST(POW(2,63) AS SIGNED)", 1690}, + {"SELECT CAST(POW(2, 63) AS UNSIGNED)", 1690}, + {"SELECT CAST(POW(-2, 63) AS UNSIGNED)", 0}, + {"SELECT CAST(POW(2,64) AS UNSIGNED)", 1690}, + {"SELECT 1-CAST(POW(2,100) AS SIGNED)", 1690}, + {"SELECT CAST(EXP(50) AS SIGNED)", 1690}, + {"SELECT CAST(1e18 * 10 AS SIGNED)", 1690}, + {"SELECT CAST('1e19' + 0 AS SIGNED)", 1690}, + {"SELECT CAST(1e19 + 0 AS UNSIGNED)", 1690}, + {"SELECT CAST(-(1e19) AS SIGNED)", 1690}, + {"SELECT CAST(-(1e19) AS UNSIGNED)", 1690}, + {"SELECT CAST(-(5e18) AS UNSIGNED)", 0}, + {"SELECT CAST(-9.3e18 AS SIGNED)", 1690}, + {"SELECT CAST(-9.2e18 AS SIGNED)", 0}, + {"SELECT CAST(9.3e18 AS SIGNED)", 0}, + {"SELECT CAST(1.9e19 AS UNSIGNED)", 0}, + {"SELECT CAST(-1 AS UNSIGNED)", 0}, + {"SELECT CAST(-1.5 AS UNSIGNED)", 0}, + {"SELECT CAST(9223372036854775808 AS SIGNED)", 0}, + {"SELECT CAST(99999999999999999999 AS SIGNED)", 0}, + {"SELECT CAST('99999999999999999999' AS SIGNED)", 0}, + {"SELECT CAST(9223372036854775807 + 1.0 AS SIGNED)", 0}, + {"SELECT CAST(CAST(1e19 AS DOUBLE) AS SIGNED)", 1690}, + {"SELECT CAST(CAST(1e19 AS DECIMAL(30)) AS SIGNED)", 0}, + {"SELECT CAST(\"3.14e100\" AS FLOAT)", 1690}, + {"SELECT CAST(3.14e100 AS FLOAT)", 1690}, + {"SELECT CAST(1e40 AS FLOAT)", 1690}, + {"SELECT CAST(-3.5e38 AS FLOAT)", 1690}, + {"SELECT CAST(3.5e38 AS DOUBLE)", 0}, + {"SELECT CAST(1e308 * 10 AS DOUBLE)", 1690}, + {"SELECT CAST(1e39 AS DECIMAL(65))", 0}, + // RANDOM_BYTES + {"SELECT RANDOM_BYTES(1)", 0}, + {"SELECT RANDOM_BYTES(1024)", 0}, + {"SELECT RANDOM_BYTES(0)", 1690}, + {"SELECT RANDOM_BYTES(1025)", 1690}, + {"SELECT RANDOM_BYTES(-1)", 1690}, + {"SELECT RANDOM_BYTES(1000000000000)", 1690}, + {"SELECT RANDOM_BYTES(0.9)", 0}, + {"SELECT RANDOM_BYTES(1024.9)", 1690}, + {"SELECT RANDOM_BYTES(1e3)", 0}, + {"SELECT RANDOM_BYTES(TRUE)", 0}, + {"SELECT RANDOM_BYTES(FALSE)", 1690}, + {"SELECT RANDOM_BYTES('5')", 0}, + {"SELECT RANDOM_BYTES('abc')", 1690}, + {"SELECT RANDOM_BYTES(NULL)", 0}, + {"SELECT LENGTH(RANDOM_BYTES(0))", 1690}, + // the constant is evaluated wherever it sits, unless something never reaches it + {"SELECT (9223372036854775807 + 1) = 1", 1690}, + {"SELECT CONCAT(9223372036854775807 + 1)", 1690}, + {"SELECT CONCAT('a', POW(1000,1000))", 1690}, + {"SELECT ROW(POW(1000, 1000), 1) = ROW(1, 1)", 1690}, + {"SELECT GREATEST(1, POW(1000,1000))", 1690}, + {"SELECT NULL + POW(1000,1000)", 1690}, + {"SELECT 1 BETWEEN 0 AND POW(1000,1000)", 1690}, + {"SELECT NULLIF(1, POW(1000,1000))", 1690}, + {"SELECT NOT POW(1000,1000)", 1690}, + {"SELECT 1 XOR POW(1000,1000)", 1690}, + {"SELECT (9223372036854775807 + 1) IS NOT NULL", 1690}, + {"SELECT (9223372036854775807 + 1) IS NULL", 0}, + {"SELECT ISNULL(9223372036854775807 + 1)", 0}, + {"SELECT 1 AND POW(1000,1000)", 1690}, + {"SELECT 1 AND 1 AND POW(1000,1000)", 1690}, + {"SELECT 0 AND POW(1000,1000)", 0}, + {"SELECT 1 AND 0 AND POW(1000,1000)", 0}, + {"SELECT NULL AND POW(1000,1000)", 1690}, + {"SELECT POW(1000,1000) AND 0", 1690}, + {"SELECT 0 OR POW(1000,1000)", 1690}, + {"SELECT 1 OR POW(1000,1000)", 0}, + {"SELECT NULL OR POW(1000,1000)", 1690}, + {"SELECT IF(1, POW(1000,1000), 1)", 1690}, + {"SELECT IF(0, POW(1000,1000), 1)", 0}, + {"SELECT IF(NULL, POW(1000,1000), 1)", 0}, + {"SELECT IF('a', POW(1000,1000), 1)", 0}, + {"SELECT IF(1 = 1, 1, POW(1000,1000))", 0}, + {"SELECT CASE WHEN 0 THEN POW(1000,1000) END", 0}, + {"SELECT CASE WHEN 1 THEN 1 ELSE POW(1000,1000) END", 0}, + {"SELECT CASE WHEN 1 THEN 1 WHEN POW(1000,1000) THEN 2 END", 0}, + {"SELECT CASE WHEN 0 THEN 1 WHEN 1 THEN 2 ELSE POW(1000,1000) END", 0}, + {"SELECT CASE 1 WHEN 1 THEN 1 ELSE POW(1000,1000) END", 0}, + {"SELECT CASE 1 WHEN 2 THEN POW(1000,1000) END", 0}, + {"SELECT CASE 1 WHEN POW(1000,1000) THEN 1 END", 1690}, + {"SELECT COALESCE(1, POW(1000,1000))", 0}, + {"SELECT COALESCE(NULL, 1, POW(1000,1000))", 0}, + {"SELECT COALESCE(NULL, POW(1000,1000))", 1690}, + {"SELECT IFNULL(1, POW(1000,1000))", 0}, + {"SELECT IFNULL(NULL, POW(1000,1000))", 1690}, + {"SELECT 1 IN (1, POW(1000,1000))", 0}, + {"SELECT 2 IN (1, POW(1000,1000))", 1690}, + // a select list without a FROM is evaluated once; a false WHERE or LIMIT 0 stops that + {"SELECT 9223372036854775807 + 1 FROM DUAL", 1690}, + {"SELECT 9223372036854775807 + 1 FROM DUAL WHERE 1 = 0", 0}, + {"SELECT 9223372036854775807 + 1 LIMIT 0", 0}, + {"SELECT 9223372036854775807 + 1 UNION SELECT 1", 1690}, + {"SELECT 1 UNION SELECT 9223372036854775807 + 1", 1690}, + {"SELECT 1 FROM users WHERE 1 = 0 UNION SELECT 9223372036854775807 + 1", 1690}, + {"SELECT 1 FROM (SELECT 9223372036854775807 + 1 AS x) d", 1690}, + {"SELECT 1 FROM (SELECT 1 UNION SELECT 9223372036854775807 + 1) d", 1690}, + {"WITH d AS (SELECT 9223372036854775807 + 1 AS x) SELECT 1 FROM d", 1690}, + {"SELECT EXISTS (SELECT 9223372036854775807 + 1)", 0}, + {"SELECT 1 FROM users u WHERE EXISTS (SELECT 9223372036854775807 + 1)", 0}, + {"SELECT 1 FROM users u WHERE (SELECT 9223372036854775807 + 1)", 1690}, + {"SELECT 1 FROM users WHERE id = (SELECT 9223372036854775807 + 1)", 1690}, + {"SELECT 1 FROM users WHERE id IN (SELECT 9223372036854775807 + 1)", 1690}, + {"SELECT 1 FROM users WHERE id > ALL (SELECT 9223372036854775807 + 1)", 1690}, + // a condition is folded before any row is read + {"SELECT 1 FROM users WHERE 9223372036854775807 * 2", 1690}, + {"SELECT 1 FROM users WHERE ABS(-9223372036854775808)", 1690}, + {"SELECT 1 FROM users WHERE CAST(POW(2,63) AS SIGNED)", 1690}, + {"SELECT 1 FROM users WHERE id = 9223372036854775807 + 1", 1690}, + {"SELECT 1 FROM users WHERE id > 9223372036854775807 + 1", 1690}, + {"SELECT 1 FROM users WHERE id IN (1, 9223372036854775807 + 1)", 1690}, + {"SELECT 1 FROM users WHERE id IN (POW(1000,1000))", 1690}, + {"SELECT 1 FROM users WHERE id BETWEEN 1 AND POW(1000,1000)", 1690}, + {"SELECT 1 FROM users WHERE name LIKE CONCAT('%', POW(1000,1000))", 1690}, + {"SELECT 1 FROM users WHERE CAST(9223372036854775807 + 1 AS CHAR)", 1690}, + {"SELECT 1 FROM users WHERE id = 1 AND 9223372036854775807 + 1", 1690}, + {"SELECT 1 FROM users WHERE id = 1 OR 9223372036854775807 + 1", 1690}, + {"SELECT 1 FROM users WHERE id = 1 AND 9223372036854775807 * 2 = 0", 1690}, + {"SELECT 1 FROM users WHERE 1 = 0 AND 9223372036854775807 + 1", 0}, + {"SELECT 1 FROM users WHERE NULL AND POW(1000,1000)", 0}, + {"SELECT 1 FROM users WHERE POW(1000,1000) AND 1 = 0", 1690}, + {"SELECT 1 FROM users WHERE id = 1 AND 1 = 0 AND POW(1000,1000)", 0}, + {"SELECT 1 FROM users WHERE 1 = 0 AND id = 1 AND 9223372036854775807 + 1", 0}, + {"SELECT 1 FROM users WHERE id = 1 AND 9223372036854775807 + 1 > 0 AND 1 = 0", 1690}, + {"SELECT 1 FROM users WHERE 1 = 0 OR POW(1000,1000)", 1690}, + {"SELECT 1 FROM users WHERE 1 = 1 OR POW(1000,1000)", 0}, + {"SELECT 1 FROM users WHERE (1 = 0) AND (POW(1000,1000) OR id)", 1690}, + {"SELECT 1 FROM users WHERE id = 1 AND (0 OR POW(1000,1000))", 1690}, + {"SELECT 1 FROM users WHERE id = 1 AND (1 OR POW(1000,1000))", 0}, + {"SELECT 1 FROM users WHERE id = 1 AND NOT (1 OR POW(1000,1000))", 0}, + {"SELECT 1 FROM users WHERE 1 = 0 AND EXISTS (SELECT 1 FROM orders WHERE 9223372036854775807 + 1)", 1690}, + {"SELECT 1 FROM users WHERE id = 1 AND IF(1 = 1, 1, POW(1000,1000))", 0}, + {"SELECT 1 FROM users WHERE id = 1 AND CASE WHEN 1 THEN 1 ELSE POW(1000,1000) END", 0}, + {"SELECT 1 FROM users WHERE id = 1 AND COALESCE(1, POW(1000,1000))", 0}, + {"SELECT 1 FROM users WHERE IF(0, 1, POW(1000,1000))", 1690}, + {"SELECT 1 FROM users u JOIN orders o ON 9223372036854775807 + 1", 1690}, + {"SELECT 1 FROM users u JOIN orders o ON 1 = 0 AND 9223372036854775807 + 1", 0}, + {"SELECT 1 FROM users u LEFT JOIN orders o ON o.user_id = u.id AND 9223372036854775807 + 1", 1690}, + {"SELECT 1 FROM users HAVING 9223372036854775807 + 1", 1690}, + {"SELECT 1 FROM users GROUP BY id HAVING 9223372036854775807 + 1", 1690}, + {"SELECT COUNT(*) FROM users HAVING 9223372036854775807 + 1", 1690}, + {"SELECT 1 FROM users GROUP BY id WITH ROLLUP HAVING 9223372036854775807 + 1", 1690}, + {"SELECT 1 FROM users u WHERE u.id = (SELECT MAX(id) FROM orders HAVING POW(1000,1000))", 1690}, + {"SELECT 1 FROM users u WHERE u.id = (SELECT MAX(id) FROM orders HAVING 1 = 0 AND POW(1000,1000))", 0}, + {"SELECT 1 FROM users u WHERE u.id = (SELECT MAX(id) FROM orders WHERE 1 = 0 AND POW(1000,1000))", 0}, + {"DELETE FROM users WHERE 9223372036854775807 + 1", 1690}, + {"DELETE FROM users WHERE 1 = 0 AND POW(1000,1000)", 0}, + {"UPDATE users SET name = 'x' WHERE 9223372036854775807 + 1", 1690}, + {"UPDATE users SET name = 'a' WHERE 1 = 0 OR POW(1000,1000)", 1690}, + // GROUP BY and ORDER BY drop a constant unevaluated (a window's ORDER BY does not: it + // runs per row, which the checker does not read yet) + {"SELECT 1 FROM users GROUP BY 9223372036854775807 + 1", 0}, + {"SELECT 1 FROM users ORDER BY 9223372036854775807 + 1", 0}, + {"SELECT id FROM users GROUP BY id, 9223372036854775807 + 1", 0}, + // INSERT ... VALUES is evaluated; INSERT ... SELECT follows the SELECT's rule + {"INSERT INTO users (id, name) VALUES (1, 9223372036854775807 + 1)", 1690}, + {"INSERT INTO users (id, name) VALUES (6, IF(1, 'a', POW(1000,1000)))", 0}, + {"INSERT INTO users (id, name) SELECT 5, 9223372036854775807 + 1", 1690}, + {"INSERT INTO users (id, name) SELECT 5, 'a' WHERE 1 = 0 AND POW(1000,1000)", 0}, + {"INSERT INTO metrics (id, small, tiny, y) VALUES (9223372036854775807 + 1, 1, 1, 2000)", 1690}, +} + +// foldPerRowCases run their constant per row: the server (with no row) accepts them, the +// checker lists the violation 1690. +var foldPerRowCases = []string{ + "SELECT 9223372036854775807 + 1 FROM users", + "SELECT (SELECT 9223372036854775807 + 1) FROM users", + "SELECT 1 FROM users WHERE RANDOM_BYTES(0)", + "SELECT 1 FROM users WHERE name = 1e308 * 10", + "SELECT 1 FROM users WHERE name = POW(1000,1000) AND id = 1", + "SELECT POW(1000,1000) FROM users WHERE 1 = 0", + "SELECT ABS(-9223372036854775808) FROM users", + "SELECT SUM(9223372036854775807 + 1) FROM users", + "SELECT id + (9223372036854775807 + 1) FROM users", + "SELECT 1 FROM (SELECT 9223372036854775807 + 1 AS x FROM users) d", + "SELECT 1 FROM users WHERE IF(id, 1, 9223372036854775807 + 1)", + "SELECT MAX(id) FROM users HAVING MAX(id) > 9223372036854775807 + 1", + "UPDATE users SET name = 9223372036854775807 + 1", + "UPDATE users SET name = 9223372036854775807 + 1 WHERE id = 99", + "INSERT INTO users (id, name) SELECT 5, 9223372036854775807 + 1 FROM users WHERE 1 = 0", + "INSERT INTO users (id, name) VALUES (5, 'z') ON DUPLICATE KEY UPDATE name = 9223372036854775807 + 1", +} + +func TestConstantFoldServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, testSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + run := func(sql string) (int, string) { + _, err := conn.ExecContext(ctx, sql) + if err == nil { + return 0, "" + } + var me *driver.MySQLError + if !errors.As(err, &me) { + t.Errorf("%s: %v", sql, err) + return -1, "" + } + return int(me.Number), me.Message + } + s := load(t) + for _, sql := range foldPerRowCases { + if got, msg := run(sql); got != 0 && got >= 0 { + t.Errorf("%s over no rows: the server says %d %s, want 0", sql, got, msg) + } + } + for _, c := range foldCases { + got, msg := run(c.sql) + if got != c.want && got >= 0 { + t.Errorf("%s: the server says %d %s, the checker %d", c.sql, got, msg, c.want) + } + if c.want != 1690 { + continue + } + // the checker's message spells the expression as the server does + _, err := Analyze(s, c.sql) + var e *Error + if !errors.As(err, &e) || e.Code != 1690 { + t.Errorf("%s: analyzer %v, want 1690", c.sql, err) + continue + } + if e.Message != msg { + t.Errorf("%s: analyzer says %q, the server %q", c.sql, e.Message, msg) + } + } + // with a row (the ON DUPLICATE KEY UPDATE case above inserted one), the per-row constant + // fails as the checker's violation says + for _, sql := range []string{"SELECT 9223372036854775807 + 1 FROM users", "SELECT (SELECT 9223372036854775807 + 1) FROM users", "UPDATE users SET name = 9223372036854775807 + 1", "SELECT 1 FROM users WHERE RANDOM_BYTES(0)", "SELECT 1 FROM users WHERE name = 1e308 * 10"} { + if got, _ := run(sql); got != 1690 { + t.Errorf("%s over a row: the server says %d, want 1690", sql, got) + } + } +} + +// TestConstantFold is the checker's side of foldCases: the statement's error, or none, and +// the violation 1690 for a constant that runs per row. +func TestConstantFold(t *testing.T) { + s := load(t) + for _, c := range foldCases { + res, err := Analyze(s, c.sql) + code := 0 + if err != nil { + e, ok := err.(*Error) + if !ok { + t.Errorf("%s: %v", c.sql, err) + continue + } + code = e.Code + } + if code != c.want { + t.Errorf("%s: got %d, want %d", c.sql, code, c.want) + continue + } + if res != nil && has1690(res.Violations) { + t.Errorf("%s: lists the violation 1690, wanted none", c.sql) + } + } + for _, sql := range foldPerRowCases { + res, err := Analyze(s, sql) + if err != nil { + t.Errorf("%s: %v", sql, err) + continue + } + if !has1690(res.Violations) { + t.Errorf("%s: no violation 1690 listed", sql) + } + } + // NO_UNSIGNED_SUBTRACTION makes a subtraction's result signed + nus, err := schema.Load(strings.Replace(testSchema, "-- sqlshape: mysql 8.4", "-- sqlshape: mysql 8.4\n-- sqlshape: server sql_mode = 'NO_UNSIGNED_SUBTRACTION'", 1)) + if err != nil { + t.Fatal(err) + } + if _, err := Analyze(nus, "SELECT CAST(1 AS UNSIGNED) - 2"); err != nil { + t.Errorf("CAST(1 AS UNSIGNED) - 2 under NO_UNSIGNED_SUBTRACTION: %v", err) + } +} + +func has1690(vs []Violation) bool { + for _, v := range vs { + if v.Code == 1690 && v.Key() == "1690" { + return true + } + } + return false +} diff --git a/check/mysql/internal/analyze/fullgroup.go b/check/mysql/internal/analyze/fullgroup.go new file mode 100644 index 0000000..294d7d7 --- /dev/null +++ b/check/mysql/internal/analyze/fullgroup.go @@ -0,0 +1,1235 @@ +package analyze + +import ( + "fmt" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlast" + "github.com/kr9ly/sqlshape/v2/x/facts" + "github.com/kr9ly/sqlshape/v2/x/sqlmode" +) + +// The server's checks on a grouped, aggregated or DISTINCT block, as sql_mode +// ONLY_FULL_GROUP_BY (the default) runs them after resolution (without the flag only the +// HAVING resolution rule and the ORDER BY aggregate rules remain, which the server applies +// in any mode): +// +// - Group_check (aggregate_check.cc): every expression of the select list, the ORDER BY, +// the HAVING and the windows' PARTITION BY / ORDER BY must be group-invariant -- equal +// to a GROUP BY expression, an aggregate, or made of columns functionally dependent on +// the group columns. The dependencies the server recognizes: the group columns; a +// table's columns once a PRIMARY / UNIQUE key of it is dependent (a nullable key column +// only where a conjunct rejects its NULL); WHERE and inner-join equalities col = col +// (both ways) and col = constant (a literal or an outer reference, not a parameter); an +// outer join's ON equalities into its nullable side; and the dependencies inside a +// derived table's, view's or CTE's body, seen through its output columns. ROLLUP allows +// no dependency beyond the group expressions themselves. Error 1055 with GROUP BY, 1140 +// for an aggregated query without it (whose ORDER BY the server drops unchecked). +// - The HAVING resolution rule the check relies on: a column named outside an aggregate +// in HAVING must be a select-list column or alias, or a GROUP BY column (1054). +// - Distinct_check: with DISTINCT, an ORDER BY expression not in the select list may +// read only select-list columns (3065). +// - An aggregate in the ORDER BY of a query that aggregates nowhere else (3029), or of a +// set operation (3028). + +// outerRef is a column reference a nested query resolved in an enclosing block. +type outerRef struct { + block *relation // the block resolved into: the address of its first relation + leaf int // index of the relation in that block + col string + at int // offset of the reference in a.text + where string // the clause the reference was typed in +} + +// blockInfo is what the checks need of a typed SELECT block. +type blockInfo struct { + sc scope + body *mysqlast.Node // the PT_query_specification + items mysqlast.List // the select items (PTI_expr_with_alias / Item_asterisk) + explicit bool // GROUP BY + aggregated bool // GROUP BY, or an aggregate of this block in the select list or HAVING + rollup bool + distinct bool + groupCols []facts.ColRef // the GROUP BY items that are columns of the block + groupExprs []mysqlast.Value // every GROUP BY item, positions and aliases resolved to the select expression + groupColAt []int // parallel to groupExprs: index into groupCols of the column an item is, -1 otherwise + ancestors []*relation // the enclosing blocks' identities + fd *fdSet // the dependencies, once computed +} + +// blockID is the identity a block is known by in outerRefs: its first relation's address. +func blockID(sc *scope) *relation { + if sc == nil || len(sc.rels) == 0 { + return nil + } + return &sc.rels[0] +} + +// groupCheck records what the checks need of a block whose select list, GROUP BY and +// HAVING are typed, and runs the checks on the select list and HAVING (the ORDER BY's wait +// for the query expression: orderCheck). +func (a *analyzer) groupCheck(sc *scope, body *mysqlast.Node) error { + info := &blockInfo{sc: *sc, body: body} + info.items, _ = body.Arg("item_list").(mysqlast.List) + info.distinct = strings.Contains(fmt.Sprint(body.Arg("options")), "SELECT_DISTINCT") + for o := sc.outer; o != nil; o = o.outer { + if id := blockID(o); id != nil { + info.ancestors = append(info.ancestors, id) + } + } + if g, ok := body.Arg("opt_group_clause").(*mysqlast.Node); ok { + info.explicit = true + olap := str(g.Arg("olap")) + info.rollup = olap != "" && olap != "UNSPECIFIED_OLAP_TYPE" + list, _ := g.Arg("group_list").(mysqlast.List) + for _, it := range list { + item := it + if oe, ok := it.(*mysqlast.Node); ok && oe.Class == "PT_order_expr" { + item = oe.Arg("item") + } + item = a.groupItem(sc, info.items, item) + info.groupExprs = append(info.groupExprs, item) + if col, ok := a.colFact(sc, item); ok { + info.groupCols = append(info.groupCols, col) + info.groupColAt = append(info.groupColAt, len(info.groupCols)-1) + } else { + info.groupColAt = append(info.groupColAt, -1) + } + } + } + having := havingExpr(body.Arg("opt_having_clause")) + info.aggregated = info.explicit + if !info.aggregated { + for _, it := range info.items { + if n, ok := it.(*mysqlast.Node); ok && n.Class == "PTI_expr_with_alias" && a.hasOwnAggregate(info, n.Arg("expr"), 0) { + info.aggregated = true + } + } + if having != nil && a.hasOwnAggregate(info, having, 0) { + info.aggregated = true + } + } + sc.info = info + if sc.facts != nil { + if a.blocks == nil { + a.blocks = map[*facts.Scope]*blockInfo{} + } + a.blocks[sc.facts] = info + } + if having != nil { + if err := a.havingResolution(info, having); err != nil { + return err + } + } + if !info.aggregated || !a.fullGroupBy() { + return nil + } + fd := a.fdClosure(info, true, nil, nil, nil) + info.fd = fd + num := 1 + for _, it := range info.items { + n, ok := it.(*mysqlast.Node) + if !ok { + continue + } + switch n.Class { + case "Item_asterisk": + table := str(arg(n, "opt_table_name", 1)) + for i := range sc.rels { + rel := &sc.rels[i] + if table != "" && !strings.EqualFold(rel.alias, table) { + continue + } + for _, c := range rel.columns() { + if !fd.isFD(facts.ColRef{Leaf: i, Column: c.Name}) { + return a.groupError(info, num, "SELECT list", rel.alias+"."+c.Name, n.Start) + } + num++ + } + } + case "PTI_expr_with_alias": + expr := n.Arg("expr") + if !info.matchesGroup(expr) { + if f := a.fdWalk(fd, expr, 0, false); f != nil { + return a.groupError(info, num, "SELECT list", f.name, f.at) + } + } + num++ + } + } + if having != nil && !info.matchesGroup(having) { + if f := a.fdWalk(fd, having, 0, true); f != nil { + return a.groupError(info, 1, "HAVING clause", f.name, f.at) + } + } + // the windows: those of the select items, then the WINDOW clause's + var windows []*mysqlast.Node + for _, it := range info.items { + windows = collectClass(it, "PT_window", windows) + } + if list, ok := body.Arg("opt_window_clause").(mysqlast.List); ok { + for _, w := range list { + if n, ok := w.(*mysqlast.Node); ok && n.Class == "PT_window" { + windows = append(windows, n) + } + } + } + for _, w := range windows { + for _, key := range []string{"partition_by", "order_by"} { + list, _ := w.Arg(key).(mysqlast.List) + for k, it := range list { + item := it + if oe, ok := it.(*mysqlast.Node); ok && oe.Class == "PT_order_expr" { + item = oe.Arg("item") + } + if info.matchesGroup(item) { + continue + } + if f := a.fdWalk(fd, item, 0, false); f != nil { + return a.groupError(info, k+1, fmt.Sprintf("PARTITION BY or ORDER BY clause of window '%s'", windowName(w)), f.name, f.at) + } + } + } + } + return nil +} + +// orderCheck runs the checks on a block's ORDER BY: an aggregate where the query does not +// aggregate (3029), group invariance (1055; an aggregated query without GROUP BY has its +// ORDER BY dropped), and DISTINCT (3065). +func (a *analyzer) orderCheck(block *scope, order mysqlast.Value) error { + info := block.info + n, ok := order.(*mysqlast.Node) + if info == nil || !ok { + return nil + } + list, _ := n.Arg("order_list").(mysqlast.List) + if len(list) == 0 { + return nil + } + items := make([]mysqlast.Value, len(list)) + for i, it := range list { + items[i] = it + if oe, ok := it.(*mysqlast.Node); ok && oe.Class == "PT_order_expr" { + items[i] = oe.Arg("item") + } + } + if !info.aggregated { + for i, item := range items { + if a.hasOwnAggregate(info, item, 0) { + return &Error{Message: fmt.Sprintf("Expression #%d of ORDER BY contains aggregate function and applies to the result of a non-aggregated query", i+1), Code: 3029, Position: a.ph.Back(nodeStart(item))} + } + } + } + if info.explicit && a.fullGroupBy() { + for i, item := range items { + if a.inSelectList(info, item) || info.matchesGroup(item) { + continue + } + if f := a.fdWalk(info.fd, item, 0, false); f != nil { + return a.groupError(info, i+1, "ORDER BY clause", f.name, f.at) + } + } + } + if info.distinct && a.fullGroupBy() { + for i, item := range items { + if a.inSelectList(info, item) { + continue + } + if f := a.distinctWalk(info, item, 0); f != nil { + return &Error{Message: fmt.Sprintf("Expression #%d of ORDER BY clause is not in SELECT list, references column '%s' which is not in SELECT list; this is incompatible with DISTINCT", i+1, f.name), Code: 3065, Position: a.ph.Back(f.at)} + } + } + } + return nil +} + +// fullGroupBy is whether the declared sql_mode has ONLY_FULL_GROUP_BY. +func (a *analyzer) fullGroupBy() bool { return a.s.Settings.SQLMode.Has(sqlmode.OnlyFullGroupBy) } + +func (a *analyzer) groupError(info *blockInfo, num int, place, col string, at int) error { + if info.explicit { + return &Error{Message: fmt.Sprintf("Expression #%d of %s is not in GROUP BY clause and contains nonaggregated column '%s' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by", num, place, col), Code: 1055, Position: a.ph.Back(at)} + } + return &Error{Message: fmt.Sprintf("In aggregated query without GROUP BY, expression #%d of %s contains nonaggregated column '%s'; this is incompatible with sql_mode=only_full_group_by", num, place, col), Code: 1140, Position: a.ph.Back(at)} +} + +// havingExpr is the HAVING condition under its PTI_having wrapper. +func havingExpr(v mysqlast.Value) mysqlast.Value { + n, ok := v.(*mysqlast.Node) + if !ok { + return nil + } + if n.Class == "PTI_having" { + return n.Arg("expr") + } + return n +} + +// groupItem resolves a GROUP BY item the way the server does: a position or an alias +// (that no table column shadows) names a select expression; anything else is itself. +func (a *analyzer) groupItem(sc *scope, items mysqlast.List, item mysqlast.Value) mysqlast.Value { + n, ok := item.(*mysqlast.Node) + if !ok { + return item + } + switch n.Class { + case "Item_int", "Item_uint": + i := intOr(n.Arg("i"), intOr(n.Arg("str"), 0)) - 1 + if i >= 0 && i < len(items) { + if ewa, ok := items[i].(*mysqlast.Node); ok && ewa.Class == "PTI_expr_with_alias" { + return ewa.Arg("expr") + } + } + case "PTI_simple_ident_ident", "PTI_simple_ident_nospvar_ident": + if _, isCol := a.colFact(sc, n); isCol { + return item + } + name := str(n.Arg("ident")) + for _, it := range items { + if ewa, ok := it.(*mysqlast.Node); ok && ewa.Class == "PTI_expr_with_alias" && strings.EqualFold(str(ewa.Arg("alias")), name) { + return ewa.Arg("expr") + } + } + } + return item +} + +// matchesGroup reports an expression equal to a GROUP BY expression (Item::eq: the same +// tree, names compared without case). +func (info *blockInfo) matchesGroup(v mysqlast.Value) bool { + if !info.explicit { + return false + } + text := mysqlast.Sprint(v) + for _, g := range info.groupExprs { + if strings.EqualFold(mysqlast.Sprint(g), text) { + return true + } + } + return false +} + +// inSelectList reports an ORDER BY item the select list already has: a position, a name +// that is a select column's, or the same expression. +func (a *analyzer) inSelectList(info *blockInfo, item mysqlast.Value) bool { + n, ok := item.(*mysqlast.Node) + if !ok { + return false + } + switch n.Class { + case "Item_int", "Item_uint": + return true // validated by orderBy + case "PTI_simple_ident_ident", "PTI_simple_ident_nospvar_ident": + name := str(n.Arg("ident")) + for _, c := range info.sc.items { + if strings.EqualFold(c.Name, name) { + return true + } + } + } + text := mysqlast.Sprint(n) + for _, it := range info.items { + if ewa, ok := it.(*mysqlast.Node); ok && ewa.Class == "PTI_expr_with_alias" && strings.EqualFold(mysqlast.Sprint(ewa.Arg("expr")), text) { + return true + } + } + return false +} + +// windowName is the name a window's error names it by. +func windowName(w *mysqlast.Node) string { + if n, ok := w.Arg("name").(*mysqlast.Node); ok { + if s := str(n.Arg("str")); s != "" { + return s + } + } + if s := str(w.Arg("name")); s != "" { + return s + } + return "" +} + +// collectClass appends the nodes of the class in v, in order. +func collectClass(v mysqlast.Value, class string, out []*mysqlast.Node) []*mysqlast.Node { + switch x := v.(type) { + case *mysqlast.Node: + if x.Class == class { + return append(out, x) + } + for _, arg := range x.Args { + out = collectClass(arg, class, out) + } + case mysqlast.List: + for _, e := range x { + out = collectClass(e, class, out) + } + } + return out +} + +// --- the walks --- + +// fdFail is a column that is not group-invariant: where it is and how the error spells it. +type fdFail struct { + at int + name string +} + +// isColumnRef reports a bare column reference node. +func isColumnRef(v mysqlast.Value) bool { + n, ok := v.(*mysqlast.Node) + if !ok { + return false + } + switch n.Class { + case "PTI_simple_ident_ident", "PTI_simple_ident_nospvar_ident", "PTI_simple_ident_q_2d", "PTI_simple_ident_q_3d": + return true + } + return false +} + +// localColumn resolves a column reference against the block's own relations only. +func (a *analyzer) localColumn(info *blockInfo, n *mysqlast.Node) (facts.ColRef, bool) { + own := scope{rels: info.sc.rels} + ref, err := a.column(own, n, "field list") + if err != nil || ref.rel == nil { + return facts.ColRef{}, false + } + for i := range info.sc.rels { + if &info.sc.rels[i] == ref.rel || info.sc.rels[i].alias == ref.rel.alias { + return facts.ColRef{Leaf: i, Column: ref.c.Name}, true + } + } + return facts.ColRef{}, false +} + +// outerRefTo finds the reference at offset at that a nested query resolved in the block. +func (a *analyzer) outerRefTo(info *blockInfo, at int) *outerRef { + id := blockID(&info.sc) + for i := range a.outerRefs { + r := &a.outerRefs[i] + if r.at == at && r.block == id { + return r + } + } + return nil +} + +// outerRefAbove reports a reference at offset at resolved in a block enclosing this one. +func (a *analyzer) outerRefAbove(info *blockInfo, at int) bool { + for i := range a.outerRefs { + r := &a.outerRefs[i] + if r.at != at { + continue + } + for _, anc := range info.ancestors { + if r.block == anc { + return true + } + } + } + return false +} + +// spell writes a column of the block the way the error names it: alias.column. +func (info *blockInfo) spell(c facts.ColRef) string { + if c.Leaf >= 0 && c.Leaf < len(info.sc.rels) { + return info.sc.rels[c.Leaf].alias + "." + c.Column + } + return c.Column +} + +// isAggregateLike: an aggregate function of some block (not a window function), or +// GROUPING(), which is one for the check. +func isAggregateLike(n *mysqlast.Node) bool { + return isAggregate(n) || n.Class == "Item_func_grouping" +} + +// isAnyValue: ANY_VALUE(x), which the check does not look into. +func isAnyValue(n *mysqlast.Node) bool { + return n.Class == "PTI_function_call_generic_ident_sys" && strings.EqualFold(str(n.Arg("ident")), "ANY_VALUE") +} + +// aggregateOwner says which block an aggregate found sub levels below the block +// aggregates in: the block (own), one enclosing it (outer), or a nested one (inner). It +// is the innermost block a column of its arguments belongs to; an aggregate without +// columns belongs to the block it is written in. +type owner byte + +const ( + ownInner owner = iota + ownOwn + ownOuter +) + +func (a *analyzer) aggregateOwner(info *blockInfo, agg *mysqlast.Node, sub int) owner { + if sub == 0 { + return ownOwn + } + var cols []*mysqlast.Node + for _, arg := range agg.Args { + cols = collectColumns(arg, cols) + } + if len(cols) == 0 { + return ownInner + } + own := false + for _, c := range cols { + switch { + case a.outerRefTo(info, c.Start) != nil: + own = true + case a.outerRefAbove(info, c.Start): + default: + return ownInner + } + } + if own { + return ownOwn + } + return ownOuter +} + +// collectColumns appends the column reference nodes in v. +func collectColumns(v mysqlast.Value, out []*mysqlast.Node) []*mysqlast.Node { + switch x := v.(type) { + case *mysqlast.Node: + if isColumnRef(x) { + return append(out, x) + } + for _, arg := range x.Args { + out = collectColumns(arg, out) + } + case mysqlast.List: + for _, e := range x { + out = collectColumns(e, out) + } + } + return out +} + +// hasOwnAggregate reports an aggregate of the block in v (sub: how many query levels +// below the block v is). +func (a *analyzer) hasOwnAggregate(info *blockInfo, v mysqlast.Value, sub int) bool { + switch x := v.(type) { + case *mysqlast.Node: + if isAggregateLike(x) { + if a.aggregateOwner(info, x, sub) == ownOwn { + return true + } + } + if x.Class == "PT_subquery" { + sub++ + } + for _, arg := range x.Args { + if a.hasOwnAggregate(info, arg, sub) { + return true + } + } + case mysqlast.List: + for _, e := range x { + if a.hasOwnAggregate(info, e, sub) { + return true + } + } + } + return false +} + +// fdWalk finds, in an expression of the block (sub levels below it), a column of the +// block that is not functionally dependent on the group columns: a column named directly, +// or through a nested query as an outer reference. An aggregate of the block (or of an +// enclosing one) is one value per group; a nested query's own aggregate is looked into for +// the block's columns. In HAVING an unqualified name that is a select alias is the +// validated select expression. +func (a *analyzer) fdWalk(fd *fdSet, v mysqlast.Value, sub int, having bool) *fdFail { + info := fd.info + switch x := v.(type) { + case mysqlast.List: + for _, e := range x { + if f := a.fdWalk(fd, e, sub, having); f != nil { + return f + } + } + case *mysqlast.Node: + switch { + case isAggregateLike(x): + if a.aggregateOwner(info, x, sub) != ownInner { + if fd.groupsKnown { + return nil + } + return &fdFail{at: x.Start, name: a.textOf(x)} + } + case isAnyValue(x): + return nil + case x.Class == "PT_window": + return nil // checked apart, with the window's place + case isColumnRef(x): + if sub == 0 { + if having && (x.Class == "PTI_simple_ident_ident" || x.Class == "PTI_simple_ident_nospvar_ident") { + if _, ok, _ := a.lookupItem(info.sc, str(x.Arg("ident"))); ok { + return nil + } + } + col, ok := a.localColumn(info, x) + if !ok { + return nil + } + if !fd.isFD(col) { + return &fdFail{at: x.Start, name: info.spell(col)} + } + return nil + } + if r := a.outerRefTo(info, x.Start); r != nil { + col := facts.ColRef{Leaf: r.leaf, Column: r.col} + if !fd.isFD(col) { + return &fdFail{at: x.Start, name: info.spell(col)} + } + } + return nil + case x.Class == "PT_subquery": + sub++ + } + for _, arg := range x.Args { + if f := a.fdWalk(fd, arg, sub, having); f != nil { + return f + } + } + } + return nil +} + +// havingResolution applies the server's rule for names in HAVING: outside an aggregate +// of the block, a column must be a select-list column or alias or a GROUP BY column; the +// tables are not consulted (1054). Through a nested query the same holds for a reference +// resolved in the block. +func (a *analyzer) havingResolution(info *blockInfo, v mysqlast.Value) error { + return a.havingWalk(info, v, 0) +} + +func (a *analyzer) havingWalk(info *blockInfo, v mysqlast.Value, sub int) error { + switch x := v.(type) { + case mysqlast.List: + for _, e := range x { + if err := a.havingWalk(info, e, sub); err != nil { + return err + } + } + case *mysqlast.Node: + switch { + case isAggregateLike(x): + if a.aggregateOwner(info, x, sub) != ownInner { + return nil // its arguments read the tables + } + case isColumnRef(x): + if sub == 0 { + if x.Class == "PTI_simple_ident_ident" || x.Class == "PTI_simple_ident_nospvar_ident" { + if _, ok, _ := a.lookupItem(info.sc, str(x.Arg("ident"))); ok { + return nil + } + } + col, ok := a.localColumn(info, x) + if !ok || info.namesColumn(col) { + return nil + } + found, ambiguous := a.outerHas(info.sc, x) + if ambiguous { + return &Error{Message: fmt.Sprintf("Column '%s' in having clause is ambiguous", a.textOf(x)), Code: 1052, Position: a.ph.Back(x.Start)} + } + if found { + // the server never consults the block's own tables here: the name is an + // enclosing block's column (measured: `... WHERE EXISTS (SELECT 1 FROM + // orders HAVING id)` reads the outer id; `HAVING note`, orders' own, is 1054) + return nil + } + return &Error{Message: fmt.Sprintf("Unknown column '%s' in 'having clause'", a.textOf(x)), Code: 1054, Position: a.ph.Back(x.Start)} + } + if r := a.outerRefTo(info, x.Start); r != nil { + col := facts.ColRef{Leaf: r.leaf, Column: r.col} + if !info.namesColumn(col) { + return &Error{Message: fmt.Sprintf("Unknown column '%s' in '%s'", info.spell(col), r.where), Code: 1054, Position: a.ph.Back(x.Start)} + } + } + return nil + case x.Class == "PT_subquery": + sub++ + } + for _, arg := range x.Args { + if err := a.havingWalk(info, arg, sub); err != nil { + return err + } + } + } + return nil +} + +// namesColumn reports a column the select list has as a plain column or the GROUP BY names. +func (info *blockInfo) namesColumn(col facts.ColRef) bool { + if info.selectsColumn(col) { + return true + } + for _, g := range info.groupCols { + if g.Leaf == col.Leaf && strings.EqualFold(g.Column, col.Column) { + return true + } + } + return false +} + +// selectsColumn reports a column the select list has as a plain column. +func (info *blockInfo) selectsColumn(col facts.ColRef) bool { + for _, c := range info.sc.items { + if c.leaf1 == col.Leaf+1 && strings.EqualFold(c.leafCol, col.Column) { + return true + } + } + return false +} + +// distinctWalk finds, in an ORDER BY expression of a DISTINCT block, a column of the block +// the select list does not have (Distinct_check): aggregates and window functions are one +// value, and a nested query is looked into for outer references. +func (a *analyzer) distinctWalk(info *blockInfo, v mysqlast.Value, sub int) *fdFail { + switch x := v.(type) { + case mysqlast.List: + for _, e := range x { + if f := a.distinctWalk(info, e, sub); f != nil { + return f + } + } + case *mysqlast.Node: + switch { + case isAggregateLike(x), isWindowFunction(x): + return nil + case isColumnRef(x): + var col facts.ColRef + if sub == 0 { + c, ok := a.localColumn(info, x) + if !ok { + return nil + } + col = c + } else { + r := a.outerRefTo(info, x.Start) + if r == nil { + return nil + } + col = facts.ColRef{Leaf: r.leaf, Column: r.col} + } + if !info.selectsColumn(col) { + return &fdFail{at: x.Start, name: info.spell(col)} + } + return nil + case x.Class == "PT_subquery": + sub++ + } + for _, arg := range x.Args { + if f := a.distinctWalk(info, arg, sub); f != nil { + return f + } + } + } + return nil +} + +// isWindowFunction: a function with an OVER clause. +func isWindowFunction(n *mysqlast.Node) bool { + for _, key := range []string{"w", "window"} { + if w, ok := n.Arg(key).(*mysqlast.Node); ok && w.Class == "PT_window" { + return true + } + } + return false +} + +// ndJoin reports an outer join's nullable side whose ON is not deterministic. +func (sc *scope) ndJoin(restrict []int) bool { + for _, nd := range sc.ndJoins { + if sameInts(nd, restrict) { + return true + } + } + return false +} + +// simplified reports an outer join the WHERE turns into an inner join: it rejects NULL +// for a column of the nullable side (simplify_joins). Only a WHERE-level mark counts (see +// scope.whereNN): HAVING runs after the join is already evaluated, so it cannot be what +// simplify_joins itself reacts to. +func (sc *scope) simplified(restrict []int) bool { + nn := sc.nnMarks + if sc.whereNN < len(nn) { + nn = nn[:sc.whereNN] + } + for _, m := range nn { + if m.restrict != nil { + continue + } + for _, i := range restrict { + if m.col.Leaf == i { + return true + } + } + } + return false +} + +func sameInts(a, b []int) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// deterministic reports an expression free of functions bound to the execution (RAND, +// NOW, UUID, user variables, ...). +func deterministic(v mysqlast.Value) bool { + switch x := v.(type) { + case *mysqlast.Node: + if ndNode(x) { + return false + } + for _, arg := range x.Args { + if !deterministic(arg) { + return false + } + } + case mysqlast.List: + for _, e := range x { + if !deterministic(e) { + return false + } + } + } + return true +} + +// ndNode: a node whose value is bound to the execution -- a variable, a function of the +// clock, the session or chance (a parameter is fixed for the execution: not one). +func ndNode(x *mysqlast.Node) bool { + switch x.Class { + case "PTI_user_variable", "PTI_variable_aux_set_var", "PTI_variable_aux_ident_or_text", "PTI_variable_aux_3d", + "PTI_get_system_variable", "Item_func_get_user_var", "Item_func_get_system_var", "Item_func_set_user_var": + return true + case "PTI_function_call_generic_ident_sys": + return nondeterministic[strings.ToUpper(str(x.Arg("ident")))] + } + for _, prefix := range ndPrefixes { + if strings.HasPrefix(x.Class, prefix) { + return true + } + } + return false +} + +var ndPrefixes = []string{"Item_func_now", "Item_func_curdate", "Item_func_curtime", "Item_func_sysdate", "Item_func_rand", "Item_func_uuid", + "Item_func_connection_id", "Item_func_user", "Item_func_current_user", "Item_func_current_role", "Item_func_database", "Item_func_version", + "Item_func_found_rows", "Item_func_row_count", "Item_func_last_insert_id", "Item_func_sleep", "Item_func_benchmark", "Item_func_get_lock", + "Item_func_release_lock", "Item_func_release_all_locks", "Item_func_is_free_lock", "Item_func_is_used_lock", "Item_func_unix_timestamp", + "Item_func_random_bytes", "Item_func_sp", "Item_func_udf", "Item_func_master_pos_wait", "Item_func_source_pos_wait", "Item_func_wait_for_executed_gtid_set", + "PTI_function_call_nonkeyword_now", "PTI_function_call_nonkeyword_sysdate", "PTI_function_call_nonkeyword_curdate", "PTI_function_call_nonkeyword_curtime", + "PTI_function_call_nonkeyword_utc"} + +// --- the dependencies --- + +// fdSet is the closure of the functional dependencies of one block: the columns known +// from the group columns (or, for a derived table's body, from the outputs the enclosing +// block knows), the relations known whole, and whether every group expression is known +// (then the block's aggregates are one value). +type fdSet struct { + info *blockInfo + known map[facts.ColRef]bool + nonNull map[facts.ColRef]bool + whole []bool + groupsKnown bool + exprKnown []string // group expressions known, as printed +} + +func (fd *fdSet) isFD(c facts.ColRef) bool { + return fd.known[c] || (c.Leaf >= 0 && c.Leaf < len(fd.whole) && fd.whole[c.Leaf]) +} + +// fdClosure computes the block's dependencies. For the block being checked (top) the +// source is its GROUP BY; for a derived table's body the source is what the enclosing +// block knows of its outputs: seeds (plain column outputs, those among them a conjunct +// makes non-NULL in seedNonNull) and seedExprs (expression outputs, as printed). +func (a *analyzer) fdClosure(info *blockInfo, top bool, seeds, seedNonNull []facts.ColRef, seedExprs []string) *fdSet { + sc := &info.sc + fs := sc.facts + fd := &fdSet{info: info, known: map[facts.ColRef]bool{}, nonNull: map[facts.ColRef]bool{}, whole: make([]bool, len(sc.rels))} + if top { + for _, c := range info.groupCols { + fd.known[c] = true + } + for _, g := range info.groupExprs { + fd.exprKnown = append(fd.exprKnown, mysqlast.Sprint(g)) + } + fd.groupsKnown = true + } else { + for _, c := range seeds { + fd.known[c] = true + } + for _, c := range seedNonNull { + fd.nonNull[c] = true + } + fd.exprKnown = seedExprs + } + if info.rollup || fs == nil { + // ROLLUP: the group expressions and nothing derived from them + if !top { + fd.groupsKnown = fd.allGroupsKnown() + } + return fd + } + for _, c := range a.nullEq[fs] { + fd.known[c] = true // `col = NULL`: a constant equality to the server's dependency check + } + whereNN := sc.nnMarks + if sc.whereNN < len(whereNN) { + whereNN = whereNN[:sc.whereNN] + } + for _, m := range whereNN { + // an ON marks its nullable side only, unless the WHERE makes the join inner + if m.restrict == nil || allowed(m.col, m.restrict) || sc.simplified(m.restrict) { + fd.nonNull[m.col] = true + } + } + // wherePreds bounds this to the WHERE / JOIN ON conjuncts: a HAVING conjunct restricts + // which groups the query returns, not what may be assumed about a row while judging + // ONLY_FULL_GROUP_BY (facts.go's havingFacts folds HAVING into fs.Preds too, for the + // obligation checker, but past wherePreds -- measured: `GROUP BY u HAVING u = 1` still + // requires a nonaggregated select-list column to depend on u alone, unlike the same + // equality written as `WHERE u = 1 GROUP BY a`). + wherePreds := fs.Preds + if sc.wherePreds < len(wherePreds) { + wherePreds = wherePreds[:sc.wherePreds] + } + for changed := true; changed; { + changed = false + for _, pr := range wherePreds { + if pr.Op != facts.Eq || (pr.Restricts != nil && sc.ndJoin(pr.Restricts)) { + continue + } + switch pr.Term.Kind { + case facts.Const: + if !fd.known[pr.Col] { + fd.known[pr.Col] = true + changed = true + } + case facts.Known: + if !fd.known[pr.Col] && a.fdConst[pr.Text+"\x00"+pr.Term.Text] { + fd.known[pr.Col] = true + changed = true + } + case facts.Column: + // col = col: both ways in WHERE or an inner join (an outer join the WHERE + // makes inner included), else only into the nullable side + l, r := pr.Col, pr.Term.Col + both := pr.Restricts == nil || sc.simplified(pr.Restricts) + if (both || allowed(r, pr.Restricts)) && !fd.known[r] && fd.isFD(l) { + fd.known[r] = true + changed = true + } + if (both || allowed(l, pr.Restricts)) && !fd.known[l] && fd.isFD(r) { + fd.known[l] = true + changed = true + } + } + } + for i := range sc.rels { + if fd.whole[i] { + continue + } + if a.relWhole(fd, i) { + fd.whole[i] = true + changed = true + } + } + } + if !top { + fd.groupsKnown = fd.allGroupsKnown() + } + return fd +} + +// allGroupsKnown: every GROUP BY expression of the block is known -- a column that is, a +// constant, or an expression the source names. +func (fd *fdSet) allGroupsKnown() bool { + if !fd.info.explicit { + return false + } + for k, g := range fd.info.groupExprs { + if !fd.groupExprKnown(fd.info, g, k) { + return false + } + } + return true +} + +// groupExprKnown: the k-th GROUP BY expression of info is known. +func (fd *fdSet) groupExprKnown(info *blockInfo, g mysqlast.Value, k int) bool { + if at := info.groupColAt[k]; at >= 0 { + return fd.isFD(info.groupCols[at]) + } + if constItem(g) { + return true + } + return fd.exprKnownHas(mysqlast.Sprint(g)) +} + +// relWhole decides whether the known columns determine every column of the relation: a +// PRIMARY / UNIQUE key of a table is known (a nullable key column only where a conjunct +// rejects its NULL); a derived relation's body determines every output from what the block +// knows of its outputs. +func (a *analyzer) relWhole(fd *fdSet, i int) bool { + rel := &fd.info.sc.rels[i] + fs := fd.info.sc.facts + if rel.table != nil { + if i >= len(fs.Leaves) { + return false + } + for _, key := range fs.Leaves[i].Keys { + ok := len(key.Columns) > 0 + for _, name := range key.Columns { + c := facts.ColRef{Leaf: i, Column: name} + if !fd.isFD(c) { + ok = false + break + } + if col := rel.table.Column(name); col != nil && !col.NotNull && !fd.nonNull[c] { + ok = false + break + } + } + if ok { + return true + } + } + return false + } + if rel.body == nil { + return false + } + body := a.blocks[rel.body] + if body == nil { + return false // a set operation: no dependency crosses it + } + // what the block knows of the outputs + var seeds, seedNonNull []facts.ColRef + var seedExprs []string + anyNotNull := false + for j, c := range rel.cols { + out := facts.ColRef{Leaf: i, Column: c.Name} + if !fd.known[out] { + continue + } + if c.leaf1 > 0 { + in := facts.ColRef{Leaf: c.leaf1 - 1, Column: c.leafCol} + seeds = append(seeds, in) + if rel.merged && fd.nonNull[out] { + seedNonNull = append(seedNonNull, in) // the server sees the merged table's column itself + } + } else if e := body.itemExpr(j); e != nil { + seedExprs = append(seedExprs, mysqlast.Sprint(e)) + } + if !c.Nullable { + anyNotNull = true + } + } + if rel.nullable && !rel.merged && !anyNotNull && !fd.info.sc.simplified([]int{i}) { + // a materialized table on the nullable side of an outer join: the dependency + // propagates only from a non-nullable output (Group_check::non_null_in_source) + return false + } + child := a.fdClosure(body, false, seeds, seedNonNull, seedExprs) + if child.allOutputs() { + return true + } + // the outputs known one by one + for j, c := range rel.cols { + out := facts.ColRef{Leaf: i, Column: c.Name} + if !fd.known[out] && child.outputFD(a, body, j, c) { + fd.known[out] = true + } + } + return false +} + +// allOutputs: the body is one row per known values -- every group expression known for a +// grouped body, every relation whole otherwise (a body without FROM is one row). +func (fd *fdSet) allOutputs() bool { + if fd.info.explicit { + return !fd.info.rollup && fd.groupsKnown + } + for _, w := range fd.whole { + if !w { + return false + } + } + return true +} + +// outputFD: output j of the body is known -- a plain column that is, a group expression +// that is, or an expression of known columns and aggregates. +func (fd *fdSet) outputFD(a *analyzer, body *blockInfo, j int, c Column) bool { + if c.leaf1 > 0 { + return fd.isFD(facts.ColRef{Leaf: c.leaf1 - 1, Column: c.leafCol}) + } + e := body.itemExpr(j) + if e == nil { + return false + } + text := mysqlast.Sprint(e) + for k, g := range body.groupExprs { + if strings.EqualFold(mysqlast.Sprint(g), text) { + return fd.groupExprKnown(body, g, k) + } + } + if body.rollup { + return false + } + return a.fdWalk(fd, e, 0, false) == nil +} + +func (fd *fdSet) exprKnownHas(text string) bool { + for _, k := range fd.exprKnown { + if strings.EqualFold(k, text) { + return true + } + } + return false +} + +// itemExpr is the expression of the body's j-th output column (nil for a column of a +// `*` expansion, which is a plain column anyway). +func (info *blockInfo) itemExpr(j int) mysqlast.Value { + k := 0 + for _, it := range info.items { + n, ok := it.(*mysqlast.Node) + if !ok { + continue + } + switch n.Class { + case "PTI_expr_with_alias": + if k == j { + return n.Arg("expr") + } + k++ + case "Item_asterisk": + table := str(arg(n, "opt_table_name", 1)) + for i := range info.sc.rels { + rel := &info.sc.rels[i] + if table != "" && !strings.EqualFold(rel.alias, table) { + continue + } + k += len(rel.columns()) + } + if k > j { + return nil + } + } + } + return nil +} + +// constItem reports an expression the server evaluates to a constant before the statement +// runs (Item::const_item): literals and deterministic functions of them, a subquery over +// no table; not a parameter, a user or system variable, a column, or a function bound to +// the execution (NOW, RAND, UUID, ...). +func constItem(v mysqlast.Value) bool { + switch x := v.(type) { + case nil, mysqlast.Token, mysqlast.Const, mysqlast.Op, mysqlast.Number, mysqlast.Flags, *mysqlast.Struct: + return true + case mysqlast.List: + for _, e := range x { + if !constItem(e) { + return false + } + } + return true + case *mysqlast.Node: + if x.Class == "Item_param" || ndNode(x) || isColumnRef(x) || isAggregateLike(x) || isWindowFunction(x) { + return false + } + if x.Class == "PT_subquery" { + qe, ok := x.Arg("query_expression").(*mysqlast.Node) + if !ok { + return false + } + body, ok := qe.Arg("body").(*mysqlast.Node) + if !ok || body.Class != "PT_query_specification" { + return false + } + from, _ := body.Arg("from_clause").(mysqlast.List) + if len(from) > 0 || body.Arg("opt_where_clause") != nil || body.Arg("opt_having_clause") != nil || body.Arg("opt_group_clause") != nil { + return false + } + return constItem(body.Arg("item_list")) + } + for _, arg := range x.Args { + if !constItem(arg) { + return false + } + } + return true + } + return false +} + +// nondeterministic are the generic-call functions whose value is bound to the execution. +var nondeterministic = map[string]bool{ + "UUID": true, "UUID_SHORT": true, "RANDOM_BYTES": true, "CONNECTION_ID": true, "VERSION": true, "DATABASE": true, "SCHEMA": true, + "USER": true, "CURRENT_USER": true, "SESSION_USER": true, "SYSTEM_USER": true, "SLEEP": true, "GET_LOCK": true, "RELEASE_LOCK": true, + "RELEASE_ALL_LOCKS": true, "IS_FREE_LOCK": true, "IS_USED_LOCK": true, "FOUND_ROWS": true, "ROW_COUNT": true, "LAST_INSERT_ID": true, + "RAND": true, "NOW": true, "SYSDATE": true, "CURDATE": true, "CURTIME": true, "CURRENT_TIMESTAMP": true, "CURRENT_DATE": true, "CURRENT_TIME": true, + "LOCALTIME": true, "LOCALTIMESTAMP": true, "UTC_DATE": true, "UTC_TIME": true, "UTC_TIMESTAMP": true, "UNIX_TIMESTAMP": true, "CURRENT_ROLE": true, + "ICU_VERSION": true, "PS_CURRENT_THREAD_ID": true, "PS_THREAD_ID": true, "ROLES_GRAPHML": true, "BENCHMARK": true, "SOURCE_POS_WAIT": true, + "MASTER_POS_WAIT": true, "WAIT_FOR_EXECUTED_GTID_SET": true, "STATEMENT_DIGEST": true, "STATEMENT_DIGEST_TEXT": true, +} + +// outerHas reports whether an enclosing block's tables have the column x names (an +// unqualified name in any of them; a qualified one in the relation of that alias), and +// whether two of one block's do (1052). A block whose own HAVING is being typed does not +// lend its tables either (its select list and GROUP BY only, as lookup already saw). +func (a *analyzer) outerHas(sc scope, x *mysqlast.Node) (found, ambiguous bool) { + table, field := "", "" + switch x.Class { + case "PTI_simple_ident_ident", "PTI_simple_ident_nospvar_ident": + field = str(x.Arg("ident")) + case "PTI_simple_ident_q_2d", "PTI_simple_ident_q_3d": + table, field = str(x.Arg("table")), str(x.Arg("field")) + default: + return false, false + } + for s := sc.outer; s != nil; s = s.outer { + if ll := a.listOf(s); ll != nil && ll.clause == "having clause" { + continue + } + var leaves []int + for i := range s.rels { + rel := &s.rels[i] + if table != "" && !strings.EqualFold(rel.alias, table) { + continue + } + if _, ok := rel.column(field); ok { + leaves = append(leaves, i) + } + } + if len(leaves) > 1 && table == "" && s.mergedLeaves(field, leaves) { + leaves = leaves[:1] + } + switch len(leaves) { + case 0: + continue + case 1: + return true, false + } + return true, true + } + return false, false +} diff --git a/check/mysql/internal/analyze/fullgroup_test.go b/check/mysql/internal/analyze/fullgroup_test.go new file mode 100644 index 0000000..bea6e4a --- /dev/null +++ b/check/mysql/internal/analyze/fullgroup_test.go @@ -0,0 +1,471 @@ +package analyze + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/oracle" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" +) + +// fullGroupSchema adds to the test schema a table with a nullable UNIQUE key and nullable +// non-key columns, which the functional dependencies turn on. +const fullGroupSchema = testSchema + ` +CREATE TABLE t1 ( + pk INT NOT NULL PRIMARY KEY, + a INT NOT NULL, + b INT, + u INT, + c INT, + UNIQUE KEY (u) +); +` + +// fullGroupCases are statements a real mysqld (8.4, sql_mode ONLY_FULL_GROUP_BY) accepts +// (0) or rejects with the error number given: the group-invariance check, the HAVING +// resolution rule, DISTINCT with ORDER BY, aggregates in ORDER BY. TestFullGroupOracle +// keeps them the server's verdicts. +var fullGroupCases = []struct { + sql string + code int +}{ + {"SELECT name, COUNT(*) FROM users GROUP BY id", 0}, + {"SELECT u.name, o.total FROM users u JOIN orders o ON o.user_id = u.id GROUP BY o.id", 0}, + {"SELECT name FROM users WHERE id = 1 GROUP BY email", 0}, + {"SELECT name, COUNT(*) FROM users WHERE id = 1", 0}, + {"SELECT u.name, o.total FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY o.id", 1055}, + {"SELECT u.name, o.total FROM users u LEFT JOIN orders o ON o.id = u.id GROUP BY u.id", 0}, + {"SELECT YEAR(created_at), COUNT(*) FROM users GROUP BY YEAR(created_at)", 0}, + {"SELECT YEAR(created_at) + 1 FROM users GROUP BY YEAR(created_at)", 1055}, + {"SELECT id + 1 AS x, COUNT(*) FROM users GROUP BY x", 0}, + {"SELECT name, COUNT(*) FROM users GROUP BY 1", 0}, + {"SELECT id, COUNT(*) FROM users GROUP BY id WITH ROLLUP", 0}, + {"SELECT name FROM users GROUP BY id WITH ROLLUP", 1055}, + {"SELECT d.n, d.c FROM (SELECT name n, COUNT(*) c FROM users GROUP BY name) d GROUP BY d.n", 0}, + {"SELECT d.name, d.email FROM (SELECT id, name, email FROM users) d GROUP BY d.id", 0}, + {"SELECT d.name FROM (SELECT id, name FROM users LIMIT 5) d GROUP BY d.id", 0}, + {"SELECT email, COUNT(*) FROM users GROUP BY name", 1055}, + {"SELECT name, COUNT(*) FROM users", 1140}, + {"SELECT GROUP_CONCAT(name), JSON_ARRAYAGG(name), JSON_OBJECTAGG('k', name) FROM users", 0}, + {"SELECT name, JSON_ARRAYAGG(name) FROM users", 1140}, + {"SELECT name FROM users GROUP BY name HAVING email = 'x'", 1054}, + {"SELECT name FROM users GROUP BY name ORDER BY email", 1055}, + {"SELECT name AS n FROM users GROUP BY name HAVING n = 'x'", 0}, + {"SELECT (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) FROM users u GROUP BY u.name", 1055}, + {"SELECT (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) FROM users u GROUP BY u.id", 0}, + {"SELECT * FROM users GROUP BY id", 0}, + {"SELECT *, COUNT(*) FROM orders GROUP BY user_id", 1055}, + {"SELECT c FROM t1 GROUP BY u", 1055}, + {"SELECT c FROM t1 WHERE u IS NOT NULL GROUP BY u", 0}, + {"SELECT c FROM t1 WHERE u > 1 GROUP BY u", 0}, + {"SELECT c FROM t1 WHERE u = 1 GROUP BY a", 0}, + {"SELECT c FROM t1 GROUP BY b", 1055}, + {"SELECT COUNT(*) FROM users ORDER BY name", 0}, + {"SELECT name FROM users ORDER BY COUNT(*)", 3029}, + {"SELECT DISTINCT name FROM users ORDER BY email", 3065}, + {"SELECT DISTINCT name FROM users ORDER BY id", 3065}, + {"SELECT DISTINCT name, id FROM users ORDER BY id + 1", 0}, + {"SELECT DISTINCT name FROM users ORDER BY COUNT(*)", 3029}, + {"SELECT DISTINCT name FROM users GROUP BY name ORDER BY email", 1055}, + {"SELECT u.name FROM users u JOIN orders o USING (id) GROUP BY o.id", 0}, + {"SELECT u.name, o.total FROM users u JOIN orders o ON o.user_id = u.id WHERE o.id = 5 GROUP BY u.email", 0}, + {"SELECT name, COUNT(*) FROM users GROUP BY id HAVING COUNT(*) > 1", 0}, + {"SELECT name, ROW_NUMBER() OVER (ORDER BY email) FROM users GROUP BY id", 0}, + {"SELECT name, ROW_NUMBER() OVER (ORDER BY email) FROM users GROUP BY name", 1055}, + {"SELECT name, ROW_NUMBER() OVER w FROM users GROUP BY name WINDOW w AS (ORDER BY email)", 1055}, + {"SELECT n FROM v_stats GROUP BY user_id", 0}, + {"SELECT v.name FROM v_users v GROUP BY v.id", 0}, + {"SELECT email FROM users WHERE name = email GROUP BY name", 0}, + {"SELECT (SELECT name FROM users WHERE id = o.user_id) FROM orders o GROUP BY o.id", 0}, + {"SELECT name, COUNT(*) FROM users WHERE name = 'x'", 0}, + {"SELECT UPPER(CONCAT(name, email)) FROM users GROUP BY CONCAT(name, email)", 1055}, + {"SELECT id AS name, COUNT(*) FROM users GROUP BY name", 1055}, + {"SELECT o.total FROM orders o JOIN users u ON u.id = o.user_id GROUP BY u.id", 1055}, + {"SELECT o.total FROM users u LEFT JOIN orders o ON o.id = 5 GROUP BY u.id", 0}, + {"SELECT d.name FROM (SELECT * FROM users WHERE id = 1) d GROUP BY d.email", 0}, + {"SELECT d.name FROM (SELECT * FROM users WHERE id = 1 LIMIT 1) d GROUP BY d.email", 0}, + {"SELECT d.name FROM (SELECT * FROM users LIMIT 1) d GROUP BY d.email", 1055}, + {"SELECT t1.c FROM t1 GROUP BY t1.b", 1055}, + {"SELECT a, b FROM t1 GROUP BY a + b", 1055}, + {"SELECT a + b FROM t1 GROUP BY b + a", 1055}, + {"SELECT a FROM t1 GROUP BY A", 0}, + {"SELECT 'x', a FROM t1 GROUP BY 'x'", 1055}, + {"SELECT c FROM t1 WHERE (u, a) = (1, 2) GROUP BY b", 0}, + {"SELECT c FROM t1 WHERE u = a GROUP BY a", 0}, + {"SELECT c FROM t1 WHERE u = a + 1 GROUP BY a", 1055}, + {"SELECT c FROM t1 WHERE u = ABS(a) GROUP BY a", 1055}, + {"SELECT c FROM t1 x JOIN t1 y ON x.u = y.a GROUP BY y.a", 1052}, + {"SELECT x.c FROM t1 x LEFT JOIN t1 y ON x.u = y.pk GROUP BY y.pk", 1055}, + {"SELECT y.c FROM t1 x LEFT JOIN t1 y ON y.u = x.a GROUP BY x.a", 0}, + {"SELECT y.c FROM t1 x LEFT JOIN t1 y ON y.b = x.a GROUP BY x.a", 1055}, + {"SELECT y.c FROM t1 x LEFT JOIN t1 y ON y.u = x.a AND y.c > 0 GROUP BY x.a", 0}, + {"SELECT y.c FROM t1 x LEFT JOIN t1 y ON y.u = x.a OR y.b = 1 GROUP BY x.a", 1055}, + {"SELECT z.c FROM t1 x LEFT JOIN (t1 y JOIN t1 z ON z.u = y.a) ON y.pk = x.a GROUP BY x.a", 0}, + {"SELECT z.c FROM t1 x LEFT JOIN (t1 y LEFT JOIN t1 z ON z.u = y.a) ON y.pk = x.a GROUP BY x.a", 0}, + {"SELECT z.c FROM t1 x LEFT JOIN (t1 y LEFT JOIN t1 z ON z.u = y.a) ON y.pk = x.a GROUP BY y.a", 0}, + {"SELECT z.c FROM t1 x LEFT JOIN (t1 y LEFT JOIN t1 z ON z.u = y.b) ON y.pk = x.a GROUP BY y.b", 0}, + {"SELECT z.c FROM t1 x LEFT JOIN (t1 y LEFT JOIN t1 z ON z.pk = y.a + 0) ON y.pk = x.a GROUP BY x.a", 1055}, + {"SELECT a FROM t1 UNION SELECT a FROM t1 ORDER BY a + 1", 0}, + {"SELECT a FROM t1 UNION SELECT a FROM t1 ORDER BY 1 + 1", 0}, + {"SELECT a FROM t1 UNION SELECT a FROM t1 ORDER BY b + 1", 1054}, + {"SELECT a FROM t1 GROUP BY a QUALIFY ROW_NUMBER() OVER (ORDER BY c) = 1", 6037}, + {"SELECT c FROM t1 GROUP BY u HAVING u IS NOT NULL", 1055}, + {"SELECT c FROM t1 WHERE u IN (1, 2) GROUP BY u", 0}, + {"SELECT MAX(c) FROM t1 GROUP BY a ORDER BY b", 1055}, + {"SELECT a FROM t1 GROUP BY a ORDER BY MAX(c)", 0}, + {"SELECT a FROM t1 GROUP BY a ORDER BY b + 0", 1055}, + {"SELECT a, b FROM t1 GROUP BY a, b ORDER BY 2", 0}, + {"SELECT a FROM t1 GROUP BY a HAVING b > 0", 1054}, + {"SELECT a FROM t1 GROUP BY a HAVING MAX(b) > 0 AND b > 0", 1054}, + {"SELECT id FROM users UNION SELECT id FROM users GROUP BY name", 1055}, + {"SELECT d.b FROM (SELECT a, b FROM t1 GROUP BY a) d", 1055}, + {"SELECT d.b FROM (SELECT a, MAX(b) b FROM t1 GROUP BY a) d GROUP BY d.a", 0}, + {"SELECT d.b FROM (SELECT a, MAX(b) b FROM t1 GROUP BY a) d GROUP BY d.a + 1", 1055}, + {"SELECT d.s FROM (SELECT b*3 AS c, SUM(a) AS s FROM t1 GROUP BY b*3) AS d GROUP BY d.c", 0}, + {"SELECT d.b FROM (SELECT a, b FROM t1 UNION SELECT a, b FROM t1) d GROUP BY d.a", 1055}, + {"SELECT d.c FROM (SELECT pk, c FROM t1 UNION ALL SELECT pk, c FROM t1) d GROUP BY d.pk", 1055}, + {"WITH w AS (SELECT pk, c FROM t1) SELECT w.c FROM w GROUP BY w.pk", 0}, + {"WITH w AS (SELECT a, c FROM t1) SELECT w.c FROM w GROUP BY w.a", 1055}, + {"SELECT x.c FROM t1 x LEFT JOIN (SELECT pk, c FROM t1) d ON d.pk = x.a GROUP BY x.pk", 0}, + {"SELECT d.c FROM t1 x LEFT JOIN (SELECT pk, c FROM t1) d ON d.pk = x.a GROUP BY x.pk", 0}, + {"SELECT d.c FROM t1 x LEFT JOIN (SELECT pk, c FROM t1 LIMIT 3) d ON d.pk = x.a GROUP BY x.pk", 0}, + {"SELECT d.c FROM t1 x LEFT JOIN (SELECT a, MAX(c) c FROM t1 GROUP BY a) d ON d.a = x.a GROUP BY x.a", 0}, + {"SELECT d.c FROM t1 x LEFT JOIN (SELECT a, MAX(c) c FROM t1 GROUP BY a) d ON d.a = x.b GROUP BY x.b", 0}, + {"SELECT c FROM t1 WHERE pk = (SELECT MAX(pk) FROM t1) GROUP BY b", 1055}, + {"SELECT c FROM t1 WHERE pk = $1 GROUP BY b", 1055}, + {"SELECT c FROM t1 WHERE pk = RAND() GROUP BY b", 1055}, + {"SELECT c FROM t1 WHERE pk = @v GROUP BY b", 1055}, + {"SELECT c, COUNT(*) FROM t1 GROUP BY a WITH ROLLUP HAVING c > 0", 1055}, + {"SELECT a, COUNT(*) FROM t1 GROUP BY a WITH ROLLUP ORDER BY b", 1055}, + {"SELECT a, GROUPING(a) FROM t1 GROUP BY a WITH ROLLUP", 0}, + {"SELECT ANY_VALUE(c) FROM t1 GROUP BY a", 0}, + {"SELECT c FROM t1 GROUP BY a, u HAVING u = 1", 1055}, + {"SELECT (SELECT x.c FROM t1 x WHERE x.pk = y.a) FROM t1 y GROUP BY y.b", 1055}, + {"SELECT (SELECT COUNT(*) FROM t1 x WHERE x.c = c) FROM t1 y GROUP BY y.b", 0}, + {"SELECT (SELECT COUNT(*) FROM t1 x WHERE x.c = y.c) FROM t1 y GROUP BY y.b", 1055}, + {"SELECT (SELECT MAX(y.c) FROM t1 x) FROM t1 y GROUP BY y.b", 0}, + {"SELECT EXISTS (SELECT 1 FROM t1 x WHERE x.c = y.c) FROM t1 y GROUP BY y.b", 1055}, + {"SELECT y.c IN (SELECT x.a FROM t1 x) FROM t1 y GROUP BY y.b", 1055}, + {"SELECT COUNT(*), COUNT(*) OVER () FROM t1", 0}, + {"SELECT a, COUNT(*) OVER (PARTITION BY c) FROM t1 GROUP BY a", 1055}, + {"SELECT SUM(a) OVER (PARTITION BY c) FROM t1 GROUP BY a", 1055}, + {"SELECT a FROM t1 GROUP BY a WINDOW w AS (PARTITION BY c)", 1055}, + {"SELECT CASE WHEN a > 0 THEN c ELSE 0 END FROM t1 GROUP BY a", 1055}, + {"SELECT IF(a > 0, c, 0) FROM t1 GROUP BY a, c", 0}, + {"SELECT COUNT(DISTINCT c), a FROM t1 GROUP BY a", 0}, + {"SELECT a, MAX(c) FROM t1 GROUP BY a, a", 0}, + {"SELECT a, c FROM t1 GROUP BY pk, a", 0}, + {"SELECT a FROM t1 GROUP BY 1 + 1", 1055}, + {"SELECT a, COUNT(*) FROM t1 GROUP BY NULL", 1055}, + {"SELECT c FROM t1 WHERE a = 1 AND b = a GROUP BY pk", 0}, + {"SELECT t1.c, u2.name FROM t1, users u2 WHERE t1.pk = 1 GROUP BY u2.id", 0}, + {"SELECT t1.c, u2.name FROM t1, users u2 WHERE t1.pk = u2.id GROUP BY u2.id", 0}, + {"SELECT t1.c, u2.name FROM t1, users u2 WHERE t1.a = u2.id GROUP BY u2.id", 1055}, + {"SELECT t1.c, u2.name FROM t1, users u2 WHERE t1.u = u2.id GROUP BY u2.id", 0}, + {"SELECT name FROM users u GROUP BY u.secret", 1055}, + {"SELECT DISTINCT a FROM t1 ORDER BY a + b", 3065}, + {"SELECT DISTINCT a + 1 AS x FROM t1 ORDER BY x", 0}, + {"SELECT DISTINCT a + 1 FROM t1 ORDER BY a + 1", 0}, + {"SELECT DISTINCT a FROM t1 ORDER BY (SELECT b FROM t1 x WHERE x.pk = t1.a)", 0}, + {"SELECT DISTINCT a FROM t1 ORDER BY (SELECT MAX(b) FROM t1 x)", 0}, + {"SELECT DISTINCT a FROM t1 ORDER BY RAND()", 0}, + {"SELECT DISTINCT a FROM t1 ORDER BY 1", 0}, + {"SELECT DISTINCT a FROM t1 ORDER BY 2", 1054}, + {"SELECT DISTINCT t1.* FROM t1 ORDER BY b", 0}, + {"SELECT DISTINCT a FROM t1 x JOIN t1 y USING (pk) ORDER BY y.b", 1052}, + {"SELECT DISTINCT * FROM t1 ORDER BY b", 0}, + {"SELECT DISTINCT a FROM t1 GROUP BY a ORDER BY MAX(b)", 0}, + {"SELECT DISTINCT a FROM t1 GROUP BY a, b ORDER BY b", 3065}, + {"SELECT DISTINCT a, ROW_NUMBER() OVER (ORDER BY b) FROM t1 ORDER BY b", 3065}, + {"SELECT DISTINCT a FROM t1 ORDER BY ROW_NUMBER() OVER (ORDER BY b)", 0}, + {"SELECT a FROM t1 UNION SELECT a FROM t1 ORDER BY MAX(a)", 3028}, + {"(SELECT a FROM t1 ORDER BY b) UNION (SELECT a FROM t1)", 0}, + {"SELECT a FROM t1 GROUP BY a ORDER BY (SELECT MAX(x.b) FROM t1 x WHERE x.pk = t1.b)", 1055}, + {"SELECT a FROM t1 GROUP BY a ORDER BY (SELECT MAX(x.b) FROM t1 x WHERE x.pk = t1.a)", 0}, + {"UPDATE t1 SET c = 1 WHERE pk IN (SELECT pk FROM (SELECT pk, c FROM t1 GROUP BY a) d)", 1055}, + {"INSERT INTO t1 (pk, a) SELECT pk, c FROM t1 GROUP BY a", 1055}, + {"SELECT a FROM t1 GROUP BY a HAVING (SELECT c FROM t1 x WHERE x.pk = t1.b) > 0", 1054}, + {"SELECT a FROM t1 GROUP BY a HAVING (SELECT c FROM t1 x WHERE x.pk = t1.a) > 0", 0}, + {"SELECT a FROM t1 GROUP BY a HAVING a > 0 OR c > 0", 1054}, + {"SELECT a FROM t1 GROUP BY a HAVING MAX(c) > 0 AND (SELECT MAX(b) FROM t1 x) > 0", 0}, + {"SELECT a, MAX(c) m FROM t1 GROUP BY a HAVING m > 0 ORDER BY m", 0}, + {"SELECT a, MAX(c) FROM t1 GROUP BY a ORDER BY MAX(c) DESC", 0}, + {"SELECT a FROM t1 GROUP BY a ORDER BY a, MIN(b), 1", 0}, + {"SELECT a FROM t1 GROUP BY a ORDER BY a + b", 1055}, + {"SELECT a b FROM t1 GROUP BY a ORDER BY b", 0}, + {"SELECT a b FROM t1 GROUP BY b", 1055}, + {"SELECT a, MAX(c) FROM t1 GROUP BY a ORDER BY NULL", 0}, + {"SELECT a, MAX(c) FROM t1 GROUP BY a ORDER BY RAND()", 0}, + {"SELECT a FROM t1 GROUP BY a ORDER BY (SELECT 1)", 0}, + {"SELECT a, COUNT(*) FROM t1 GROUP BY a ORDER BY c LIMIT 1", 1055}, + {"SELECT COUNT(*) FROM t1 HAVING a > 0", 1054}, + {"SELECT COUNT(*) FROM t1 HAVING MAX(a) > 0", 0}, + {"SELECT a FROM t1 HAVING a > 0", 0}, + {"SELECT a FROM t1 HAVING MAX(a) > 0", 1140}, + {"SELECT a FROM t1 HAVING COUNT(*) > 0", 1140}, + {"SELECT a, ROW_NUMBER() OVER () FROM t1 HAVING a > 0", 0}, + {"SELECT 1 FROM t1 GROUP BY a ORDER BY b", 1055}, + {"SELECT a FROM t1 GROUP BY a ORDER BY t1.b", 1055}, + {"SELECT * FROM t1 GROUP BY a", 1055}, + {"SELECT t1.*, MAX(c) FROM t1 GROUP BY pk", 0}, + {"SELECT x.* FROM t1 x JOIN t1 y ON x.a = y.pk GROUP BY y.pk", 1055}, + {"SELECT x.* FROM t1 x LEFT JOIN t1 y ON x.a = y.pk GROUP BY y.pk", 1055}, + {"SELECT x.*, y.a FROM t1 x LEFT JOIN t1 y ON y.a = x.pk GROUP BY x.pk", 0}, + {"SELECT x.*, y.c FROM t1 x LEFT JOIN t1 y ON y.u = x.pk GROUP BY x.pk", 0}, + {"SELECT x.*, y.c FROM t1 x RIGHT JOIN t1 y ON y.u = x.pk GROUP BY y.pk", 0}, + {"SELECT x.*, y.c FROM t1 x RIGHT JOIN t1 y ON y.u = x.pk GROUP BY x.pk", 1055}, + {"SELECT x.c FROM t1 x JOIN t1 y ON x.u = y.pk WHERE y.pk = 3 GROUP BY y.b", 0}, + {"SELECT x.c FROM t1 x LEFT JOIN t1 y ON x.u = y.pk WHERE y.pk = 3 GROUP BY y.b", 0}, + {"SELECT x.c FROM t1 x LEFT JOIN t1 y ON x.u = y.pk AND y.b = 3 GROUP BY x.pk", 0}, + {"SELECT y.c FROM t1 x LEFT JOIN t1 y ON x.u = y.pk AND y.b = 3 GROUP BY x.pk", 0}, + {"SELECT y.c FROM t1 x LEFT JOIN t1 y ON y.pk = 3 GROUP BY x.pk", 0}, + {"SELECT y.c FROM t1 x LEFT JOIN t1 y ON y.u = 3 GROUP BY x.pk", 0}, + {"SELECT y.c FROM t1 x LEFT JOIN t1 y ON y.pk = x.b GROUP BY x.pk", 0}, + {"SELECT y.c FROM t1 x LEFT JOIN t1 y ON y.pk = x.b GROUP BY x.b", 0}, + {"SELECT y.c FROM t1 x LEFT JOIN t1 y ON y.pk = COALESCE(x.b, 0) GROUP BY x.b", 1055}, + {"SELECT y.c FROM t1 x LEFT JOIN t1 y ON y.pk = x.b + x.a GROUP BY x.b", 1055}, + {"SELECT y.c FROM t1 x LEFT JOIN t1 y ON y.pk = x.b + x.a GROUP BY x.b, x.a", 1055}, + {"SELECT y.c FROM t1 x LEFT JOIN t1 y ON y.u = x.b GROUP BY x.b", 0}, + {"SELECT y.c FROM t1 x JOIN t1 y ON y.u = x.b GROUP BY x.b", 0}, + {"SELECT y.c FROM t1 x JOIN t1 y ON y.u = x.b WHERE x.b IS NOT NULL GROUP BY x.b", 0}, + {"SELECT y.c FROM t1 x JOIN t1 y ON y.u = x.b WHERE y.u IS NOT NULL GROUP BY x.b", 0}, + {"SELECT y.c FROM t1 x JOIN t1 y ON y.u = x.b WHERE y.u > 0 GROUP BY x.b", 0}, + {"SELECT y.c FROM t1 x LEFT JOIN t1 y ON y.u = x.b WHERE y.u IS NOT NULL GROUP BY x.b", 0}, + {"SELECT y.c FROM t1 x LEFT JOIN t1 y ON x.a = y.u GROUP BY x.a", 0}, + {"SELECT y.c FROM t1 x LEFT JOIN t1 y ON y.u = x.pk GROUP BY x.pk", 0}, + {"SELECT y.c FROM t1 x LEFT JOIN t1 y ON y.u = x.pk WHERE y.pk IS NOT NULL GROUP BY x.pk", 0}, + {"SELECT y.c FROM t1 x LEFT JOIN t1 y ON y.u = x.pk AND y.c IS NOT NULL GROUP BY x.pk", 0}, + {"SELECT c FROM t1 WHERE u = 1 OR u = 2 GROUP BY u", 1055}, + {"SELECT c FROM t1 WHERE u <=> 1 GROUP BY u", 1055}, + {"SELECT c FROM t1 WHERE NOT u = 1 GROUP BY u", 0}, + {"SELECT c FROM t1 WHERE u LIKE '1' GROUP BY u", 0}, + {"SELECT c FROM t1 WHERE u BETWEEN 1 AND 2 GROUP BY u", 0}, + {"SELECT c FROM t1 WHERE u + 1 = 2 GROUP BY u", 1055}, + {"SELECT c FROM t1 WHERE COALESCE(u, 0) = 2 GROUP BY u", 1055}, + {"SELECT c FROM t1 WHERE u IS NULL GROUP BY u", 1055}, + {"SELECT c FROM t1 WHERE u IS NULL GROUP BY a", 1055}, + {"SELECT c FROM t1 WHERE u = NULL GROUP BY a", 0}, + {"SELECT c FROM t1 WHERE u = b GROUP BY u", 0}, + {"SELECT c FROM t1 WHERE u = b GROUP BY b", 0}, + {"SELECT c FROM t1 WHERE u = a GROUP BY b", 1055}, + {"SELECT c FROM t1 WHERE pk = a GROUP BY a", 0}, + {"SELECT c FROM t1 WHERE pk = a GROUP BY b", 1055}, + {"SELECT c FROM t1 WHERE a = 1 GROUP BY a", 1055}, + {"SELECT c FROM t1 WHERE a = 1 AND b = 2 GROUP BY u", 1055}, + {"SELECT a, c FROM t1 WHERE a = 1 AND b = 2 GROUP BY u", 1055}, + {"SELECT c FROM t1 GROUP BY a HAVING a = 1", 1055}, + {"SELECT COUNT(*), c FROM t1 WHERE a = 1 AND pk = 2", 0}, + {"SELECT COUNT(*), c FROM t1 WHERE pk = 2", 0}, + {"SELECT COUNT(*), c FROM t1 WHERE pk = 2 OR pk = 3", 1140}, + {"SELECT COUNT(*), c FROM t1 WHERE u = 2", 0}, + {"SELECT COUNT(*), c FROM t1 WHERE u <=> 2", 1140}, + {"SELECT COUNT(*), c FROM t1 WHERE u <=> NULL", 1140}, + {"SELECT a FROM t1 HAVING b > 0", 1054}, + {"SELECT a FROM t1 HAVING MAX(b) > 0 AND a > 0", 1140}, + {"SELECT a FROM t1 GROUP BY a HAVING t1.a > 0", 0}, + {"SELECT a FROM t1 GROUP BY a HAVING pk > 0", 1054}, + {"SELECT a FROM t1 GROUP BY a, u HAVING u > 0", 0}, + {"SELECT a FROM t1 GROUP BY a HAVING (SELECT MAX(x.c) FROM t1 x WHERE x.pk = t1.pk) > 0", 1054}, + {"SELECT a FROM t1 GROUP BY a HAVING EXISTS (SELECT 1 FROM t1 x WHERE x.b = t1.c)", 1054}, + {"SELECT a AS z FROM t1 GROUP BY a HAVING z > 0 AND a > 0", 0}, + {"SELECT a + 1 AS z FROM t1 GROUP BY a HAVING a > 0", 0}, + {"SELECT a + 1 AS z FROM t1 GROUP BY z HAVING a > 0", 1054}, + {"SELECT a FROM t1 GROUP BY a HAVING a + b > 0", 1054}, + {"SELECT a FROM t1 GROUP BY a HAVING SUM(b) > 0 AND MAX(t1.c) > 0", 0}, + {"SELECT x.d FROM (SELECT pk, c + 1 AS d FROM t1) x GROUP BY x.pk", 0}, + {"SELECT x.d FROM (SELECT pk, c + 1 AS d FROM t1 LIMIT 3) x GROUP BY x.pk", 0}, + {"SELECT x.d FROM (SELECT pk, RAND() AS d FROM t1) x GROUP BY x.pk", 0}, + {"SELECT x.d FROM (SELECT b, MAX(c) AS d FROM t1 GROUP BY b) x GROUP BY x.b", 0}, + {"SELECT x.d FROM (SELECT b, MAX(c) AS d FROM t1 GROUP BY b) x GROUP BY x.d", 0}, + {"SELECT x.d, x.e FROM (SELECT b, MAX(c) AS d, MIN(c) AS e FROM t1 GROUP BY b) x GROUP BY x.d", 1055}, + {"SELECT x.b FROM (SELECT b, MAX(c) AS d FROM t1 GROUP BY b) x GROUP BY x.d", 1055}, + {"SELECT x.e FROM (SELECT a, b, MAX(c) AS e FROM t1 GROUP BY a, b) x GROUP BY x.a", 1055}, + {"SELECT x.e FROM (SELECT a, b, MAX(c) AS e FROM t1 GROUP BY a, b) x WHERE x.b = 1 GROUP BY x.a", 0}, + {"SELECT x.e FROM (SELECT a, b, MAX(c) AS e FROM t1 GROUP BY a, b) x WHERE x.b = 1 LIMIT 1", 0}, + {"SELECT x.c FROM (SELECT pk, c FROM t1 WHERE u = 5) x GROUP BY x.a", 1054}, + {"SELECT x.c FROM (SELECT pk, c, a FROM t1 WHERE u = 5) x GROUP BY x.a", 0}, + {"SELECT x.c FROM (SELECT pk, c, a FROM t1 WHERE u = 5 LIMIT 2) x GROUP BY x.a", 0}, + {"SELECT x.c FROM (SELECT pk, c, a FROM t1 WHERE pk = 5 LIMIT 2) x GROUP BY x.a", 0}, + {"SELECT x.c FROM (SELECT pk, c, a FROM t1 WHERE u = 5 LIMIT 2) x GROUP BY x.pk", 0}, + {"SELECT y.c FROM t1 x LEFT JOIN (SELECT b, c FROM t1) y ON y.b = x.a GROUP BY x.a", 1055}, + {"SELECT y.c FROM t1 x LEFT JOIN (SELECT b, c FROM t1 LIMIT 5) y ON y.b = x.a GROUP BY x.a", 1055}, + {"SELECT y.c FROM t1 x LEFT JOIN (SELECT u, c FROM t1) y ON y.u = x.a GROUP BY x.a", 0}, + {"SELECT y.c FROM t1 x LEFT JOIN (SELECT u, c FROM t1 LIMIT 5) y ON y.u = x.a GROUP BY x.a", 1055}, + {"SELECT y.c FROM t1 x LEFT JOIN (SELECT b, MAX(c) c FROM t1 GROUP BY b) y ON y.b = x.a GROUP BY x.a", 1055}, + {"SELECT y.c FROM t1 x LEFT JOIN (SELECT b, MAX(c) c FROM t1 GROUP BY b) y ON y.b = x.b GROUP BY x.b", 1055}, + {"SELECT y.c FROM t1 x LEFT JOIN (SELECT a, b, MAX(c) c FROM t1 GROUP BY a, b) y ON y.b = x.b AND y.a = x.a GROUP BY x.b, x.a", 0}, + {"SELECT y.d FROM t1 x LEFT JOIN (SELECT pk, c + 1 AS d FROM t1) y ON y.pk = x.a GROUP BY x.a", 0}, + {"SELECT y.d FROM t1 x LEFT JOIN (SELECT pk, COALESCE(c, 0) AS d FROM t1) y ON y.pk = x.a GROUP BY x.a", 0}, + {"SELECT y.d FROM t1 x LEFT JOIN (SELECT pk, COALESCE(c, 0) AS d FROM t1 LIMIT 5) y ON y.pk = x.a GROUP BY x.a", 0}, + {"SELECT y.d FROM t1 x JOIN (SELECT pk, COALESCE(c, 0) AS d FROM t1) y ON y.pk = x.a GROUP BY x.a", 0}, + {"SELECT y.c FROM t1 x LEFT JOIN t1 y ON y.pk = x.a AND RAND() > 0.5 GROUP BY x.a", 1055}, + {"SELECT y.c FROM t1 x LEFT JOIN t1 y ON y.pk = x.a AND (SELECT MAX(b) FROM t1) > 0 GROUP BY x.a", 0}, + {"SELECT c FROM t1 WHERE u = 1 + 1 GROUP BY a", 0}, + {"SELECT c FROM t1 WHERE u = ABS(-1) GROUP BY a", 0}, + {"SELECT c FROM t1 WHERE u = NOW() GROUP BY a", 1055}, + {"SELECT c FROM t1 WHERE u = CURRENT_DATE GROUP BY a", 1055}, + {"SELECT c FROM t1 WHERE u = @@version GROUP BY a", 1055}, + {"SELECT c FROM t1 WHERE u = (SELECT 1) GROUP BY a", 0}, + {"SELECT c FROM t1 WHERE u = CAST('1' AS SIGNED) GROUP BY a", 0}, + {"SELECT c FROM t1 WHERE u = _utf8mb4'1' GROUP BY a", 0}, + {"SELECT c FROM t1 WHERE u = -1 GROUP BY a", 0}, + {"SELECT c FROM t1 WHERE u = DATE '2020-01-01' GROUP BY a", 0}, + {"SELECT c FROM t1 WHERE u = TRUE GROUP BY a", 0}, + {"SELECT c FROM t1 WHERE u = 0x01 GROUP BY a", 0}, + {"SELECT c FROM t1 WHERE u = b'1' GROUP BY a", 0}, + {"SELECT c FROM t1 WHERE u = 1.5 GROUP BY a", 0}, + {"SELECT c FROM t1 WHERE u = 1e1 GROUP BY a", 0}, + {"SELECT c FROM t1 WHERE u = 'x' 'y' GROUP BY a", 0}, + {"SELECT c FROM t1 WHERE 1 = u GROUP BY a", 0}, + {"SELECT c FROM t1 WHERE (u) = 1 GROUP BY a", 0}, + {"SELECT c FROM t1 WHERE u = 1 AND 1 = 1 GROUP BY a", 0}, + {"SELECT c FROM t1 WHERE u = 1 AND (a = 1 AND b = 2) GROUP BY a", 0}, + {"SELECT c FROM t1 WHERE (u = 1 OR u = 1) GROUP BY a", 1055}, + {"SELECT c FROM t1 WHERE u = 1 OR FALSE GROUP BY a", 1055}, + {"SELECT c FROM t1 WHERE u = pk GROUP BY a", 1055}, + {"SELECT c FROM t1 WHERE u = pk GROUP BY pk", 0}, + {"SELECT c FROM t1 WHERE u = pk GROUP BY u", 0}, + {"SELECT (SELECT c FROM t1 x WHERE x.pk = t1.a) FROM t1 GROUP BY a", 0}, + {"SELECT (SELECT c FROM t1 x WHERE x.pk = a) FROM t1 GROUP BY a", 0}, + {"SELECT (SELECT c FROM t1 x WHERE x.pk = t1.b) FROM t1 GROUP BY a", 1055}, + {"SELECT (SELECT MAX(x.c + t1.b) FROM t1 x) FROM t1 GROUP BY a", 1055}, + {"SELECT (SELECT MAX(x.c) + t1.b FROM t1 x) FROM t1 GROUP BY a", 1055}, + {"SELECT (SELECT MAX(t1.b) FROM t1 x) FROM t1 GROUP BY a", 0}, + {"SELECT (SELECT t1.b FROM t1 x LIMIT 1) FROM t1 GROUP BY a", 1055}, + {"SELECT (SELECT COUNT(*) FROM t1 x GROUP BY x.a HAVING x.a = t1.b LIMIT 1) FROM t1 GROUP BY a", 1055}, + {"SELECT (SELECT COUNT(*) FROM t1 x WHERE x.a = t1.b GROUP BY x.a LIMIT 1) FROM t1 GROUP BY a", 1055}, + {"SELECT (SELECT x.b FROM (SELECT b FROM t1 y WHERE y.pk = t1.b) x LIMIT 1) FROM t1 GROUP BY a", 1055}, + {"SELECT (SELECT x.b FROM (SELECT b FROM t1 y WHERE y.pk = t1.a) x LIMIT 1) FROM t1 GROUP BY a", 0}, + {"SELECT SUM(a) OVER (ORDER BY c) FROM t1 GROUP BY a, c", 0}, + {"SELECT SUM(a) OVER (ORDER BY c ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) FROM t1 GROUP BY a", 1055}, + {"SELECT SUM(a) OVER (PARTITION BY c, b ORDER BY u) FROM t1 GROUP BY a", 1055}, + {"SELECT SUM(a) OVER (PARTITION BY a ORDER BY b, c) FROM t1 GROUP BY a", 1055}, + {"SELECT SUM(a) OVER (PARTITION BY MAX(c)) FROM t1 GROUP BY a", 0}, + {"SELECT SUM(a) OVER (PARTITION BY MAX(c) + b) FROM t1 GROUP BY a", 1055}, + {"SELECT a FROM t1 GROUP BY a WINDOW w AS (PARTITION BY a), w2 AS (PARTITION BY b)", 1055}, + {"SELECT a FROM t1 GROUP BY a WINDOW w AS (PARTITION BY a ORDER BY b)", 1055}, + {"SELECT a, FIRST_VALUE(b) OVER w FROM t1 GROUP BY a WINDOW w AS (ORDER BY c)", 1055}, + {"SELECT a, FIRST_VALUE(b) OVER (w) FROM t1 GROUP BY a WINDOW w AS (ORDER BY a)", 1055}, + {"SELECT a, MAX(b) OVER (w ORDER BY c) FROM t1 GROUP BY a WINDOW w AS (PARTITION BY a)", 1055}, + {"SELECT a, MAX(b) OVER () FROM t1 GROUP BY a", 1055}, + {"SELECT a, MAX(MAX(b)) OVER () FROM t1 GROUP BY a", 0}, + {"SELECT DISTINCT a FROM t1 ORDER BY t1.a", 0}, + {"SELECT DISTINCT t1.a FROM t1 ORDER BY a", 0}, + {"SELECT DISTINCT a AS b FROM t1 ORDER BY b", 0}, + {"SELECT DISTINCT a AS c FROM t1 ORDER BY c", 0}, + {"SELECT DISTINCT a AS c FROM t1 ORDER BY t1.c", 3065}, + {"SELECT DISTINCT a, b FROM t1 ORDER BY a + b", 0}, + {"SELECT DISTINCT a, b FROM t1 ORDER BY (SELECT MAX(x.c) FROM t1 x WHERE x.pk = t1.c)", 3065}, + {"SELECT DISTINCT a, b FROM t1 ORDER BY EXISTS (SELECT 1 FROM t1 x WHERE x.pk = t1.c)", 3065}, + {"SELECT DISTINCT a, b FROM t1 ORDER BY MAX(c)", 3029}, + {"SELECT DISTINCT a, MAX(c) FROM t1 GROUP BY a ORDER BY MAX(c)", 0}, + {"SELECT DISTINCT a, MAX(c) FROM t1 GROUP BY a ORDER BY MIN(c)", 0}, + {"SELECT DISTINCT a, MAX(c) FROM t1 GROUP BY a ORDER BY MAX(c) + 1", 0}, + {"SELECT DISTINCT a FROM t1 GROUP BY a ORDER BY b", 1055}, + {"SELECT DISTINCT a FROM t1 GROUP BY a ORDER BY a + 1", 0}, + {"SELECT DISTINCT a FROM t1 GROUP BY a, b ORDER BY a + b", 3065}, + {"SELECT DISTINCT a FROM t1 ORDER BY a DESC, RAND()", 0}, + {"SELECT DISTINCT x.a FROM t1 x JOIN t1 y ON x.pk = y.a ORDER BY y.a", 3065}, + {"SELECT DISTINCT x.a FROM t1 x JOIN t1 y ON x.pk = y.a ORDER BY x.a + y.a", 3065}, + {"SELECT DISTINCT x.* FROM t1 x JOIN t1 y ON x.pk = y.a ORDER BY y.a", 3065}, + {"SELECT DISTINCT x.* FROM t1 x JOIN t1 y ON x.pk = y.a ORDER BY x.c", 0}, + {"SELECT DISTINCT * FROM t1 x ORDER BY x.c", 0}, + {"SELECT DISTINCT d.a FROM (SELECT a, b FROM t1) d ORDER BY d.b", 3065}, + {"SELECT DISTINCT d.a FROM (SELECT a, b FROM t1) d ORDER BY d.a + 1", 0}, + {"SELECT DISTINCT d.a FROM (SELECT a + 1 AS a, b FROM t1) d ORDER BY d.a", 0}, + {"SELECT DISTINCT a FROM t1 ORDER BY ROW_NUMBER() OVER (ORDER BY b) + c", 3065}, + {"SELECT DISTINCT a FROM t1 ORDER BY 1 + 1", 0}, + {"SELECT DISTINCT a FROM t1 ORDER BY NULL", 0}, + {"SELECT DISTINCT a FROM t1 ORDER BY @v", 0}, + {"SELECT DISTINCT a FROM t1 ORDER BY $1", 0}, + {"SELECT DISTINCT a FROM t1 ORDER BY a LIMIT 1", 0}, + {"SELECT DISTINCT a FROM t1 ORDER BY (SELECT 1)", 0}, + {"SELECT a FROM t1 ORDER BY MAX(a) + 1", 3029}, + {"SELECT a FROM t1 ORDER BY ROW_NUMBER() OVER ()", 0}, + {"SELECT a FROM t1 ORDER BY (SELECT MAX(x.a) FROM t1 x)", 0}, + {"SELECT a FROM t1 ORDER BY (SELECT MAX(t1.a) FROM t1 x)", 3029}, + {"SELECT a FROM t1 ORDER BY COUNT(*) OVER ()", 0}, + {"SELECT MAX(a) FROM t1 ORDER BY COUNT(*)", 0}, + {"SELECT a FROM t1 GROUP BY a ORDER BY COUNT(*)", 0}, + {"SELECT a FROM t1 HAVING a > 0 ORDER BY COUNT(*)", 3029}, + {"SELECT a FROM t1 UNION SELECT a FROM t1 ORDER BY ROW_NUMBER() OVER ()", 3028}, + {"SELECT COUNT(*), c FROM t1 WHERE pk = 2 ORDER BY c", 0}, + {"SELECT COUNT(*) FROM t1 ORDER BY c", 0}, + {"SELECT COUNT(*) FROM t1 HAVING c > 0", 1054}, + {"SELECT * FROM t1 GROUP BY pk, a WITH ROLLUP", 1055}, + {"SELECT pk, a FROM t1 GROUP BY a, pk WITH ROLLUP", 0}, + {"SELECT pk FROM t1 GROUP BY pk, a WITH ROLLUP HAVING pk > 0", 0}, + {"SELECT pk FROM t1 GROUP BY pk WITH ROLLUP ORDER BY a", 1055}, + {"SELECT pk FROM t1 GROUP BY pk WITH ROLLUP ORDER BY pk", 0}, + {"SELECT pk + 1 FROM t1 GROUP BY pk + 1 WITH ROLLUP", 0}, + {"SELECT pk FROM t1 GROUP BY pk WITH ROLLUP ORDER BY GROUPING(pk)", 0}, + {"SELECT a, c FROM t1 GROUP BY a ORDER BY ANY_VALUE(c)", 1055}, + {"SELECT a, ANY_VALUE(c) FROM t1 GROUP BY a ORDER BY ANY_VALUE(c) + c", 1055}, + {"SELECT id FROM users u GROUP BY name HAVING (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) > 0", 1055}, + {"SELECT a FROM t1 GROUP BY a HAVING (SELECT COUNT(*) FROM t1 x WHERE x.a = t1.a) > 0", 0}, + {"SELECT a FROM t1 GROUP BY a HAVING (SELECT COUNT(*) FROM t1 x WHERE x.a = t1.u) > 0", 1054}, + {"SELECT a FROM t1 GROUP BY a HAVING (SELECT COUNT(*) FROM t1 x WHERE x.a = u) > 0", 0}, + {"SELECT a FROM t1 GROUP BY a HAVING (SELECT COUNT(*) FROM t1 x WHERE x.a = c) > 0", 0}, + {"SELECT a FROM t1 GROUP BY a HAVING (SELECT COUNT(*) FROM t1 x WHERE x.pk = a) > 0", 0}, + {"SELECT a AS q FROM t1 GROUP BY a HAVING (SELECT COUNT(*) FROM t1 x WHERE x.pk = q) > 0", 0}, + {"SELECT c FROM t1 WHERE u = 1 GROUP BY a HAVING c > 0", 0}, + {"SELECT c FROM t1 WHERE u = 1 GROUP BY a HAVING b > 0", 1054}, + {"SELECT c FROM t1 WHERE pk = 1 GROUP BY a HAVING b > 0", 1054}, + {"SELECT c FROM t1 GROUP BY pk HAVING b > 0", 1054}, + {"SELECT c FROM t1 GROUP BY pk HAVING t1.b > 0", 1054}, + {"SELECT x.c FROM t1 x JOIN t1 y ON y.pk = x.a GROUP BY x.pk HAVING y.b > 0", 1054}, + {"SELECT x.c FROM t1 x JOIN t1 y ON y.pk = x.a GROUP BY x.pk HAVING MAX(y.b) > 0", 0}, + {"SELECT x.c FROM t1 x JOIN t1 y ON y.pk = x.a GROUP BY x.pk HAVING y.pk > 0", 1054}, +} + +func TestFullGroup(t *testing.T) { + s, err := schema.Load(fullGroupSchema) + if err != nil { + t.Fatal(err) + } + for _, c := range fullGroupCases { + _, err := Analyze(s, c.sql) + var e *Error + switch { + case err == nil && c.code == 0: + case err == nil: + t.Errorf("%s: accepted, want error %d", c.sql, c.code) + case errors.As(err, &e) && e.Code == c.code: + case errors.As(err, &e): + t.Errorf("%s: error %d (%s), want %d", c.sql, e.Code, e.Message, c.code) + default: + t.Errorf("%s: %v, want %d", c.sql, err, c.code) + } + } +} + +// TestFullGroupOracle checks fullGroupCases against a real mysqld: the error numbers must +// agree. Skipped without a mysqld on PATH (nix-shell -p mysql84). +func TestFullGroupOracle(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + o, err := oracle.Start(ctx, fullGroupSchema) + if errors.Is(err, oracle.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer o.Close() + for _, c := range fullGroupCases { + _, err := o.Describe(ctx, c.sql) + var oe *oracle.Error + got := 0 + if errors.As(err, &oe) { + got = oe.Number + } else if err != nil { + t.Errorf("%s: %v", c.sql, err) + continue + } + if got != c.code { + t.Errorf("%s: the server says %d, the table %d", c.sql, got, c.code) + } + } +} diff --git a/check/mysql/internal/analyze/funcval.go b/check/mysql/internal/analyze/funcval.go new file mode 100644 index 0000000..64c4e91 --- /dev/null +++ b/check/mysql/internal/analyze/funcval.go @@ -0,0 +1,594 @@ +package analyze + +// The run-time failures a function decides by a constant argument's value, judged the way +// fold.go judges constant arithmetic (measured in TestFuncValServer): +// +// - INET_ATON / INET6_ATON / UNHEX / STR_TO_DATE only warn when the value does not +// parse, so the failure is an error only in a strict write outside IGNORE +// (strictWriteStmt): 1411 ER_WRONG_VALUE_FOR_TYPE, except a parsed date with a +// non-space tail, which is the 1292 make_truncated_value_warning raises; +// - UUID_TO_BIN / BIN_TO_UUID raise 1411 with my_error on every evaluation, whatever +// the statement or the sql_mode (measured: SELECT UUID_TO_BIN('zz') fails); +// - PERIOD_ADD / PERIOD_DIFF raise 1210 ER_WRONG_ARGUMENTS the same way for a constant +// that is no period (Item_func_period_add::val_int). +// +// Where each failure lands follows fold.go's placement: a term the optimizer folds before +// reading rows is the statement's error, one that runs per row is the violation keyed by +// its own number ("1411" / "1210"), and a query over no rows does not fail (measured: +// SELECT UUID_TO_BIN('zz') FROM t WHERE FALSE runs, the same select item over a row +// fails). The messages quote the argument as the server does: inet_aton / inet6_aton / +// unhex print the argument item (a string literal keeps its own quotes: ''122.256''), +// uuid_to_bin / bin_to_uuid / str_to_date print the value itself. + +import ( + "fmt" + "math/big" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlast" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" +) + +// foldFuncVal is foldCall's arm for the functions above; handled is false when name is +// none of them. A valid UNHEX carries its decoded bytes as the call's value (c, valued), +// so that BIN_TO_UUID(UNHEX(...)) judges the inner result's length. +func (a *analyzer) foldFuncVal(name string, n *mysqlast.Node, args []mysqlast.Value) (handled bool, c cval, valued bool, fail *foldFail) { + strictGated := false + switch name { + case "INET_ATON", "INET6_ATON", "STR_TO_DATE": + if !a.strictWriteStmt() { + return true, cval{}, false, nil + } + case "UNHEX": + strictGated = !a.strictWriteStmt() // the value still folds; only its own 1411 is gated + case "UUID_TO_BIN", "BIN_TO_UUID", "PERIOD_ADD", "PERIOD_DIFF": + default: + return false, cval{}, false, nil + } + vals := make([]cval, len(args)) + for i, arg := range args { + v, ok, argFail := a.foldArg(arg) + if argFail != nil { + return true, cval{}, false, argFail + } + if !ok { + return true, cval{}, false, nil // an argument this file cannot value: not judged + } + vals[i] = v + } + switch name { + case "INET_ATON": + if len(vals) == 1 && !vals[0].isNull() { + if s, ok := cvalString(vals[0]); ok && !inetAton(s) { + return true, cval{}, false, &foldFail{code: 1411, raw: fmt.Sprintf("Incorrect string value: '%s' for function inet_aton", a.printItem(args[0])), at: n.Start} + } + } + case "INET6_ATON": + if len(vals) == 1 && !vals[0].isNull() { + if s, ok := cvalString(vals[0]); ok && !strToIPv4(s) && !strToIPv6(s) { + return true, cval{}, false, &foldFail{code: 1411, raw: fmt.Sprintf("Incorrect string value: '%s' for function inet6_aton", a.printItem(args[0])), at: n.Start} + } + } + case "UNHEX": + if len(vals) == 1 && !vals[0].isNull() { + if s, ok := cvalString(vals[0]); ok { + if !allHexDigits(s) { + if strictGated { + return true, cval{}, false, nil + } + return true, cval{}, false, &foldFail{code: 1411, raw: fmt.Sprintf("Incorrect string value: '%s' for function unhex", a.printItem(args[0])), at: n.Start} + } + if b, ok := hexLiteralBytes(s); ok { + return true, cval{kind: cStr, s: string(b), fn: true}, true, nil + } + } + } + case "STR_TO_DATE": + if len(vals) == 2 && !vals[0].isNull() && !vals[1].isNull() { + s, ok1 := cvalString(vals[0]) + f, ok2 := cvalString(vals[1]) + if ok1 && ok2 { + if fail := a.strToDateFail(s, f, n.Start); fail != nil { + return true, cval{}, false, fail + } + } + } + case "UUID_TO_BIN": + if len(vals) >= 1 && !vals[0].isNull() { + if s, ok := cvalString(vals[0]); ok && !validUUIDText(s) { + return true, cval{}, false, &foldFail{code: 1411, raw: fmt.Sprintf("Incorrect string value: '%s' for function uuid_to_bin", s), at: n.Start} + } + } + case "BIN_TO_UUID": + if len(vals) >= 1 && !vals[0].isNull() { + if s, ok := cvalString(vals[0]); ok && len(s) != 16 { + return true, cval{}, false, &foldFail{code: 1411, raw: fmt.Sprintf("Incorrect string value: '%s' for function bin_to_uuid", escapeBinary(s)), at: n.Start} + } + } + case "PERIOD_ADD", "PERIOD_DIFF": + fname := "period_add" + if name == "PERIOD_DIFF" { + fname = "period_diff" + } + // the first argument of PERIOD_ADD, both of PERIOD_DIFF, must be periods + check := vals + if name == "PERIOD_ADD" && len(vals) == 2 { + check = vals[:1] + } + for _, v := range check { + if v.isNull() { + continue + } + if p, ok := cvalInt(v); ok && !validPeriod(p) { + return true, cval{}, false, &foldFail{code: 1210, raw: "Incorrect arguments to " + fname, at: n.Start} + } + } + } + return true, cval{}, false, nil +} + +// foldArg folds one argument, additionally reading the hex and binary literals fold.go +// leaves alone (BIN_TO_UUID(0x11)'s bytes are its string value). +func (a *analyzer) foldArg(v mysqlast.Value) (cval, bool, *foldFail) { + if n, ok := v.(*mysqlast.Node); ok { + if n.Class == "PTI_udf_expr" { + return a.foldArg(n.Arg("expr")) + } + if n.Class == "Item_hex_string" { + if b, ok := hexLiteralBytes(str(n.Arg("literal"))); ok { + return cval{kind: cStr, s: string(b)}, true, nil + } + return cval{}, false, nil + } + } + return a.fold(v) +} + +// hexLiteralBytes decodes a 0xABCD / x'ABCD' literal's text into its bytes. +func hexLiteralBytes(s string) ([]byte, bool) { + s = strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X") + if strings.HasPrefix(strings.ToLower(s), "x'") { + s = strings.TrimSuffix(s[2:], "'") + } + if len(s)%2 == 1 { + s = "0" + s + } + out := make([]byte, 0, len(s)/2) + for i := 0; i+1 < len(s); i += 2 { + hi, ok1 := hexDigit(s[i]) + lo, ok2 := hexDigit(s[i+1]) + if !ok1 || !ok2 { + return nil, false + } + out = append(out, hi<<4|lo) + } + return out, true +} + +func hexDigit(c byte) (byte, bool) { + switch { + case c >= '0' && c <= '9': + return c - '0', true + case c >= 'a' && c <= 'f': + return c - 'a' + 10, true + case c >= 'A' && c <= 'F': + return c - 'A' + 10, true + } + return 0, false +} + +// cvalString is the value in a string context; ok is false for a kind whose exact string +// form this file does not spell (a DOUBLE, a DECIMAL). +func cvalString(c cval) (string, bool) { + switch c.kind { + case cStr: + return c.s, true + case cInt, cUint: + return c.i.String(), true + } + return "", false +} + +// cvalInt is the value in an integer context, rounding a decimal half away from zero as +// val_int does; ok is false for a string holding no integer prefix or a DOUBLE. +func cvalInt(c cval) (int64, bool) { + switch c.kind { + case cInt, cUint: + if c.i.IsInt64() { + return c.i.Int64(), true + } + case cDec: + half := big.NewRat(1, 2) + d := new(big.Rat).Set(c.d) + if d.Sign() >= 0 { + d.Add(d, half) + } else { + d.Sub(d, half) + } + i := new(big.Int).Quo(d.Num(), d.Denom()) + if i.IsInt64() { + return i.Int64(), true + } + case cStr: + // my_strtoll10 through val_int: the numeric prefix; not needed precisely here, + // digits alone are enough for the periods the corpus writes + s := strings.TrimSpace(c.s) + neg := strings.HasPrefix(s, "-") + s = strings.TrimPrefix(s, "-") + end := 0 + for end < len(s) && s[end] >= '0' && s[end] <= '9' { + end++ + } + if end == 0 { + return 0, true // no digits: 0, as my_strtoll10 reads it + } + i, ok := new(big.Int).SetString(s[:end], 10) + if !ok || !i.IsInt64() { + return 0, false + } + if neg { + return -i.Int64(), true + } + return i.Int64(), true + } + return 0, false +} + +// inetAton is Item_func_inet_aton::val_int's parse: digit groups of at most 255 separated +// by dots, at most four groups, not ending on a dot, nothing else. +func inetAton(s string) bool { + if s == "" { + return false + } + byteVal, dots := 0, 0 + last := byte('.') + for i := 0; i < len(s); i++ { + last = s[i] + switch { + case last >= '0' && last <= '9': + byteVal = byteVal*10 + int(last-'0') + if byteVal > 255 { + return false + } + case last == '.': + dots++ + byteVal = 0 + default: + return false + } + } + return last != '.' && dots <= 3 +} + +// strToIPv4 is item_inetfunc.cc's str_to_ipv4: exactly four groups of one to three +// digits, each at most 255, 7 to 15 characters. +func strToIPv4(s string) bool { + if len(s) < 7 || len(s) > 15 { + return false + } + byteVal, chars, dots := 0, 0, 0 + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= '0' && c <= '9': + chars++ + if chars > 3 { + return false + } + byteVal = byteVal*10 + int(c-'0') + if byteVal > 255 { + return false + } + case c == '.': + if chars == 0 { + return false + } + dots++ + if dots > 3 { + return false + } + byteVal, chars = 0, 0 + default: + return false + } + } + return chars > 0 && dots == 3 +} + +// strToIPv6 is item_inetfunc.cc's str_to_ipv6: hex groups separated by ':', one '::' gap, +// an optional trailing IPv4 part, filling exactly 16 bytes. +func strToIPv6(s string) bool { + if len(s) < 2 || len(s) > 8*4+7 { + return false + } + p := 0 + if s[0] == ':' { + p++ + if p >= len(s) || s[p] != ':' { + return false + } + } + const size = 16 + dst := 0 + gap := -1 + groupStart := p + chars, groupVal := 0, 0 + for ; p < len(s); p++ { + c := s[p] + switch { + case c == ':': + groupStart = p + 1 + if chars == 0 { + if gap >= 0 { + return false + } + gap = dst + continue + } + if p+1 >= len(s) { + return false // ending at ':' + } + if dst+2 > size { + return false + } + dst += 2 + chars, groupVal = 0, 0 + case c == '.': + if dst+4 > size { + return false + } + if !strToIPv4(s[groupStart:]) { + return false + } + dst += 4 + chars = 0 + p = len(s) // break + default: + d, ok := hexDigit(c) + if !ok { + return false + } + if chars >= 4 { + return false + } + groupVal = groupVal<<4 | int(d) + _ = groupVal + chars++ + } + } + if chars > 0 { + if dst+2 > size { + return false + } + dst += 2 + } + if gap >= 0 { + if dst == size { + return false // no room for the gap + } + dst = size + } + return dst == size +} + +func allHexDigits(s string) bool { + for i := 0; i < len(s); i++ { + if _, ok := hexDigit(s[i]); !ok { + return false + } + } + return true +} + +// validUUIDText is mysql::gtid::Uuid::parse's formats: 32 hex digits, the dashed +// 8-4-4-4-12 form, or the dashed form in braces; nothing else (a braced 32-digit form +// or a stray space is refused, measured). +func validUUIDText(s string) bool { + if len(s) == 38 && s[0] == '{' && s[37] == '}' { + s = s[1:37] + } + switch len(s) { + case 32: + return allHexDigits(s) + case 36: + for i := 0; i < 36; i++ { + if i == 8 || i == 13 || i == 18 || i == 23 { + if s[i] != '-' { + return false + } + continue + } + if _, ok := hexDigit(s[i]); !ok { + return false + } + } + return true + } + return false +} + +// escapeBinary spells a value in an error message as ErrConvString does: printable ASCII +// kept, anything else as \xNN. +func escapeBinary(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 0x20 && c < 0x7f { + b.WriteByte(c) + continue + } + fmt.Fprintf(&b, "\\x%02X", c) + } + return b.String() +} + +// validPeriod is Item_func_period_add's test: a positive YYMM / YYYYMM whose month is +// 1 to 12 (period.h's valid_period). +func validPeriod(p int64) bool { + return p > 0 && p%100 >= 1 && p%100 <= 12 +} + +// wrongArguments is the resolve-time 1210 family (measured in TestFuncValServer, each +// firing wherever the expression sits, dead branches and empty tables included): +// +// - NTILE's argument must be a positive literal (the grammar already refuses a negative +// or non-integer one; a literal 0 is 1210); +// - NTH_VALUE's position must be a positive integer literal (0, a negative, a decimal +// or a string is 1210); +// - LIKE's ESCAPE must be a constant of at most one character (a column is 1210, a +// constant's string form of two characters too); +// - MATCH's columns must be columns of one relation (a select alias, or columns of two +// tables, is 1210 "Incorrect arguments to MATCH"; a derived table's columns pass, +// measured), and AGAINST's argument must hold no column (1210 "... to AGAINST"). +func (a *analyzer) wrongArguments(sc scope, n *mysqlast.Node, where string) *Error { + wrong := func(name string) *Error { + return &Error{Message: "Incorrect arguments to " + name, Code: 1210, Position: a.ph.Back(n.Start)} + } + switch n.Class { + case "Item_ntile": + if arg, ok := n.Arg("a").(*mysqlast.Node); ok && arg.Class == "Item_int" && str(arg.Arg("i")) == "0" { + return wrong("ntile") + } + case "Item_nth_value": + pos, ok := n.Arg("n").(*mysqlast.Node) + if !ok { + return nil + } + switch pos.Class { + case "Item_int": + if str(pos.Arg("i")) == "0" { + return wrong("nth_value") + } + case "Item_func_neg", "Item_decimal", "Item_float", + "PTI_text_literal_text_string", "PTI_text_literal_nchar_string", "PTI_text_literal_underscore_charset": + return wrong("nth_value") + } + case "Item_func_like": + esc, ok := n.Arg("escape").(*mysqlast.Node) + if !ok { + return nil + } + switch esc.Class { + case "Item_null", "Item_param": + return nil + case "PTI_text_literal_underscore_charset", "PTI_literal_underscore_charset_hex_num", "PTI_literal_underscore_charset_bin_num": + return nil // a introducer'd literal: its length is the charset's business (and 1270 is not this rule) + } + // the server wants const_for_execution(): a column is refused, while a scalar + // subquery or an expression over constants is evaluated (its own failures -- + // more than one row, 1242 -- are the execution's); this file only judges what + // it can be sure of: a column outside any subquery is 1210, a constant this + // file folds is measured + if a.nonConstOutsideSubquery(sc, esc) { + return wrong("ESCAPE") + } + if v, ok, fail := a.fold(esc); ok && fail == nil && !v.isNull() { + if s, ok := cvalString(v); ok && len([]rune(s)) > 1 { + return wrong("ESCAPE") + } + } + case "Item_func_match": + if a.nonConstOutsideSubquery(sc, n.Arg("against")) { + return wrong("AGAINST") + } + args, _ := n.Arg("a").(mysqlast.List) + var rel *relation + var base *schema.Table + for _, arg := range args { + an, ok := arg.(*mysqlast.Node) + if !ok || !strings.HasPrefix(an.Class, "PTI_simple_ident") { + return wrong("MATCH") + } + ref, err := a.column(sc, an, where) + if err != nil { + continue // its own resolution error is reported by the argument walk + } + if ref.rel == nil && ref.c.base == nil { + // a select alias that is no table column (HAVING st over GROUP_CONCAT); + // an alias that names a plain column (HAVING inhalt under SELECT *, + // ORDER BY match(betreff) over the b.betreff item) resolves as the + // column it is and passes (measured) + return wrong("MATCH") + } + if (rel != nil || base != nil) && rel != ref.rel && (ref.c.baseTable == nil || base != ref.c.baseTable) { + return wrong("MATCH") // columns of two relations + } + rel, base = ref.rel, ref.c.baseTable + } + } + return nil +} + +// nonConstOutsideSubquery reports a reference the statement cannot run as a constant: a +// column or `@@var` read outside any subquery (a scalar subquery is a constant to +// ESCAPE and AGAINST, measured -- its own shape failures are the execution's), unless +// the name resolves to a routine variable or parameter, which is a run-time constant. +func (a *analyzer) nonConstOutsideSubquery(sc scope, v mysqlast.Value) bool { + switch x := v.(type) { + case mysqlast.List: + for _, e := range x { + if a.nonConstOutsideSubquery(sc, e) { + return true + } + } + case *mysqlast.Node: + switch x.Class { + case "PTI_singlerow_subselect", "PTI_exists_subselect", "Item_in_subselect", "PT_subquery": + return false + case "PTI_simple_ident_ident", "PTI_simple_ident_nospvar_ident": + if _, ok := a.lookupVar(str(x.Arg("ident"))); ok { + return false // a routine variable or parameter + } + return true + case "PTI_simple_ident_q_2d", "PTI_simple_ident_q_3d": + return true + } + return a.nonConstOutsideSubquery(sc, mysqlast.List(x.Args)) + case *mysqlast.Struct: + for _, k := range x.Order { + if a.nonConstOutsideSubquery(sc, x.Fields[k]) { + return true + } + } + } + return false +} + +// nameConstArgs is NAME_CONST's resolve-time rule (Item_name_const::do_itemize / +// fix_fields, measured): each argument must be a literal, a literal under one unary +// minus, or a COLLATE-wrapped string literal -- never a folded expression (1+1, -(-1)), +// a boolean or temporal literal, or anything with a column; a NULL name is the reserved +// syntax's own 1382. +func (a *analyzer) nameConstArgs(args []mysqlast.Value, at int) *Error { + if len(args) != 2 { + return nil + } + if n, ok := args[0].(*mysqlast.Node); ok && n.Class == "Item_null" { + return &Error{Message: "The 'NAME_CONST' syntax is reserved for purposes internal to the MySQL server", Code: 1382, Position: a.ph.Back(at)} + } + for _, arg := range args { + if !nameConstLiteral(arg, true) { + return &Error{Message: "Incorrect arguments to NAME_CONST", Code: 1210, Position: a.ph.Back(at)} + } + } + return nil +} + +func nameConstLiteral(v mysqlast.Value, negOK bool) bool { + n, ok := v.(*mysqlast.Node) + if !ok { + return false + } + switch n.Class { + case "Item_int", "Item_uint", "Item_decimal", "Item_float", "Item_null", "Item_param", + "PTI_text_literal_text_string", "PTI_text_literal_nchar_string", "PTI_text_literal_underscore_charset", + "Item_hex_string", "Item_bin_string", "PTI_literal_underscore_charset_hex_num", "PTI_literal_underscore_charset_bin_num": + return true + case "Item_func_neg": + return negOK && nameConstLiteral(n.Arg("a"), false) + case "Item_func_set_collation": + inner, ok := n.Arg("a").(*mysqlast.Node) + return ok && strings.HasPrefix(inner.Class, "PTI_text_literal") + } + return false +} diff --git a/check/mysql/internal/analyze/funcval_server_test.go b/check/mysql/internal/analyze/funcval_server_test.go new file mode 100644 index 0000000..54c2e81 --- /dev/null +++ b/check/mysql/internal/analyze/funcval_server_test.go @@ -0,0 +1,318 @@ +package analyze + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// The constant function arguments the server refuses by value (funcval.go / strtodate.go), +// pinned against mysqld per sql_mode group: INET_ATON / INET6_ATON / UNHEX / STR_TO_DATE +// warn, so only a strict write fails (1411, or the tail's 1292); UUID_TO_BIN / BIN_TO_UUID +// (1411) and PERIOD_ADD / PERIOD_DIFF (1210) fail every statement; NAME_CONST, ESCAPE, +// NTILE, NTH_VALUE and MATCH / AGAINST are refused at resolution (1210 / 1382), rows or +// none. Each case's message is the analyzer's too. +func TestFuncValServer(t *testing.T) { + const ddl = "CREATE TABLE t (a INT, b VARBINARY(16), c VARCHAR(40));\n" + + "CREATE TABLE ft (x INT, txt TEXT, txt2 TEXT, FULLTEXT (txt));\n" + + "CREATE TABLE ft2 (y INT, doc TEXT, FULLTEXT (doc));\n" + type tc struct { + sql string + want int + msg string + } + groups := map[string][]tc{ + "": { // the default strict mode + // inet_aton: Item_func_inet_aton::val_int's parse + {"INSERT INTO t (a) VALUES (INET_ATON('122.256'))", 1411, "Incorrect string value: ''122.256'' for function inet_aton"}, + {"INSERT INTO t (a) VALUES (INET_ATON('122.226.'))", 1411, "Incorrect string value: ''122.226.'' for function inet_aton"}, + {"INSERT INTO t (a) VALUES (INET_ATON('1.2.3.4.5'))", 1411, "Incorrect string value: ''1.2.3.4.5'' for function inet_aton"}, + {"INSERT INTO t (a) VALUES (INET_ATON(' 1.2.3.4'))", 1411, "Incorrect string value: '' 1.2.3.4'' for function inet_aton"}, + {"INSERT INTO t (a) VALUES (INET_ATON('192.168.0x8.2'))", 1411, "Incorrect string value: ''192.168.0x8.2'' for function inet_aton"}, + {"INSERT INTO t (a) VALUES (INET_ATON('1.2.3.4'))", 0, ""}, + {"INSERT INTO t (a) VALUES (INET_ATON('1.2.3'))", 0, ""}, + {"INSERT INTO t (a) VALUES (INET_ATON('01.2.3.4'))", 0, ""}, + {"INSERT INTO t (a) VALUES (INET_ATON(NULL))", 0, ""}, + {"SELECT INET_ATON('122.256')", 0, ""}, + {"INSERT IGNORE INTO t (a) VALUES (INET_ATON('122.256'))", 0, ""}, + // inet6_aton: str_to_ipv4 / str_to_ipv6 + {"INSERT INTO t (b) VALUES (INET6_ATON('1.0002.3.4'))", 1411, "Incorrect string value: ''1.0002.3.4'' for function inet6_aton"}, + {"INSERT INTO t (b) VALUES (INET6_ATON('1.2.255'))", 1411, "Incorrect string value: ''1.2.255'' for function inet6_aton"}, + {"INSERT INTO t (b) VALUES (INET6_ATON('1::2::3'))", 1411, "Incorrect string value: ''1::2::3'' for function inet6_aton"}, + {"INSERT INTO t (b) VALUES (INET6_ATON('12345::'))", 1411, "Incorrect string value: ''12345::'' for function inet6_aton"}, + {"INSERT INTO t (b) VALUES (INET6_ATON('::ffff:1.2.3.400'))", 1411, "Incorrect string value: ''::ffff:1.2.3.400'' for function inet6_aton"}, + {"INSERT INTO t (b) VALUES (INET6_ATON('1:2:3:4:5:6:7'))", 1411, "Incorrect string value: ''1:2:3:4:5:6:7'' for function inet6_aton"}, + {"INSERT INTO t (b) VALUES (INET6_ATON('1:2:3:4:5:6:7:8:9'))", 1411, "Incorrect string value: ''1:2:3:4:5:6:7:8:9'' for function inet6_aton"}, + {"INSERT INTO t (b) VALUES (INET6_ATON(':1::2'))", 1411, "Incorrect string value: '':1::2'' for function inet6_aton"}, + {"INSERT INTO t (b) VALUES (INET6_ATON('1:2:'))", 1411, "Incorrect string value: ''1:2:'' for function inet6_aton"}, + {"INSERT INTO t (b) VALUES (INET6_ATON('::1'))", 0, ""}, + {"INSERT INTO t (b) VALUES (INET6_ATON('::'))", 0, ""}, + {"INSERT INTO t (b) VALUES (INET6_ATON('1:2:3:4:5:6:7:8'))", 0, ""}, + {"INSERT INTO t (b) VALUES (INET6_ATON('::ffff:1.2.3.4'))", 0, ""}, + {"INSERT INTO t (b) VALUES (INET6_ATON('fe80::1'))", 0, ""}, + {"INSERT INTO t (b) VALUES (INET6_ATON('1.2.3.4'))", 0, ""}, + // unhex + {"INSERT INTO t (b) VALUES (UNHEX('GG'))", 1411, "Incorrect string value: ''GG'' for function unhex"}, + {"INSERT INTO t (b) VALUES (UNHEX('0A F'))", 1411, "Incorrect string value: ''0A F'' for function unhex"}, + {"INSERT INTO t (b) VALUES (UNHEX('0AF'))", 0, ""}, + {"INSERT INTO t (b) VALUES (UNHEX(123))", 0, ""}, + {"SELECT UNHEX('GG')", 0, ""}, + // str_to_date + {"INSERT INTO t (c) VALUES (STR_TO_DATE('a', '%Y'))", 1411, "Incorrect datetime value: 'a' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024-01-32', '%Y-%m-%d'))", 1411, "Incorrect datetime value: '2024-01-32' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024-02-30', '%Y-%m-%d'))", 1411, "Incorrect datetime value: '2024-02-30' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024-00-01', '%Y-%m-%d'))", 1411, "Incorrect datetime value: '2024-00-01' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('0000-00-00', '%Y-%m-%d'))", 1411, "Incorrect datetime value: '0000-00-00' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024', '%Y-%m-%d'))", 1411, "Incorrect datetime value: '2024' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024/01/01', '%Y-%m-%d'))", 1411, "Incorrect datetime value: '2024/01/01' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('15:61:00', '%H:%i:%s'))", 1411, "Incorrect datetime value: '15:61:00' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('13', '%h'))", 1411, "Incorrect datetime value: '13' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('Feb 29 2023', '%b %d %Y'))", 1411, "Incorrect datetime value: 'Feb 29 2023' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('13:30:45 PM', '%r'))", 1411, "Incorrect time value: '13:30:45 PM' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('24:30:45', '%T'))", 1411, "Incorrect datetime value: '24:30:45' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('', '%Y'))", 1411, "Incorrect datetime value: '' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024-01-01', ''))", 1411, "Incorrect datetime value: '2024-01-01' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024', '%q'))", 1411, "Incorrect datetime value: '2024' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('99999', '%Y'))", 1411, "Incorrect datetime value: '99999' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('60', '%y'))", 1411, "Incorrect datetime value: '60' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('0', '%w'))", 1411, "Incorrect datetime value: '0' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('31.10.0000 15.30', '%d.%m.%Y %H.%i'))", 1411, "Incorrect datetime value: '31.10.0000 15.30' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('58 Mon 2024', '%u %a %Y'))", 1411, "Incorrect datetime value: '58 Mon 2024' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('5 Monday 24', '%V %W %X'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024-01-01 extra', '%Y-%m-%d'))", 1292, "Truncated incorrect date value: '2024-01-01 extra'"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024-01-01x', '%Y-%m-%d'))", 1292, "Truncated incorrect date value: '2024-01-01x'"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('23:30:45x', '%H:%i:%s'))", 1292, "Truncated incorrect time value: '23:30:45x'"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024-01-01', '%Y-%m-%d'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024-01-01', '%Y-%m-%dx'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE(' 2024-01-01', '%Y-%m-%d'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024- 01-01', '%Y-%m-%d'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024-1-1', '%Y-%m-%d'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('Feb 29 2024', '%b %d %Y'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('February 29 2024', '%M %d %Y'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('5 Monday 2024', '%u %W %Y'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('5 Monday 2024', '%V %W %x'))", 1411, "Incorrect datetime value: '5 Monday 2024' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('5 Monday 2024', '%V %W %X'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('366 2024', '%j %Y'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('366 2023', '%j %Y'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('11:30:45 PM', '%r'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('23:30:45', '%T'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('123', '%f'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('3rd 1 2024', '%D %m %Y'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('abc-01-2024', '%@-%m-%Y'))", 1411, "Incorrect datetime value: 'abc-01-2024' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE(NULL, '%Y'))", 0, ""}, + // uuid_to_bin / bin_to_uuid: every statement, every mode + {"SELECT UUID_TO_BIN('zz')", 1411, "Incorrect string value: 'zz' for function uuid_to_bin"}, + {"INSERT IGNORE INTO t (b) VALUES (UUID_TO_BIN('zz'))", 1411, "Incorrect string value: 'zz' for function uuid_to_bin"}, + {"SELECT UUID_TO_BIN('{6ccd780cbaba102695645b8c656024db}')", 1411, "Incorrect string value: '{6ccd780cbaba102695645b8c656024db}' for function uuid_to_bin"}, + {"SELECT UUID_TO_BIN('6ccd780cb-aba-1026-9564-5b8c656024db')", 1411, "Incorrect string value: '6ccd780cb-aba-1026-9564-5b8c656024db' for function uuid_to_bin"}, + {"SELECT UUID_TO_BIN(' 6ccd780cbaba102695645b8c656024db')", 1411, "Incorrect string value: ' 6ccd780cbaba102695645b8c656024db' for function uuid_to_bin"}, + {"SELECT UUID_TO_BIN(123)", 1411, "Incorrect string value: '123' for function uuid_to_bin"}, + {"SELECT UUID_TO_BIN('6ccd780c-baba-1026-9564-5b8c656024db')", 0, ""}, + {"SELECT UUID_TO_BIN('{6ccd780c-baba-1026-9564-5b8c656024db}')", 0, ""}, + {"SELECT UUID_TO_BIN('6CCD780CBABA102695645B8C656024DB')", 0, ""}, + {"SELECT UUID_TO_BIN('6ccd780c-baba-1026-9564-5b8c656024db', 2)", 0, ""}, + {"SELECT UUID_TO_BIN(NULL)", 0, ""}, + {"SELECT BIN_TO_UUID(0x11)", 1411, "Incorrect string value: '\\x11' for function bin_to_uuid"}, + {"SELECT BIN_TO_UUID(0x6ccd780cbaba102695645b8c656024db)", 0, ""}, + {"SELECT BIN_TO_UUID('0123456789abcdef')", 0, ""}, + {"SELECT BIN_TO_UUID(UNHEX('7f9d04ae61b34468ac798ffcc984ab668'))", 1411, `Incorrect string value: '\x07\xF9\xD0J\xE6\x1B4F\x8A\xC7\x98\xFF\xCC\x98J\xB6h' for function bin_to_uuid`}, + {"SELECT BIN_TO_UUID(UNHEX('7f9d04ae61b34468ac798ffcc984ab66'))", 0, ""}, + // period_add / period_diff: every statement, every mode + {"SELECT PERIOD_ADD(200013, 1)", 1210, "Incorrect arguments to period_add"}, + {"SELECT PERIOD_ADD(0, 1)", 1210, "Incorrect arguments to period_add"}, + {"SELECT PERIOD_ADD(13, 1)", 1210, "Incorrect arguments to period_add"}, + {"SELECT PERIOD_ADD('x', 1)", 1210, "Incorrect arguments to period_add"}, + {"SELECT PERIOD_DIFF(200013, 200001)", 1210, "Incorrect arguments to period_diff"}, + {"SELECT PERIOD_DIFF(200001, 0)", 1210, "Incorrect arguments to period_diff"}, + {"INSERT INTO t (a) VALUES (PERIOD_ADD(200013, 1))", 1210, "Incorrect arguments to period_add"}, + {"SELECT PERIOD_ADD(200012, 1)", 0, ""}, + {"SELECT PERIOD_ADD(101, 1)", 0, ""}, + {"SELECT PERIOD_ADD(1000001, 1)", 0, ""}, + {"SELECT PERIOD_ADD(200001.5, 1)", 0, ""}, + {"SELECT PERIOD_ADD('200001', 1)", 0, ""}, + {"SELECT PERIOD_ADD(NULL, 1)", 0, ""}, + // name_const: at resolution + {"SELECT NAME_CONST(a, 1) FROM t", 1210, "Incorrect arguments to NAME_CONST"}, + {"SELECT NAME_CONST('n', a) FROM t", 1210, "Incorrect arguments to NAME_CONST"}, + {"SELECT NAME_CONST('n', 1+1)", 1210, "Incorrect arguments to NAME_CONST"}, + {"SELECT NAME_CONST('n', -(-1))", 1210, "Incorrect arguments to NAME_CONST"}, + {"SELECT NAME_CONST('n', TRUE)", 1210, "Incorrect arguments to NAME_CONST"}, + {"SELECT NAME_CONST('n', DATE'2024-01-01')", 1210, "Incorrect arguments to NAME_CONST"}, + {"SELECT NAME_CONST(NULL, 1)", 1382, "The 'NAME_CONST' syntax is reserved for purposes internal to the MySQL server"}, + {"SELECT NAME_CONST('n', -1)", 0, ""}, + {"SELECT NAME_CONST('n', 1.5)", 0, ""}, + {"SELECT NAME_CONST('n', NULL)", 0, ""}, + {"SELECT NAME_CONST('n', 0x41)", 0, ""}, + {"SELECT NAME_CONST(1, 1)", 0, ""}, + {"SELECT NAME_CONST('n', 'a' COLLATE utf8mb4_bin)", 0, ""}, + // escape: at resolution + {"SELECT 'a' LIKE 'b' ESCAPE 'xx'", 1210, "Incorrect arguments to ESCAPE"}, + {"SELECT 'a' LIKE 'b' ESCAPE 33", 1210, "Incorrect arguments to ESCAPE"}, + {"SELECT 'a' LIKE 'b' ESCAPE a FROM t", 1210, "Incorrect arguments to ESCAPE"}, + {"SELECT 'a' LIKE 'b' ESCAPE 'x'", 0, ""}, + {"SELECT 'a' LIKE 'b' ESCAPE ''", 0, ""}, + {"SELECT 'a' LIKE 'b' ESCAPE 3", 0, ""}, + {"SELECT 'a' LIKE 'b' ESCAPE NULL", 0, ""}, + {"SELECT 'a' LIKE 'b' ESCAPE (SELECT 'x')", 0, ""}, + {"SELECT 'a%' LIKE 'a!%' ESCAPE '' || '!'", 0, ""}, + // window arguments: at resolution, rows or none + {"SELECT NTILE(0) OVER () FROM t", 1210, "Incorrect arguments to ntile"}, + {"SELECT NTILE(0) OVER () FROM t WHERE FALSE", 1210, "Incorrect arguments to ntile"}, + {"SELECT NTILE(2) OVER () FROM t", 0, ""}, + {"SELECT NTH_VALUE(a, 0) OVER () FROM t", 1210, "Incorrect arguments to nth_value"}, + {"SELECT NTH_VALUE(a, -1) OVER () FROM t", 1210, "Incorrect arguments to nth_value"}, + {"SELECT NTH_VALUE(a, 1.5) OVER () FROM t", 1210, "Incorrect arguments to nth_value"}, + {"SELECT NTH_VALUE(a, 'x') OVER () FROM t", 1210, "Incorrect arguments to nth_value"}, + {"SELECT NTH_VALUE(a, 2) OVER () FROM t", 0, ""}, + // match / against: at resolution + {"SELECT * FROM ft WHERE MATCH (txt) AGAINST (ft.txt)", 1210, "Incorrect arguments to AGAINST"}, + {"SELECT * FROM ft WHERE MATCH (txt) AGAINST (x)", 1210, "Incorrect arguments to AGAINST"}, + {"SELECT * FROM ft, ft2 WHERE MATCH (txt, doc) AGAINST ('x')", 1210, "Incorrect arguments to MATCH"}, + {"SELECT GROUP_CONCAT(txt) AS st FROM ft HAVING MATCH(st) AGAINST('x')", 1210, "Incorrect arguments to MATCH"}, + {"SELECT * FROM ft WHERE MATCH (txt) AGAINST ('x')", 0, ""}, + {"SELECT * FROM ft WHERE MATCH (ft.txt) AGAINST ('x')", 0, ""}, + {"SELECT * FROM ft WHERE MATCH (txt) AGAINST (concat('a','b'))", 0, ""}, + {"SELECT * FROM (SELECT txt FROM ft) d WHERE MATCH (d.txt) AGAINST ('x')", 0, ""}, + {"SELECT MATCH (txt) AGAINST ('x') FROM ft", 0, ""}, + {"SELECT * FROM ft HAVING MATCH (txt) AGAINST ('x')", 0, ""}, + {"SELECT x FROM ft GROUP BY x, MATCH(txt) AGAINST ('x')", 0, ""}, + {"SELECT txt FROM ft ORDER BY MATCH(txt) AGAINST ('x') DESC", 0, ""}, + // placement: a term the optimizer never reaches + {"SELECT INET_ATON('122.256')", 0, ""}, + {"INSERT INTO t (a) VALUES (IF(0, INET_ATON('122.256'), 1))", 0, ""}, + {"INSERT INTO t (a) SELECT INET_ATON('122.256') FROM t WHERE FALSE", 0, ""}, + {"SELECT UUID_TO_BIN('zz') FROM t WHERE FALSE", 0, ""}, + {"SELECT IF(0, UUID_TO_BIN('zz'), 1)", 0, ""}, + {"SELECT PERIOD_ADD(200013, 1) FROM t WHERE FALSE", 0, ""}, + }, + "-- sqlshape: server sql_mode = NO_ENGINE_SUBSTITUTION\n": { // non-strict + {"INSERT INTO t (a) VALUES (INET_ATON('122.256'))", 0, ""}, + {"INSERT INTO t (a) VALUES (STR_TO_DATE('a', '%Y'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024-01-01 extra', '%Y-%m-%d'))", 0, ""}, + {"INSERT INTO t (b) VALUES (UNHEX('GG'))", 0, ""}, + {"INSERT INTO t (b) VALUES (UUID_TO_BIN('zz'))", 1411, "Incorrect string value: 'zz' for function uuid_to_bin"}, + {"SELECT PERIOD_ADD(200013, 1)", 1210, "Incorrect arguments to period_add"}, + {"SELECT NAME_CONST('n', 1+1)", 1210, "Incorrect arguments to NAME_CONST"}, + {"SELECT NTILE(0) OVER () FROM t", 1210, "Incorrect arguments to ntile"}, + }, + "-- sqlshape: server sql_mode = STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION\n": { // strict without the zero-date modes + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024-00-01', '%Y-%m-%d'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('0000-00-00', '%Y-%m-%d'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024', '%Y-%m-%d'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024', '%Y'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024-02-30', '%Y-%m-%d'))", 1411, "Incorrect datetime value: '2024-02-30' for function str_to_date"}, + {"INSERT INTO t (a) VALUES (INET_ATON('122.256'))", 1411, "Incorrect string value: ''122.256'' for function inet_aton"}, + }, + "-- sqlshape: server sql_mode = STRICT_TRANS_TABLES,ALLOW_INVALID_DATES,NO_ENGINE_SUBSTITUTION\n": { + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024-02-30', '%Y-%m-%d'))", 0, ""}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024-01-32', '%Y-%m-%d'))", 0, ""}, + }, + } + for header, cases := range groups { + hdr := "-- sqlshape: mysql 8.4\n" + header + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + db, err := mysqltest.Start(ctx, hdr+ddl) + if errors.Is(err, mysqltest.ErrNoServer) { + cancel() + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + cancel() + t.Fatal(err) + } + conn := db.Conn() + s, err := schema.Load(hdr + ddl) + if err != nil { + cancel() + t.Fatal(err) + } + for _, c := range cases { + _, serr := conn.ExecContext(ctx, c.sql) + var me *driver.MySQLError + scode, smsg := 0, "" + if errors.As(serr, &me) { + scode, smsg = int(me.Number), me.Message + } else if serr != nil { + t.Errorf("%s: %v", c.sql, serr) + continue + } + if scode != c.want || smsg != c.msg { + t.Errorf("[%s] %s: the server says %d %q, the checker %d %q", strings.TrimSpace(header), c.sql, scode, smsg, c.want, c.msg) + continue + } + _, aerr := Analyze(s, c.sql) + if c.want == 0 { + if aerr != nil { + t.Errorf("[%s] %s: analyzer %v, the server accepts", strings.TrimSpace(header), c.sql, aerr) + } + continue + } + var e *Error + if !errors.As(aerr, &e) || e.Code != c.want || e.Message != c.msg { + t.Errorf("[%s] %s: analyzer %v, want %d %q", strings.TrimSpace(header), c.sql, aerr, c.want, c.msg) + } + } + db.Close() + cancel() + } +} + +// The per-row placements: a constant the statement evaluates per row is the violation +// keyed by its own number, and the server fails only over a row (fold.go's placement, +// applied to funcval.go's functions). +func TestFuncValPerRowServer(t *testing.T) { + const ddl = "CREATE TABLE t (a INT, b VARBINARY(16), c VARCHAR(40));\nINSERT INTO t (a) VALUES (1);\n" + cases := []struct { + sql string + key string + }{ + {"UPDATE t SET a = INET_ATON('122.256')", "1411"}, + {"SELECT UUID_TO_BIN('zz') FROM t", "1411"}, + {"UPDATE t SET c = STR_TO_DATE('a', '%Y')", "1411"}, + {"SELECT PERIOD_ADD(200013, 1) FROM t", "1210"}, + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, "-- sqlshape: mysql 8.4\n"+ddl) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + s, err := schema.Load("-- sqlshape: mysql 8.4\n" + ddl) + if err != nil { + t.Fatal(err) + } + for _, c := range cases { + _, serr := conn.ExecContext(ctx, c.sql) + var me *driver.MySQLError + if !errors.As(serr, &me) || itoa(int(me.Number)) != c.key { + t.Errorf("%s: the server says %v, want the error %s over a row", c.sql, serr, c.key) + continue + } + r, aerr := Analyze(s, c.sql) + if aerr != nil { + t.Errorf("%s: analyzer %v, want the violation %s", c.sql, aerr, c.key) + continue + } + found := false + for _, v := range r.Violations { + if v.Constraint == c.key { + found = true + } + } + if !found { + t.Errorf("%s: analyzer violations %v, want %s", c.sql, r.Violations, c.key) + } + } +} diff --git a/check/mysql/internal/analyze/funcval_test.go b/check/mysql/internal/analyze/funcval_test.go new file mode 100644 index 0000000..88a080a --- /dev/null +++ b/check/mysql/internal/analyze/funcval_test.go @@ -0,0 +1,72 @@ +package analyze + +import ( + "errors" + "testing" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" +) + +// The checker's side of TestFuncValServer: the same rules without a server. +func TestFuncVal(t *testing.T) { + s, err := schema.Load("-- sqlshape: mysql 8.4\n" + + "CREATE TABLE t (a INT, b VARBINARY(16), c VARCHAR(40));\n" + + "CREATE TABLE ft (x INT, txt TEXT, FULLTEXT (txt));\n" + + "CREATE TABLE ft2 (y INT, doc TEXT, FULLTEXT (doc));\n") + if err != nil { + t.Fatal(err) + } + cases := []struct { + sql string + want int + msg string + }{ + {"INSERT INTO t (a) VALUES (INET_ATON('122.256'))", 1411, "Incorrect string value: ''122.256'' for function inet_aton"}, + {"INSERT INTO t (b) VALUES (INET6_ATON('1::2::3'))", 1411, "Incorrect string value: ''1::2::3'' for function inet6_aton"}, + {"INSERT INTO t (b) VALUES (UNHEX('GG'))", 1411, "Incorrect string value: ''GG'' for function unhex"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024-02-30', '%Y-%m-%d'))", 1411, "Incorrect datetime value: '2024-02-30' for function str_to_date"}, + {"INSERT INTO t (c) VALUES (STR_TO_DATE('2024-01-01x', '%Y-%m-%d'))", 1292, "Truncated incorrect date value: '2024-01-01x'"}, + {"SELECT UUID_TO_BIN('zz')", 1411, "Incorrect string value: 'zz' for function uuid_to_bin"}, + {"SELECT BIN_TO_UUID(0x11)", 1411, "Incorrect string value: '\\x11' for function bin_to_uuid"}, + {"SELECT PERIOD_ADD(200013, 1)", 1210, "Incorrect arguments to period_add"}, + {"SELECT NAME_CONST('n', 1+1)", 1210, "Incorrect arguments to NAME_CONST"}, + {"SELECT NAME_CONST(NULL, 1)", 1382, "The 'NAME_CONST' syntax is reserved for purposes internal to the MySQL server"}, + {"SELECT 'a' LIKE 'b' ESCAPE 'xx'", 1210, "Incorrect arguments to ESCAPE"}, + {"SELECT NTILE(0) OVER () FROM t", 1210, "Incorrect arguments to ntile"}, + {"SELECT NTH_VALUE(a, 0) OVER () FROM t", 1210, "Incorrect arguments to nth_value"}, + {"SELECT * FROM ft, ft2 WHERE MATCH (txt, doc) AGAINST ('x')", 1210, "Incorrect arguments to MATCH"}, + {"SELECT * FROM ft WHERE MATCH (txt) AGAINST (ft.txt)", 1210, "Incorrect arguments to AGAINST"}, + {"SELECT INET_ATON('122.256')", 0, ""}, // not a strict write + {"INSERT IGNORE INTO t (a) VALUES (INET_ATON('122.256'))", 0, ""}, + {"SELECT UUID_TO_BIN('zz') FROM t WHERE FALSE", 0, ""}, // per row, over what rows say + {"SELECT PERIOD_ADD(200012, 1)", 0, ""}, + {"SELECT * FROM ft WHERE MATCH (txt) AGAINST ('x')", 0, ""}, + } + for _, c := range cases { + _, err := Analyze(s, c.sql) + if c.want == 0 { + if err != nil { + t.Errorf("%s: %v, want ok", c.sql, err) + } + continue + } + var e *Error + if !errors.As(err, &e) || e.Code != c.want || e.Message != c.msg { + t.Errorf("%s: %v, want %d %q", c.sql, err, c.want, c.msg) + } + } + // the per-row placement raises the violation instead + r, err := Analyze(s, "UPDATE t SET a = INET_ATON('122.256')") + if err != nil { + t.Fatalf("per-row: %v", err) + } + found := false + for _, v := range r.Violations { + if v.Code == 1411 && v.Constraint == "1411" { + found = true + } + } + if !found { + t.Errorf("per-row: violations %v, want 1411", r.Violations) + } +} diff --git a/check/mysql/internal/analyze/geom.go b/check/mysql/internal/analyze/geom.go new file mode 100644 index 0000000..5920f14 --- /dev/null +++ b/check/mysql/internal/analyze/geom.go @@ -0,0 +1,880 @@ +package analyze + +import ( + "encoding/binary" + "encoding/hex" + "fmt" + "math" + "math/big" + "strconv" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlast" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" +) + +// The spatial types. A geometry value's type is one of seven (POINT ... GEOMETRYCOLLECTION, +// wkb_point .. wkb_geometrycollection in spatial.h), a column is declared with one of them +// or with GEOMETRY (any). The checker carries a value's type as the typed's Name when it +// knows it -- a constructor's (POINT(1, 1) is a point), a typed reader's (ST_PointFromText +// returns a point or fails), a constant WKT's or WKB's -- and "geometry" when it does not +// (a GEOMETRY column, ST_Buffer). What it judges with that, each measured on mysqld 8.4 +// (TestGeometryServer; found by the corpus probe's gis files): +// +// - ST_GeomFromText and the typed variants over a constant WKT parse it the way +// spatial.cc's create_from_wkt / Gis_*::init_from_wkt do (parseWKT): a malformed text, +// a LINESTRING of one point, a ring of fewer than four points or not closed, a +// MULTIPOINT mixing its two bracket styles, an empty MULTIPOINT, is 3037 "Invalid GIS +// data provided to function "; a text of another type than the typed variant's +// (ST_PointFromText('LINESTRING(...)'); the GEOMCOLL variants also take the MULTI* +// types) is 3516 "WKT value is a geometry of unexpected type ...". ST_GeomFromWKB and +// its variants over a constant WKB (a hex literal, an UNHEX of a constant) read it as +// create_from_wkb does (readWKB), either byte order per header, and refuse trailing +// bytes, the same way; a geometry value given to them (the internal format, not a WKB) +// is 3037; +// - a constant SRID argument of those readers outside 0 .. 4294967295 is 1690 "SRID value +// is out of range in ''" (validate_srid_arg); +// - the constructors LINESTRING / POLYGON / MULTIPOINT / MULTILINESTRING / MULTIPOLYGON +// take arguments of one type each (points, linestrings, points, linestrings, polygons) +// and an argument known to be another geometry type is 1210 "Incorrect arguments to +// "; LINESTRING of one argument is 3037 "linestring"; a POLYGON ring the checker +// can read whole (a LINESTRING of POINT constants) with fewer than four points or not +// closed is 3037 "polygon" (Item_func_spatial_collection::val_str); +// - a function whose resolve_type rejects geometry arguments (reject_geometry_args: the +// arithmetic and bit operators, the numeric functions, BETWEEN ...) given an argument +// known to be a geometry is 1210 "Incorrect arguments to " (a comparison of two +// geometries is accepted, measured, though the catalog's Item_bool_func2 fact says +// otherwise: the operator's own class does not run it); +// - a value stored into a spatial column (Field_geom::store_internal) must be the +// internal format -- a 4-byte SRID and a little-endian WKB, well-formed, of the column's +// type (any for GEOMETRY; the MULTI* types for GEOMETRYCOLLECTION), no trailing bytes +// -- or it is 1416 "Cannot get geometry object from data you send to the GEOMETRY +// field", whatever the sql_mode and under IGNORE too: a number or a character string +// literal can never be one, a hex / UNHEX constant is read (wellFormedWKB), and a value +// of a known other geometry type fails on every execution (the statement's error) or +// on every non-NULL value (a nullable expression: the 1416 violation, keyed "1416" the +// way 1442 is). +// +// Not read: the SRID a stored value must match when the column declares one (3643), the +// spatial reference system a constant SRID must name (3548), the geometry functions' +// own run-time validity checks (ST_Centroid over a degenerate ring is 3037 at run time, +// which only the data decides), a constructor's or reader's non-constant argument (the +// same checks run per row), and GeoJSON. A reader's or constructor's error over constants +// is reported as the statement's own, though a SELECT over an empty table never evaluates +// it: the constant is wrong whatever the data. + +const ( + wkbPoint = 1 + iota + wkbLinestring + wkbPolygon + wkbMultipoint + wkbMultilinestring + wkbMultipolygon + wkbGeometrycollection +) + +var geometryNames = map[int]string{ + wkbPoint: "point", wkbLinestring: "linestring", wkbPolygon: "polygon", wkbMultipoint: "multipoint", + wkbMultilinestring: "multilinestring", wkbMultipolygon: "multipolygon", wkbGeometrycollection: "geometrycollection", +} + +// geometryCode is the wkb type of a spatial type name (a column's or a typed's), 0 for +// GEOMETRY (any) and -1 for a type that is not spatial. +func geometryCode(name string) int { + switch name { + case "geometry": + return 0 + case "geomcollection": + return wkbGeometrycollection + } + for code, n := range geometryNames { + if n == name { + return code + } + } + return -1 +} + +func isGeometry(t schema.Type) bool { return geometryCode(t.Name) >= 0 } + +// geometryFits says a value of type sub is accepted where super is expected: the same +// type, any under GEOMETRY, a MULTI* under GEOMETRYCOLLECTION (is_subtype_of). +func geometryFits(sub, super int) bool { + return super == 0 || sub == super || (super == wkbGeometrycollection && (sub == wkbMultipoint || sub == wkbMultilinestring || sub == wkbMultipolygon)) +} + +func geometryType(code int, nullable bool) typed { + name := "geometry" + if code > 0 { + name = geometryNames[code] + } + return known(name, nullable) +} + +// ---- WKT (spatial.cc's Gis_read_stream and init_from_wkt) ---- + +type wktReader struct { + s string + i int +} + +func (r *wktReader) skipSpace() { + for r.i < len(r.s) && isSpace(r.s[r.i]) { + r.i++ + } +} + +func (r *wktReader) end() bool { + r.skipSpace() + return r.i >= len(r.s) +} + +func isVarStart(c byte) bool { return c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' } +func isVar(c byte) bool { return isVarStart(c) || isDigit(c) } + +func (r *wktReader) numericBeginning() bool { + c := r.s[r.i] + return isDigit(c) || c == '-' || c == '+' || (c == '.' && r.i+1 < len(r.s) && isDigit(r.s[r.i+1])) +} + +// tok is get_next_toc_type: the kind of the next token. +func (r *wktReader) tok() byte { + r.skipSpace() + switch { + case r.i >= len(r.s): + return 0 + case isVarStart(r.s[r.i]): + return 'w' + case r.numericBeginning(): + return 'n' + case r.s[r.i] == '(', r.s[r.i] == ')', r.s[r.i] == ',': + return r.s[r.i] + } + return '?' +} + +func (r *wktReader) word() (string, bool) { + r.skipSpace() + if r.i >= len(r.s) || !isVarStart(r.s[r.i]) { + return "", false + } + start := r.i + for r.i < len(r.s) && isVar(r.s[r.i]) { + r.i++ + } + return r.s[start:r.i], true +} + +// number is get_next_number: my_strntod's longest float prefix. +func (r *wktReader) number() (float64, bool) { + r.skipSpace() + if r.i >= len(r.s) || !r.numericBeginning() { + return 0, false + } + j := r.i + if r.s[j] == '-' || r.s[j] == '+' { + j++ + } + digits := 0 + for j < len(r.s) && isDigit(r.s[j]) { + j++ + digits++ + } + if j < len(r.s) && r.s[j] == '.' { + j++ + for j < len(r.s) && isDigit(r.s[j]) { + j++ + digits++ + } + } + if digits == 0 { + return 0, false + } + if j < len(r.s) && (r.s[j] == 'e' || r.s[j] == 'E') { + k := j + 1 + if k < len(r.s) && (r.s[k] == '-' || r.s[k] == '+') { + k++ + } + if k < len(r.s) && isDigit(r.s[k]) { + for k < len(r.s) && isDigit(r.s[k]) { + k++ + } + j = k + } + } + f, err := strconv.ParseFloat(r.s[r.i:j], 64) + if err != nil { + if ne, ok := err.(*strconv.NumError); !ok || ne.Err != strconv.ErrRange { + return 0, false + } + } + r.i = j + return f, true +} + +func (r *wktReader) symbol(c byte) bool { + r.skipSpace() + if r.i >= len(r.s) || r.s[r.i] != c { + return false + } + r.i++ + return true +} + +// parseWKT reads a WKT the way create_from_wkt does with check_trailing: the geometry's +// wkb type, or 0 when the text is not a well-formed geometry. +func parseWKT(s string) int { + r := &wktReader{s: s} + code, ok := r.geometry() + if !ok || !r.end() { + return 0 + } + return code +} + +func (r *wktReader) geometry() (int, bool) { + w, ok := r.word() + if !ok { + return 0, false + } + code := 0 + switch strings.ToUpper(w) { + case "POINT": + code = wkbPoint + case "LINESTRING": + code = wkbLinestring + case "POLYGON": + code = wkbPolygon + case "MULTIPOINT": + code = wkbMultipoint + case "MULTILINESTRING": + code = wkbMultilinestring + case "MULTIPOLYGON": + code = wkbMultipolygon + case "GEOMETRYCOLLECTION": + code = wkbGeometrycollection // GEOMCOLLECTION is a function name, not a WKT word (measured) + default: + return 0, false + } + return code, r.body(code) +} + +func (r *wktReader) point(parens bool) ([2]uint64, bool) { + if parens && !r.symbol('(') { + return [2]uint64{}, false + } + x, ok := r.number() + if !ok { + return [2]uint64{}, false + } + y, ok := r.number() + if !ok { + return [2]uint64{}, false + } + if parens && !r.symbol(')') { + return [2]uint64{}, false + } + return [2]uint64{math.Float64bits(x), math.Float64bits(y)}, true +} + +// linestring reads "(x y, x y ...)": at least two points; a polygon ring at least four, +// closed (the first and last points equal, compared as the stored doubles are). +func (r *wktReader) linestring(ring bool) bool { + if !r.symbol('(') { + return false + } + var first, last [2]uint64 + n := 0 + for { + p, ok := r.point(false) + if !ok { + return false + } + if n == 0 { + first = p + } + last = p + n++ + if !r.symbol(',') { + break + } + } + if n < 2 || ring && (n < 4 || first != last) { + return false + } + return r.symbol(')') +} + +func (r *wktReader) body(code int) bool { + switch code { + case wkbPoint: + _, ok := r.point(true) + return ok + case wkbLinestring: + return r.linestring(false) + case wkbPolygon: + if !r.symbol('(') { + return false + } + for { + if !r.linestring(true) { + return false + } + if !r.symbol(',') { + break + } + } + return r.symbol(')') + case wkbMultipoint: + if !r.symbol('(') { + return false + } + parens := r.tok() == '(' + for { + if _, ok := r.point(parens); !ok { + return false + } + if !r.symbol(',') { + break + } + } + return r.symbol(')') + case wkbMultilinestring, wkbMultipolygon: + if !r.symbol('(') { + return false + } + for { + var ok bool + if code == wkbMultilinestring { + ok = r.linestring(false) + } else { + ok = r.body(wkbPolygon) + } + if !ok { + return false + } + if !r.symbol(',') { + break + } + } + return r.symbol(')') + case wkbGeometrycollection: + if r.tok() == 'w' { + w, ok := r.word() + return ok && strings.EqualFold(w, "EMPTY") + } + if !r.symbol('(') { + return false + } + n := 0 + for { + if n == 0 && r.tok() == ')' { + break + } + if _, ok := r.geometry(); !ok { + return false + } + n++ + if !r.symbol(',') { + break + } + } + return r.symbol(')') + } + return false +} + +// ---- WKB (spatial.cc's wkb_scanner / Geometry_well_formed_checker and init_from_wkb) ---- + +type wkbReader struct { + b []byte + // ndrOnly: every header must be little-endian (a stored value); closed: polygon rings + // must be closed (create_from_wkb's readers; the stored value's checker does not look) + ndrOnly, closed bool +} + +func (r *wkbReader) uint32At(pos int, le bool) uint32 { + if le { + return binary.LittleEndian.Uint32(r.b[pos:]) + } + return binary.BigEndian.Uint32(r.b[pos:]) +} + +// element reads one geometry at pos: with a header (byte order, type) when hasHdr, else of +// type code in byte order le; outer is the type it sits in (0 at the top, or an expected +// type). It returns the position after it and the type read, or ok false. +func (r *wkbReader) element(pos int, hasHdr bool, code int, le bool, outer int, depth int) (int, int, bool) { + if depth > 64 { + return 0, 0, false + } + if hasHdr { + if pos+5 > len(r.b) { + return 0, 0, false + } + switch r.b[pos] { + case 0: + le = false + case 1: + le = true + default: + return 0, 0, false + } + if r.ndrOnly && !le { + return 0, 0, false + } + code = int(r.uint32At(pos+1, le)) + pos += 5 + if code < wkbPoint || code > wkbGeometrycollection { + return 0, 0, false + } + // what may sit where (R3, R4): the expected type at the top, any in a collection, + // the MULTI* types' own components + switch { + case depth == 0: + if outer != 0 && !geometryFits(code, outer) { + return 0, 0, false + } + case outer == wkbGeometrycollection: + case outer == wkbMultipoint && code == wkbPoint, outer == wkbMultilinestring && code == wkbLinestring, outer == wkbMultipolygon && code == wkbPolygon: + default: + return 0, 0, false + } + } + switch code { + case wkbPoint: + if pos+16 > len(r.b) { + return 0, 0, false + } + return pos + 16, code, true + case wkbLinestring: + if pos+4 > len(r.b) { + return 0, 0, false + } + n := int(r.uint32At(pos, le)) + pos += 4 + ring := outer == wkbPolygon + if n < 2 || ring && n < 4 || n > (len(r.b)-pos)/16 { + return 0, 0, false + } + if ring && r.closed && string(r.b[pos:pos+16]) != string(r.b[pos+(n-1)*16:pos+n*16]) { + return 0, 0, false + } + return pos + n*16, code, true + case wkbPolygon, wkbMultipoint, wkbMultilinestring, wkbMultipolygon, wkbGeometrycollection: + if pos+4 > len(r.b) { + return 0, 0, false + } + n := int(r.uint32At(pos, le)) + pos += 4 + if n == 0 && (code == wkbPolygon || r.ndrOnly && code != wkbGeometrycollection) { + // a polygon needs a ring (init_from_wkb, R7); a stored value's MULTI* needs an + // element too (R8), which the readers do not require + return 0, 0, false + } + compHdr := code != wkbPolygon + for i := 0; i < n; i++ { + var ok bool + pos, _, ok = r.element(pos, compHdr, wkbLinestring, le, code, depth+1) + if !ok { + return 0, 0, false + } + } + return pos, code, true + } + return 0, 0, false +} + +// readWKB reads a WKB (a header and its data, either byte order) as create_from_wkb does: +// the geometry's type, or 0 when it is malformed or has trailing bytes. +func readWKB(b []byte) int { + r := &wkbReader{b: b, closed: true} + end, code, ok := r.element(0, true, 0, true, 0, 0) + if !ok || end != len(b) { + return 0 + } + return code +} + +// wellFormedWKB judges a value in the internal format (a 4-byte SRID, then a little-endian +// WKB) for a column of the given type code (0: GEOMETRY), as Field_geom::store_internal +// does: is_well_formed with the column's type, no trailing bytes. +func wellFormedWKB(b []byte, column int) bool { + if len(b) < 13 { + return false + } + r := &wkbReader{b: b[4:], ndrOnly: true} + end, _, ok := r.element(0, true, 0, true, column, 0) + return ok && end == len(r.b) +} + +// ---- the readers' and constructors' typing ---- + +// fromTextType is the geometry type a ST_*FromText / ST_*FromWKB variant returns (0: any), +// by the function's name; ok false for another function. +func fromTextType(name string) (code int, ok bool) { + up := strings.ToUpper(name) + if !strings.HasPrefix(up, "ST_") { + return 0, false + } + body := strings.TrimPrefix(up, "ST_") + suffix := "" + for _, s := range []string{"FROMTEXT", "FROMTXT", "FROMWKB"} { + if strings.HasSuffix(body, s) { + suffix = s + body = strings.TrimSuffix(body, s) + } + } + if suffix == "" { + return 0, false + } + switch body { + case "GEOM", "GEOMETRY": + return 0, true + case "POINT": + return wkbPoint, true + case "LINE", "LINESTRING": + return wkbLinestring, true + case "POLY", "POLYGON": + return wkbPolygon, true + case "MPOINT", "MULTIPOINT": + return wkbMultipoint, true + case "MLINE", "MULTILINESTRING": + return wkbMultilinestring, true + case "MPOLY", "MULTIPOLYGON": + return wkbMultipolygon, true + case "GEOMCOLL", "GEOMETRYCOLLECTION": + return wkbGeometrycollection, true + } + return 0, false +} + +// hexConstant is the bytes of a hex literal (0x..., x'...') or of UNHEX over a string +// literal; ok false for anything else. +func hexConstant(v mysqlast.Value) ([]byte, bool) { + n, ok := v.(*mysqlast.Node) + if !ok { + return nil, false + } + text := "" + switch n.Class { + case "Item_hex_string", "PTI_literal_underscore_charset_hex_num": + text = str(n.Arg("literal")) + case "PTI_function_call_generic_ident_sys": + name, args := funcCallParts(n) + if !strings.EqualFold(name, "UNHEX") || len(args) != 1 { + return nil, false + } + arg, ok := args[0].(*mysqlast.Node) + if !ok { + return nil, false + } + text, ok = stringLiteral(arg) + if !ok { + return nil, false + } + default: + return nil, false + } + if len(text)%2 == 1 { + text = "0" + text + } + b, err := hex.DecodeString(text) + if err != nil { + return nil, false + } + return b, true +} + +// geometryReader types ST_GeomFromText / ST_GeomFromWKB and their typed variants: a +// constant text or WKB is read and judged (3037), a constant SRID bounded (1690), and the +// result carries the type it is known to have. +func (a *analyzer) geometryReader(name string, args []mysqlast.Value, ts []typed, at int) (typed, error) { + allowed, _ := fromTextType(name) + fname := strings.ToLower(name) + pos := a.ph.Back(at) + invalid := &Error{Message: fmt.Sprintf("Invalid GIS data provided to function %s.", fname), Code: 3037, Position: pos} + if len(args) >= 2 { + if num, ok := numericLiteral(argNode(args[1])); ok { + if num.rat.Sign() < 0 || num.rat.Cmp(new(big.Rat).SetUint64(math.MaxUint32)) > 0 { + return unknown, &Error{Message: fmt.Sprintf("SRID value is out of range in '%s'", fname), Code: 1690, Position: pos} + } + } + } + unexpected := func(kind string, code int) *Error { + return &Error{Message: fmt.Sprintf("%s value is a geometry of unexpected type %s in %s.", kind, strings.ToUpper(geometryNames[code]), fname), Code: 3516, Position: pos} + } + result := allowed + if len(args) >= 1 { + if strings.HasSuffix(strings.ToUpper(name), "WKB") { + if b, ok := hexConstant(args[0]); ok { + code := readWKB(b) + if code == 0 { + return unknown, invalid + } + if !geometryFits(code, allowed) { + return unknown, unexpected("WKB", code) + } + result = code + } else if len(ts) > 0 && ts[0].known && isGeometry(ts[0].typ) { + // a geometry value is the internal format, an SRID before the WKB: never a + // WKB itself (measured: 3037 on 8.4, unlike what older documentation says) + return unknown, invalid + } + } else if lit, ok := stringLiteral(argNode(args[0])); ok { + code := parseWKT(lit) + if code == 0 { + return unknown, invalid + } + if !geometryFits(code, allowed) { + return unknown, unexpected("WKT", code) + } + result = code + } + } + if allowed == wkbGeometrycollection && result == allowed && !(len(args) >= 1 && isConstantArg(args[0])) { + result = 0 // a MULTI* passes the GEOMCOLL variants too + } + return geometryType(result, true), nil +} + +func argNode(v mysqlast.Value) *mysqlast.Node { + n, _ := v.(*mysqlast.Node) + if n == nil { + return &mysqlast.Node{} + } + return n +} + +func isConstantArg(v mysqlast.Value) bool { + n, ok := v.(*mysqlast.Node) + if !ok { + return false + } + if _, ok := stringLiteral(n); ok { + return true + } + _, ok = hexConstant(n) + return ok +} + +// spatialCollection types LINESTRING(...) / POLYGON(...) / MULTIPOINT(...) / +// MULTILINESTRING(...) / MULTIPOLYGON(...) / GEOMETRYCOLLECTION(...): the arguments must +// be geometries of the constructor's component type (1210), LINESTRING needs two (3037), +// and a POLYGON ring the checker can read whole must have four closed points (3037). +func (a *analyzer) spatialCollection(sc scope, n *mysqlast.Node, where string) (typed, error) { + list, _ := n.Arg("list").(mysqlast.List) + ts, err := a.exprs(sc, list, where) + if err != nil { + return unknown, err + } + code := wkbCode(str(n.Arg("ct"))) + item := wkbCode(str(n.Arg("it"))) + fname := geometryNames[code] + if code == wkbGeometrycollection { + fname = "geomcollection" + } + pos := a.ph.Back(n.Start) + invalid := &Error{Message: fmt.Sprintf("Invalid GIS data provided to function %s.", fname), Code: 3037, Position: pos} + // val_str's checks run per row: a constant argument fails on every execution the + // statement evaluates, a column's value is the data's (measured: polygon(t.a) over an + // empty table runs), so only constants are judged + if code != wkbGeometrycollection { + for i, t := range ts { + if t.known && isGeometry(t.typ) && constantGeometry(list[i]) { + if c := geometryCode(t.typ.Name); c > 0 && c != item { + return unknown, &Error{Message: fmt.Sprintf("Incorrect arguments to %s", fname), Code: 1210, Position: pos} + } + } + } + } + allConstant := true + for _, e := range list { + if !constantGeometry(e) { + allConstant = false + } + } + switch code { + case wkbLinestring: + if len(list) < 2 && allConstant { + return unknown, invalid + } + case wkbPolygon: + for _, ring := range list { + if pts, ok := constantRing(ring); ok && (len(pts) < 4 || pts[0] != pts[len(pts)-1]) { + return unknown, invalid + } + } + } + return geometryType(code, anyNullable(ts)), nil +} + +// constantRing reads a LINESTRING(POINT(x, y), ...) of numeric literals as its points' +// stored doubles; ok false when any part is not such a constant. +func constantRing(v mysqlast.Value) ([][2]uint64, bool) { + n, ok := v.(*mysqlast.Node) + if !ok || n.Class != "Item_func_spatial_collection" || wkbCode(str(n.Arg("ct"))) != wkbLinestring { + return nil, false + } + list, _ := n.Arg("list").(mysqlast.List) + var out [][2]uint64 + for _, e := range list { + p, ok := e.(*mysqlast.Node) + if !ok || p.Class != "Item_func_point" { + return nil, false + } + var xy [2]uint64 + for i, arg := range []mysqlast.Value{p.Arg("a"), p.Arg("b")} { + num, ok := numericLiteral(argNode(arg)) + if !ok { + return nil, false + } + f, _ := num.rat.Float64() + xy[i] = math.Float64bits(f) + } + out = append(out, xy) + } + return out, true +} + +// wkbCode reads the grammar's Geometry::wkb_* constant. +func wkbCode(s string) int { + s = strings.TrimPrefix(s, "Geometry::wkb_") + for code, name := range geometryNames { + if name == s { + return code + } + } + return 0 +} + +// rejectsGeometry is the reject_geometry_args check a class's resolve_type runs: an +// argument known to be a geometry is 1210 "Incorrect arguments to ". +func (a *analyzer) rejectsGeometry(class, name string, ts []typed, at int) *Error { + found := false + for _, f := range classFacts(class) { + if strings.HasPrefix(f, "reject_geometry_args(") { + found = true + } + } + if !found || !geometryOperand(ts) { + return nil + } + return a.geometryRejected(name, at) +} + +// geometryOperand: an argument is known to be a geometry. +func geometryOperand(ts []typed) bool { + for _, t := range ts { + if t.known && isGeometry(t.typ) { + return true + } + } + return false +} + +func (a *analyzer) geometryRejected(name string, at int) *Error { + return &Error{Message: fmt.Sprintf("Incorrect arguments to %s", name), Code: 1210, Position: a.ph.Back(at)} +} + +// constantGeometry: the expression is a geometry built from constants alone (a +// constructor over literals, a reader over a constant text or WKB), so it is never NULL. +func constantGeometry(v mysqlast.Value) bool { + n, ok := v.(*mysqlast.Node) + if !ok { + return false + } + switch n.Class { + case "Item_func_point": + _, okA := numericLiteral(argNode(n.Arg("a"))) + _, okB := numericLiteral(argNode(n.Arg("b"))) + return okA && okB + case "Item_func_spatial_collection": + list, _ := n.Arg("list").(mysqlast.List) + for _, e := range list { + if !constantGeometry(e) { + return false + } + } + return true + case "PTI_function_call_generic_ident_sys": + name, args := funcCallParts(n) + if _, ok := fromTextType(name); !ok || len(args) == 0 || !isConstantArg(args[0]) { + return false + } + for _, arg := range args[1:] { + if _, ok := numericLiteral(argNode(arg)); !ok { + return false + } + } + return true + } + return false +} + +// operatorName is func_name() of the grammar-built operator classes, for a message. +func operatorName(class string) string { + switch class { + case "Item_func_plus": + return "+" + case "Item_func_minus", "Item_func_neg": + return "-" + case "Item_func_mul": + return "*" + case "Item_func_div": + return "/" + case "Item_func_int_div": + return "DIV" + case "Item_func_mod": + return "%" + case "Item_func_bit_or": + return "|" + case "Item_func_bit_and": + return "&" + case "Item_func_bit_xor": + return "^" + case "Item_func_shift_left": + return "<<" + case "Item_func_shift_right": + return ">>" + case "Item_func_bit_neg": + return "~" + case "Item_func_between": + return "between" + } + return strings.ToLower(strings.TrimPrefix(class, "Item_func_")) +} + +// geometryStore judges a value stored into a spatial column (Field_geom::store_internal): +// nil when nothing is known against it, an *Error when every execution fails, or a +// Violation when every non-NULL value does. +func (a *analyzer) geometryStore(table *schema.Table, col *schema.Column, v mysqlast.Value, t typed) (*Error, *Violation) { + column := geometryCode(col.Type.Name) + if column < 0 { + return nil, nil + } + fail := &Error{Message: "Cannot get geometry object from data you send to the GEOMETRY field", Code: 1416, Position: -1} + if n, ok := v.(*mysqlast.Node); ok { + fail.Position = a.ph.Back(n.Start) + if b, ok := hexConstant(v); ok { + if !wellFormedWKB(b, column) { + return fail, nil + } + return nil, nil + } + if _, ok := numericLiteral(n); ok { + return fail, nil + } + if _, ok := stringLiteral(n); ok { + return fail, nil + } + } + if t.known && isGeometry(t.typ) { + if c := geometryCode(t.typ.Name); c > 0 && !geometryFits(c, column) { + if !t.nullable || constantGeometry(v) { + return fail, nil + } + return nil, &Violation{Code: 1416, Constraint: "1416", Table: table.Name, Columns: []string{col.Name}, SQLState: "22003"} + } + } + return nil, nil +} diff --git a/check/mysql/internal/analyze/geom_server_test.go b/check/mysql/internal/analyze/geom_server_test.go new file mode 100644 index 0000000..45932e2 --- /dev/null +++ b/check/mysql/internal/analyze/geom_server_test.go @@ -0,0 +1,268 @@ +package analyze + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +const geomSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE geo ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + g GEOMETRY, + p POINT, + ls LINESTRING, + pg POLYGON NOT NULL, + gc GEOMETRYCOLLECTION, + mp MULTIPOINT +); +` + +// geomCases are statements over the spatial types the checker judges (geom.go), each pinned +// against mysqld: want is the error number the server raises, 0 when it runs. +var geomCases = []struct { + sql string + want int +}{ + // WKT: what create_from_wkt accepts + {"SELECT ST_GeomFromText('POINT(1 1)')", 0}, + {"SELECT ST_GeomFromText('POINT(1e2 -3.5)')", 0}, + {"SELECT ST_GeomFromText('POINT(.5 1)')", 0}, + {"SELECT ST_GeomFromText(' point ( 1 1 ) ')", 0}, + {"SELECT ST_GeomFromText('POINT(1 . 1)')", 3037}, + {"SELECT ST_GeomFromText('POINT(1,1)')", 3037}, + {"SELECT ST_GeomFromText('POINT(1 1) x')", 3037}, + {"SELECT ST_GeomFromText('')", 3037}, + {"SELECT ST_GeomFromText('LINESTRING(0 0)')", 3037}, + {"SELECT ST_GeomFromText('LINESTRING(0 0,1 1)')", 0}, + {"SELECT ST_GeomFromText('LINESTRING(0 0,1 1,)')", 3037}, + {"SELECT ST_GeomFromText('POLYGON((0 0, 1 1))')", 3037}, + {"SELECT ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 1))')", 3037}, + {"SELECT ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))')", 0}, + {"SELECT ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0),(0 0,1 1,1 0,0 0))')", 0}, + {"SELECT ST_GeomFromText('MULTIPOINT()')", 3037}, + {"SELECT ST_GeomFromText('MULTIPOINT(0 0, (1 1))')", 3037}, + {"SELECT ST_GeomFromText('MULTIPOINT((0 0), 1 1)')", 3037}, + {"SELECT ST_GeomFromText('MULTIPOINT((0 0),(1 1))')", 0}, + {"SELECT ST_GeomFromText('MULTIPOINT(0 0, 1 1)')", 0}, + {"SELECT ST_GeomFromText('MULTILINESTRING((9 8))')", 3037}, + {"SELECT ST_GeomFromText('MULTILINESTRING((9 8,1 1))')", 0}, + {"SELECT ST_GeomFromText('MULTIPOLYGON(((0 0,5 5)))')", 3037}, + {"SELECT ST_GeomFromText('MULTIPOLYGON(((0 0,1 1,1 0,0 0)))')", 0}, + {"SELECT ST_GeomFromText('GEOMETRYCOLLECTION()')", 0}, + {"SELECT ST_GeomFromText('GEOMETRYCOLLECTION EMPTY')", 0}, + {"SELECT ST_GeomFromText('GEOMCOLLECTION(POINT(1 1))')", 3037}, + {"SELECT ST_GeomFromText('GeometryCollection(())')", 3037}, + {"SELECT ST_GeomFromText('GeometryCollection(POLYGON((0 0)))')", 3037}, + {"SELECT ST_GeomFromText('GeometryCollection(point(0 0),)')", 3037}, + {"SELECT ST_GeomFromText('GeometryCollection(,point(0 0))')", 3037}, + {"SELECT ST_GeomFromText('GeometryCollection((point(0 0)))')", 3037}, + {"SELECT ST_GeomFromText('GeometryCollection(GeometryCollection(point(0 0)))')", 0}, + {"SELECT ST_GeomFromText('CIRCLE(0 0)')", 3037}, + // the typed readers + {"SELECT ST_PointFromText('POINT(1 1)')", 0}, + {"SELECT ST_PointFromText('LINESTRING(0 0,1 1)')", 3516}, + {"SELECT ST_LineFromText('LINESTRING(0 0,1 1)')", 0}, + {"SELECT ST_PolyFromText('POLYGON((1 1, 10 1, 10 20, 1 20, 1 1), (5 5))')", 3037}, + {"SELECT ST_PolygonFromText('POLYGON((0 0,1 1,1 0,0 0))')", 0}, + {"SELECT ST_MPointFromText('MULTIPOINT(0 0)')", 0}, + {"SELECT ST_MPointFromText('POINT(0 0)')", 3516}, + {"SELECT ST_GeomCollFromText('MULTIPOINT(0 0)')", 0}, + {"SELECT ST_GeomCollFromText('POINT(0 0)')", 3516}, + // SRID + {"SELECT ST_GeomFromText('POINT(1 1)', 0)", 0}, + {"SELECT ST_GeomFromText('POINT(1 1)', 4326)", 0}, + {"SELECT ST_GeomFromText('POINT(1 1)', -1)", 1690}, + {"SELECT ST_GeomFromText('POINT(1 1)', 4294967295)", 3548}, + {"SELECT ST_GeomFromText('POINT(1 1)', 4294967296)", 1690}, + {"SELECT ST_PolyFromWKB(0x0101000000000000000000F03F000000000000F03F, -1)", 1690}, + // WKB: what create_from_wkb accepts, either byte order, no trailing bytes + {"SELECT ST_GeomFromWKB(0x0101000000000000000000F03F000000000000F03F)", 0}, + {"SELECT ST_GeomFromWKB(0x00000000013FF00000000000003FF0000000000000)", 0}, + {"SELECT ST_GeomFromWKB(0x0101000000000000000000F03F000000000000F03F11)", 3037}, + {"SELECT ST_GeomFromWKB(0x0101000000000000000000F03F)", 3037}, + {"SELECT ST_GeomFromWKB(0x020100000000000000000000000000000000000000)", 3037}, + {"SELECT ST_GeomFromWKB(0x0102000000010000000000000000000000000000000000000000)", 3037}, + {"SELECT ST_GeomFromWKB(0x010200000002000000000000000000000000000000000000000000000000000000000000000000F03F)", 0}, + {"SELECT ST_GeomFromWKB(0x01070000000100000002010000000000000000000000)", 3037}, + {"SELECT ST_GeomFromWKB(0x010700000000000000)", 0}, + {"SELECT ST_GeomFromWKB(0x010400000000000000)", 0}, + {"SELECT ST_GeomFromWKB(0x010300000000000000)", 3037}, + {"SELECT ST_GeomFromWKB(UNHEX('0101000000000000000000F03F000000000000F03F'))", 0}, + {"SELECT ST_GeomFromWKB(UNHEX('0101000000000000000000F03F'))", 3037}, + {"SELECT ST_PointFromWKB(0x010200000002000000000000000000000000000000000000000000000000000000000000000000F03F)", 3516}, + {"SELECT ST_GeomFromWKB(ST_GeomFromText('POINT(1 1)'))", 3037}, + // the constructors + {"SELECT POINT(1, 1)", 0}, + {"SELECT LINESTRING(POINT(0,0))", 3037}, + {"SELECT LINESTRING(POINT(0,0), POINT(1,1))", 0}, + {"SELECT POLYGON(LINESTRING(POINT(0,0), POINT(1,1), POINT(1,0), POINT(0,0)))", 0}, + {"SELECT POLYGON(LINESTRING(POINT(0,0), POINT(1,1), POINT(1,0)))", 3037}, + {"SELECT POLYGON(LINESTRING(POINT(0,0), POINT(1,1), POINT(1,0), POINT(0,1)))", 3037}, + {"SELECT POLYGON(POINT(1,1))", 1210}, + {"SELECT MULTIPOINT(LINESTRING(POINT(0,0), POINT(1,1)))", 1210}, + {"SELECT MULTIPOINT(POINT(1,1), POINT(2,2))", 0}, + {"SELECT MULTILINESTRING(POINT(1,1))", 1210}, + {"SELECT MULTIPOLYGON(POINT(1,1))", 1210}, + {"SELECT MULTIPOLYGON(POLYGON(LINESTRING(POINT(0,0), POINT(1,1), POINT(1,0), POINT(0,0))))", 0}, + {"SELECT GEOMETRYCOLLECTION(POINT(1,1), LINESTRING(POINT(0,0), POINT(1,1)))", 0}, + {"SELECT GEOMETRYCOLLECTION()", 0}, + {"SELECT LINESTRING(ST_GeomFromText('POINT(0 0)'), ST_GeomFromText('POINT(1 1)'))", 0}, + {"SELECT LINESTRING(ST_GeomFromText('LINESTRING(0 0,1 1)'), POINT(1,1))", 1210}, + // functions that reject a geometry argument + {"SELECT SIN(POINT(1,1))", 1210}, + {"SELECT POINT(1,1) + 1", 1210}, + {"SELECT POINT(1,1) | 1", 1210}, + {"SELECT -POINT(1,1)", 1210}, + {"SELECT POINT(1,1) = POINT(1,1)", 0}, + {"SELECT POINT(1,1) BETWEEN 1 AND 2", 1210}, + {"SELECT ABS(g) FROM geo", 1210}, + {"SELECT LENGTH(g) FROM geo", 0}, + {"SELECT ST_AsText(POINT(1,1))", 0}, + // stores into a spatial column: the internal format of the column's type + {"INSERT INTO geo (p, pg) VALUES (POINT(1,1), ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'))", 0}, + {"INSERT INTO geo (pg, p) VALUES (ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'), LINESTRING(POINT(0,0),POINT(1,1)))", 1416}, + {"INSERT INTO geo (pg, g) VALUES (ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'), LINESTRING(POINT(0,0),POINT(1,1)))", 0}, + {"INSERT INTO geo (pg, gc) VALUES (ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'), MULTIPOINT(POINT(1,1)))", 0}, + {"INSERT INTO geo (pg, gc) VALUES (ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'), POINT(1,1))", 1416}, + {"INSERT INTO geo (pg, p) VALUES (ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'), ST_GeomFromText('LINESTRING(0 0,1 1)'))", 1416}, + {"INSERT INTO geo (pg, p) VALUES (ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'), ST_PointFromText('POINT(1 1)'))", 0}, + {"INSERT INTO geo (pg) VALUES (POINT(1,1))", 1416}, + {"INSERT INTO geo (pg, p) VALUES (ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'), 'x')", 1416}, + {"INSERT INTO geo (pg, p) VALUES (ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'), 1)", 1416}, + {"INSERT INTO geo (pg, g) VALUES (ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'), 0x000000000101000000000000000000F03F000000000000F03F)", 0}, + {"INSERT INTO geo (pg, g) VALUES (ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'), UNHEX('000000000101000000000000000000F03F000000000000F03F'))", 0}, + {"INSERT INTO geo (pg, g) VALUES (ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'), UNHEX('0000000001010000000000000000F03F'))", 1416}, + {"INSERT INTO geo (pg, g) VALUES (ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'), UNHEX('000000000101000000000000000000F03F000000000000F03F00'))", 1416}, + {"INSERT INTO geo (pg, g) VALUES (ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'), UNHEX('0000000000000000013FF00000000000003FF0000000000000'))", 1416}, + {"INSERT INTO geo (pg, p) VALUES (ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'), UNHEX('00000000010200000002000000000000000000000000000000000000000000000000000000000000000000F03F'))", 1416}, + {"INSERT INTO geo (pg, ls) VALUES (ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'), UNHEX('00000000010200000002000000000000000000000000000000000000000000000000000000000000000000F03F'))", 0}, + {"INSERT INTO geo (pg, gc) VALUES (ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'), UNHEX('000000000104000000010000000101000000000000000000F03F000000000000F03F'))", 0}, + {"INSERT INTO geo (pg, mp) VALUES (ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'), UNHEX('00000000010400000000000000'))", 1416}, + {"INSERT IGNORE INTO geo (pg, p) VALUES (ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'), 'x')", 1416}, + {"UPDATE geo SET p = LINESTRING(POINT(0,0),POINT(1,1)) WHERE id = 1", 1416}, + {"UPDATE geo SET p = POINT(0,0) WHERE id = 1", 0}, + {"INSERT INTO geo (pg, p) SELECT pg, p FROM geo WHERE id = 1", 0}, +} + +// TestGeometry checks the checker's verdict on each case, without a server, and the +// violations it predicts for the data-decided stores. +func TestGeometry(t *testing.T) { + s, err := schema.Load(geomSchema) + if err != nil { + t.Fatal(err) + } + for _, c := range geomCases { + _, err := Analyze(s, c.sql) + want := c.want + if want == 3548 { + want = 0 // the spatial reference system's existence is not read + } + if got := errCode(err); got != want { + t.Errorf("%s: checker says %d (%v), want %d", c.sql, got, err, want) + } + } + // a nullable LINESTRING column into a POINT column: every non-NULL value fails + r, err := Analyze(s, "INSERT INTO geo (pg, p) SELECT pg, ls FROM geo") + if err != nil { + t.Fatal(err) + } + found := false + for _, v := range r.Violations { + if v.Code == 1416 && v.Key() == "1416" { + found = true + } + } + if !found { + t.Errorf("INSERT ... SELECT ls into p: predicted %v, want a 1416 violation", r.Violations) + } + // the types the constructors and readers carry + cases := []struct{ sql, want string }{ + {"SELECT POINT(1,1)", "point"}, + {"SELECT LINESTRING(POINT(0,0),POINT(1,1))", "linestring"}, + {"SELECT ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))')", "polygon"}, + {"SELECT ST_PointFromText(ST_AsText(g)) FROM geo", "point"}, + {"SELECT ST_GeomCollFromText(ST_AsText(g)) FROM geo", "geometry"}, + {"SELECT ST_GeomFromWKB(0x0101000000000000000000F03F000000000000F03F)", "point"}, + {"SELECT ST_Buffer(POINT(1,1), 1)", "geometry"}, + {"SELECT g FROM geo", "geometry"}, + {"SELECT p FROM geo", "point"}, + } + for _, c := range cases { + r, err := Analyze(s, c.sql) + if err != nil || len(r.Columns) != 1 || !r.Columns[0].Known { + t.Errorf("%s: %v", c.sql, err) + continue + } + if got := r.Columns[0].Type.Name; got != c.want { + t.Errorf("%s: typed %s, want %s", c.sql, got, c.want) + } + } +} + +// TestGeometryServer runs each case on mysqld and requires the server's error number to be +// the checker's (3548, the unknown spatial reference system, is the server's alone). Skipped +// without a mysqld on PATH (nix-shell -p mysql84). +func TestGeometryServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, geomSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + if _, err := conn.ExecContext(ctx, "INSERT INTO geo (id, pg, p, ls) VALUES (1, ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'), POINT(1,1), LINESTRING(POINT(0,0),POINT(1,1)))"); err != nil { + t.Fatal(err) + } + run := func(sql string) int { + _, err := conn.ExecContext(ctx, sql) + if err == nil { + return 0 + } + var me *driver.MySQLError + if !errors.As(err, &me) { + t.Errorf("%s: %v", sql, err) + return -1 + } + return int(me.Number) + } + for _, c := range geomCases { + if got := run(c.sql); got != c.want && got >= 0 { + t.Errorf("%s: the server says %d, the checker %d", c.sql, got, c.want) + } + } + // the data-decided store: every non-NULL LINESTRING fails, a NULL passes + if got := run("INSERT INTO geo (pg, p) SELECT pg, ls FROM geo WHERE id = 1"); got != 1416 { + t.Errorf("INSERT ... SELECT ls into p over a row: the server says %d, want 1416", got) + } + if got := run("INSERT INTO geo (pg, p) SELECT pg, NULL FROM geo WHERE id = 1"); got != 0 { + t.Errorf("INSERT ... SELECT NULL into p: the server says %d, want 0", got) + } + // the geometry errors do not depend on the sql_mode + if _, err := conn.ExecContext(ctx, "SET SESSION sql_mode = ''"); err != nil { + t.Fatal(err) + } + for _, sql := range []string{"INSERT INTO geo (pg, p) VALUES (ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 0))'), 'x')", "INSERT INTO geo (pg) VALUES (POINT(1,1))"} { + if got := run(sql); got != 1416 { + t.Errorf("%s without strict mode: the server says %d, want 1416", sql, got) + } + } + lenient, err := schema.Load(strings.Replace(geomSchema, "-- sqlshape: mysql 8.4\n", "-- sqlshape: mysql 8.4\n-- sqlshape: server sql_mode = ''\n", 1)) + if err != nil { + t.Fatal(err) + } + if _, err := Analyze(lenient, "INSERT INTO geo (pg) VALUES (POINT(1,1))"); errCode(err) != 1416 { + t.Errorf("the checker without strict mode: %v, want 1416", err) + } +} diff --git a/check/mysql/internal/analyze/itemname_server_test.go b/check/mysql/internal/analyze/itemname_server_test.go new file mode 100644 index 0000000..71ff034 --- /dev/null +++ b/check/mysql/internal/analyze/itemname_server_test.go @@ -0,0 +1,77 @@ +package analyze + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// The name of an unaliased select item, pinned against the server (itemName's rules): a +// column, a string / numeric / NULL literal are named by their own token even inside +// parentheses; everything else is named by the item's source text, parentheses and inner +// spacing included. +func TestItemNameServer(t *testing.T) { + const ddl = "CREATE TABLE t (a INT, b INT);\n" + queries := []string{ + "SELECT a > b FROM t", + "SELECT (a > b) FROM t", + "SELECT (a) FROM t", + "SELECT ((a)) FROM t", + "SELECT ( a + 1 ) FROM t", + "SELECT ('x')", + "SELECT (3)", + "SELECT (NULL)", + "SELECT (TRUE)", + "SELECT (1.5)", + "SELECT (1e0)", + "SELECT (0x1F)", + "SELECT (DATE'2024-01-01')", + "SELECT (-1)", + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + srv, err := mysqltest.Start(ctx, "-- sqlshape: mysql 8.4\n"+ddl) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer srv.Close() + db, err := sql.Open("mysql", srv.DSN()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + s, err := schema.Load("-- sqlshape: mysql 8.4\n" + ddl) + if err != nil { + t.Fatal(err) + } + for _, q := range queries { + rows, err := db.QueryContext(ctx, q) + if err != nil { + t.Fatalf("%s: %v", q, err) + } + want, _ := rows.Columns() + rows.Close() + r, err := Analyze(s, q) + if err != nil { + t.Errorf("%s: %v", q, err) + continue + } + if len(r.Columns) != len(want) { + t.Errorf("%s: %d columns, server %d", q, len(r.Columns), len(want)) + continue + } + for i, c := range r.Columns { + if c.Name != want[i] { + t.Errorf("%s: column %d named %q, server %q", q, i+1, c.Name, want[i]) + } + } + } +} diff --git a/check/mysql/internal/analyze/load.go b/check/mysql/internal/analyze/load.go new file mode 100644 index 0000000..f3288be --- /dev/null +++ b/check/mysql/internal/analyze/load.go @@ -0,0 +1,156 @@ +package analyze + +import ( + "fmt" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlast" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/facts" +) + +// LOCK TABLES, UNLOCK TABLES, LOAD DATA and SELECT ... INTO OUTFILE / DUMPFILE: statements +// a program runs (Exec) that read or write tables without a result set. Inside a trigger +// or routine body the first three are refused by the server's grammar at CREATE time +// (body.go, 1314); OUTFILE is allowed there. + +// lockTables types LOCK TABLES: every table must exist (1146, measured; a view may be +// locked), and one table may not be named twice without distinct aliases (1066, measured). +// The statement has no columns and no parameters; its facts are a read of every table it +// names, so the relations it touches are reported. +func (a *analyzer) lockTables(n *mysqlast.Node) error { + a.facts = &facts.Facts{Kind: facts.Select, Top: &facts.Scope{At: -1}} + seen := map[string]bool{} + items, _ := n.Arg("tables").(mysqlast.List) + for _, item := range items { + tl, ok := item.(*mysqlast.Node) + if !ok || tl.Class != "table_lock" { + return fmt.Errorf("analyze: LOCK TABLES item not understood: %s", mysqlast.Sprint(item)) + } + rel, err := a.target(tl.Arg("table_ident"), tl.Arg("opt_table_alias"), nil) + if err != nil { + return err + } + rel.pos = nodeStart(tl) + key := strings.ToLower(rel.alias) + if seen[key] { + return &Error{Message: fmt.Sprintf("Not unique table/alias: '%s'", rel.alias), Code: 1066, Position: a.ph.Back(rel.pos)} + } + seen[key] = true + a.facts.Top.Leaves = append(a.facts.Top.Leaves, a.leafFacts(*rel)) + } + return nil +} + +// unlockTables types UNLOCK TABLES: nothing to resolve, no columns, no parameters. +func (a *analyzer) unlockTables(n *mysqlast.Node) error { + a.facts = &facts.Facts{Kind: facts.Select, Top: &facts.Scope{At: -1}} + return nil +} + +// loadData types LOAD DATA [LOCAL] INFILE ... INTO TABLE t: an INSERT of the file's rows. +// The target must be a base table (a view is 1288 "The target table v of the LOAD is not +// updatable", measured), the column list resolves against it (1054, a `@var` in the list +// takes the field and assigns nothing), and each SET assignment types its expression +// against the column. The file's values are unknown, so every listed column may receive a +// NULL: for a NOT NULL column that is 1263 ("NULL supplied to NOT NULL column", measured; +// a SET of NULL is the INSERT's own 1048), the keys, foreign keys and checks the columns +// take part in are the INSERT's (1062 / 1452 / 3819, measured), IGNORE turns them into +// warnings and REPLACE deletes the colliding row first (measured: two rows affected), the +// same rules insertViolations applies to an INSERT -- except that a NOT NULL column the +// column list leaves out is not the INSERT's 1364: it takes its type's implicit default +// (measured: `(id)` alone loads, a = 0). The row count is the file's. +func (a *analyzer) loadData(n *mysqlast.Node) error { + x, _ := mysqlast.AsPTLoadTable(n) + rel, err := a.target(x.Table(), nil, nil) + if err != nil { + return err + } + if rel.table == nil { + return &Error{Message: fmt.Sprintf("The target table %s of the LOAD is not updatable", rel.alias), Code: 1288, Position: a.ph.Back(nodeStart(x.Table()))} + } + rel.target = true + rel.pos = nodeStart(x.Table()) + dup := str(x.OnDuplicate()) + w := &write{kind: facts.Insert, table: rel.table, ignore: strings.HasSuffix(dup, "IGNORE_DUP"), replace: strings.HasSuffix(dup, "REPLACE_DUP"), inserted: map[string]bool{}, query: true, load: true} + a.write = w + sc := scope{rels: []relation{*rel}} + var targets []*schema.Column + var values []facts.Term + if cols, ok := x.OptFieldsOrVars().(mysqlast.List); ok && len(cols) > 0 { + for _, c := range cols { + if cn, ok := c.(*mysqlast.Node); ok && cn.Class == "Item_user_var_as_out_param" { + continue // `@var`: the field goes to the variable, not a column + } + a.assigning = true + col, err := a.targetColumn(rel, c, "field list") + a.assigning = false + if err != nil { + return err + } + targets = append(targets, col) + } + } else { + targets = rel.table.Columns + } + for _, c := range targets { + w.inserted[c.Name] = true + w.values = append(w.values, assignment{col: c, nullable: true, fromFile: true}) + values = append(values, facts.Term{Kind: facts.Known, Text: "?"}) + } + setCols, _ := x.OptSetFields().(mysqlast.List) + setExprs, _ := x.OptSetExprs().(mysqlast.List) + for i, c := range setCols { + if i >= len(setExprs) { + break + } + a.assigning = true + col, err := a.targetColumn(rel, c, "field list") + a.assigning = false + if err != nil { + return err + } + as, err := a.assign(sc, rel.table, col, setExprs[i]) + if err != nil { + return err + } + replaced := false + for j := range w.values { + if w.values[j].col == col { + // SET overrides the field the list gave the column, if any + w.values[j], values[j], replaced = as, a.storedTerm(sc, col, setExprs[i]), true + } + } + if !replaced { + w.inserted[col.Name] = true + w.values = append(w.values, as) + targets = append(targets, col) + values = append(values, a.storedTerm(sc, col, setExprs[i])) + } + } + a.facts = &facts.Facts{Kind: facts.Insert, Top: &facts.Scope{At: -1, Leaves: []facts.Leaf{a.leafFacts(*rel)}, Many: "LOAD DATA loads every row of the file"}} + a.facts.Writes = []facts.Write{a.writeFacts(facts.Insert, rel, nil, targets, values)} + if w.replace { + a.facts.Writes = append(a.facts.Writes, facts.Write{Table: rel.table.Name, Kind: facts.Delete, Position: int32(a.ph.Back(rel.pos))}) + } + return nil +} + +// selectIntoFile reports whether a SELECT sends its rows to a file (INTO OUTFILE / +// DUMPFILE), trailing (the statement's own "into") or inside the query specification +// (opt_into1): the statement then returns no result set to the client. +func selectIntoFile(n *mysqlast.Node) bool { + isFile := func(v mysqlast.Value) bool { + d, ok := v.(*mysqlast.Node) + return ok && (d.Class == "PT_into_destination_outfile" || d.Class == "PT_into_destination_dumpfile") + } + if isFile(n.Arg("into")) { + return true + } + qe, ok := n.Arg("qe").(*mysqlast.Node) + if !ok { + return false + } + body, ok := qe.Arg("body").(*mysqlast.Node) + return ok && body.Class == "PT_query_specification" && isFile(body.Arg("opt_into1")) +} diff --git a/check/mysql/internal/analyze/load_test.go b/check/mysql/internal/analyze/load_test.go new file mode 100644 index 0000000..9c8ba17 --- /dev/null +++ b/check/mysql/internal/analyze/load_test.go @@ -0,0 +1,317 @@ +package analyze + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// The statements this file covers were the third adversarial round's blind spot: LOCK +// TABLES, LOAD DATA and a FIELDS / LINES clause did not fold (mysqlast reported them +// unsupported, so schema.Load refused a whole CREATE PROCEDURE holding one), ALTER VIEW was +// not read, and a top-level SELECT ... FOR UPDATE was "query expression not understood". +const loadSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE p (id INT PRIMARY KEY); +CREATE TABLE t (id INT PRIMARY KEY, a INT NOT NULL, b VARCHAR(10), c INT, pid INT, u INT UNIQUE, + CONSTRAINT fk FOREIGN KEY (pid) REFERENCES p (id), CONSTRAINT chk CHECK (c > 0)); +CREATE VIEW v AS SELECT id, a FROM t; +` + +func loadLoadSchema(t *testing.T, more string) *schema.Schema { + t.Helper() + s, err := schema.Load(loadSchema + more) + if err != nil { + t.Fatal(err) + } + return s +} + +// codeKeys spells a result's violations as code:key, sorted. +func codeKeys(r *Result) string { + var out []string + for _, v := range r.Violations { + out = append(out, fmt.Sprintf("%d:%s", v.Code, v.Key())) + } + sort.Strings(out) + return strings.Join(out, " ") +} + +// leafTables spells the top scope's leaves as table/alias. +func leafTables(r *Result) string { + var out []string + if r.Facts != nil && r.Facts.Top != nil { + for _, l := range r.Facts.Top.Leaves { + out = append(out, l.Table+"/"+l.Alias) + } + } + return strings.Join(out, " ") +} + +func TestLockTables(t *testing.T) { + s := loadLoadSchema(t, "") + r, err := Analyze(s, "LOCK TABLES t WRITE, p AS x READ LOCAL, v READ") + if err != nil { + t.Fatal(err) + } + if len(r.Columns) != 0 || len(r.Params) != 0 || len(r.Violations) != 0 { + t.Errorf("LOCK TABLES: %d columns, %d params, %d violations", len(r.Columns), len(r.Params), len(r.Violations)) + } + if got := leafTables(r); got != "t/t p/x v/v" { + t.Errorf("relations %q", got) + } + if r, err := Analyze(s, "UNLOCK TABLES"); err != nil || len(r.Columns) != 0 { + t.Errorf("UNLOCK TABLES: %v", err) + } + for sql, code := range map[string]int{ + "LOCK TABLES nosuch WRITE": 1146, + "LOCK TABLES t WRITE, t READ": 1066, + } { + if _, err := Analyze(s, sql); errCode(err) != code { + t.Errorf("%s: %v, want %d", sql, err, code) + } + } +} + +func TestLoadData(t *testing.T) { + s := loadLoadSchema(t, "") + for _, c := range []struct { + sql, viol, params string + writes int + }{ + // every column takes a field: the keys, the foreign key, the check, and a NULL for + // a NOT NULL column (1263, not the INSERT's 1048) + {"LOAD DATA INFILE '/tmp/f' INTO TABLE t", "1062:PRIMARY 1062:t.u 1263:t.a 1263:t.id 1452:fk 3819:chk", "", 1}, + // a column list: only those columns; a @var takes its field and assigns nothing; SET + // assigns typed values (a placeholder takes the column's type) + {"LOAD DATA INFILE '/tmp/f' INTO TABLE t (id, a, @x) SET b = @x, c = $1", "1062:PRIMARY 1263:t.a 1263:t.id 3819:chk", "int", 1}, + // SET NULL into a NOT NULL column is the INSERT's own 1048 + {"LOAD DATA INFILE '/tmp/f' INTO TABLE t (id) SET a = NULL", "1048:t.a 1062:PRIMARY 1263:t.id", "", 1}, + // a NOT NULL column with no default left out entirely takes the type's implicit + // default (measured): not the INSERT's 1364 + {"LOAD DATA INFILE '/tmp/f' INTO TABLE t (id)", "1062:PRIMARY 1263:t.id", "", 1}, + {"LOAD DATA INFILE '/tmp/f' IGNORE INTO TABLE t", "", "", 1}, + // REPLACE deletes the colliding row: a second write, of Kind Delete + {"LOAD DATA LOCAL INFILE '/tmp/f' REPLACE INTO TABLE t FIELDS TERMINATED BY ',' LINES TERMINATED BY '\\n' IGNORE 1 LINES", "1263:t.a 1263:t.id 1452:fk 3819:chk", "", 2}, + } { + r, err := Analyze(s, c.sql) + if err != nil { + t.Errorf("%s: %v", c.sql, err) + continue + } + if len(r.Columns) != 0 { + t.Errorf("%s: %d columns, want none", c.sql, len(r.Columns)) + } + if got := codeKeys(r); got != c.viol { + t.Errorf("%s: violations %q, want %q", c.sql, got, c.viol) + } + var ps []string + for _, p := range r.Params { + ps = append(ps, param(p)) + } + if got := strings.Join(ps, ","); got != c.params { + t.Errorf("%s: params %q, want %q", c.sql, got, c.params) + } + if r.Facts == nil || len(r.Facts.Writes) != c.writes || r.Facts.Top.Many == "" || leafTables(r) != "t/t" { + t.Errorf("%s: facts %+v", c.sql, r.Facts) + } + } + for sql, code := range map[string]int{ + "LOAD DATA INFILE '/tmp/f' INTO TABLE nosuch": 1146, + "LOAD DATA INFILE '/tmp/f' INTO TABLE t (id, nosuch)": 1054, + "LOAD DATA INFILE '/tmp/f' INTO TABLE v": 1288, + "LOAD DATA INFILE '/tmp/f' INTO TABLE t SET nosuch = 1": 1054, + "LOAD DATA INFILE '/tmp/f' INTO TABLE t SET a = nosuch": 1054, + } { + if _, err := Analyze(s, sql); errCode(err) != code { + t.Errorf("%s: %v, want %d", sql, err, code) + } + } +} + +// SELECT ... INTO OUTFILE / DUMPFILE returns no result set (the rows go to a file on the +// server); the query is typed all the same, in either INTO position and under a locking +// clause. A top-level SELECT with a trailing FOR UPDATE / FOR SHARE keeps its columns. +func TestSelectIntoFileAndLocking(t *testing.T) { + s := loadLoadSchema(t, "") + for _, c := range []struct { + sql string + columns int + params string + }{ + {"SELECT a, b INTO OUTFILE '/tmp/o' FIELDS TERMINATED BY ',' FROM t WHERE id = $1", 0, "int"}, + {"SELECT a FROM t WHERE id = $1 INTO OUTFILE '/tmp/o'", 0, "int"}, + {"SELECT a INTO DUMPFILE '/tmp/o' FROM t LIMIT 1", 0, ""}, + {"SELECT a FROM t FOR UPDATE INTO OUTFILE '/tmp/o'", 0, ""}, + {"SELECT a INTO OUTFILE '/tmp/o' FROM t FOR SHARE", 0, ""}, + {"(SELECT a FROM t) UNION (SELECT id FROM p) INTO OUTFILE '/tmp/o'", 0, ""}, + {"SELECT id, a FROM t WHERE id = $1 FOR UPDATE", 2, "int"}, + {"SELECT id FROM t FOR SHARE NOWAIT", 1, ""}, + {"SELECT id FROM t LOCK IN SHARE MODE", 1, ""}, + } { + r, err := Analyze(s, c.sql) + if err != nil { + t.Errorf("%s: %v", c.sql, err) + continue + } + var ps []string + for _, p := range r.Params { + ps = append(ps, param(p)) + } + if len(r.Columns) != c.columns || strings.Join(ps, ",") != c.params { + t.Errorf("%s: %d columns, params %v; want %d, %q", c.sql, len(r.Columns), ps, c.columns, c.params) + } + } + if _, err := Analyze(s, "SELECT nosuch INTO OUTFILE '/tmp/o' FROM t"); errCode(err) != 1054 { + t.Errorf("INTO OUTFILE of an unknown column: %v, want 1054", err) + } +} + +// Inside a body: LOCK / UNLOCK TABLES, LOAD DATA and ALTER VIEW are 1314 for every routine +// kind; SELECT ... INTO OUTFILE is allowed even in a trigger or function (no result set). +func TestBodyLockLoadAlterViewOutfile(t *testing.T) { + s := loadLoadSchema(t, ` +CREATE TABLE u (id INT PRIMARY KEY); +CREATE PROCEDURE p1() BEGIN LOCK TABLES t WRITE; END; +CREATE FUNCTION f1() RETURNS INT DETERMINISTIC BEGIN UNLOCK TABLES; RETURN 1; END; +CREATE TRIGGER tr1 BEFORE INSERT ON u FOR EACH ROW BEGIN LOAD DATA INFILE '/tmp/x' INTO TABLE t; END; +CREATE PROCEDURE p2() BEGIN ALTER VIEW v AS SELECT a FROM t; END; +CREATE FUNCTION f2() RETURNS INT DETERMINISTIC BEGIN SELECT a INTO OUTFILE '/tmp/o' FROM t; RETURN 1; END; +CREATE TRIGGER tr2 BEFORE INSERT ON u FOR EACH ROW BEGIN SELECT a FROM t FOR UPDATE INTO OUTFILE '/tmp/o'; END; +`) + if _, err := AnalyzeRoutine(s, s.RoutineOf(schema.Procedure, "p1")); errCode(err) != 1314 || !strings.HasPrefix(err.Error(), "LOCK is not allowed") { + t.Errorf("p1: %v", err) + } + if _, err := AnalyzeRoutine(s, s.RoutineOf(schema.Function, "f1")); errCode(err) != 1314 || !strings.HasPrefix(err.Error(), "UNLOCK is not allowed") { + t.Errorf("f1: %v", err) + } + if _, err := AnalyzeTrigger(s, s.Trigger("tr1")); errCode(err) != 1314 || !strings.HasPrefix(err.Error(), "LOAD DATA is not allowed") { + t.Errorf("tr1: %v", err) + } + if _, err := AnalyzeRoutine(s, s.RoutineOf(schema.Procedure, "p2")); errCode(err) != 1314 || !strings.HasPrefix(err.Error(), "ALTER VIEW is not allowed") { + t.Errorf("p2: %v", err) + } + if br, err := AnalyzeRoutine(s, s.RoutineOf(schema.Function, "f2")); err != nil || len(br.Statements) != 1 { + t.Errorf("f2: %v", err) + } + if br, err := AnalyzeTrigger(s, s.Trigger("tr2")); err != nil || len(br.Statements) != 1 || len(br.Statements[0].LockedReads) != 1 { + t.Errorf("tr2: %v %+v", err, br) + } +} + +// The measurements the tests above rest on, against mysqld 8.4: the CREATE-time refusals +// (1314 for every routine kind, OUTFILE accepted), the resolution errors (1146 / 1066 / +// 1288 / 1054 / 1347), and LOAD DATA's failure modes on a real file (1263 for a NULL field +// in a NOT NULL column, 1048 for SET NULL, no 1364 for an omitted NOT NULL column, 1062 / +// 1452 / 3819, IGNORE swallowing, REPLACE affecting two rows). +func TestLockLoadOutfileAlterViewServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, loadSchema+"INSERT INTO p VALUES (1);\nINSERT INTO t VALUES (1, 1, 'x', 1, 1, 1);\n") + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + code := func(err error) int { + var me *driver.MySQLError + if errors.As(err, &me) { + return int(me.Number) + } + return 0 + } + dir := t.TempDir() + file := func(name, content string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return p + } + s := loadLoadSchema(t, "") + for _, c := range []struct { + sql string + want int + }{ + {"CREATE PROCEDURE p1() BEGIN LOCK TABLES t WRITE; END", 1314}, + {"CREATE FUNCTION f1() RETURNS INT DETERMINISTIC BEGIN UNLOCK TABLES; RETURN 1; END", 1314}, + {"CREATE TRIGGER tr1 BEFORE INSERT ON p FOR EACH ROW BEGIN LOAD DATA INFILE '/tmp/x' INTO TABLE t; END", 1314}, + {"CREATE PROCEDURE p2() BEGIN ALTER VIEW v AS SELECT a FROM t; END", 1314}, + {"CREATE FUNCTION f2() RETURNS INT DETERMINISTIC BEGIN SELECT a INTO OUTFILE '" + filepath.Join(dir, "f2.out") + "' FROM t; RETURN 1; END", 0}, + {"CREATE TRIGGER tr2 BEFORE INSERT ON p FOR EACH ROW BEGIN SELECT a FROM t FOR UPDATE INTO OUTFILE '" + filepath.Join(dir, "tr2.out") + "'; END", 0}, + {"ALTER VIEW nope AS SELECT id FROM t", 1146}, + {"ALTER VIEW t AS SELECT id FROM t", 1347}, + {"ALTER VIEW v AS SELECT a, COUNT(*) n FROM t GROUP BY a WITH CHECK OPTION", 1368}, + {"ALTER ALGORITHM=TEMPTABLE VIEW v (x, y, z) AS SELECT id, a, b FROM t", 0}, + {"LOCK TABLES nosuch WRITE", 1146}, + {"LOCK TABLES t WRITE, t READ", 1066}, + {"LOCK TABLES t WRITE, p AS x READ LOCAL, v READ", 0}, + {"UNLOCK TABLES", 0}, + {"LOAD DATA INFILE '" + file("null.csv", "2\t\\N\tx\t1\t1\t2\n") + "' INTO TABLE t", 1263}, + {"LOAD DATA INFILE '" + file("dup.csv", "1\t1\tx\t1\t1\t3\n") + "' INTO TABLE t", 1062}, + {"LOAD DATA INFILE '" + file("uniq.csv", "3\t1\tx\t1\t1\t1\n") + "' INTO TABLE t", 1062}, + {"LOAD DATA INFILE '" + file("fk.csv", "4\t1\tx\t1\t9\t4\n") + "' INTO TABLE t", 1452}, + {"LOAD DATA INFILE '" + file("chk.csv", "5\t1\tx\t-1\t1\t5\n") + "' INTO TABLE t", 3819}, + {"LOAD DATA INFILE '" + file("id.csv", "8\n") + "' INTO TABLE t (id) SET a = NULL", 1048}, + {"LOAD DATA INFILE '" + filepath.Join(dir, "id.csv") + "' INTO TABLE t (id)", 0}, // a omitted: no 1364, the implicit default + {"LOAD DATA INFILE '" + filepath.Join(dir, "dup.csv") + "' IGNORE INTO TABLE t", 0}, + {"LOAD DATA INFILE '" + filepath.Join(dir, "null.csv") + "' IGNORE INTO TABLE t", 0}, + {"LOAD DATA INFILE '" + file("ok.csv", "7\t1\tx\t1\t1\t7\n") + "' INTO TABLE t", 0}, + {"LOAD DATA INFILE '" + filepath.Join(dir, "ok.csv") + "' INTO TABLE t (id, a, b, c, pid, u, nosuch)", 1054}, + {"LOAD DATA INFILE '" + filepath.Join(dir, "ok.csv") + "' INTO TABLE v", 1288}, + {"LOAD DATA INFILE '" + filepath.Join(dir, "ok.csv") + "' INTO TABLE nosuch", 1146}, + {"SELECT a INTO OUTFILE '" + filepath.Join(dir, "sel.out") + "' FROM t WHERE id = 1", 0}, + {"SELECT nosuch INTO OUTFILE '" + filepath.Join(dir, "sel2.out") + "' FROM t", 1054}, + } { + _, err := db.Conn().ExecContext(ctx, c.sql) + if got := code(err); got != c.want { + t.Errorf("server: %s => %v, want %d", c.sql, err, c.want) + } + } + // REPLACE: the colliding row is deleted and the new one inserted, two rows affected + res, err := db.Conn().ExecContext(ctx, "LOAD DATA INFILE '"+filepath.Join(dir, "dup.csv")+"' REPLACE INTO TABLE t") + if err != nil { + t.Errorf("REPLACE: %v", err) + } else if n, _ := res.RowsAffected(); n != 2 { + t.Errorf("REPLACE affected %d rows, want 2", n) + } + // the analyzer's own verdicts on the same statements: the errors above where the + // server's is a resolution error, the violation set where it is a constraint + for sql, want := range map[string]int{ + "LOCK TABLES nosuch WRITE": 1146, + "LOCK TABLES t WRITE, t READ": 1066, + "LOAD DATA INFILE '/f' INTO TABLE t (id, a, b, c, pid, u, nosuch)": 1054, + "LOAD DATA INFILE '/f' INTO TABLE v": 1288, + "LOAD DATA INFILE '/f' INTO TABLE nosuch": 1146, + "SELECT nosuch INTO OUTFILE '/o' FROM t": 1054, + } { + if _, err := Analyze(s, sql); errCode(err) != want { + t.Errorf("analyzer: %s => %v, want %d", sql, err, want) + } + } + r, err := Analyze(s, "LOAD DATA INFILE '/f' INTO TABLE t") + if err != nil { + t.Fatal(err) + } + for _, code := range []int{1263, 1062, 1452, 3819} { + found := false + for _, v := range r.Violations { + found = found || v.Code == code + } + if !found { + t.Errorf("analyzer: LOAD DATA into t lacks the %d the server raised", code) + } + } +} diff --git a/check/mysql/internal/analyze/lower.go b/check/mysql/internal/analyze/lower.go new file mode 100644 index 0000000..e99f9a1 --- /dev/null +++ b/check/mysql/internal/analyze/lower.go @@ -0,0 +1,104 @@ +package analyze + +import ( + "fmt" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlast" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlparse" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/facts" + "github.com/kr9ly/sqlshape/v2/x/placeholder" +) + +// Lower reads a declared predicate over t in the facts language: the expression is parsed +// and typed against the table alone (an error is the declaration's, positioned in it), and +// each conjunct becomes a facts.Pred whose ColRef.Leaf is 0, standing for the subject. This +// is x/obligation's Lowerer for MySQL. +func Lower(s *schema.Schema, expr string, t *schema.Table) ([]facts.Pred, error) { + prefix := "SELECT 1 FROM `" + t.Name + "` WHERE " + text, ph := placeholder.Rewrite(prefix + expr) + cst, err := mysqlparse.Parse(text, s.Settings.ParseMode()) + if err != nil { + if pe, ok := err.(*mysqlparse.Error); ok { + return nil, &Error{Message: pe.Message, Code: 1064, Position: max(ph.Back(pe.Offset)-len(prefix), 0)} + } + return nil, err + } + root, err := mysqlast.BuildMode(text, cst, s.Settings.ParseMode()) + if err != nil { + return nil, err + } + where := whereOf(root) + if where == nil { + return nil, fmt.Errorf("analyze: one boolean expression expected") + } + a := &analyzer{s: s, text: text, ph: ph, params: make([]Param, ph.Count())} + sc := scope{rels: []relation{{alias: t.Name, table: t}}} + if err := a.condition(sc, where, "where clause"); err != nil { + if e, ok := err.(*Error); ok && e.Position >= len(prefix) { + e.Position -= len(prefix) // positions are the declaration's, not the wrapper's + } + return nil, err + } + fs := &facts.Scope{} + for _, c := range conjuncts(where) { + a.predFacts(&sc, fs, c, nil) + } + return fs.Preds, nil +} + +// whereOf digs the WHERE expression out of a parsed `SELECT ... WHERE `. +func whereOf(root mysqlast.Value) mysqlast.Value { + n, ok := root.(*mysqlast.Node) + if !ok || n.Class != "PT_select_stmt" { + return nil + } + qe, ok := n.Arg("qe").(*mysqlast.Node) + if !ok { + return nil + } + body, ok := qe.Arg("body").(*mysqlast.Node) + if !ok || body.Class != "PT_query_specification" { + return nil + } + w := body.Arg("opt_where_clause") + if wn, ok := w.(*mysqlast.Node); ok && wn.Class == "PTI_where" { + return wn.Arg("expr") + } + return w +} + +// ViewResult is a view's defining query analyzed on its own: its output columns, the +// base column each one passes through unchanged (empty otherwise), and its facts for the +// obligations the schema declares. +type ViewResult struct { + Columns []Column + Sources []ViewSource + Facts *facts.Facts +} + +// ViewSource is the base column a view's output column is a plain reference to. +type ViewSource struct { + Table, Column string +} + +// AnalyzeView types v's query against s, the way the server does at CREATE VIEW. +func AnalyzeView(s *schema.Schema, v *schema.View) (*ViewResult, error) { + _, ph := placeholder.Rewrite(v.Definition) + a := &analyzer{s: s, text: v.Definition, ph: ph, facts: &facts.Facts{Kind: facts.Select}, definingView: v.Name} + cols, body, err := a.view(v) + if err != nil { + return nil, err + } + res := &ViewResult{Columns: cols, Facts: a.facts} + res.Facts.Top = body + res.Facts.Uses = a.uses + for _, c := range cols { + src := ViewSource{} + if c.base != nil && c.baseTable != nil { + src = ViewSource{Table: c.baseTable.Name, Column: c.base.Name} + } + res.Sources = append(res.Sources, src) + } + return res, nil +} diff --git a/check/mysql/internal/analyze/main_test.go b/check/mysql/internal/analyze/main_test.go new file mode 100644 index 0000000..e73ad4d --- /dev/null +++ b/check/mysql/internal/analyze/main_test.go @@ -0,0 +1,12 @@ +package analyze + +import ( + "os" + "testing" + + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// One mysqld per set of server settings serves the whole package (mysqltest.Main); a test +// gets it with the database dropped, not a server of its own. +func TestMain(m *testing.M) { os.Exit(mysqltest.Main(m)) } diff --git a/check/mysql/internal/analyze/nested_call_server_test.go b/check/mysql/internal/analyze/nested_call_server_test.go new file mode 100644 index 0000000..840e4d5 --- /dev/null +++ b/check/mysql/internal/analyze/nested_call_server_test.go @@ -0,0 +1,100 @@ +package analyze + +import ( + "context" + "errors" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// TestNestedCallsServer measures nestedCallSchema against mysqld: the error each +// statement raises (1442 for the overlap shapes, 1305 for a missing routine, nothing for +// the rest) is the one the checker predicts, an unqualified abs() is the native function +// and db.abs() the stored one, and an AFTER INSERT trigger sees NEW.col of a NOT NULL +// column non-NULL while the server's own NOT NULL check runs before it. +func TestNestedCallsServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, nestedCallSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + s, err := schema.Load(nestedCallSchema) + if err != nil { + t.Fatal(err) + } + conn := db.Conn() + var dbname string + if err := conn.QueryRowContext(ctx, "SELECT DATABASE()").Scan(&dbname); err != nil { + t.Fatal(err) + } + for _, sql := range []string{"INSERT INTO t VALUES (1, 1)", "INSERT INTO other VALUES (1, 1)"} { + // the second one fires other_ai, whose function writes other: 1442, the row is not stored + _, _ = conn.ExecContext(ctx, sql) + } + + serverCode := func(err error) int { + if err == nil { + return 0 + } + var me *driver.MySQLError + if errors.As(err, &me) { + return int(me.Number) + } + return -1 + } + for _, sql := range []string{ + "CALL p_same_stmt()", "CALL p_same_stmt_nested()", "CALL p_two_stmts()", "CALL p_two_stmts_call()", + "CALL p_update_then_call()", "CALL p_other()", "CALL p_missing()", + "SELECT fcall(1) FROM t", "SELECT fnest(1) FROM t", "SELECT fnest(1) FROM other", + "INSERT INTO other VALUES (2, 1)", "INSERT INTO t VALUES (2, 1)", "INSERT INTO t SELECT id + 10, v FROM other", + "SELECT " + dbname + ".nope(1)", + } { + _, serr := conn.ExecContext(ctx, sql) + _, cerr := Analyze(s, sql) + got, want := errCode(cerr), serverCode(serr) + if sql == "INSERT INTO other VALUES (2, 1)" { + // the trigger's own 1442 is reported on the trigger's definition (AnalyzeTrigger), + // not on the statement that fires it, the way a direct own-table write is + _, terr := AnalyzeTrigger(s, s.Trigger("other_ai")) + got = errCode(terr) + } + if got != want { + t.Errorf("%s: server %v, checker %v", sql, serr, cerr) + } + } + + var native, stored int + if err := conn.QueryRowContext(ctx, "SELECT abs(-1), "+dbname+".abs(-1)").Scan(&native, &stored); err != nil { + t.Fatal(err) + } + if native != 1 || stored != 42 { + t.Errorf("abs(-1) = %d, %s.abs(-1) = %d: want the native 1 and the stored 42", native, dbname, stored) + } + + // AFTER INSERT: NEW.id (AUTO_INCREMENT), NEW.v (NOT NULL DEFAULT) and NEW.w (NOT NULL, + // given) are all non-NULL; an explicit NULL into v is 1048 before the AFTER trigger runs + if _, err := conn.ExecContext(ctx, "INSERT INTO nn (w) VALUES (1)"); err != nil { + t.Fatalf("INSERT INTO nn (w): %v", err) + } + var n int + if err := conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM log2").Scan(&n); err != nil || n != 2 { + t.Errorf("log2 rows after one INSERT: %d %v, want 2 (BEFORE and AFTER each stored NEW.w)", n, err) + } + _, err = conn.ExecContext(ctx, "INSERT INTO nn (v, w) VALUES (NULL, 2)") + if serverCode(err) != 1048 { + t.Errorf("INSERT NULL into NOT NULL v: got %v, want 1048", err) + } + if err := conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM log2").Scan(&n); err != nil || n != 2 { + t.Errorf("log2 rows after the refused INSERT: %d %v, want still 2 (no AFTER trigger ran)", n, err) + } +} diff --git a/check/mysql/internal/analyze/nested_call_test.go b/check/mysql/internal/analyze/nested_call_test.go new file mode 100644 index 0000000..81c4c5d --- /dev/null +++ b/check/mysql/internal/analyze/nested_call_test.go @@ -0,0 +1,178 @@ +package analyze + +import ( + "testing" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" +) + +// nestedCallSchema: routines calling routines, a stored function named like a builtin, and +// triggers whose bodies call functions -- every shape measured on mysqld 8.4 in +// TestNestedCallsServer. +const nestedCallSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, v INT NOT NULL); +CREATE TABLE other (id INT NOT NULL PRIMARY KEY, v INT NOT NULL); +CREATE TABLE log2 (w INT NOT NULL); +CREATE TABLE nn (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, v INT NOT NULL DEFAULT 5, w INT NOT NULL, x INT); +CREATE FUNCTION abs(x INT) RETURNS INT DETERMINISTIC RETURN 42; +CREATE FUNCTION writer(x INT) RETURNS INT MODIFIES SQL DATA BEGIN UPDATE t SET v = v + 1 WHERE id = -1; RETURN x; END; +CREATE FUNCTION writer_other(x INT) RETURNS INT MODIFIES SQL DATA BEGIN UPDATE other SET v = v + 1 WHERE id = -1; RETURN x; END; +CREATE PROCEDURE q() BEGIN UPDATE t SET v = v + 1 WHERE id = -1; END; +CREATE FUNCTION fcall(x INT) RETURNS INT MODIFIES SQL DATA BEGIN CALL q(); RETURN x; END; +CREATE FUNCTION fnest(x INT) RETURNS INT MODIFIES SQL DATA BEGIN RETURN writer(x); END; +-- the statement that calls the writer references its table: 1442 when CALLed +CREATE PROCEDURE p_same_stmt() BEGIN UPDATE t SET v = writer(v); END; +CREATE PROCEDURE p_same_stmt_nested() BEGIN UPDATE t SET v = fnest(v); END; +-- two statements: the read of t and the write of t through writer() do not collide +CREATE PROCEDURE p_two_stmts() BEGIN DECLARE n INT; SELECT COUNT(*) INTO n FROM t; SET @x = writer(1); END; +CREATE PROCEDURE p_two_stmts_call() BEGIN DECLARE n INT; SELECT COUNT(*) INTO n FROM t; CALL q(); END; +CREATE PROCEDURE p_update_then_call() BEGIN UPDATE t SET v = v + 1 WHERE id = -1 AND v = (SELECT MAX(v) FROM other); CALL q(); END; +-- the statement references other, the writer writes t: no collision +CREATE PROCEDURE p_other() BEGIN UPDATE other SET v = writer(v) WHERE id = -1; END; +CREATE PROCEDURE p_missing() BEGIN CALL no_such_procedure(); END; +CREATE PROCEDURE p_wrong_arity() BEGIN CALL q(1); END; +-- a trigger calling a function that writes the trigger's own table: 1442 always +CREATE TRIGGER other_ai AFTER INSERT ON other FOR EACH ROW BEGIN SET @y = writer_other(1); END; +-- a trigger calling a function that writes another table: no collision, even when the +-- firing statement reads that table +CREATE TRIGGER t_ai AFTER INSERT ON t FOR EACH ROW BEGIN SET @y = writer_other(1); END; +-- NEW.col of a NOT NULL column: nullable in BEFORE, as declared in AFTER +CREATE TRIGGER nn_bi BEFORE INSERT ON nn FOR EACH ROW BEGIN INSERT INTO log2 (w) VALUES (NEW.w); END; +CREATE TRIGGER nn_ai AFTER INSERT ON nn FOR EACH ROW BEGIN INSERT INTO log2 (w) VALUES (NEW.w); END; +` + +func loadNestedCalls(t *testing.T) *schema.Schema { + t.Helper() + s, err := schema.Load(nestedCallSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + return s +} + +// errCode is the analyze.Error code of err, 0 for nil, -1 for another error. +func errCode(err error) int { + if err == nil { + return 0 + } + if ae, ok := err.(*Error); ok { + return ae.Code + } + return -1 +} + +// TestQualifiedCallNamesTheStoredFunction: an unqualified abs() is the native function even +// when the schema declares a FUNCTION abs; db.abs() is the schema's own; db.nope() is 1305. +func TestQualifiedCallNamesTheStoredFunction(t *testing.T) { + s := loadNestedCalls(t) + r, err := Analyze(s, "SELECT abs(-1) AS a") + if err != nil { + t.Fatal(err) + } + if c := r.Columns[0]; c.Type.Name != "bigint" || c.Nullable { + t.Errorf("abs(-1) must be the native function (bigint not null), got %s nullable=%v", c.Type.Name, c.Nullable) + } + r, err = Analyze(s, "SELECT mydb.abs(-1) AS a") + if err != nil { + t.Fatal(err) + } + if c := r.Columns[0]; c.Type.Name != "int" || !c.Nullable { + t.Errorf("mydb.abs(-1) must be the stored function (int, nullable), got %s nullable=%v", c.Type.Name, c.Nullable) + } + if _, err := Analyze(s, "SELECT mydb.nope(1)"); errCode(err) != 1305 { + t.Errorf("mydb.nope(1): got %v, want 1305", err) + } +} + +// TestNestedCallOverlap: the 1442 overlap rule applied per body statement, transitively +// through nested calls, and to a trigger's own table. +func TestNestedCallOverlap(t *testing.T) { + s := loadNestedCalls(t) + for _, c := range []struct { + sql string + code int + }{ + {"CALL p_same_stmt()", 1442}, + {"CALL p_same_stmt_nested()", 1442}, + {"CALL p_two_stmts()", 0}, + {"CALL p_two_stmts_call()", 0}, + {"CALL p_update_then_call()", 0}, + {"CALL p_other()", 0}, + {"CALL p_missing()", 1305}, + {"CALL p_wrong_arity()", 1318}, + {"SELECT fcall(1) FROM t", 1442}, // f CALLs a procedure writing t + {"SELECT fnest(1) FROM t", 1442}, // f RETURNs a function writing t + {"SELECT fnest(1) FROM other", 0}, // t is not this statement's + {"INSERT INTO t VALUES (1, 1)", 0}, // t_ai writes other, not t + {"INSERT INTO t SELECT id + 10, v FROM other", 0}, // reading other does not collide with the trigger's function writing it + } { + _, err := Analyze(s, c.sql) + if got := errCode(err); got != c.code { + t.Errorf("%s: got %v, want code %d", c.sql, err, c.code) + } + } + for name, want := range map[string][]string{"p_two_stmts": {"t"}, "p_two_stmts_call": {"t"}, "p_other": {"other", "t"}, "fcall": {"t"}, "fnest": {"t"}} { + kind := schema.Procedure + if name == "fcall" || name == "fnest" { + kind = schema.Function + } + br, err := AnalyzeRoutine(s, s.RoutineOf(kind, name)) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if got := tableSet(br.WriteTables); got != tableSet(want) { + t.Errorf("%s WriteTables: got %s, want %s", name, got, tableSet(want)) + } + } + if _, err := AnalyzeTrigger(s, s.Trigger("other_ai")); errCode(err) != 1442 { + t.Errorf("other_ai (its function writes the trigger's own table): got %v, want 1442", err) + } + if br, err := AnalyzeTrigger(s, s.Trigger("t_ai")); err != nil || tableSet(br.WriteTables) != "other" { + t.Errorf("t_ai: got %v %v, want no error and WriteTables [other]", err, br) + } +} + +// TestAfterTriggerNewIsAsDeclared: NEW.w of a NOT NULL column may be NULL in a BEFORE INSERT +// trigger (its INSERT into a NOT NULL column carries 1048) and is as declared in an AFTER +// one (no 1048) -- measured in TestNestedCallsServer. +func TestAfterTriggerNewIsAsDeclared(t *testing.T) { + s := loadNestedCalls(t) + br, err := AnalyzeTrigger(s, s.Trigger("nn_bi")) + if err != nil { + t.Fatal(err) + } + if got := violationKeys(br.Violations); got != "1048 log2.w" { + t.Errorf("BEFORE: got %q, want 1048 log2.w", got) + } + br, err = AnalyzeTrigger(s, s.Trigger("nn_ai")) + if err != nil { + t.Fatal(err) + } + if got := violationKeys(br.Violations); got != "" { + t.Errorf("AFTER: got %q, want no violation", got) + } +} + +func tableSet(names []string) string { + m := map[string]bool{} + for _, n := range names { + m[n] = true + } + var out []string + for _, n := range []string{"log2", "nn", "other", "t"} { + if m[n] { + out = append(out, n) + } + } + s := "" + for i, n := range out { + if i > 0 { + s += " " + } + s += n + } + return s +} diff --git a/check/mysql/internal/analyze/oracle_test.go b/check/mysql/internal/analyze/oracle_test.go new file mode 100644 index 0000000..6b0ed29 --- /dev/null +++ b/check/mysql/internal/analyze/oracle_test.go @@ -0,0 +1,112 @@ +package analyze + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/oracle" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" +) + +// TestOracle checks the analyzer's answers against a real mysqld: every statement of +// analyzeCases and errorCases is described by the server, and the result columns' names, +// types and nullability (for the columns the analyzer types) and the error numbers must +// agree. Skipped without a mysqld on PATH (nix-shell -p mysql84). +func TestOracle(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + o, err := oracle.Start(ctx, testSchema) + if errors.Is(err, oracle.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer o.Close() + s := load(t) + untyped := 0 + for _, c := range analyzeCases { + d, err := o.Describe(ctx, c.sql) + if err != nil { + t.Errorf("%s: the server rejects it: %v", c.sql, err) + continue + } + r, err := Analyze(s, c.sql) + if err != nil { + t.Errorf("%s: the analyzer rejects what the server accepts: %v", c.sql, err) + continue + } + if len(r.Columns) != len(d.Columns) { + t.Errorf("%s: %d columns, the server says %d", c.sql, len(r.Columns), len(d.Columns)) + continue + } + for i, col := range r.Columns { + oc := d.Columns[i] + if col.Name != oc.Name { + t.Errorf("%s: column %d named %q, the server says %q", c.sql, i+1, col.Name, oc.Name) + } + if !col.Known { + untyped++ + continue + } + if got, want := typeKey(col.Type), oracleKey(oc); got != want { + t.Errorf("%s: column %q is %s, the server says %s", c.sql, col.Name, got, want) + } + if col.Nullable != oc.Nullable { + t.Errorf("%s: column %q nullable=%v, the server says %v", c.sql, col.Name, col.Nullable, oc.Nullable) + } + } + } + for _, c := range errorCases { + _, err := o.Describe(ctx, c.sql) + var oe *oracle.Error + if !errors.As(err, &oe) { + t.Errorf("%s: the server accepts what the analyzer rejects with %d (%v)", c.sql, c.code, err) + continue + } + if oe.Number != c.code { + t.Errorf("%s: the analyzer says %d, the server %d (%s)", c.sql, c.code, oe.Number, oe.Message) + } + } + t.Logf("server %s: %d statements agree; %d columns the analyzer leaves untyped", o.Version, len(analyzeCases), untyped) +} + +// typeKey reduces a type to what the wire metadata can confirm and Go cares about: the +// type name and its signedness, with the character string types as one class and the +// binary string types as another (the analyzer does not compute lengths, which is what +// decides VARCHAR against TEXT on the wire; Go receives both the same way). +func typeKey(t schema.Type) string { + name := t.Name + switch name { + case "char", "varchar", "tinytext", "text", "mediumtext", "longtext", "enum", "set": + name = "string" + case "binary", "varbinary", "tinyblob", "blob", "mediumblob", "longblob": + name = "binary" + case "point", "linestring", "polygon", "multipoint", "multilinestring", "multipolygon", "geometrycollection", "geomcollection": + name = "geometry" // the wire says GEOMETRY for every spatial type + } + if t.Unsigned { + return name + " unsigned" + } + return name +} + +// oracleKey is the driver's DatabaseTypeName in typeKey's spelling. +func oracleKey(c oracle.Column) string { + name := strings.ToLower(c.Type) + unsigned := strings.HasPrefix(name, "unsigned ") + name = strings.TrimPrefix(name, "unsigned ") + switch name { + case "char", "varchar", "tinytext", "text", "mediumtext", "longtext", "enum", "set": + name = "string" + case "binary", "varbinary", "tinyblob", "blob", "mediumblob", "longblob": + name = "binary" + } + if unsigned { + return name + " unsigned" + } + return name +} diff --git a/check/mysql/internal/analyze/probe_test.go b/check/mysql/internal/analyze/probe_test.go new file mode 100644 index 0000000..c1c7816 --- /dev/null +++ b/check/mysql/internal/analyze/probe_test.go @@ -0,0 +1,199 @@ +package analyze + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/catalog" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/oracle" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" +) + +var probeUpdate = flag.Bool("probe-update", false, "rewrite testdata/probe.golden from the server's answers") + +// The probe schema: one column per representative type, so that every registry function +// can be called over every kind of argument. +const probeSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE t ( + i INT NOT NULL, + u BIGINT UNSIGNED NOT NULL, + d DECIMAL(10,2) NOT NULL, + f DOUBLE NOT NULL, + s VARCHAR(20) NOT NULL, + b VARBINARY(20) NOT NULL, + dt DATETIME(6) NOT NULL, + da DATE NOT NULL, + tm TIME NOT NULL, + j JSON, + n INT +); +` + +// probeArgs are the argument shapes tried for each arity: the same column of each type in +// every position, then a string first with integers after (SUBSTRING, LEFT, REPEAT ...), +// an integer first with strings after, and a NULL in the last position. +var probeArgs = [][]string{ + {"i"}, {"u"}, {"d"}, {"f"}, {"s"}, {"b"}, {"dt"}, {"da"}, {"tm"}, {"j"}, {"n"}, + {"s", "i", "i", "i"}, {"i", "s", "s", "s"}, {"s", "s", "NULL", "NULL"}, {"dt", "i", "i", "i"}, +} + +// TestProbe generates `SELECT f(args) FROM t` for every function of the native registry +// over the representative argument types, asks a real mysqld and the analyzer, and compares +// them against testdata/probe.golden: one line per statement, `=` when the analyzer agrees +// with the server on the type and nullability, `?` when it leaves the column untyped, `!` +// when it disagrees, `x` when the server rejects the call (the analyzer does not check +// argument types) and `X` when the analyzer rejects what the server accepts. The golden +// file is the record of what the rules cover; a change to it is a change to review. +// Skipped without a mysqld on PATH (nix-shell -p mysql84); -probe-update rewrites it. +func TestProbe(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + o, err := oracle.Start(ctx, probeSchema) + if errors.Is(err, oracle.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer o.Close() + s, err := schema.Load(probeSchema) + if err != nil { + t.Fatal(err) + } + + var lines []string + counts := map[byte]int{} + for i := range catalog.Functions { + f := &catalog.Functions[i] + if f.Internal { + continue + } + seen := map[string]bool{} + for _, shape := range probeArgs { + for n := max(f.Min, 0); n <= maxArity(f); n++ { + if !f.Accepts(n) { + continue + } + args := make([]string, n) + for i := range args { + args[i] = shape[min(i, len(shape)-1)] + } + sql := fmt.Sprintf("SELECT %s(%s) FROM t", f.Name, strings.Join(args, ", ")) + if seen[sql] { + continue + } + seen[sql] = true + mark, line := probeOne(ctx, o, s, sql) + counts[mark]++ + lines = append(lines, line) + } + } + } + sort.Strings(lines) + summary := fmt.Sprintf("# server %s: %d statements; agree %d, untyped %d, disagree %d, server rejects %d, analyzer rejects %d", + o.Version, len(lines), counts['='], counts['?'], counts['!'], counts['x'], counts['X']) + t.Log(summary) + got := summary + "\n" + strings.Join(lines, "\n") + "\n" + path := filepath.Join("testdata", "probe.golden") + if *probeUpdate { + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%v (run with -probe-update to create it)", err) + } + if string(want) != got { + wl, gl := strings.Split(string(want), "\n"), strings.Split(got, "\n") + shown := 0 + for i := 0; i < max(len(wl), len(gl)) && shown < 40; i++ { + var w, g string + if i < len(wl) { + w = wl[i] + } + if i < len(gl) { + g = gl[i] + } + if w != g { + t.Errorf("golden: %s\n now: %s", w, g) + shown++ + } + } + t.Errorf("testdata/probe.golden differs (rerun with -probe-update after reviewing)") + } +} + +func maxArity(f *catalog.Function) int { + if f.Max < 0 { + return max(f.Min, 1) + 1 + } + return min(f.Max, max(f.Min, 0)+2) +} + +// probeOne is one statement's line: the mark, the statement, what the analyzer says and +// what the server says. +func probeOne(ctx context.Context, o *oracle.Oracle, s *schema.Schema, sql string) (byte, string) { + d, oerr := o.Describe(ctx, sql) + r, aerr := Analyze(s, sql) + var oe *oracle.Error + switch { + case oerr != nil && errors.As(oerr, &oe): + if aerr != nil { + return 'x', fmt.Sprintf("x %s\t%v\t%d %s", sql, aerr, oe.Number, oe.Message) + } + return 'x', fmt.Sprintf("x %s\t%s\t%d %s", sql, ours(r), oe.Number, oe.Message) + case oerr != nil: + return 'x', fmt.Sprintf("x %s\t\t%v", sql, oerr) + case aerr != nil: + return 'X', fmt.Sprintf("X %s\t%v\t%s", sql, aerr, theirs(d)) + } + if len(r.Columns) != 1 || len(d.Columns) != 1 { + return '!', fmt.Sprintf("! %s\t%s\t%s", sql, ours(r), theirs(d)) + } + c, oc := r.Columns[0], d.Columns[0] + if !c.Known { + return '?', fmt.Sprintf("? %s\t?\t%s", sql, theirs(d)) + } + if typeKey(c.Type) != oracleKey(oc) || c.Nullable != oc.Nullable { + return '!', fmt.Sprintf("! %s\t%s\t%s", sql, ours(r), theirs(d)) + } + return '=', fmt.Sprintf("= %s\t%s", sql, ours(r)) +} + +func ours(r *Result) string { + var parts []string + for _, c := range r.Columns { + if !c.Known { + parts = append(parts, "?") + continue + } + k := typeKey(c.Type) + if c.Nullable { + k += " null" + } + parts = append(parts, k) + } + return strings.Join(parts, ", ") +} + +func theirs(d *oracle.Description) string { + var parts []string + for _, c := range d.Columns { + k := oracleKey(c) + if c.Nullable { + k += " null" + } + parts = append(parts, k) + } + return strings.Join(parts, ", ") +} diff --git a/check/mysql/internal/analyze/query.go b/check/mysql/internal/analyze/query.go new file mode 100644 index 0000000..f38bcfc --- /dev/null +++ b/check/mysql/internal/analyze/query.go @@ -0,0 +1,784 @@ +package analyze + +import ( + "fmt" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlast" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/facts" +) + +// queryExpression types a PT_query_expression (WITH, a body, ORDER BY, LIMIT) and returns +// its result columns. outer is the enclosing scope for a subquery, nil at the top. +func (a *analyzer) queryExpression(v mysqlast.Value, outer *scope) ([]Column, error) { + cols, _, err := a.queryExpressionFacts(v, outer) + return cols, err +} + +// queryExpressionFacts is queryExpression, also returning the block's facts when the body +// is one SELECT (nil for a set operation): what a derived table or a view contributes to +// the proof. +func (a *analyzer) queryExpressionFacts(v mysqlast.Value, outer *scope) ([]Column, *facts.Scope, error) { + cols, body, _, err := a.queryExpressionBlock(v, outer) + return cols, body, err +} + +// queryExpressionBlock is queryExpressionFacts with the block's scope too, when the +// expression is one SELECT (nil for a set operation): INSERT ... SELECT ... ON DUPLICATE +// KEY UPDATE resolves its assignments against the SELECT's tables as well as the target. +func (a *analyzer) queryExpressionBlock(v mysqlast.Value, outer *scope) ([]Column, *facts.Scope, *scope, error) { + qe, ok := v.(*mysqlast.Node) + if !ok || qe.Class != "PT_query_expression" { + return nil, nil, nil, fmt.Errorf("analyze: query expression not understood: %s", mysqlast.Sprint(v)) + } + ctes, err := a.with(qe.Arg("with_clause"), outer) + if err != nil { + return nil, nil, nil, err + } + sc := outer.derived() + sc.ctes = append(append([]relation{}, sc.ctes...), ctes...) + a.limitZero = limitIsZero(qe.Arg("limit")) // LIMIT 0: the select list is never evaluated (fold.go) + cols, block, body, err := a.bodyScope(qe.Arg("body"), sc) + a.limitZero = false + if err != nil { + return nil, nil, nil, err + } + // ORDER BY of the whole expression: against the result columns, and for a single + // SELECT also against its tables and its select list + if block != nil { + a.pushList(block, "order clause", cols, block.itemList) + } + err = a.orderBy(qe.Arg("order"), cols, block, "order clause") + if block != nil { + a.popList() + } + if err != nil { + return nil, nil, nil, err + } + if block != nil { + if err := a.orderCheck(block, qe.Arg("order")); err != nil { + return nil, nil, nil, err + } + } + if err := a.limit(qe.Arg("limit")); err != nil { + return nil, nil, nil, err + } + if body != nil { + if limitOne(qe.Arg("limit")) { + body.Single = true + } + body.Children = append(body.Children, cteBodies(ctes)...) + } + return cols, body, block, nil +} + +// subqueryFacts is subquery, with the block's facts (see queryExpressionFacts). +func (a *analyzer) subqueryFacts(v mysqlast.Value, outer *scope) ([]Column, *facts.Scope, error) { + n, ok := v.(*mysqlast.Node) + if !ok || n.Class != "PT_subquery" { + return nil, nil, fmt.Errorf("analyze: subquery not understood: %s", mysqlast.Sprint(v)) + } + a.depth++ + defer func() { a.depth-- }() + cols, body, err := a.queryExpressionFacts(n.Arg("query_expression"), outer) + if err == nil && body != nil { + if a.subFacts == nil { + a.subFacts = map[*mysqlast.Node]*facts.Scope{} + } + a.subFacts[n] = body + } + return cols, body, err +} + +// orderBy resolves the items of an ORDER BY / GROUP BY list the way the server does +// (find_order_in_list): an integer is a 1-based position in the select list; an +// unqualified name is looked up in the select list first (an alias, or the column an +// item is), then, for GROUP BY, a table column of the same name wins; what the select list +// does not have resolves against the tables of the block (block nil: a set operation, whose +// ORDER BY sees only the result columns). Any other expression is typed against the block. +func (a *analyzer) orderBy(v mysqlast.Value, cols []Column, block *scope, where string) error { + n, ok := v.(*mysqlast.Node) + if !ok { + return nil + } + list, _ := n.Arg("order_list").(mysqlast.List) + if list == nil { + list, _ = n.Arg("group_list").(mysqlast.List) + } + a.noFold++ // a constant item is dropped from the list, never evaluated (fold.go) + defer func() { a.noFold-- }() + for k, it := range list { + item := it + if oe, ok := it.(*mysqlast.Node); ok && oe.Class == "PT_order_expr" { + item = oe.Arg("item") + } + if err := a.orderItem(item, cols, block, where, k+1); err != nil { + return err + } + } + return nil +} + +func (a *analyzer) orderItem(item mysqlast.Value, cols []Column, block *scope, where string, num int) error { + n, ok := item.(*mysqlast.Node) + if !ok { + return nil + } + if block == nil && where == "order clause" && (isAggregateLike(n) || isWindowFunction(n) || containsAggregate(n)) { + return &Error{Message: fmt.Sprintf("Expression #%d of ORDER BY contains aggregate function and applies to a UNION, EXCEPT or INTERSECT", num), Code: 3028, Position: a.ph.Back(n.Start)} + } + if block != nil && where == "group statement" && !isColumnRef(n) && n.Class != "Item_int" && n.Class != "Item_uint" { + // an aggregate cannot be grouped on (measured): one that is a select item as + // written is 1056 with its text, an alias of one inside the expression is 1056 + // with the server's '???', any other is 1111 + if containsOwn(n, func(x *mysqlast.Node) bool { return x.Class == "Item_func_grouping" }) { + return &Error{Message: "Can't group on 'GROUPING function'", Code: 1056, Position: a.ph.Back(n.Start)} + } + if containsOwn(n, func(x *mysqlast.Node) bool { return isAggregate(x) }) { + for _, it := range block.itemList { + if ewa, ok := it.(*mysqlast.Node); ok && ewa.Class == "PTI_expr_with_alias" { + if e, ok := ewa.Arg("expr").(*mysqlast.Node); ok && strings.EqualFold(a.textOf(e), a.textOf(n)) { + return &Error{Message: fmt.Sprintf("Can't group on '%s'", a.textOf(n)), Code: 1056, Position: a.ph.Back(n.Start)} + } + } + } + return &Error{Message: "Invalid use of group function", Code: 1111, Position: a.ph.Back(n.Start)} + } + if a.namesAggregateAlias(*block, n) { + return &Error{Message: "Can't group on '???'", Code: 1056, Position: a.ph.Back(n.Start)} + } + } + switch n.Class { + case "Item_int", "Item_uint": + pos := intOr(n.Arg("i"), 0) + if n.Class == "Item_uint" { + pos = intOr(n.Arg("str"), 0) + } + if pos < 1 || pos > len(cols) { + return &Error{Message: fmt.Sprintf("Unknown column '%d' in '%s'", pos, where), Code: 1054, Position: a.ph.Back(n.Start)} + } + return nil + case "PTI_simple_ident_ident", "PTI_simple_ident_nospvar_ident": + name := str(n.Arg("ident")) + matches := 0 + var first *Column + for i := range cols { + if strings.EqualFold(cols[i].Name, name) { + if first != nil && first.base != nil && first.base == cols[i].base && first.leaf1 == cols[i].leaf1 { + continue // the same table column twice is one item (find_item_in_list) + } + matches++ + if first == nil { + first = &cols[i] + } + } + } + if block != nil && where == "group statement" { + // a table column of the same name shadows a select-list alias (with a warning): + // the lookup is of the tables alone, so not under the clause's own name + if _, err := a.lookup(*block, "", name, "group statement (tables)", n.Start); err == nil { + return nil + } + if matches == 1 && aliasOfAggregate(block.itemList, name) { + return &Error{Message: fmt.Sprintf("Can't group on '%s'", name), Code: 1056, Position: a.ph.Back(n.Start)} + } + } + if matches == 1 { + return nil + } + if matches > 1 { + return &Error{Message: fmt.Sprintf("Column '%s' in %s is ambiguous", name, where), Code: 1052, Position: a.ph.Back(n.Start)} + } + if block == nil { + return &Error{Message: fmt.Sprintf("Unknown column '%s' in '%s'", name, where), Code: 1054, Position: a.ph.Back(n.Start)} + } + _, err := a.expr(*block, n, where) + return err + } + if block == nil { + // a set operation's ORDER BY sees only the result columns: type the expression + // against them as a relation of their own + result := scope{rels: []relation{{cols: cols}}} + _, err := a.expr(result, n, where) + return err + } + _, err := a.expr(*block, n, where) + return err +} + +// subquery types a PT_subquery (a parenthesized query expression). +func (a *analyzer) subquery(v mysqlast.Value, outer *scope) ([]Column, error) { + n, ok := v.(*mysqlast.Node) + if !ok || n.Class != "PT_subquery" { + return nil, fmt.Errorf("analyze: subquery not understood: %s", mysqlast.Sprint(v)) + } + cols, body, err := a.subqueryFacts(n, outer) + outer.child(body) // a subquery of a condition or a select list is a nested block of the enclosing one + return cols, err +} + +// body types a query expression body: a single SELECT, a set operation over bodies, or a +// parenthesized query expression. sc is the scope the body's FROM clauses start from. +func (a *analyzer) body(v mysqlast.Value, sc scope) ([]Column, error) { + cols, _, _, err := a.bodyScope(v, sc) + return cols, err +} + +// bodyScope is body, also returning the block's scope when the body is one SELECT (nil +// for a set operation), for the ORDER BY at the expression level, and the body's facts: the +// block's, or a set operation's level (which may combine rows) with the arms under it. +func (a *analyzer) bodyScope(v mysqlast.Value, sc scope) ([]Column, *scope, *facts.Scope, error) { + n, ok := v.(*mysqlast.Node) + if !ok { + return nil, nil, nil, fmt.Errorf("analyze: query body not understood: %s", mysqlast.Sprint(v)) + } + switch n.Class { + case "PT_query_specification": + cols, block, err := a.querySpecification(n, sc) + if block == nil { + return cols, nil, nil, err + } + return cols, block, block.facts, err + case "PT_query_expression": + // a parenthesized body: `WITH cte AS (...) (SELECT * FROM cte)` nests a query + // expression here, and the enclosing WITH's CTEs stay visible in it (measured; + // sc.rels is still empty at this point, so passing sc only adds the CTEs) + cols, body, err := a.queryExpressionFacts(n, &sc) + return cols, nil, body, err + case "PT_union", "PT_except", "PT_intersect": + cols, fs, err := a.setOperation(n, sc) + return cols, nil, fs, err + } + return nil, nil, nil, fmt.Errorf("analyze: %s is not supported yet", n.Class) +} + +// querySpecification types one SELECT block and returns its columns and its scope. HAVING +// and GROUP BY see the select list's names as well as the tables' (a MySQL extension). +func (a *analyzer) querySpecification(body *mysqlast.Node, sc scope) ([]Column, *scope, error) { + sc.kids = new([]*facts.Scope) + sc.tag = new(byte) + sc, err := a.from(body.Arg("from_clause"), sc) + if err != nil { + return nil, nil, err + } + // the select list is resolved before the WHERE (setup_fields, then setup_conds): its + // errors come first. A constant select item runs per row over a FROM (its 1690 is a + // violation, fold.go), once without one (the statement's error); an EXISTS never + // evaluates its subquery's select list + perRow, off := a.foldPerRow, a.existsList || a.limitZero + a.existsList, a.limitZero = false, false + // a select item over a FROM runs per row; so does a subquery's without one when the + // subquery itself sits in a per-row position (measured: `SELECT (SELECT ) + // FROM t` over no rows runs, `WHERE (SELECT )` fails before any) + a.foldPerRow = len(sc.rels) > 0 || perRow + if len(sc.rels) == 0 { + // without a FROM, a constant false WHERE returns no row before the select list is + // evaluated (measured: `SELECT 9223372036854775807 + 1 FROM DUAL WHERE 1 = 0` runs) + w := body.Arg("opt_where_clause") + if wn, ok := w.(*mysqlast.Node); ok && wn.Class == "PTI_where" { + w = wn.Arg("expr") + } + if b, _, ok := a.constBool(w); ok && !b { + off = true + } + } + if off { + a.noFold++ + } + itemList, _ := body.Arg("item_list").(mysqlast.List) + a.pushList(&sc, "field list", nil, itemList) + cols, err := a.items(sc, body.Arg("item_list")) + a.popList() + if off { + a.noFold-- + } + a.foldPerRow = perRow + if err != nil { + return nil, nil, err + } + sc.items = cols + sc.itemList = itemList + if err := a.condition(sc, body.Arg("opt_where_clause"), "where clause"); err != nil { + return nil, nil, err + } + a.pushList(&sc, "group statement", cols, itemList) + err = a.orderBy(body.Arg("opt_group_clause"), cols, &sc, "group statement") + a.popList() + if err != nil { + return nil, nil, err + } + a.pushList(&sc, "having clause", cols, itemList) + err = a.condition(sc, body.Arg("opt_having_clause"), "having clause") + a.popList() + if err != nil { + return nil, nil, err + } + if q, ok := body.Arg("opt_qualify_clause").(*mysqlast.Node); ok { + // 8.4 accepts the clause only under the hypergraph optimizer, which is off + return nil, nil, &Error{Message: "'QUALIFY clause' can be used only if the hypergraph optimizer is enabled.", Code: 6037, Position: a.ph.Back(q.Start)} + } + sc.facts = a.block(&sc, body) + if err := a.groupCheck(&sc, body); err != nil { + return nil, nil, err + } + if sc.info != nil && sc.info.aggregated && !sc.info.explicit { + // an aggregate of the block in its select list or HAVING without GROUP BY groups + // the whole input into one row, whatever the aggregate sits inside (COUNT(*), + // COALESCE(SUM(total), 0), MAX(id) + 1 alike); an aggregate a subquery owns + // (its own columns only) does not count, as groupCheck's ownership says + sc.facts.Single = true + } + if sc.info != nil && sc.info.rollup { + // the super-aggregate rows of ROLLUP hold NULL in every non-aggregated column that + // reads the grouped columns (an Item_rollup_group_item wraps each occurrence of a + // GROUP BY expression; a constant item such as `1+1` is left alone and keeps its + // nullability, measured on mysqld 8.4 -- TestOracle, the corpus probe's olap file) + k := 0 + for _, it := range sc.itemList { + n, ok := it.(*mysqlast.Node) + if !ok { + continue + } + switch n.Class { + case "PTI_expr_with_alias": + if k < len(cols) && !isAggregateLike(exprNode(n.Arg("expr"))) && containsColumnRef(n.Arg("expr")) { + cols[k].Nullable = true + } + k++ + case "Item_asterisk": + for ; k < len(cols); k++ { + cols[k].Nullable = true + } + } + } + sc.items = cols + } + // the statement's own block (not a subquery's, not a set operation's arm) is what the + // contracts are judged on + if a.depth == 0 && a.setOp == 0 && a.facts != nil && a.facts.Kind == facts.Select && a.facts.Top == nil { + a.facts.Top = sc.facts + if sc.facts.Single { + a.facts.AtMostOne = true // an aggregate over the whole input is one row + } + } + return cols, &sc, nil +} + +// setOperation types UNION / EXCEPT / INTERSECT: the column names are the first +// operand's, each column's type is the operands' aggregated as the server does for a +// UNION (Item_type_holder over field_type_merge), and it is nullable when any operand's is. +func (a *analyzer) setOperation(n *mysqlast.Node, sc scope) ([]Column, *facts.Scope, error) { + // the level has no leaves of its own: its facts say a set operation may combine rows, + // and the arms are the blocks under it + fs := &facts.Scope{At: -1, Many: setOpName(n.Class) + " may combine rows"} + if a.depth == 0 && a.setOp == 0 && a.facts != nil && a.facts.Kind == facts.Select && a.facts.Top == nil { + a.facts.Top = fs + } + a.setOp++ + defer func() { a.setOp-- }() + list, _ := n.Arg("list").(mysqlast.List) + var out []Column + for i, arm := range list { + cols, _, armFacts, err := a.bodyScope(arm, sc) + if err != nil { + return nil, nil, err + } + if armFacts != nil { + fs.Children = append(fs.Children, armFacts) + } + if i == 0 { + out = cols + continue + } + if len(cols) != len(out) { + return nil, nil, &Error{Message: "The used SELECT statements have a different number of columns", Code: 1222, Position: a.ph.Back(n.Start)} + } + for j := range out { + out[j] = mergeColumn(out[j], cols[j]) + } + } + return out, fs, nil +} + +// setOpName spells a set operation's node class the way the statement does. +func setOpName(class string) string { + switch class { + case "PT_except": + return "EXCEPT" + case "PT_intersect": + return "INTERSECT" + } + return "UNION" +} + +// mergeColumn is the type of a set operation's column over two operands' columns. +func mergeColumn(x, y Column) Column { + if !x.Known || !y.Known { + return Column{Name: x.Name, Nullable: x.Nullable || y.Nullable} + } + t := aggregate([]typed{{typ: x.Type, known: true, nullable: x.Nullable}, {typ: y.Type, known: true, nullable: y.Nullable}}) + return Column{Name: x.Name, Type: t.typ, Known: t.known, Nullable: x.Nullable || y.Nullable} +} + +// with types a WITH clause and returns its common table expressions as relations, in +// order; a later one sees the earlier ones. A recursive CTE takes its columns from the +// arms of its UNION that do not refer to it (the anchor), which the server also requires +// to come first; the recursive arms are then checked with the CTE in scope. +func (a *analyzer) with(v mysqlast.Value, outer *scope) ([]relation, error) { + n, ok := v.(*mysqlast.Node) + if !ok { + return nil, nil + } + recursive := isTrue(n.Arg("r")) + list, _ := n.Arg("l").(mysqlast.List) + var ctes []relation + for _, c := range list { + cte, ok := c.(*mysqlast.Node) + if !ok || cte.Class != "PT_common_table_expr" || len(cte.Args) < 5 { + return nil, fmt.Errorf("analyze: common table expression not understood: %s", mysqlast.Sprint(c)) + } + name := str(cte.Args[0]) + names, _ := cte.Args[4].(mysqlast.List) + sc := outer.derived() + sc.ctes = append(append([]relation{}, sc.ctes...), ctes...) + var cols []Column + var body *facts.Scope + var merged bool + var err error + if recursive { + cols, err = a.recursiveCTE(name, names, cte.Args[3], sc) + } else { + sc.kids = new([]*facts.Scope) // the body's facts, to hang under the query that has the WITH + if cols, err = a.subquery(cte.Args[3], &sc); err == nil { + if len(*sc.kids) > 0 { + body = (*sc.kids)[0] + } + merged = mergeable(subqueryExpression(cte.Args[3])) + if !merged { + cols = materialized(cols, subqueryExpression(cte.Args[3])) + } + cols, err = renamed(cols, names, name, a.ph.Back(cte.Start)) + } + } + if err != nil { + return nil, err + } + ctes = append(ctes, relation{alias: name, cols: cols, body: body, cte: true, merged: merged}) + } + return ctes, nil +} + +// recursiveCTE types a recursive CTE's body: anchor arms first, without the CTE in +// scope; the recursive arms are checked with it in scope (under its declared column +// names) and do not change the types. Every column of a recursive CTE is nullable +// (Query_expression::prepare: "Always nullable, per SQL standard"). +func (a *analyzer) recursiveCTE(name string, names mysqlast.List, sub mysqlast.Value, sc scope) ([]Column, error) { + cols, err := a.recursiveCTEBody(name, names, sub, sc) + if err != nil { + return nil, err + } + for i := range cols { + cols[i].Nullable = true + } + return cols, nil +} + +func (a *analyzer) recursiveCTEBody(name string, names mysqlast.List, sub mysqlast.Value, sc scope) ([]Column, error) { + n, ok := sub.(*mysqlast.Node) + if !ok || n.Class != "PT_subquery" { + return nil, fmt.Errorf("analyze: subquery not understood: %s", mysqlast.Sprint(sub)) + } + qe, ok := n.Arg("query_expression").(*mysqlast.Node) + if !ok { + return nil, fmt.Errorf("analyze: query expression not understood: %s", mysqlast.Sprint(n.Arg("query_expression"))) + } + union, ok := qe.Arg("body").(*mysqlast.Node) + if !ok || union.Class != "PT_union" { + cols, err := a.subquery(sub, &sc) // no self-reference possible without a UNION + if err != nil { + return nil, err + } + return renamed(cols, names, name, a.ph.Back(n.Start)) + } + arms, _ := union.Arg("list").(mysqlast.List) + inner := sc.derived() + inner.outer = sc.outer + var out []Column + for i, arm := range arms { + if out == nil { + cols, err := a.body(arm, inner) + if err == nil { + if i > 0 { + if len(cols) != len(out) { + return nil, &Error{Message: "The used SELECT statements have a different number of columns", Code: 1222, Position: a.ph.Back(union.Start)} + } + } + if out, err = renamed(cols, names, name, a.ph.Back(n.Start)); err != nil { + return nil, err + } + continue + } + if e, isDB := err.(*Error); !isDB || e.Code != 1146 { + return nil, err + } + // the arm refers to a table that does not exist: itself, if it is the CTE + if i == 0 { + return nil, &Error{Message: fmt.Sprintf("Recursive Common Table Expression '%s' should contain a UNION", name), Code: 3573, Position: a.ph.Back(union.Start)} + } + } + // a recursive arm: the CTE, typed so far, is in scope + with := inner + with.ctes = append(append([]relation{}, inner.ctes...), relation{alias: name, cols: out}) + cols, err := a.body(arm, with) + if err != nil { + return nil, err + } + if len(cols) != len(out) { + return nil, &Error{Message: "The used SELECT statements have a different number of columns", Code: 1222, Position: a.ph.Back(union.Start)} + } + } + return out, nil +} + +// scalarSubquery types a subquery used as a value: its single column, NULL when the +// column can be or when the query may produce no row (Item_singlerow_subselect::resolve_type: +// a query block without tables, WHERE, HAVING or LIMIT is guaranteed one row, and a UNION +// with such an arm is never empty). +func (a *analyzer) scalarSubquery(v mysqlast.Value, sc scope, at int) (typed, error) { + cols, err := a.subquery(v, &sc) + if err != nil { + return unknown, err + } + if len(cols) != 1 { + return unknown, &Error{Message: "Operand should contain 1 column(s)", Code: 1241, Position: a.ph.Back(at)} + } + return typed{typ: cols[0].Type, known: cols[0].Known, nullable: cols[0].Nullable || possiblyEmpty(subqueryExpression(v))}, nil +} + +// subqueryExpression is the PT_query_expression under a PT_subquery. +func subqueryExpression(v mysqlast.Value) mysqlast.Value { + if n, ok := v.(*mysqlast.Node); ok && n.Class == "PT_subquery" { + return n.Arg("query_expression") + } + return v +} + +// possiblyEmpty reports whether a query expression may produce no row. +func possiblyEmpty(v mysqlast.Value) bool { + qe, ok := v.(*mysqlast.Node) + if !ok || qe.Class != "PT_query_expression" { + return true + } + if qe.Arg("limit") != nil { + return true + } + return !oneRow(qe.Arg("body")) +} + +// oneRow reports whether a query body is guaranteed a row: a SELECT without tables, +// WHERE or HAVING, or a UNION with such an arm (an INTERSECT / EXCEPT may always be empty). +func oneRow(v mysqlast.Value) bool { + n, ok := v.(*mysqlast.Node) + if !ok { + return false + } + switch n.Class { + case "PT_query_specification": + from, _ := n.Arg("from_clause").(mysqlast.List) + return len(from) == 0 && constTrue(whereExpr(n.Arg("opt_where_clause"))) && n.Arg("opt_having_clause") == nil + case "PT_query_expression": + return !possiblyEmpty(n) + case "PT_union": + list, _ := n.Arg("list").(mysqlast.List) + for _, arm := range list { + if oneRow(arm) { + return true + } + } + } + return false +} + +// mergeable is mysqlast.Mergeable: shared with the schema loader, which refuses WITH CHECK +// OPTION on a view the server would not merge (1368) the way the server does at CREATE. +func mergeable(v mysqlast.Value) bool { return mysqlast.Mergeable(v) } + +// containsClass is mysqlast.ContainsClass. +func containsClass(v mysqlast.Value, class string) bool { return mysqlast.ContainsClass(v, class) } + +// materialized retypes the columns of a query the server materializes into a temporary +// table. For a single SELECT the temporary table's fields come from its items +// (create_tmp_field_from_item): a column that is not a plain table column and has an +// integer result becomes an int when its display width is under 10 digits, a bigint +// otherwise; widths the analyzer does not compute leave the type as it is. A set +// operation's fields come from Item_type_holder over field_type_merge, which keeps the +// types the operands were merged to. +func materialized(cols []Column, qe mysqlast.Value) []Column { + single := singleBlock(qe) + out := make([]Column, len(cols)) + for i, c := range cols { + if single && c.base == nil && c.Known && kindOf(c.Type) == "INT_RESULT" && c.Type.Length > 0 && c.Type.Length < 10 && c.Type.Name != "year" { + c.Type = schema.Type{Name: "int", Unsigned: c.Type.Unsigned, Length: c.Type.Length, Dec: -1} + } + c.base = nil // the temporary table's column is nobody's + out[i] = c + } + return out +} + +// singleBlock reports whether a query expression is one SELECT (however parenthesized). +func singleBlock(v mysqlast.Value) bool { + n, ok := v.(*mysqlast.Node) + if !ok { + return false + } + switch n.Class { + case "PT_query_expression": + return singleBlock(n.Arg("body")) + case "PT_query_specification": + return true + } + return false +} + +// typeOfColumn is a result column as a typed value. +func typeOfColumn(c Column) typed { + return typed{typ: c.Type, known: c.Known, nullable: c.Nullable} +} + +// aliasOfAggregate reports a select alias whose expression is an aggregate: the server +// cannot group on it (ER_WRONG_GROUP_FIELD, "Can't group on 'c'"). +func aliasOfAggregate(items mysqlast.List, name string) bool { + for _, it := range items { + ewa, ok := it.(*mysqlast.Node) + if !ok || ewa.Class != "PTI_expr_with_alias" || !strings.EqualFold(str(ewa.Arg("alias")), name) { + continue + } + return containsAggregate(ewa.Arg("expr")) + } + return false +} + +// containsOwn reports a node pred accepts anywhere in v outside any subquery (whose +// aggregates are its own). +func containsOwn(v mysqlast.Value, pred func(*mysqlast.Node) bool) bool { + switch x := v.(type) { + case mysqlast.List: + for _, e := range x { + if containsOwn(e, pred) { + return true + } + } + case *mysqlast.Node: + if x.Class == "PT_subquery" { + return false + } + if pred(x) { + return true + } + for _, arg := range x.Args { + if containsOwn(arg, pred) { + return true + } + } + } + return false +} + +// namesAggregateAlias reports an unqualified name in v (outside any subquery) that is no +// table column of the block but the alias of an aggregate select item. +func (a *analyzer) namesAggregateAlias(block scope, v mysqlast.Value) bool { + switch x := v.(type) { + case mysqlast.List: + for _, e := range x { + if a.namesAggregateAlias(block, e) { + return true + } + } + case *mysqlast.Node: + if x.Class == "PT_subquery" { + return false + } + if x.Class == "PTI_simple_ident_ident" || x.Class == "PTI_simple_ident_nospvar_ident" { + name := str(x.Arg("ident")) + if _, err := a.lookup(block, "", name, "group statement (tables)", x.Start); err != nil && aliasOfAggregate(block.itemList, name) { + return true + } + return false + } + for _, arg := range x.Args { + if a.namesAggregateAlias(block, arg) { + return true + } + } + } + return false +} + +// aliasOfWindow reports a select alias whose expression carries a window function: an +// outer reference to it is 3594. +func aliasOfWindow(items mysqlast.List, name string) bool { + for _, it := range items { + ewa, ok := it.(*mysqlast.Node) + if !ok || ewa.Class != "PTI_expr_with_alias" || !strings.EqualFold(str(ewa.Arg("alias")), name) { + continue + } + return containsWindow(ewa.Arg("expr")) + } + return false +} + +// containsWindow reports a window function anywhere in v. +func containsWindow(v mysqlast.Value) bool { + switch x := v.(type) { + case *mysqlast.Node: + if isWindowFunction(x) { + return true + } + for _, arg := range x.Args { + if containsWindow(arg) { + return true + } + } + case mysqlast.List: + for _, e := range x { + if containsWindow(e) { + return true + } + } + } + return false +} + +// exprNode is v as a node (an empty node otherwise). +func exprNode(v mysqlast.Value) *mysqlast.Node { + if n, ok := v.(*mysqlast.Node); ok { + return n + } + return &mysqlast.Node{} +} + +// containsColumnRef reports a column reference (a bare or qualified identifier, or a +// `*`) anywhere under v, outside of aggregates or not. +func containsColumnRef(v mysqlast.Value) bool { + switch x := v.(type) { + case *mysqlast.Node: + switch x.Class { + case "PTI_simple_ident_ident", "PTI_simple_ident_nospvar_ident", "PTI_simple_ident_q_2d", "PTI_simple_ident_q_3d", "Item_asterisk": + return true + } + for _, arg := range x.Args { + if containsColumnRef(arg) { + return true + } + } + case mysqlast.List: + for _, e := range x { + if containsColumnRef(e) { + return true + } + } + } + return false +} diff --git a/check/mysql/internal/analyze/resolve_server_test.go b/check/mysql/internal/analyze/resolve_server_test.go new file mode 100644 index 0000000..f995c02 --- /dev/null +++ b/check/mysql/internal/analyze/resolve_server_test.go @@ -0,0 +1,249 @@ +package analyze + +import ( + "context" + "errors" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// rowidSchema declares the key shapes `_rowid` resolves against (relation.rowid): the +// first key after the server's sort must be unique over one NOT NULL integer column. +const rowidSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE r1 (a INT, b VARCHAR(10), UNIQUE (b)); +CREATE TABLE r2 (a INT, b INT, UNIQUE (a, b)); +CREATE TABLE r3 (a INT NULL, UNIQUE (a)); +CREATE TABLE r4 (a INT, b BIGINT NOT NULL, c VARCHAR(5) NOT NULL, UNIQUE (c), UNIQUE (b)); +CREATE TABLE r5 (a INT, b DECIMAL(5) NOT NULL, UNIQUE (b)); +CREATE TABLE r6 (a INT, b YEAR NOT NULL, UNIQUE (b)); +CREATE TABLE r7 (a INT, b BIGINT UNSIGNED NOT NULL, c INT NOT NULL, PRIMARY KEY (c), UNIQUE (b)); +CREATE TABLE r8 (a INT, b VARCHAR(5) NOT NULL, c INT NOT NULL, PRIMARY KEY (b), UNIQUE (c)); +CREATE TABLE r9 (a INT, b INT NOT NULL, c INT NOT NULL, KEY (b), UNIQUE (c)); +CREATE TABLE r10 (a INT, b INT, c INT NOT NULL, UNIQUE (b), UNIQUE (c)); +CREATE TABLE r11 (a INT, b BIT(8) NOT NULL, UNIQUE (b)); +CREATE TABLE r12 (a INT, b INT NOT NULL, UNIQUE (b), UNIQUE (a, b)); +CREATE TABLE r14 (a VARCHAR(5) NOT NULL, b INT NOT NULL, PRIMARY KEY (a, b), UNIQUE (b)); +CREATE TABLE r15 (a INT, b INT NOT NULL, c INT, UNIQUE (c), UNIQUE (b)); +CREATE TABLE r17 (a INT, b TINYINT NOT NULL, UNIQUE (b)); +CREATE TABLE r18 (a INT, b BOOLEAN NOT NULL, UNIQUE (b)); +CREATE TABLE r19 (a INT, b MEDIUMINT NOT NULL, UNIQUE (b)); +CREATE TABLE r20 (a INT, b DOUBLE NOT NULL, UNIQUE (b)); +CREATE TABLE r21 (a INT, b DATE NOT NULL, UNIQUE (b)); +CREATE TABLE r22 (a INT, b ENUM('x') NOT NULL, UNIQUE (b)); +CREATE TABLE r24 (a INT, b INT NOT NULL, UNIQUE ((b + 1))); +CREATE TABLE r25 (a INT, b INT NOT NULL, c INT NOT NULL, UNIQUE (b), UNIQUE (c), PRIMARY KEY (a)); +CREATE TABLE r26 (a INT, b INT GENERATED ALWAYS AS (a + 1) STORED NOT NULL, UNIQUE (b)); +CREATE TABLE r27 (a INT, b INT NOT NULL INVISIBLE, UNIQUE (b)); +CREATE TABLE r29 (a INT, b VARCHAR(5) NOT NULL, KEY (b), c INT NOT NULL, UNIQUE (c)); +CREATE TABLE r30 (a INT, b VARCHAR(5), UNIQUE (b), c INT NOT NULL, UNIQUE (c)); +CREATE TABLE users (id BIGINT UNSIGNED NOT NULL PRIMARY KEY, name VARCHAR(100) NOT NULL); +CREATE TABLE orders (id BIGINT UNSIGNED NOT NULL PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, note TEXT, total DECIMAL(10,2) NOT NULL); +CREATE VIEW v_users AS SELECT id, name FROM users; +` + +// resolveCases are name resolutions pinned against mysqld: want is the server's error +// number, 0 when the statement runs. They cover the select list's aliases seen through a +// nested query (lookup / outerItem), the same-level GROUP BY / ORDER BY / HAVING +// expressions, HAVING's own tables (havingWalk), `_rowid`, and what ON DUPLICATE KEY +// UPDATE sees. +var resolveCases = []struct { + sql string + want int +}{ + // aliases in the block's own ORDER BY / GROUP BY / HAVING expressions + {"SELECT COUNT(*) AS c FROM users ORDER BY c+1", 0}, + {"SELECT name COLLATE utf8mb4_bin AS n2 FROM users ORDER BY n2, hex(n2)", 0}, + {"SELECT 'a' AS f1 FROM users WHERE id='8' GROUP BY f1 ORDER BY CONCAT(f1)", 0}, + {"SELECT id AS name FROM users ORDER BY name + 1", 0}, + {"SELECT id AS name FROM users HAVING name + 1", 0}, + {"SELECT COUNT(*) AS name FROM users GROUP BY name + 1", 0}, + {"SELECT id + 1 AS c FROM users GROUP BY c", 0}, + {"SELECT id AS c FROM users WHERE c+1", 1054}, + {"SELECT name c, c FROM users", 1054}, + {"SELECT name AS c, name AS c FROM users ORDER BY c + 1", 0}, + {"SELECT name AS c, name AS c FROM users ORDER BY c", 0}, + {"SELECT name c, name c FROM users HAVING c", 0}, + {"SELECT name AS c, id AS c FROM users HAVING c + 1", 1052}, + {"SELECT name c, id c FROM users ORDER BY c + 1", 1052}, + {"SELECT name c, id c FROM users ORDER BY c", 1052}, + {"SELECT name c, id c FROM users GROUP BY c + 1", 1052}, + {"SELECT name c, id c FROM users GROUP BY c", 1052}, + // an aggregate cannot be grouped on + {"SELECT 1 FROM users GROUP BY SUM(id)", 1111}, + {"SELECT COUNT(*) FROM users GROUP BY COUNT(*)", 1056}, + {"SELECT SUM(id) s FROM users GROUP BY s+1", 1056}, + {"SELECT SUM(id) s FROM users GROUP BY s", 1056}, + {"SELECT name FROM users GROUP BY (SELECT 1 FROM orders GROUP BY COUNT(*) LIMIT 1)", 1111}, + // VALUES() outside ON DUPLICATE KEY UPDATE is NULL + {"SELECT VALUES(id) FROM users", 0}, + // aliases seen from a nested query: by the enclosing clause it sits in + {"SELECT name c, (SELECT id FROM orders WHERE note = c) FROM users", 0}, + {"SELECT name c FROM users WHERE (SELECT id FROM orders WHERE note = c)", 1054}, + {"SELECT name c FROM users HAVING (SELECT id FROM orders WHERE note = c)", 0}, + {"SELECT name c FROM users ORDER BY (SELECT id FROM orders WHERE note = c)", 0}, + {"SELECT name c FROM users JOIN orders ON (SELECT id FROM orders WHERE note = c)", 1054}, + {"SELECT name c FROM users WHERE id IN (SELECT id FROM orders HAVING c)", 1054}, + {"SELECT name c FROM users WHERE id IN (SELECT id FROM orders ORDER BY c)", 1054}, + {"SELECT name c FROM users JOIN (SELECT c) d", 1054}, + {"SELECT name c FROM users GROUP BY c HAVING (SELECT 1 HAVING c)", 0}, + {"SELECT name c, (SELECT 1 FROM orders JOIN users ON note = c) FROM users", 0}, + {"SELECT name c, (SELECT (SELECT 1 FROM orders WHERE note = c)) FROM users", 0}, + {"SELECT name c, (SELECT 1 FROM (SELECT c) d) FROM users", 0}, + {"SELECT 1 AS a, (SELECT a UNION SELECT a)", 0}, + {"SELECT 1 AS c1, (SELECT COUNT(*) FROM orders HAVING c1 > 0) FROM DUAL", 0}, + {"SELECT 1 AS c, (SELECT c)", 0}, + {"SELECT id AS c, (SELECT 1 FROM orders GROUP BY c) FROM users", 0}, + {"SELECT id AS c FROM users ORDER BY (SELECT 1 FROM orders ORDER BY c)", 0}, + // a later alias is a forward reference + {"SELECT (SELECT 1 FROM orders WHERE note = c), name c FROM users", 1247}, + {"SELECT (SELECT c), name c FROM users", 1247}, + {"SELECT (SELECT c), 1 AS c", 1247}, + {"SELECT (SELECT 1 FROM orders GROUP BY c), id AS c FROM users", 1247}, + // an aggregate's alias: only from the nested query's HAVING, never from a GROUP BY + {"SELECT COUNT(*) AS c, (SELECT 1 FROM orders HAVING c) FROM users", 0}, + {"SELECT SUM(id) AS s, (SELECT 1 HAVING s) FROM users", 0}, + {"SELECT COUNT(*) AS c FROM users HAVING (SELECT 1 FROM orders HAVING c)", 0}, + {"SELECT COUNT(*) AS c FROM users ORDER BY (SELECT 1 FROM orders HAVING c)", 0}, + {"SELECT COUNT(*) AS c FROM users ORDER BY (SELECT 1 HAVING c)", 0}, + {"SELECT COUNT(*) AS c, (SELECT c) FROM users", 1247}, + {"SELECT COUNT(*) AS c, (SELECT 1 FROM orders WHERE c) FROM users", 1247}, + {"SELECT COUNT(*) AS c, (SELECT 1 FROM orders ORDER BY c) FROM users", 1247}, + {"SELECT COUNT(*) AS c FROM users GROUP BY (SELECT 1 FROM orders HAVING c)", 1247}, + {"SELECT COUNT(*) AS c FROM users GROUP BY (SELECT c)", 1247}, + {"SELECT COUNT(*) AS c FROM users HAVING (SELECT c)", 1247}, + {"SELECT COUNT(*) AS c FROM users ORDER BY (SELECT 1 FROM orders GROUP BY c)", 1247}, + {"SELECT SUM(id) + 1 AS s FROM users GROUP BY (SELECT 1 HAVING s)", 1247}, + {"SELECT (SELECT c), COUNT(*) AS c FROM users", 1247}, + {"SELECT COUNT(*) AS c FROM users WHERE (SELECT c)", 1054}, + // HAVING never consults the block's own tables, but an enclosing block's + {"SELECT name FROM users HAVING id > 0", 1054}, + {"SELECT COUNT(*) FROM users HAVING name = 'x'", 1054}, + {"SELECT COUNT(*) FROM users GROUP BY name HAVING name = 'x'", 0}, + {"SELECT COUNT(*) FROM users HAVING MAX(id) > 1", 0}, + {"SELECT 1 FROM users WHERE EXISTS (SELECT 1 FROM orders HAVING id)", 0}, + {"SELECT 1 FROM users WHERE EXISTS (SELECT 1 FROM orders HAVING note)", 1054}, + {"SELECT 1 FROM users u WHERE EXISTS (SELECT 1 FROM orders HAVING u.id)", 0}, + {"SELECT 1 FROM users u WHERE EXISTS (SELECT 1 FROM orders o HAVING o.note)", 1054}, + {"SELECT 1 FROM users u WHERE EXISTS (SELECT 1 FROM orders o GROUP BY note HAVING o.note)", 0}, + {"SELECT 1 FROM users WHERE EXISTS (SELECT 1 FROM orders HAVING (id / -7777777777) IN ('a'))", 0}, + {"SELECT (SELECT 1 FROM orders HAVING name) FROM users", 0}, + // _rowid + {"SELECT _rowid FROM r1", 1054}, + {"SELECT _rowid FROM r2", 1054}, + {"SELECT _rowid FROM r3", 1054}, + {"SELECT _rowid FROM r4", 1054}, + {"SELECT _rowid FROM r5", 1054}, + {"SELECT _rowid FROM r6", 0}, + {"SELECT _rowid FROM r7", 0}, + {"SELECT _rowid FROM r8", 1054}, + {"SELECT _rowid FROM r9", 0}, + {"SELECT _rowid FROM r10", 0}, + {"SELECT _rowid FROM r11", 0}, + {"SELECT _rowid FROM r12", 0}, + {"SELECT _rowid FROM r14", 1054}, + {"SELECT _rowid FROM r15", 0}, + {"SELECT _rowid FROM r17", 0}, + {"SELECT _rowid FROM r18", 0}, + {"SELECT _rowid FROM r19", 0}, + {"SELECT _rowid FROM r20", 1054}, + {"SELECT _rowid FROM r21", 1054}, + {"SELECT _rowid FROM r22", 1054}, + {"SELECT _rowid FROM r24", 1054}, + {"SELECT _rowid FROM r25", 0}, + {"SELECT _rowid FROM r26", 0}, + {"SELECT _rowid FROM r27", 0}, + {"SELECT _rowid FROM r29", 0}, + {"SELECT _rowid FROM r30", 0}, + {"SELECT _ROWID FROM users", 0}, + {"SELECT _rowid, users._rowid FROM users", 0}, + {"SELECT u._rowid FROM users u", 0}, + {"SELECT _rowid FROM users, orders", 1054}, + {"SELECT _rowid FROM users JOIN orders USING (id)", 1054}, + {"SELECT u._rowid, o._rowid FROM users u LEFT JOIN orders o ON 1 = 0", 0}, + {"SELECT _rowid FROM (SELECT id FROM users) d", 1054}, + {"SELECT _rowid FROM v_users", 1054}, + {"SELECT * FROM users ORDER BY _rowid", 0}, + {"SELECT name FROM users WHERE _rowid = 1", 0}, + {"SELECT name FROM users GROUP BY _rowid", 0}, + {"SELECT (SELECT _rowid) FROM users", 0}, + {"SELECT (SELECT _rowid FROM orders, users LIMIT 1) FROM users", 0}, + {"UPDATE users SET _rowid = 1", 0}, + {"UPDATE users SET name = 'y' WHERE _rowid = 7", 0}, + {"DELETE FROM users WHERE _rowid = 7", 0}, + {"INSERT INTO users (_rowid, name) VALUES (5, 'x')", 0}, + // ON DUPLICATE KEY UPDATE sees the SELECT's tables + {"INSERT INTO users (id, name) SELECT id, name FROM users u ON DUPLICATE KEY UPDATE name = u.name", 0}, + {"INSERT INTO users (id, name) SELECT id, name FROM users u ON DUPLICATE KEY UPDATE users.name = u.name", 0}, + {"INSERT INTO users (id, name) SELECT id, name FROM users u ON DUPLICATE KEY UPDATE name = nosuch.name", 1054}, + {"INSERT INTO users (id, name) SELECT o.id, 'x' FROM orders o ON DUPLICATE KEY UPDATE name = note", 0}, + {"INSERT INTO users (id, name) SELECT o.id, 'x' FROM orders o ON DUPLICATE KEY UPDATE name = o.total", 0}, + {"INSERT INTO users (id, name) SELECT o.id, 'x' FROM (SELECT id FROM orders) o ON DUPLICATE KEY UPDATE name = o.id", 0}, + {"INSERT INTO users (id, name) SELECT o.id, 'x' FROM orders o ON DUPLICATE KEY UPDATE name = (SELECT total FROM orders WHERE id = o.id)", 0}, + {"INSERT INTO users (id, name) SELECT u.id, u.name FROM users u ON DUPLICATE KEY UPDATE name = id", 1052}, + {"INSERT INTO users (id, name) SELECT o.id, 'x' AS nm FROM orders o ON DUPLICATE KEY UPDATE name = nm", 1054}, +} + +func TestNameResolutionServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, rowidSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + for _, c := range resolveCases { + _, err := conn.ExecContext(ctx, c.sql) + got := 0 + if err != nil { + var me *driver.MySQLError + if !errors.As(err, &me) { + t.Errorf("%s: %v", c.sql, err) + continue + } + got = int(me.Number) + } + if got != c.want { + t.Errorf("%s: the server says %d (%v), the checker %d", c.sql, got, err, c.want) + } + } +} + +// TestNameResolution is the checker's side of resolveCases. +func TestNameResolution(t *testing.T) { + s, err := schema.Load(rowidSchema) + if err != nil { + t.Fatal(err) + } + for _, c := range resolveCases { + res, err := Analyze(s, c.sql) + got := 0 + if err != nil { + e, ok := err.(*Error) + if !ok { + t.Errorf("%s: %v", c.sql, err) + continue + } + got = e.Code + } + if got != c.want { + t.Errorf("%s: got %d (%v), want %d", c.sql, got, err, c.want) + } + if err == nil && c.sql == "SELECT VALUES(id) FROM users" && (len(res.Columns) != 1 || res.Columns[0].Type.Name != "null") { + t.Errorf("VALUES(id) outside INSERT: %+v, want the null type", res.Columns) + } + if err == nil && c.sql == "SELECT _rowid, users._rowid FROM users" { + if len(res.Columns) != 2 || res.Columns[0].Name != "_rowid" || res.Columns[0].Type.Name != "bigint" || !res.Columns[0].Type.Unsigned || res.Columns[0].Nullable { + t.Errorf("_rowid columns: %+v", res.Columns) + } + } + } +} diff --git a/check/mysql/internal/analyze/settings_test.go b/check/mysql/internal/analyze/settings_test.go new file mode 100644 index 0000000..9a56cbc --- /dev/null +++ b/check/mysql/internal/analyze/settings_test.go @@ -0,0 +1,314 @@ +package analyze + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/oracle" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" + "github.com/kr9ly/sqlshape/v2/x/sqlmode" +) + +// settings_test.go: the analyzer follows the server settings the schema declares +// (`-- sqlshape: server ...`). The unit tests state the rules; TestSettingsServer starts a +// mysqld with the same declarations (mysqltest passes them through) and confirms every +// rule against it. + +const settingsTables = ` +CREATE TABLE a (id INT NOT NULL PRIMARY KEY, n INT NULL, u INT UNSIGNED NOT NULL, s VARCHAR(20) NOT NULL, r REAL NULL); +CREATE TABLE b (s VARCHAR(20) NULL); +` + +var settingsSchemas = map[string]string{ + "default": "-- sqlshape: mysql 8.4\n" + settingsTables, + "empty": "-- sqlshape: mysql 8.4\n-- sqlshape: server sql_mode = ''\n" + settingsTables, + "ansi": "-- sqlshape: mysql 8.4\n-- sqlshape: server sql_mode = 'ANSI'\n" + settingsTables, + "nounsub": "-- sqlshape: mysql 8.4\n-- sqlshape: server sql_mode = 'NO_UNSIGNED_SUBTRACTION,STRICT_ALL_TABLES'\n" + settingsTables, +} + +// settingsCase is one statement judged under one declared mode: the result column's type +// and nullability (as typeKey / oracleKey reduce them) when it is accepted, or the error +// number when it is rejected. +type settingsCase struct { + schema string + sql string + typ string // "" for an error case + null bool + code int +} + +var settingsCases = []settingsCase{ + // ONLY_FULL_GROUP_BY: on by default and under ANSI, off under '' + {"default", "SELECT s FROM a GROUP BY n", "", false, 1055}, + {"ansi", "SELECT s FROM a GROUP BY n", "", false, 1055}, + {"empty", "SELECT s FROM a GROUP BY n", "string", false, 0}, + {"empty", "SELECT DISTINCT s FROM a ORDER BY n", "string", false, 0}, + {"default", "SELECT DISTINCT s FROM a ORDER BY n", "", false, 3065}, + // the rules the server applies in every mode stay + {"empty", "SELECT s FROM a HAVING n > 1", "", false, 1054}, + {"empty", "SELECT s FROM a ORDER BY COUNT(*)", "", false, 3029}, + // PIPES_AS_CONCAT: `||` is OR (a bigint(1)) without it, CONCAT under ANSI + {"default", "SELECT 'a' || 'b' FROM a", "bigint", false, 0}, + {"ansi", "SELECT 'a' || 'b' FROM a", "string", false, 0}, + {"ansi", "SELECT s || s FROM a", "string", false, 0}, + // ANSI_QUOTES: "s" is a column under ANSI, a string literal otherwise + {"ansi", `SELECT "s" FROM b`, "string", true, 0}, + {"default", `SELECT "s" FROM b`, "string", false, 0}, + {"ansi", `SELECT "nope" FROM b`, "", false, 1054}, + // strict mode: Item_str_func is nullable only in strict mode (the default; ANSI is not strict) + {"default", "SELECT CONCAT(s, 'x') FROM a", "string", true, 0}, + {"empty", "SELECT CONCAT(s, 'x') FROM a", "string", false, 0}, + {"ansi", "SELECT CONCAT(s, 'x') FROM a", "string", false, 0}, + {"ansi", "SELECT CONCAT(s, 'x') FROM b", "string", true, 0}, + // REAL_AS_FLOAT: the column r is FLOAT under ANSI, DOUBLE otherwise + {"default", "SELECT r FROM a", "double", true, 0}, + {"ansi", "SELECT r FROM a", "float", true, 0}, + // NO_UNSIGNED_SUBTRACTION: an unsigned difference is signed + {"default", "SELECT u - 1 FROM a", "bigint unsigned", false, 0}, + {"nounsub", "SELECT u - 1 FROM a", "bigint", false, 0}, + {"nounsub", "SELECT u + 1 FROM a", "bigint unsigned", false, 0}, +} + +func loadSettings(t *testing.T, name string) *schema.Schema { + t.Helper() + s, err := schema.Load(settingsSchemas[name]) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("%s: schema problems: %v", name, s.Problems) + } + return s +} + +func TestSettingsRules(t *testing.T) { + for _, c := range settingsCases { + s := loadSettings(t, c.schema) + r, err := Analyze(s, c.sql) + if c.code != 0 { + var ae *Error + if !errors.As(err, &ae) || ae.Code != c.code { + t.Errorf("[%s] %s: got %v, want error %d", c.schema, c.sql, err, c.code) + } + continue + } + if err != nil { + t.Errorf("[%s] %s: %v", c.schema, c.sql, err) + continue + } + if len(r.Columns) != 1 || !r.Columns[0].Known { + t.Errorf("[%s] %s: columns %+v", c.schema, c.sql, r.Columns) + continue + } + if got := typeKey(r.Columns[0].Type); got != c.typ || r.Columns[0].Nullable != c.null { + t.Errorf("[%s] %s: %s nullable=%v, want %s nullable=%v", c.schema, c.sql, got, r.Columns[0].Nullable, c.typ, c.null) + } + } +} + +// Without strict mode a NULL for a NOT NULL column is an error (1048) only in a single-row +// INSERT / REPLACE, its ON DUPLICATE KEY UPDATE included; more rows, the query form and an +// UPDATE store the implicit default with a warning. +var notNullCases = []struct { + sql string + strict, lax bool // whether a.s (NOT NULL) is listed as a possible violation +}{ + {"INSERT INTO a (id, u, s) VALUES (1, 1, $1)", true, true}, + {"REPLACE INTO a (id, u, s) VALUES (1, 1, $1)", true, true}, + {"INSERT INTO a (id, u, s) VALUES (1, 1, 'x') ON DUPLICATE KEY UPDATE s = $1", true, true}, + {"INSERT INTO a (id, u, s) VALUES (1, 1, $1), (2, 1, 'y')", true, false}, + {"INSERT INTO a (id, u, s) SELECT 3, 1, s FROM b", true, false}, + {"UPDATE a SET s = $1 WHERE id = 1", true, false}, +} + +func TestSettingsNotNull(t *testing.T) { + for _, c := range notNullCases { + for name, want := range map[string]bool{"default": c.strict, "empty": c.lax} { + r, err := Analyze(loadSettings(t, name), c.sql) + if err != nil { + t.Fatalf("[%s] %s: %v", name, c.sql, err) + } + got := false + for _, v := range r.Violations { + if v.Code == 1048 && v.Key() == "a.s" { + got = true + } + } + if got != want { + t.Errorf("[%s] %s: NOT NULL violation listed = %v, want %v", name, c.sql, got, want) + } + } + } +} + +// lower_case_table_names: 0 distinguishes, 1 lower-cases, 2 compares without case. +func TestSettingsLowerCaseTableNames(t *testing.T) { + const tables = "CREATE TABLE Users (id INT PRIMARY KEY);\nCREATE VIEW ActiveUsers AS SELECT id FROM Users;\n" + for lctn, want := range map[string]string{"0": "Users", "1": "users", "2": "Users"} { + s, err := schema.Load("-- sqlshape: mysql 8.4\n-- sqlshape: server lower_case_table_names = " + lctn + "\n" + tables) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("lctn=%s: %v", lctn, s.Problems) + } + if s.Tables[0].Name != want || s.Table(want) == nil { + t.Errorf("lctn=%s: table named %q, want %q", lctn, s.Tables[0].Name, want) + } + for _, sql := range []string{"SELECT id FROM USERS", "SELECT id FROM activeusers"} { + r, err := Analyze(s, sql) + if lctn == "0" { + var ae *Error + if !errors.As(err, &ae) || ae.Code != 1146 { + t.Errorf("lctn=0: %s: got %v, want 1146", sql, err) + } + continue + } + if err != nil { + t.Errorf("lctn=%s: %s: %v", lctn, sql, err) + continue + } + if got := r.Facts.Top.Leaves[0].Table; got != strings.ToLower(want) && got != want && got != "ActiveUsers" && got != "activeusers" { + t.Errorf("lctn=%s: %s: leaf table %q", lctn, sql, got) + } + } + if lctn == "1" { + if r, err := Analyze(s, "SELECT id FROM ACTIVEUSERS"); err != nil || r.Facts.Top.Leaves[0].Table != "activeusers" { + t.Errorf("lctn=1: the view is named lower-cased: %v %+v", err, r) + } + } + } +} + +func TestSettingsProblems(t *testing.T) { + cases := []struct{ line, want string }{ + {"-- sqlshape: server sql_mode = 'STRICT'", `"STRICT" is not a mode of MySQL 8.4`}, + {"-- sqlshape: server lower_case_table_names = 3", "want 0, 1 or 2"}, + {"-- sqlshape: server max_allowed_packet = 64M", "not a variable sqlshape reads for MySQL (sql_mode, lower_case_table_names, max_sp_recursion_depth)"}, + } + for _, c := range cases { + s, err := schema.Load("-- sqlshape: mysql 8.4\n" + c.line + "\nCREATE TABLE t (a INT);") + if err != nil { + t.Fatal(err) + } + if len(s.Problems) != 1 || !strings.Contains(s.Problems[0].Message, c.want) || s.Problems[0].Position != 23 { + t.Errorf("%s: problems %v, want one at 23 saying %q", c.line, s.Problems, c.want) + } + if len(s.Tables) != 1 || len(s.Tables[0].Directives) != 0 { + t.Errorf("%s: the setting is not the table's directive: %+v", c.line, s.Tables) + } + } + if _, err := schema.Load("-- sqlshape: mysql 8.4\n-- sqlshape: server sql_mode\n"); err == nil || !strings.Contains(err.Error(), "want ` = `") { + t.Errorf("malformed: %v", err) + } + s, _ := schema.Load(settingsSchemas["ansi"]) + if !s.Settings.SQLMode.Has(sqlmode.ANSI|sqlmode.ANSIQuotes|sqlmode.OnlyFullGroupBy) || s.Settings.Strict() { + t.Errorf("ansi: %v", s.Settings.SQLMode) + } + if s, _ := schema.Load(settingsSchemas["default"]); s.Settings.SQLMode != sqlmode.Default || !s.Settings.Strict() { + t.Errorf("default: %v", s.Settings.SQLMode) + } +} + +// TestSettingsServer confirms every case above against a mysqld started with the same +// declarations. Skipped without a mysqld on PATH (nix-shell -p mysql84). +func TestSettingsServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + oracles := map[string]*oracle.Oracle{} + for name, text := range settingsSchemas { + o, err := oracle.Start(ctx, text) + if errors.Is(err, oracle.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatalf("%s: %v", name, err) + } + defer o.Close() + oracles[name] = o + } + for _, c := range settingsCases { + d, err := oracles[c.schema].Describe(ctx, c.sql) + if c.code != 0 { + var oe *oracle.Error + if !errors.As(err, &oe) || oe.Number != c.code { + t.Errorf("[%s] %s: the server says %v, the analyzer %d", c.schema, c.sql, err, c.code) + } + continue + } + if err != nil { + t.Errorf("[%s] %s: the server rejects it: %v", c.schema, c.sql, err) + continue + } + if len(d.Columns) != 1 || oracleKey(d.Columns[0]) != c.typ || d.Columns[0].Nullable != c.null { + t.Errorf("[%s] %s: the server says %+v, the analyzer %s nullable=%v", c.schema, c.sql, d.Columns, c.typ, c.null) + } + } + // the NOT NULL matrix needs execution: a fresh server without strict mode + db, err := mysqltest.Start(ctx, settingsSchemas["empty"]) + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + if _, err := conn.ExecContext(ctx, "INSERT INTO b VALUES (NULL)"); err != nil { + t.Fatal(err) + } + for _, c := range notNullCases { + sql := strings.ReplaceAll(c.sql, "$1", "NULL") + if c.sql == "INSERT INTO a (id, u, s) VALUES (1, 1, 'x') ON DUPLICATE KEY UPDATE s = $1" { + conn.ExecContext(ctx, "INSERT INTO a (id, u, s) VALUES (1, 1, 'x')") // the row the update hits + } + _, err := conn.ExecContext(ctx, sql) + rejected := err != nil && strings.Contains(err.Error(), "Error 1048") + if err != nil && !rejected { + t.Errorf("%s: %v", sql, err) + } + if rejected != c.lax { + t.Errorf("%s: the server without strict mode rejects it = %v, the analyzer lists NOT NULL = %v", sql, rejected, c.lax) + } + conn.ExecContext(ctx, "DELETE FROM a") + } + t.Log(fmt.Sprintf("server %s agrees on %d statements and %d writes", db.Version, len(settingsCases), len(notNullCases))) +} + +// A directive names a table as the server would resolve it: under lower_case_table_names 1 +// or 2 any spelling reaches the declared table, and the waiver meets the facts' leaf. +func TestSettingsDirectiveNames(t *testing.T) { + for _, lctn := range []string{"1", "2"} { + s, err := schema.Load("-- sqlshape: mysql 8.4\n-- sqlshape: server lower_case_table_names = " + lctn + ` +-- sqlshape: visible where deleted_at IS NULL +CREATE TABLE Users (id INT PRIMARY KEY, deleted_at DATETIME NULL); +-- sqlshape: unfiltered USERS +CREATE VIEW AllUsers AS SELECT id FROM users; +`) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("lctn=%s: %v", lctn, s.Problems) + } + want := s.Tables[0].Name + if !s.Views[0].Unfiltered[want] { + t.Errorf("lctn=%s: the view's unfiltered keys are %v, want %q", lctn, s.Views[0].Unfiltered, want) + } + r, err := Analyze(s, "-- sqlshape: unfiltered uSeRs\nSELECT id FROM Users") + if err != nil { + t.Fatal(err) + } + if lf := r.Facts.Top.Leaves[0]; lf.Table != want || len(lf.Waived) != 1 || lf.Waived[0] != "unfiltered" { + t.Errorf("lctn=%s: leaf %+v, want table %q waived [unfiltered]", lctn, lf, want) + } + } + // at 0 the spelling must match exactly: a differently spelled waiver waives nothing + s, _ := schema.Load("-- sqlshape: mysql 8.4\nCREATE TABLE Users (id INT PRIMARY KEY);\n") + r, err := Analyze(s, "-- sqlshape: unfiltered users\nSELECT id FROM Users") + if err != nil || len(r.Facts.Top.Leaves[0].Waived) != 0 { + t.Errorf("lctn=0: %v %+v", err, r.Facts.Top.Leaves[0]) + } +} diff --git a/check/mysql/internal/analyze/store.go b/check/mysql/internal/analyze/store.go new file mode 100644 index 0000000..8532542 --- /dev/null +++ b/check/mysql/internal/analyze/store.go @@ -0,0 +1,1098 @@ +package analyze + +import ( + "fmt" + "math" + "math/big" + "strings" + "time" + "unicode/utf8" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlast" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/sqlmode" +) + +// A literal written straight into a column -- INSERT ... VALUES ('2004-13-15'), UPDATE +// ... SET tiny = 128 -- is stored by the server's Field::store, which in strict mode +// turns every value it cannot keep into an error: the statement then fails on every +// execution, so the checker reports the failure as the statement's own error, with the +// number and message the server would use (measured on mysqld 8.4, TestLiteralStoreServer; +// found by the corpus probe, whose largest run-time classes were these). Outside strict +// mode the same values are stored adjusted with a warning, and under INSERT / UPDATE +// IGNORE too, so nothing is reported then. The rules are field.cc's and my_time.cc's: +// +// - an integer column (Field_tiny ... Field_longlong) rejects a value outside its range +// (1264 "Out of range value"), a string that holds no number (1366 "Incorrect integer +// value") or one followed by other text (1265 "Data truncated"), a real rounded first +// (rint), a decimal rounded half away from zero; +// - a YEAR takes 0, 1-99 (as 2001-2069 / 1970-1999) and 1901-2155, anything else is 1264; +// - a DECIMAL(M,D) rejects more than M-D integer digits and a negative value into an +// UNSIGNED column (1264); a FLOAT a value beyond FLT_MAX (1264); +// - a DATE / DATETIME / TIMESTAMP column parses a string with str_to_datetime under the +// session's sql_mode (NO_ZERO_DATE, NO_ZERO_IN_DATE, ALLOW_INVALID_DATES) and a number +// with number_to_datetime, a TIME column with str_to_time / number_to_time; whatever +// they cut, find out of range or find zero where zero is forbidden is 1292 "Incorrect +// date / datetime / time value" (a DATE that merely loses a time part is a note, and +// stored); +// - a CHAR / VARCHAR / BINARY / VARBINARY (n) column rejects a string longer than n +// characters (bytes when binary) once trailing spaces are dropped (1406 "Data too +// long"); +// - an ENUM rejects a string that is none of its members (1265; a short number is an +// index, 1 to the member count), a SET a list with a member it lacks (1265); +// +// A spatial column is judged by geom.go's geometryStore (1416), whatever the sql_mode. +// +// Not read: a hex / bit literal (a number in a numeric column, a string elsewhere), the +// double types (the lexer already bounds their literals), JSON and BIT columns, character +// set conversion between the literal's and the column's, and a value inside a routine or +// trigger body (the body's own run-time failures are its raised violations, not the +// statement's error). + +// storeChecks says whether literalStore judges an assignment of row into table: strict +// mode for that table (strictFor: STRICT_TRANS_TABLES alone is strict for a +// nontransactional engine only while nothing has been written, i.e. the first row -- a +// later row's bad value is adjusted with a warning, measured on MyISAM), no IGNORE, a +// top-level statement. +func (a *analyzer) storeChecks(table *schema.Table, row int) bool { + if a.write == nil || a.write.ignore || a.routine != nil || a.trig != nil { + return false + } + if a.strictFor(table) { + return true + } + return a.s.Settings.SQLMode.StrictTransOnly() && row <= 1 +} + +// literalStore is the error the server raises when the literal v is stored into col, nil +// when v is not a literal or is stored as written. row is the VALUES row (1-based) the +// message names. +func (a *analyzer) literalStore(col *schema.Column, v mysqlast.Value, row int) *Error { + n, ok := v.(*mysqlast.Node) + if !ok { + return nil + } + at := a.ph.Back(n.Start) + fail := func(code int, msg string) *Error { + return &Error{Message: fmt.Sprintf("%s for column '%s' at row %d", msg, col.Name, row), Code: code, Position: at} + } + if n.Class == "PTI_temporal_literal" { + return a.temporalLiteralStore(col, n, row) + } + num, isNum := numericLiteral(n) + str, isStr := stringLiteral(n) + if !isNum && !isStr { + return nil + } + t := col.Type + switch t.Name { + case "tinyint", "smallint", "mediumint", "int", "bigint": + lo, hi := intRange(t) + var val *big.Rat + if isNum { + val = num.rat + if num.real { + val = roundHalfEven(val) + } else { + val = roundHalfAway(val) + } + } else { + r, tail, ok := numericString(str) + if !ok { + return fail(1366, fmt.Sprintf("Incorrect integer value: '%s'", str)) + } + val = roundHalfAway(r) + if tail { + if val.Cmp(lo) < 0 || val.Cmp(hi) > 0 { + return fail(1264, "Out of range value") + } + return fail(1265, "Data truncated") + } + } + if val.Cmp(lo) < 0 || val.Cmp(hi) > 0 { + return fail(1264, "Out of range value") + } + case "year": + var val *big.Rat + if isNum { + val = roundHalfAway(num.rat) + } else { + r, tail, ok := numericString(str) + if !ok { + return fail(1366, fmt.Sprintf("Incorrect integer value: '%s'", str)) + } + val = roundHalfAway(r) + if tail { + return fail(1265, "Data truncated") + } + } + if !val.IsInt() { + return nil + } + y := val.Num() + if y.Sign() < 0 || y.Cmp(big.NewInt(2155)) > 0 || (y.Cmp(big.NewInt(100)) >= 0 && y.Cmp(big.NewInt(1901)) < 0) { + return fail(1264, "Out of range value") + } + case "decimal": + var val *big.Rat + if isNum { + val = num.rat + } else { + r, tail, ok := numericString(str) + if !ok { + return fail(1366, fmt.Sprintf("Incorrect decimal value: '%s'", str)) + } + if tail { + return fail(1265, "Data truncated") + } + val = r + } + if t.Unsigned && val.Sign() < 0 { + return fail(1264, "Out of range value") + } + m, d := t.Length, t.Dec + if m <= 0 { + m = 10 + } + if d < 0 { + d = 0 + } + // rounded to the scale first: 999.999 into a DECIMAL(5,2) is 1000.00, an overflow + scale := new(big.Rat).SetInt(new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(d)), nil)) + rounded := roundHalfAway(new(big.Rat).Mul(val, scale)) + limit := new(big.Rat).SetInt(new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(m)), nil)) + if new(big.Rat).Abs(rounded).Cmp(limit) >= 0 { + return fail(1264, "Out of range value") + } + case "float": + if !isNum { + return nil + } + f, _ := num.rat.Float64() + if math.Abs(f) > math.MaxFloat32 { + return fail(1264, "Out of range value") + } + case "date", "datetime", "timestamp": + flags := a.dateFlags(t.Name) + var tm mysqlTime + var warn int + if isNum { + ip := new(big.Int).Quo(num.rat.Num(), num.rat.Denom()) + if !ip.IsInt64() || ip.Sign() < 0 { + return fail(1292, fmt.Sprintf("Incorrect %s value: '%s'", temporalWord(t.Name), num.text)) + } + if !numberToDatetime(ip.Int64(), flags, &tm, &warn) { + return fail(1292, fmt.Sprintf("Incorrect %s value: '%s'", temporalWord(t.Name), num.text)) + } + } else { + if !strToDatetime(str, flags, &tm, &warn) || warn&(timeWarnTruncated|timeWarnOutOfRange|timeWarnZeroDate|timeWarnZeroInDate) != 0 { + return fail(1292, fmt.Sprintf("Incorrect %s value: '%s'", temporalWord(t.Name), str)) + } + // a displacement over a zero month or day fails the time zone conversion even + // where the flags allowed the parse, with the conversion's own message + // (measured with NO_ZERO_IN_DATE off) + if tm.hasTZ && (tm.month == 0 || tm.day == 0) { + return &Error{Message: fmt.Sprintf("Truncated incorrect temporal value: '%s'", displacedSpelling(tm)), Code: code1292, Position: at} + } + } + if t.Name == "timestamp" && !zeroDate(tm) && !timestampInRange(roundTimestampFrac(tm, t.Dec)) { + // the TIMESTAMP range, 1970-01-01 00:00:01 to 2038-01-19 03:14:07 UTC: exact when + // the literal carries a time zone displacement, otherwise judged only where the + // session's time zone (within +-14 hours) cannot move the verdict + return fail(1292, fmt.Sprintf("Incorrect datetime value: '%s'", literalSpelling(num, isNum, str))) + } + case "time": + var tm mysqlTime + var warn int + if isNum { + ip := new(big.Int).Quo(num.rat.Num(), num.rat.Denom()) + if !ip.IsInt64() || !numberToTime(ip.Int64(), &tm, &warn) { + return fail(1292, fmt.Sprintf("Incorrect time value: '%s'", num.text)) + } + } else if !strToTime(str, &tm, &warn) || warn != 0 { + return fail(1292, fmt.Sprintf("Incorrect time value: '%s'", str)) + } + case "char", "varchar", "binary", "varbinary": + if !isStr || t.Length < 0 { + return nil + } + s := strings.TrimRight(str, " ") + length := utf8.RuneCountInString(s) + if isBinary(t) { + length = len(s) + } + if length > t.Length { + return fail(1406, "Data too long") + } + case "enum": + if isStr { + s := strings.TrimRight(str, " ") + if enumIndex(t, s) > 0 { + return nil + } + if len(s) < 6 { + if r, tail, ok := numericString(s); ok && !tail && r.IsInt() && r.Sign() > 0 && r.Num().Cmp(big.NewInt(int64(len(t.Values)))) <= 0 { + return nil + } + } + return fail(1265, "Data truncated") + } + val := roundHalfAway(num.rat) + if !val.IsInt() || val.Sign() <= 0 || val.Num().Cmp(big.NewInt(int64(len(t.Values)))) > 0 { + return fail(1265, "Data truncated") + } + case "set": + if !isStr { + return nil + } + s := strings.TrimRight(str, " ") + if s == "" { + return nil + } + for _, member := range strings.Split(s, ",") { + if enumIndex(t, strings.TrimRight(member, " ")) == 0 { + return fail(1265, "Data truncated") + } + } + } + return nil +} + +// temporalLiteralStore judges a DATE'...' / TIMESTAMP'...' literal stored into a +// TIMESTAMP column: the value must fit 1970-01-01 00:00:01 .. 2038-01-19 03:14:07 UTC, +// exactly when the literal carries a time zone displacement (the range is defined in UTC, +// so no session time zone can move the verdict; measured: TestTemporalLiteralStoreServer), +// otherwise only where the session's time zone (within +-14 hours) cannot move it. The +// message spells the literal's own value where the server spells it converted to the +// session's time zone, which the checker does not know. +func (a *analyzer) temporalLiteralStore(col *schema.Column, n *mysqlast.Node, row int) *Error { + if col.Type.Name != "timestamp" { + return nil + } + ft := strings.TrimPrefix(str(n.Arg("field_type")), "MYSQL_TYPE_") + if ft != "DATETIME" && ft != "DATE" { + return nil + } + tok, ok := n.Arg("literal").(mysqlast.Token) + if !ok { + return nil + } + var tm mysqlTime + var warn int + if !strToDatetime(tok.Value, timeFuzzyDate, &tm, &warn) { + return nil // the literal's own 1525 check already judged it + } + tm = roundTimestampFrac(tm, col.Type.Dec) + if !zeroDate(tm) && !timestampInRange(tm) { + // an UPDATE's SET stores per matched row: no row, no failure (measured), so the + // checker lists the violation there instead of failing the statement + if a.foldPerRow { + table := "" + if a.write != nil && a.write.table != nil { + table = a.write.table.Name + } + a.storeRaised = append(a.storeRaised, Violation{Code: code1292, Constraint: itoa(code1292), Table: table, SQLState: constraintSQLState(code1292)}) + return nil + } + return &Error{Message: fmt.Sprintf("Incorrect datetime value: '%s' for column '%s' at row %d", tok.Value, col.Name, row), Code: code1292, Position: a.ph.Back(n.Start)} + } + return nil +} + +// temporalLiteral is item_create.cc's create_temporal_literal check on a DATE'...' / +// TIME'...' / TIMESTAMP'...' literal: the value must parse as exactly the named type with +// no warning, under TIME_FUZZY_DATE plus the sql_mode's NO_ZERO_IN_DATE / NO_ZERO_DATE / +// ALLOW_INVALID_DATES; otherwise the statement fails with 1525 (ER_WRONG_VALUE). +func (a *analyzer) temporalLiteral(typ, value string, start int) *Error { + flags := a.dateFlags("date") // TIME_FUZZY_DATE and the mode's flags, whatever the type + var tm mysqlTime + var warn int + ok := false + word := "DATETIME" + switch typ { + case "date": + word = "DATE" + ok = strToDatetime(value, flags, &tm, &warn) && warn == 0 && tm.fields <= 3 + case "time": + word = "TIME" + ok = strToTime(value, &tm, &warn) && warn == 0 + default: + ok = strToDatetime(value, flags, &tm, &warn) && warn == 0 && tm.fields > 3 + } + if ok { + // a displacement over a zero month or day fails the time zone conversion whatever + // the sql_mode allows the parse: 1292 "Truncated incorrect temporal value", the + // datetime respelt without its fraction and the displacement's hour unpadded + // (measured with NO_ZERO_IN_DATE off: TIMESTAMP '2020-00-01 08:00:00.123456+00:00' + // -> '2020-00-01 08:00:00+0:00'; under the default mode the parse above fails first) + if tm.hasTZ && (tm.month == 0 || tm.day == 0) { + return &Error{Message: fmt.Sprintf("Truncated incorrect temporal value: '%s'", displacedSpelling(tm)), Code: code1292, Position: a.ph.Back(start)} + } + return nil + } + return &Error{Message: fmt.Sprintf("Incorrect %s value: '%s'", word, value), Code: 1525, Position: a.ph.Back(start)} +} + +// dateFlags is Field_*::date_flags for a column type under the schema's sql_mode: a DATE / +// DATETIME is fuzzy (a 0 month or day is allowed unless NO_ZERO_IN_DATE), a TIMESTAMP never +// is and never honors ALLOW_INVALID_DATES (it stores a real moment; measured: under +// STRICT_ALL_TABLES,ALLOW_INVALID_DATES a DATETIME takes '2004-02-30 15:30:04' and a +// TIMESTAMP refuses it); NO_ZERO_DATE and ALLOW_INVALID_DATES otherwise follow the mode. +func (a *analyzer) dateFlags(typ string) timeFlags { + mode := a.s.Settings.SQLMode.Expand() + var f timeFlags + if typ != "timestamp" { + f |= timeFuzzyDate + if mode.Has(sqlmode.AllowInvalidDates) { + f |= timeInvalidDates + } + } else { + f |= timeNoZeroInDate + } + if mode.Has(sqlmode.NoZeroDate) { + f |= timeNoZeroDate + } + if mode.Has(sqlmode.NoZeroInDate) { + f |= timeNoZeroInDate + } + return f +} + +// zeroDate says the date part is the fully zero one, which a TIMESTAMP stores as the zero +// timestamp without a range check; a year 0 over a real month and day is range-checked +// (measured: '0000-10-31 15:30:00' into a TIMESTAMP is 1292). +func zeroDate(tm mysqlTime) bool { + return tm.year == 0 && tm.month == 0 && tm.day == 0 +} + +// roundTimestampFrac rounds a datetime's fractional seconds to the column's precision, +// half up, before the TIMESTAMP range check: the carry can move the seconds (measured: +// TIMESTAMP'1970-01-01 00:00:00.999999+00:00' into a TIMESTAMP is 1970-01-01 00:00:01 and +// stored, while .000001 rounds down and stays out of range). +func roundTimestampFrac(tm mysqlTime, dec int) mysqlTime { + if dec < 0 { + dec = 0 + } + if dec >= 6 || tm.secondPart == 0 { + return tm + } + step := uint(1) + for i := dec; i < 6; i++ { + step *= 10 + } + rem := tm.secondPart % step + tm.secondPart -= rem + if rem*2 >= step { + tm.secondPart += step + if tm.secondPart >= 1000000 { + tm.secondPart = 0 + t := time.Date(int(tm.year), time.Month(tm.month), int(tm.day), int(tm.hour), int(tm.minute), int(tm.second), 0, time.UTC).Add(time.Second) + tm.year, tm.month, tm.day = uint(t.Year()), uint(t.Month()), uint(t.Day()) + tm.hour, tm.minute, tm.second = uint(t.Hour()), uint(t.Minute()), uint(t.Second()) + } + } + return tm +} + +// timestampInRange says a non-zero datetime fits a TIMESTAMP column (see literalStore). +func timestampInRange(tm mysqlTime) bool { + if tm.hasTZ { + utc := time.Date(int(tm.year), time.Month(tm.month), int(tm.day), int(tm.hour), int(tm.minute), int(tm.second), 0, time.UTC).Add(-time.Duration(tm.tzOffset) * time.Second) + return !utc.Before(time.Date(1970, 1, 1, 0, 0, 1, 0, time.UTC)) && !utc.After(time.Date(2038, 1, 19, 3, 14, 7, 0, time.UTC)) + } + local := time.Date(int(tm.year), time.Month(tm.month), int(tm.day), int(tm.hour), int(tm.minute), int(tm.second), 0, time.UTC) + return !local.Before(time.Date(1969, 12, 31, 10, 0, 1, 0, time.UTC)) && !local.After(time.Date(2038, 1, 19, 17, 14, 7, 0, time.UTC)) +} + +func temporalWord(typ string) string { + switch typ { + case "date": + return "date" + case "time": + return "time" + } + return "datetime" +} + +func literalSpelling(num numLiteral, isNum bool, str string) string { + if isNum { + return num.text + } + return str +} + +// intRange is the value range of an integer column type. +func intRange(t schema.Type) (lo, hi *big.Rat) { + bits := map[string]uint{"tinyint": 8, "smallint": 16, "mediumint": 24, "int": 32, "bigint": 64}[t.Name] + if t.Unsigned { + max := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), bits), big.NewInt(1)) + return new(big.Rat), new(big.Rat).SetInt(max) + } + max := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), bits-1), big.NewInt(1)) + min := new(big.Int).Neg(new(big.Int).Lsh(big.NewInt(1), bits-1)) + return new(big.Rat).SetInt(min), new(big.Rat).SetInt(max) +} + +// enumIndex is the 1-based member of an ENUM / SET the string names, compared the way the +// column's collation does (case-insensitively unless it is a _bin or _cs collation); 0 when +// none. +func enumIndex(t schema.Type, s string) int { + exact := strings.HasSuffix(t.Collation, "_bin") || strings.HasSuffix(t.Collation, "_cs") || isBinary(t) + for i, v := range t.Values { + if v == s || !exact && strings.EqualFold(v, s) { + return i + 1 + } + } + return 0 +} + +// numLiteral is a numeric literal's value: rat is exact, real says it was written as a +// float (1.5E3, which Field_num::store(double) rounds half to even rather than half away), +// text is its spelling for a message. +type numLiteral struct { + rat *big.Rat + real bool + text string +} + +// numericLiteral reads Item_int / Item_uint / Item_decimal / Item_float, TRUE / FALSE, and +// any of them under a unary minus. +func numericLiteral(n *mysqlast.Node) (numLiteral, bool) { + neg := false + for n.Class == "Item_func_neg" { + inner, ok := n.Arg("a").(*mysqlast.Node) + if !ok { + return numLiteral{}, false + } + neg = !neg + n = inner + } + var text string + real := false + switch n.Class { + case "Item_int": + text = str(n.Arg("i")) + case "Item_uint", "Item_decimal": + text = str(n.Arg("str")) + case "Item_float": + text = str(n.Arg("str")) + real = true + case "Item_func_true": + text = "1" + case "Item_func_false": + text = "0" + default: + return numLiteral{}, false + } + r, ok := new(big.Rat).SetString(text) + if !ok { + return numLiteral{}, false + } + if neg { + r.Neg(r) + text = "-" + text + } + return numLiteral{rat: r, real: real, text: text}, true +} + +// stringLiteral reads a quoted string literal's value. +func stringLiteral(n *mysqlast.Node) (string, bool) { + switch n.Class { + case "PTI_text_literal_text_string", "PTI_text_literal_nchar_string", "PTI_text_literal_underscore_charset": + if tok, ok := n.Arg("literal").(mysqlast.Token); ok { + return tok.Value, true + } + } + return "", false +} + +// numericString reads a number out of a string the way my_strntoull10rnd / str2my_decimal +// do: leading spaces and tabs, an optional sign, digits with an optional fraction and +// exponent. ok is false when no digit was read (EDOM); tail says other text followed the +// number (truncation). +func numericString(s string) (r *big.Rat, tail bool, ok bool) { + i := 0 + for i < len(s) && (s[i] == ' ' || s[i] == '\t') { + i++ + } + start := i + if i < len(s) && (s[i] == '-' || s[i] == '+') { + i++ + } + digits := 0 + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + digits++ + } + if i < len(s) && s[i] == '.' { + i++ + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + digits++ + } + } + if digits == 0 { + return nil, false, false + } + if i < len(s) && (s[i] == 'e' || s[i] == 'E') { + j := i + 1 + if j < len(s) && (s[j] == '-' || s[j] == '+') { + j++ + } + k := j + for k < len(s) && s[k] >= '0' && s[k] <= '9' { + k++ + } + if k > j { + i = k + } + } + r, ok = new(big.Rat).SetString(s[start:i]) + if !ok { + return nil, false, false + } + return r, strings.TrimRight(s[i:], " ") != "", true +} + +// roundHalfAway rounds to the nearest integer, halves away from zero (my_decimal2int). +func roundHalfAway(r *big.Rat) *big.Rat { + if r.IsInt() { + return r + } + half := big.NewRat(1, 2) + x := new(big.Rat).Abs(r) + x.Add(x, half) + q := new(big.Int).Quo(x.Num(), x.Denom()) + out := new(big.Rat).SetInt(q) + if r.Sign() < 0 { + out.Neg(out) + } + return out +} + +// roundHalfEven rounds to the nearest integer, halves to even (rint). +func roundHalfEven(r *big.Rat) *big.Rat { + if r.IsInt() { + return r + } + f, _ := r.Float64() + return new(big.Rat).SetFloat64(math.RoundToEven(f)) +} + +// ---- my_time.cc: the temporal parsers, ported for literal validation ---- + +type timeFlags uint + +const ( + timeFuzzyDate timeFlags = 1 << iota + timeDatetimeOnly + timeNoZeroInDate + timeNoZeroDate + timeInvalidDates +) + +const ( + timeWarnTruncated = 1 << iota + timeWarnOutOfRange + timeWarnZeroDate + timeWarnZeroInDate +) + +type mysqlTime struct { + year, month, day, hour, minute, second, secondPart uint + neg bool + isTime bool + fields int + // hasTZ / tzOffset: a {+-}HH:MM displacement written after the value, in seconds + hasTZ bool + tzOffset int +} + +var daysInMonth = [12]uint{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} + +func isLeap(y uint) bool { return y%4 == 0 && (y%100 != 0 || y%400 == 0 || y == 0) } + +// checkDate is my_time.cc's check_date: a zero month or day, an impossible day of month, a +// zero date, each as the flags allow. +func checkDate(t mysqlTime, notZero bool, flags timeFlags, warn *int) bool { + if notZero { + if (flags&timeNoZeroInDate != 0 || flags&timeFuzzyDate == 0) && (t.month == 0 || t.day == 0) { + *warn = timeWarnZeroInDate + return true + } + if flags&timeInvalidDates == 0 && t.month != 0 && t.day > daysInMonth[t.month-1] && !(t.month == 2 && isLeap(t.year) && t.day == 29) { + *warn = timeWarnOutOfRange + return true + } + } else if flags&timeNoZeroDate != 0 { + *warn = timeWarnZeroDate + return true + } + return false +} + +func checkDatetimeRange(t mysqlTime) bool { + maxHour := uint(23) + if t.isTime { + maxHour = 838 + } + return t.year > 9999 || t.month > 12 || t.day > 31 || t.minute > 59 || t.second > 59 || t.secondPart > 999999 || t.hour > maxHour +} + +func isDigit(c byte) bool { return c >= '0' && c <= '9' } +func isSpace(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v' +} +func isPunct(c byte) bool { + return c > ' ' && c < 0x7f && !isDigit(c) && !(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') +} + +// tzDisplacement parses {+-}HH:MM to the end of s (trailing spaces allowed), in seconds. +func tzDisplacement(s string) (int, bool) { + s = strings.TrimRight(s, " \t\n\r\f\v") + if len(s) != 6 || (s[0] != '+' && s[0] != '-') || !isDigit(s[1]) || !isDigit(s[2]) || s[3] != ':' || !isDigit(s[4]) || !isDigit(s[5]) { + return 0, false + } + h := int(s[1]-'0')*10 + int(s[2]-'0') + m := int(s[4]-'0')*10 + int(s[5]-'0') + if m > 59 || h > 14 || (h == 14 && m > 0) || (s[0] == '-' && h == 0 && m == 0) { + return 0, false + } + off := h*3600 + m*60 + if s[0] == '-' { + off = -off + } + return off, true +} + +// strToDatetime is my_time.cc's str_to_datetime: parseDatetime's verdict as a bool (false on +// tdNone and tdError alike; warn says which); on success warn may still carry +// timeWarnTruncated for trailing text. +func strToDatetime(s string, flags timeFlags, t *mysqlTime, warn *int) bool { + return parseDatetime(s, flags, t, warn) == tdOK +} + +// parseDatetime's verdicts: read; not a datetime at all (MYSQL_TIMESTAMP_NONE: no leading +// digit, a rejected delimiter, a bad displacement -- str_to_time then tries its own +// shapes); a datetime shape the value does not fit (MYSQL_TIMESTAMP_ERROR: a field out of +// range, a zero the flags forbid). +const ( + tdOK = iota + tdNone + tdError +) + +func parseDatetime(s string, flags timeFlags, t *mysqlTime, warn *int) int { + const maxParts = 8 + var date, dateLen [maxParts]uint + end := len(s) + i := 0 + for i < end && isSpace(s[i]) { + i++ + } + if i >= end || !isDigit(s[i]) { + *warn = timeWarnTruncated + return tdNone + } + pos := i + for pos < end && (isDigit(s[pos]) || s[pos] == 'T') { + pos++ + } + digits := pos - i + internal := false + yearLength := 0 + fieldLength := 4 + if pos == end || s[pos] == '.' { + if digits == 4 || digits == 8 || digits >= 14 { + yearLength = 4 + } else { + yearLength = 2 + } + fieldLength = yearLength + internal = true + } + const allowSpace = (1 << 2) | (1 << 6) + const allowHyphen = (1 << 0) | (1 << 1) + const allowColon = (1 << 3) | (1 << 4) + notZero := uint(0) + foundDelimiter, foundSpace, foundDisplacement := false, false, false + displacement := 0 + lastFieldPos := i + part := 0 + for part = 0; part < maxParts-1 && i < end && isDigit(s[i]); part++ { + start := i + tmp := uint(s[i] - '0') + i++ + scanUntilDelim := !internal && part != 6 + remaining := fieldLength - 1 + for i < end && isDigit(s[i]) && (scanUntilDelim || remaining > 0) { + tmp = tmp*10 + uint(s[i]-'0') + i++ + remaining-- + if tmp > 999999 { + *warn = timeWarnTruncated + return tdNone + } + } + dateLen[part] = uint(i - start) + date[part] = tmp + notZero |= tmp + fieldLength = 2 + lastFieldPos = i + if i == end { + part++ + break + } + if part == 2 && s[i] == 'T' { + i++ + continue + } + if part == 5 { + if s[i] == '.' { + i++ + lastFieldPos = i + fieldLength = 6 + } else if isDigit(s[i]) { + part++ + break + } else if s[i] == '+' || s[i] == '-' { + off, ok := tzDisplacement(s[i:]) + if !ok { + *warn = timeWarnTruncated + return tdNone + } + foundDisplacement, displacement = true, off + i = end + lastFieldPos = i + } + continue + } + if part == 6 && (s[i] == '+' || s[i] == '-') { + off, ok := tzDisplacement(s[i:]) + if !ok { + *warn = timeWarnTruncated + return tdNone + } + foundDisplacement, displacement = true, off + i = end + lastFieldPos = i + } + for i < end && (isPunct(s[i]) || isSpace(s[i])) { + if isSpace(s[i]) { + if allowSpace&(1<= 10000101000000: + if nr > 99999999999999 { + *warn = timeWarnOutOfRange + return false + } + case nr < 101: + *warn = timeWarnTruncated + return false + case nr <= (70-1)*10000+1231: + nr = (nr + 20000000) * 1000000 + case nr < 70*10000+101: + *warn = timeWarnTruncated + return false + case nr <= 991231: + nr = (nr + 19000000) * 1000000 + case nr < 10000101 && flags&timeFuzzyDate == 0: + *warn = timeWarnTruncated + return false + case nr <= 99991231: + nr *= 1000000 + case nr < 101000000: + *warn = timeWarnTruncated + return false + case nr <= (70-1)*10000000000+1231235959: + nr += 20000000000000 + case nr < 70*10000000000+101000000: + *warn = timeWarnTruncated + return false + case nr <= 991231235959: + nr += 19000000000000 + } + part1 := nr / 1000000 + part2 := nr - part1*1000000 + t.year = uint(part1 / 10000) + part1 %= 10000 + t.month = uint(part1 / 100) + t.day = uint(part1 % 100) + t.hour = uint(part2 / 10000) + part2 %= 10000 + t.minute = uint(part2 / 100) + t.second = uint(part2 % 100) + if !checkDatetimeRange(*t) && !checkDate(*t, nr != 0, flags, warn) { + return true + } + if *warn == 0 { + *warn = timeWarnTruncated + } + return false +} + +// strToTime is my_time.cc's str_to_time: a full datetime (its time part), [-] D HH:MM:SS, +// HH:MM:SS, MM:SS, HHMMSS and their shorter forms, with a fraction. false on error; on +// success warn carries the range and truncation warnings. +func strToTime(s string, t *mysqlTime, warn *int) bool { + end := len(s) + i := 0 + for i < end && isSpace(s[i]) { + i++ + } + *t = mysqlTime{isTime: true} + if i < end && s[i] == '-' { + t.neg = true + i++ + } + if i == end { + return false + } + start := i + if end-i >= 12 { + var dt mysqlTime + var w int + switch parseDatetime(s[i:], timeFuzzyDate|timeDatetimeOnly, &dt, &w) { + case tdOK: + *t = mysqlTime{hour: dt.hour, minute: dt.minute, second: dt.second, secondPart: dt.secondPart, isTime: true} + *warn = w &^ timeWarnTruncated + return true + case tdError: + // a datetime shape the value does not fit is this function's error too; tdNone + // (not a datetime at all) falls through to the time shapes + *warn = w + return false + } + } + var date [5]uint64 + var value uint64 + for i < end && isDigit(s[i]) { + value = value*10 + uint64(s[i]-'0') + if value > math.MaxUint32 { + return false + } + i++ + } + endOfDays := i + for i < end && isSpace(s[i]) { + i++ + } + state := 0 + foundDays, foundHours := false, false + switch { + case end-i > 1 && i != endOfDays && isDigit(s[i]): + date[0] = value + state = 1 + foundDays = true + case end-i > 1 && s[i] == ':' && isDigit(s[i+1]): + date[1] = value + state = 2 + foundHours = true + i++ + default: + date[1] = value / 10000 + date[2] = value / 100 % 100 + date[3] = value % 100 + state = 4 + } + if state != 4 { + for { + value = 0 + for i < end && isDigit(s[i]) { + value = value*10 + uint64(s[i]-'0') + i++ + } + date[state] = value + state++ + if state == 4 || end-i < 2 || s[i] != ':' || !isDigit(s[i+1]) { + break + } + i++ + } + if state != 4 { + if !foundHours && !foundDays { + // the fields read are the last ones: [M]M:SS, [S]S + shift := 4 - state + for k := 3; k >= 0; k-- { + if k-shift >= 0 { + date[k] = date[k-shift] + } else { + date[k] = 0 + } + } + } + } + } + // the fraction + if end-i >= 2 && s[i] == '.' && isDigit(s[i+1]) { + i++ + fieldLength := 5 + value = uint64(s[i] - '0') + i++ + for i < end && isDigit(s[i]) { + if fieldLength > 0 { + value = value*10 + uint64(s[i]-'0') + } + fieldLength-- + i++ + } + for k := fieldLength; k > 0; k-- { + value *= 10 + } + date[4] = value + } else if end-i == 1 && s[i] == '.' { + i++ + } + if end-i > 1 && (s[i] == 'e' || s[i] == 'E') && (isDigit(s[i+1]) || ((s[i+1] == '-' || s[i+1] == '+') && end-i > 2 && isDigit(s[i+2]))) { + return false + } + for _, d := range date { + if d > math.MaxUint32 { + return false + } + } + t.hour = uint(date[1] + date[0]*24) + t.minute = uint(date[2]) + t.second = uint(date[3]) + t.secondPart = uint(date[4]) + if t.minute >= 60 || t.second >= 60 || t.secondPart > 999999 { + *warn |= timeWarnOutOfRange + return false + } + if t.hour > 838 || (t.hour == 838 && t.minute == 59 && t.second == 59 && t.secondPart != 0) { + *warn |= timeWarnOutOfRange + } + for ; i < end; i++ { + if !isSpace(s[i]) { + *warn |= timeWarnTruncated + if i == start { + return false + } + break + } + } + return true +} + +// numberToTime is my_time.cc's number_to_time: HHMMSS, or a full datetime number. +func numberToTime(nr int64, t *mysqlTime, warn *int) bool { + const timeMax = 8385959 + if nr > timeMax { + if nr >= 10000000000 { + var w int + if numberToDatetime(nr, 0, t, &w) { + t.isTime = true + return true + } + } + *warn |= timeWarnOutOfRange + return false + } + if nr < -timeMax { + *warn |= timeWarnOutOfRange + return false + } + *t = mysqlTime{isTime: true} + if nr < 0 { + t.neg = true + nr = -nr + } + if nr%100 >= 60 || nr/100%100 >= 60 { + *warn |= timeWarnOutOfRange + return false + } + t.hour = uint(nr / 10000) + t.minute = uint(nr / 100 % 100) + t.second = uint(nr % 100) + return true +} diff --git a/check/mysql/internal/analyze/store_server_test.go b/check/mysql/internal/analyze/store_server_test.go new file mode 100644 index 0000000..8fc1922 --- /dev/null +++ b/check/mysql/internal/analyze/store_server_test.go @@ -0,0 +1,290 @@ +package analyze + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +const storeSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE lit ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + ti TINYINT, + tu TINYINT UNSIGNED, + sm SMALLINT, + mi MEDIUMINT UNSIGNED, + i INT, + iu INT UNSIGNED, + bi BIGINT, + bu BIGINT UNSIGNED, + y YEAR, + de DECIMAL(5,2), + du DECIMAL(5,2) UNSIGNED, + fl FLOAT, + d DATE, + dt DATETIME, + dt3 DATETIME(3), + ts TIMESTAMP NULL, + tm TIME, + tm2 TIME(2), + c3 CHAR(3), + v5 VARCHAR(5), + b2 BINARY(2), + vb3 VARBINARY(3), + e ENUM('small', 'Large', 'x-large'), + st SET('a', 'b', 'c'), + g GEOMETRY, + p POINT +); +CREATE TABLE lit_myisam (ti TINYINT, d DATE) ENGINE=MyISAM; +` + +// storeCases are literal stores the checker judges, each pinned against mysqld: want is the +// error number the server raises in strict mode, 0 when it stores the value. +var storeCases = []struct { + sql string + want int +}{ + // integers: range, strings, rounding + {"INSERT INTO lit (ti) VALUES (127)", 0}, + {"INSERT INTO lit (ti) VALUES (128)", 1264}, + {"INSERT INTO lit (ti) VALUES (-128)", 0}, + {"INSERT INTO lit (ti) VALUES (-129)", 1264}, + {"INSERT INTO lit (tu) VALUES (255)", 0}, + {"INSERT INTO lit (tu) VALUES (256)", 1264}, + {"INSERT INTO lit (tu) VALUES (-1)", 1264}, + {"INSERT INTO lit (tu) VALUES (-1.1E-3)", 0}, + {"INSERT INTO lit (tu) VALUES ('-1.2E-3')", 0}, + {"INSERT INTO lit (tu) VALUES (-0.6)", 1264}, + {"INSERT INTO lit (ti) VALUES (127.4)", 0}, + {"INSERT INTO lit (ti) VALUES (127.5)", 1264}, + {"INSERT INTO lit (ti) VALUES (1.5E2)", 1264}, + {"INSERT INTO lit (sm) VALUES (32767), (32768)", 1264}, + {"INSERT INTO lit (mi) VALUES (16777215)", 0}, + {"INSERT INTO lit (mi) VALUES (16777216)", 1264}, + {"INSERT INTO lit (i) VALUES (2147483647)", 0}, + {"INSERT INTO lit (i) VALUES (2147483648)", 1264}, + {"INSERT INTO lit (i) VALUES (-2147483649.0)", 1264}, + {"INSERT INTO lit (iu) VALUES (4294967295)", 0}, + {"INSERT INTO lit (iu) VALUES (4294967296)", 1264}, + {"INSERT INTO lit (bi) VALUES (9223372036854775807)", 0}, + {"INSERT INTO lit (bi) VALUES (9223372036854775808)", 1264}, + {"INSERT INTO lit (bi) VALUES (-9223372036854775808)", 0}, + {"INSERT INTO lit (bi) VALUES (-9223372036854775809)", 1264}, + {"INSERT INTO lit (bu) VALUES (18446744073709551615)", 0}, + {"INSERT INTO lit (bu) VALUES (18446744073709551616)", 1264}, + {"INSERT INTO lit (ti) VALUES ('127')", 0}, + {"INSERT INTO lit (ti) VALUES ('128')", 1264}, + {"INSERT INTO lit (ti) VALUES (' 12 ')", 0}, + {"INSERT INTO lit (ti) VALUES ('12a')", 1265}, + {"INSERT INTO lit (ti) VALUES ('abc')", 1366}, + {"INSERT INTO lit (ti) VALUES ('')", 1366}, + {"INSERT INTO lit (ti) VALUES ('1e2')", 0}, + {"INSERT INTO lit (ti) VALUES ('1.5')", 0}, + {"INSERT INTO lit (ti) VALUES (TRUE)", 0}, + // YEAR + {"INSERT INTO lit (y) VALUES (2155)", 0}, + {"INSERT INTO lit (y) VALUES (2156)", 1264}, + {"INSERT INTO lit (y) VALUES (1900)", 1264}, + {"INSERT INTO lit (y) VALUES (99)", 0}, + {"INSERT INTO lit (y) VALUES (0)", 0}, + {"INSERT INTO lit (y) VALUES (-1)", 1264}, + {"INSERT INTO lit (y) VALUES ('2155')", 0}, + {"INSERT INTO lit (y) VALUES ('2156')", 1264}, + // DECIMAL / FLOAT + {"INSERT INTO lit (de) VALUES (999.99)", 0}, + {"INSERT INTO lit (de) VALUES (999.999)", 1264}, + {"INSERT INTO lit (de) VALUES (999.994)", 0}, + {"INSERT INTO lit (de) VALUES (1000)", 1264}, + {"INSERT INTO lit (de) VALUES (-999.99)", 0}, + {"INSERT INTO lit (du) VALUES (-1)", 1264}, + {"INSERT INTO lit (de) VALUES ('1000')", 1264}, + {"INSERT INTO lit (de) VALUES ('abc')", 1366}, + {"INSERT INTO lit (fl) VALUES (3.4e38)", 0}, + {"INSERT INTO lit (fl) VALUES (3.5e38)", 1264}, + // DATE / DATETIME / TIMESTAMP strings + {"INSERT INTO lit (d) VALUES ('2004-02-29')", 0}, + {"INSERT INTO lit (d) VALUES ('2005-02-29')", 1292}, + {"INSERT INTO lit (d) VALUES ('2004-13-15')", 1292}, + {"INSERT INTO lit (d) VALUES ('2004-10-32')", 1292}, + {"INSERT INTO lit (d) VALUES ('2004-0-31')", 1292}, + {"INSERT INTO lit (d) VALUES ('0000-00-00')", 1292}, + {"INSERT INTO lit (d) VALUES ('2004-01-04 10:00:00')", 0}, + {"INSERT INTO lit (d) VALUES ('2004-01-04 25:00:00')", 1292}, + {"INSERT INTO lit (d) VALUES ('20040104')", 0}, + {"INSERT INTO lit (d) VALUES ('040104')", 0}, + {"INSERT INTO lit (d) VALUES ('2004/01/04')", 0}, + {"INSERT INTO lit (d) VALUES ('2004-01-04x')", 1292}, + {"INSERT INTO lit (d) VALUES ('abc')", 1292}, + {"INSERT INTO lit (d) VALUES ('')", 1292}, + {"INSERT INTO lit (d) VALUES (1)", 1292}, + {"INSERT INTO lit (d) VALUES (20040104)", 0}, + {"INSERT INTO lit (d) VALUES (0)", 1292}, + {"INSERT INTO lit (dt) VALUES ('2004-02-29 15:65:00')", 1292}, + {"INSERT INTO lit (dt) VALUES ('2004-02-29 15:59:60')", 1292}, + {"INSERT INTO lit (dt) VALUES ('2004-02-29 15:59:59')", 0}, + {"INSERT INTO lit (dt) VALUES ('2004-02-29T15:59:59')", 0}, + {"INSERT INTO lit (dt) VALUES ('2004-02-29 15:59:59.5')", 0}, + {"INSERT INTO lit (dt) VALUES ('2004-02-29 15:59:59.1234567')", 0}, + {"INSERT INTO lit (dt) VALUES ('2004-02-29 15:59:59+01:00')", 0}, + {"INSERT INTO lit (dt) VALUES ('2004-02-29 15:59:59+15:00')", 1292}, + {"INSERT INTO lit (dt) VALUES ('0000-00-00 00:00:00')", 1292}, + {"INSERT INTO lit (dt) VALUES ('2004-02-29')", 0}, + {"INSERT INTO lit (dt) VALUES (20040229155959)", 0}, + {"INSERT INTO lit (dt) VALUES (20040229156000)", 1292}, + {"INSERT INTO lit (dt) VALUES (-1)", 1292}, + {"INSERT INTO lit (dt3) VALUES ('2004-02-29 15:59:59.9999')", 0}, + {"INSERT INTO lit (ts) VALUES ('2038-01-19 03:14:08+00:00')", 1292}, + {"INSERT INTO lit (ts) VALUES ('2039-01-01 00:00:00')", 1292}, + {"INSERT INTO lit (ts) VALUES ('1969-12-31 00:00:00')", 1292}, + {"INSERT INTO lit (ts) VALUES ('2004-02-29 15:59:59')", 0}, + {"INSERT INTO lit (ts) VALUES ('2004-02-00 15:59:59')", 1292}, + // TIME + {"INSERT INTO lit (tm) VALUES ('838:59:59')", 0}, + {"INSERT INTO lit (tm) VALUES ('839:00:00')", 1292}, + {"INSERT INTO lit (tm) VALUES ('-838:59:59.9999999')", 1292}, + {"INSERT INTO lit (tm) VALUES ('10:70:00')", 1292}, + {"INSERT INTO lit (tm) VALUES ('10:00:60')", 1292}, + {"INSERT INTO lit (tm) VALUES ('1 10:00:00')", 0}, + {"INSERT INTO lit (tm) VALUES ('100000')", 0}, + {"INSERT INTO lit (tm) VALUES ('12:34')", 0}, + {"INSERT INTO lit (tm) VALUES ('2004-02-29 15:59:59')", 0}, + {"INSERT INTO lit (tm) VALUES ('abc')", 1292}, + {"INSERT INTO lit (tm) VALUES ('10:00:00x')", 1292}, + {"INSERT INTO lit (tm) VALUES (8385959)", 0}, + {"INSERT INTO lit (tm) VALUES (8385960)", 1292}, + {"INSERT INTO lit (tm) VALUES (-8385959)", 0}, + {"INSERT INTO lit (tm) VALUES (20040229155959)", 0}, + {"INSERT INTO lit (tm2) VALUES ('10:00:00.999')", 0}, + {"INSERT INTO lit (tm) VALUES ('010203.12345')", 0}, + {"INSERT INTO lit (tm) VALUES ('2004-13-29 15:59:59')", 1292}, + {"INSERT INTO lit (dt) VALUES ('2019-09-20 10:00:00.999999+02:00 ')", 0}, + // strings + {"INSERT INTO lit (c3) VALUES ('abc')", 0}, + {"INSERT INTO lit (c3) VALUES ('abcd')", 1406}, + {"INSERT INTO lit (c3) VALUES ('abc ')", 0}, + {"INSERT INTO lit (c3) VALUES ('日本語')", 0}, + {"INSERT INTO lit (v5) VALUES ('abcdef')", 1406}, + {"INSERT INTO lit (v5) VALUES ('日本語日本')", 0}, + {"INSERT INTO lit (b2) VALUES ('ab')", 0}, + {"INSERT INTO lit (b2) VALUES ('abc')", 1406}, + {"INSERT INTO lit (vb3) VALUES ('日')", 0}, + {"INSERT INTO lit (vb3) VALUES ('日本')", 1406}, + // ENUM / SET + {"INSERT INTO lit (e) VALUES ('small')", 0}, + {"INSERT INTO lit (e) VALUES ('LARGE')", 0}, + {"INSERT INTO lit (e) VALUES ('medium')", 1265}, + {"INSERT INTO lit (e) VALUES ('small ')", 0}, + {"INSERT INTO lit (e) VALUES ('3')", 0}, + {"INSERT INTO lit (e) VALUES ('4')", 1265}, + {"INSERT INTO lit (e) VALUES (3)", 0}, + {"INSERT INTO lit (e) VALUES (4)", 1265}, + {"INSERT INTO lit (e) VALUES (0)", 1265}, + {"INSERT INTO lit (e) VALUES ('')", 1265}, + {"INSERT INTO lit (st) VALUES ('a,c')", 0}, + {"INSERT INTO lit (st) VALUES ('a,d')", 1265}, + {"INSERT INTO lit (st) VALUES ('')", 0}, + {"INSERT INTO lit (st) VALUES ('A')", 0}, + // geometry + {"INSERT INTO lit (g) VALUES ('Garbage')", 1416}, + {"INSERT INTO lit (g) VALUES (1)", 1416}, + {"INSERT INTO lit (p) VALUES (1.11)", 1416}, + {"INSERT INTO lit (p) VALUES (ST_GeomFromText('POINT(1 1)'))", 0}, + // the statement shapes: UPDATE, ON DUPLICATE KEY UPDATE, REPLACE, the row number + {"UPDATE lit SET ti = 128 WHERE id = 1", 1264}, + {"UPDATE lit SET d = '2004-13-15' WHERE id = 1", 1292}, + {"INSERT INTO lit (id, ti) VALUES (1, 1) ON DUPLICATE KEY UPDATE ti = 128", 1264}, + {"REPLACE INTO lit (ti) VALUES (128)", 1264}, + {"INSERT INTO lit (ti) VALUES (1), (128)", 1264}, + // STRICT_TRANS_TABLES alone: a nontransactional table is strict for the first row only + {"INSERT INTO lit_myisam (ti) VALUES (128)", 1264}, + {"INSERT INTO lit_myisam (d) VALUES ('2004-13-15')", 1292}, + {"INSERT INTO lit_myisam (ti) VALUES (1), (128)", 0}, + {"INSERT INTO lit_myisam (d) VALUES ('2004-01-01'), ('2004-13-15')", 0}, + // what is not judged: IGNORE, a NULL (an expression such as 100 + 28 is not read either, + // though the server folds it and fails the same way) + {"INSERT IGNORE INTO lit (ti) VALUES (128)", 0}, + {"UPDATE IGNORE lit SET ti = 128 WHERE id = 1", 0}, + {"INSERT INTO lit (ti) VALUES (NULL)", 0}, +} + +// TestLiteralStore checks the checker's verdict on each store case, without a server. +func TestLiteralStore(t *testing.T) { + s, err := schema.Load(storeSchema) + if err != nil { + t.Fatal(err) + } + for _, c := range storeCases { + _, err := Analyze(s, c.sql) + if got := errCode(err); got != c.want { + t.Errorf("%s: checker says %d (%v), want %d", c.sql, got, err, c.want) + } + } +} + +// TestLiteralStoreServer runs each store case on mysqld (strict mode, the default) and +// requires the server's error number to be the checker's, or the value to be stored when +// the checker says nothing. Skipped without a mysqld on PATH (nix-shell -p mysql84). +func TestLiteralStoreServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, storeSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + if _, err := conn.ExecContext(ctx, "INSERT INTO lit (id) VALUES (1)"); err != nil { + t.Fatal(err) + } + for _, c := range storeCases { + _, err := conn.ExecContext(ctx, c.sql) + got := 0 + if err != nil { + var me *driver.MySQLError + if !errors.As(err, &me) { + t.Errorf("%s: %v", c.sql, err) + continue + } + got = int(me.Number) + } + if got != c.want { + msg := "" + if err != nil { + msg = strings.TrimSpace(err.Error()) + } + t.Errorf("%s: the server says %d (%s), the checker %d", c.sql, got, msg, c.want) + } + } + // outside strict mode the same values are stored adjusted, with a warning + if _, err := conn.ExecContext(ctx, "SET SESSION sql_mode = ''"); err != nil { + t.Fatal(err) + } + for _, sql := range []string{"INSERT INTO lit (ti) VALUES (128)", "INSERT INTO lit (d) VALUES ('2004-13-15')", "INSERT INTO lit (c3) VALUES ('abcd')", "INSERT INTO lit (e) VALUES ('medium')"} { + if _, err := conn.ExecContext(ctx, sql); err != nil { + t.Errorf("%s without strict mode: %v", sql, err) + } + } + lenient, err := schema.Load(strings.Replace(storeSchema, "-- sqlshape: mysql 8.4\n", "-- sqlshape: mysql 8.4\n-- sqlshape: server sql_mode = ''\n", 1)) + if err != nil { + t.Fatal(err) + } + for _, c := range storeCases { + if c.want == 0 || c.want == 1416 { + continue // a geometry store fails whatever the mode (geom.go) + } + if _, err := Analyze(lenient, c.sql); err != nil { + t.Errorf("%s: the checker still says %v without strict mode", c.sql, err) + } + } +} diff --git a/check/mysql/internal/analyze/strtodate.go b/check/mysql/internal/analyze/strtodate.go new file mode 100644 index 0000000..3b8faff --- /dev/null +++ b/check/mysql/internal/analyze/strtodate.go @@ -0,0 +1,460 @@ +package analyze + +// STR_TO_DATE over two constants, judged as item_timefunc.cc runs it (measured in +// TestFuncValServer): fix_from_format decides the result type from the format's +// specifiers (a time part makes a TIME or DATETIME, %f forces microseconds), and +// extract_date_time reads the value under the format. A failure -- a specifier that +// cannot read its field, a literal that does not match, a date check_date refuses under +// the session's zero-date flags -- is the warning 1411 ("Incorrect datetime value" from +// the outer call, "Incorrect time value" from the %r / %T sub-parse); a parsed value +// followed by a non-space tail is the 1292 make_truncated_value_warning raises, spelt +// with the result type's word. A strict write escalates either to the error +// (foldFuncVal's gate). + +import ( + "fmt" + "strings" +) + +const maxDayNumber = 3652424 + +// my_locale_en_US's names, the only locale STR_TO_DATE's parse reads. +var ( + monthNames = [...]string{"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"} + abMonthNames = [...]string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"} + dayNames = [...]string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"} + abDayNames = [...]string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} +) + +// strToDateFail judges STR_TO_DATE(val, format); nil when the parse succeeds or the +// checker cannot follow it. +func (a *analyzer) strToDateFail(val, format string, at int) *foldFail { + kind := strings.ToLower(strToDateFormatType(format)) + var t mysqlTime + flags := a.dateFlags("date") // TIME_FUZZY_DATE plus the mode's zero-date flags + if kind == "time" { + flags &^= timeNoZeroDate // val_datetime: a TIME result allows the zero date + } + word, truncated, ok := extractDateTime(format, val, &t, kind, flags, false) + if !ok { + return &foldFail{code: 1411, raw: fmt.Sprintf("Incorrect %s value: '%s' for function str_to_date", word, val), at: at} + } + if truncated { + return &foldFail{code: 1292, raw: fmt.Sprintf("Truncated incorrect %s value: '%s'", kind, val), at: at} + } + if flags&timeNoZeroDate != 0 && kind != "time" && (t.year == 0 || t.month == 0 || t.day == 0) { + // date_should_be_null: a parsed date with any zero part is refused under + // NO_ZERO_DATE for a DATE / DATETIME result ('31.10.0000 15.30', measured) + return &foldFail{code: 1411, raw: fmt.Sprintf("Incorrect datetime value: '%s' for function str_to_date", val), at: at} + } + return nil +} + +// strToDateFormatType is Item_func_str_to_date::fix_from_format: the result's type from +// which specifier classes the format uses (expr.go's strToDateType reads it off the AST +// for typing; this one takes the format text the fold hands over). +func strToDateFormatType(format string) string { + const timeParts = "HISThiklrs" + const dateParts = "MVUXYWabcjmvuxyw" + datePart, timePart, frac := false, false, false + for i := 0; i < len(format); i++ { + if format[i] == '%' && i+1 < len(format) { + i++ + c := format[i] + switch { + case c == 'f': + frac, timePart = true, true + case strings.IndexByte(timeParts, c) >= 0: + timePart = true + case strings.IndexByte(dateParts, c) >= 0: + datePart = true + } + if datePart && frac { + return "DATETIME" + } + } + } + if frac { + return "TIME" + } + if timePart { + if datePart { + return "DATETIME" + } + return "TIME" + } + return "DATE" +} + +// extractDateTime is item_timefunc.cc's extract_date_time. word is the type word a +// failure spells ("datetime", or "time" from a %r / %T sub-parse), truncated reports a +// non-space tail after a successful parse, ok is false on a failure. sub returns after +// the format alone (the recursive %r / %T call). +func extractDateTime(format, val string, t *mysqlTime, kind string, flags timeFlags, sub bool) (word string, truncated, ok bool) { + word = "datetime" + if sub { + word = "time" + } + weekday, yearday, daypart := 0, 0, uint(0) + weekNumber := -1 + usaTime := false + sundayFirst := false + strictWeek := false + strictWeekYear := -1 + strictWeekYearType := false + v := 0 // position in val + p := 0 // position in format + for ; p < len(format) && v < len(val); p++ { + for v < len(val) && val[v] == ' ' { + v++ // skip pre-space before each field (MY_SEQ_SPACES) + } + if v >= len(val) { + break + } + if format[p] == '%' && p+1 < len(format) { + p++ + switch format[p] { + case 'Y': + n, width, good := readInt(val, &v, 4) + if !good { + return word, false, false + } + if width <= 2 { + n = year2000(n) + } + t.year = uint(n) + case 'y': + n, _, good := readInt(val, &v, 2) + if !good { + return word, false, false + } + t.year = uint(year2000(n)) + case 'm', 'c': + n, _, good := readInt(val, &v, 2) + if !good { + return word, false, false + } + t.month = uint(n) + case 'M': + m := checkWord(monthNames[:], val, &v) + if m <= 0 { + return word, false, false + } + t.month = uint(m) + case 'b': + m := checkWord(abMonthNames[:], val, &v) + if m <= 0 { + return word, false, false + } + t.month = uint(m) + case 'd', 'e': + n, _, good := readInt(val, &v, 2) + if !good { + return word, false, false + } + t.day = uint(n) + case 'D': + n, _, good := readInt(val, &v, 2) + if !good { + return word, false, false + } + t.day = uint(n) + v += min(len(val)-v, 2) // skip st / nd / th + case 'h', 'I', 'l': + usaTime = true + fallthrough + case 'k', 'H': + n, _, good := readInt(val, &v, 2) + if !good { + return word, false, false + } + t.hour = uint(n) + case 'i': + n, _, good := readInt(val, &v, 2) + if !good { + return word, false, false + } + t.minute = uint(n) + case 's', 'S': + n, _, good := readInt(val, &v, 2) + if !good { + return word, false, false + } + t.second = uint(n) + case 'f': + n, width, good := readInt(val, &v, 6) + if !good { + return word, false, false + } + for ; width < 6; width++ { + n *= 10 + } + t.secondPart = uint(n) + case 'p': + if len(val)-v < 2 || !usaTime { + return word, false, false + } + switch strings.ToUpper(val[v : v+2]) { + case "PM": + daypart = 12 + case "AM": + default: + return word, false, false + } + v += 2 + case 'W': + w := checkWord(dayNames[:], val, &v) + if w <= 0 { + return word, false, false + } + weekday = w + case 'a': + w := checkWord(abDayNames[:], val, &v) + if w <= 0 { + return word, false, false + } + weekday = w + case 'w': + n, _, good := readInt(val, &v, 1) + if !good || n < 0 || n >= 7 { + return word, false, false + } + weekday = n + if weekday == 0 { + weekday = 7 + } + case 'j': + n, _, good := readInt(val, &v, 3) + if !good { + return word, false, false + } + yearday = n + case 'V', 'U', 'v', 'u': + sundayFirst = format[p] == 'U' || format[p] == 'V' + strictWeek = format[p] == 'V' || format[p] == 'v' + n, _, good := readInt(val, &v, 2) + if !good || n < 0 || (strictWeek && n == 0) || n > 53 { + return word, false, false + } + weekNumber = n + case 'X', 'x': + strictWeekYearType = format[p] == 'X' + n, _, good := readInt(val, &v, 4) + if !good { + return word, false, false + } + strictWeekYear = n + case 'r': + w, _, good := subExtract("%I:%i:%S %p", val, &v, t, kind, flags) + if !good { + return w, false, false + } + case 'T': + w, _, good := subExtract("%H:%i:%S", val, &v, t, kind, flags) + if !good { + return w, false, false + } + case '.': + for v < len(val) && isPunct(val[v]) { + v++ + } + case '@': + for v < len(val) && isAlpha(val[v]) { + v++ + } + case '#': + for v < len(val) && val[v] >= '0' && val[v] <= '9' { + v++ + } + default: + return word, false, false + } + } else if format[p] != ' ' { + if val[v] != format[p] { + return word, false, false + } + v++ + } + } + if usaTime { + if t.hour > 12 || t.hour < 1 { + return word, false, false + } + t.hour = t.hour%12 + daypart + } + if sub { + // the recursive %r / %T parse stops after its own format; hand the position back + t.fields = v + return word, false, true + } + if yearday > 0 { + days := calcDaynr(t.year, 1, 1) + yearday - 1 + if days <= 0 || days > maxDayNumber { + return word, false, false + } + t.year, t.month, t.day = dateFromDaynr(days) + } + if weekNumber >= 0 && weekday != 0 { + if (strictWeek && (strictWeekYear < 0 || strictWeekYearType != sundayFirst)) || + (!strictWeek && strictWeekYear >= 0) { + return word, false, false + } + year := t.year + if strictWeek { + year = uint(strictWeekYear) + } + days := calcDaynr(year, 1, 1) + weekdayB := calcWeekday(days, sundayFirst) + if sundayFirst { + pad := 0 + if weekdayB != 0 { + pad = 7 + } + days += pad - weekdayB + (weekNumber-1)*7 + weekday%7 + } else { + pad := 0 + if weekdayB > 3 { + pad = 7 + } + days += pad - weekdayB + (weekNumber-1)*7 + (weekday - 1) + } + if days <= 0 || days > maxDayNumber { + return word, false, false + } + t.year, t.month, t.day = dateFromDaynr(days) + } + if kind == "time" { + flags &^= timeNoZeroDate + } + var warn int + if t.month > 12 || t.hour > 23 || t.minute > 59 || t.second > 59 || + checkDate(*t, t.year != 0 || t.month != 0 || t.day != 0, flags, &warn) { + return word, false, false + } + for ; v < len(val); v++ { + if val[v] != ' ' { + return word, true, true + } + } + return word, false, true +} + +// subExtract runs the %r / %T sub-parse in place, advancing the caller's position. +func subExtract(subFormat, val string, v *int, t *mysqlTime, kind string, flags timeFlags) (string, bool, bool) { + var tt mysqlTime + tt = *t + word, _, ok := extractDateTime(subFormat, val[*v:], &tt, kind, flags, true) + if !ok { + return word, false, false + } + consumed := tt.fields + tt.fields = t.fields + *t = tt + *v += consumed + return word, false, true +} + +// readInt reads up to maxChars digits (my_strtoll10 bounded to val+min(maxChars, len)): +// at least one digit, the value and the digit count. +func readInt(val string, v *int, maxChars int) (n, width int, ok bool) { + end := min(*v+maxChars, len(val)) + i := *v + for i < end && val[i] >= '0' && val[i] <= '9' { + n = n*10 + int(val[i]-'0') + i++ + } + if i == *v { + return 0, 0, false + } + width = i - *v + *v = i + return n, width, true +} + +// checkWord is my_time.cc's check_word: the 1-based index of the list word the value +// starts with, advancing past it; 0 when none matches. +func checkWord(words []string, val string, v *int) int { + rest := val[*v:] + for i, w := range words { + if len(rest) >= len(w) && strings.EqualFold(rest[:len(w)], w) { + *v += len(w) + return i + 1 + } + } + return 0 +} + +// year2000 is year_2000_handling: a one- or two-digit year lands in 1970 .. 2069. +func year2000(y int) int { + if y < 70 { + return y + 2000 + } + if y < 100 { + return y + 1900 + } + return y +} + +func isAlpha(c byte) bool { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') } + +// calcDaynr is my_time.cc's calc_daynr: days since year 0. +func calcDaynr(year, month, day uint) int { + if year == 0 && month == 0 { + return 0 + } + delsum := int(365*year) + 31*(int(month)-1) + int(day) + y := int(year) + if month <= 2 { + y-- + } else { + delsum -= (int(month)*4 + 23) / 10 + } + return delsum + y/4 - (y/100+1)*3/4 +} + +// calcWeekday is my_time.cc's calc_weekday. +func calcWeekday(daynr int, sundayFirstDay bool) int { + offset := 5 + if sundayFirstDay { + offset = 6 + } + return (daynr + offset) % 7 +} + +// dateFromDaynr is my_time.cc's get_date_from_daynr. +func dateFromDaynr(daynr int) (year, month, day uint) { + if daynr <= 365 || daynr >= 3652500 { + return 0, 0, 0 + } + y := uint(daynr * 100 / 36525) + temp := (((y - 1) / 100) + 1) * 3 / 4 + dayOfYear := uint(daynr) - y*365 - (y-1)/4 + temp + daysThisYear := daysInYear(y) + for dayOfYear > daysThisYear { + dayOfYear -= daysThisYear + y++ + daysThisYear = daysInYear(y) + } + leapDay := uint(0) + if daysThisYear == 366 { + if dayOfYear > 31+28 { + dayOfYear-- + if dayOfYear == 31+28 { + leapDay = 1 + } + } + } + m := uint(1) + for i := 0; dayOfYear > daysInMonth[i]; i++ { + dayOfYear -= daysInMonth[i] + m++ + } + return y, m, dayOfYear + leapDay +} + +func daysInYear(y uint) uint { + if isLeap(y) { + return 366 + } + return 365 +} diff --git a/check/mysql/internal/analyze/sysvar_server_test.go b/check/mysql/internal/analyze/sysvar_server_test.go new file mode 100644 index 0000000..aa424d2 --- /dev/null +++ b/check/mysql/internal/analyze/sysvar_server_test.go @@ -0,0 +1,226 @@ +package analyze + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/catalog" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// Every entry of the generated catalog.SysVars, pinned against mysqld: a session read of +// a GLOBAL-only variable and a global read of a SESSION-only one are the statement's +// 1238, spelt as the server spells it; the matching scope reads run. A 1193 from the +// server would mean the generator listed a variable this build does not compile +// (sysVarDefined, parsegen/sysvars.go). +func TestSysVarScopeServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, testSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + run := func(sql string) (int, string) { + _, err := conn.ExecContext(ctx, sql) + if err == nil { + return 0, "" + } + var me *driver.MySQLError + if !errors.As(err, &me) { + t.Errorf("%s: %v", sql, err) + return -1, "" + } + return int(me.Number), me.Message + } + s := load(t) + check := func(sql string, want int, wantMsg string) { + got, msg := run(sql) + if got < 0 { + return + } + if got != want || (want != 0 && msg != wantMsg) { + t.Errorf("%s: the server says %d %q, the checker %d %q", sql, got, msg, want, wantMsg) + return + } + _, err := Analyze(s, sql) + if want == 0 { + if err != nil { + t.Errorf("%s: analyzer %v, the server accepts", sql, err) + } + return + } + var e *Error + if !errors.As(err, &e) || e.Code != want || e.Message != wantMsg { + t.Errorf("%s: analyzer %v, want %d %q", sql, err, want, wantMsg) + } + } + for name, scope := range catalog.SysVars { + session, global := 0, 0 + var sessionMsg, globalMsg string + switch scope { + case 'g': + session, sessionMsg = 1238, fmt.Sprintf("Variable '%s' is a GLOBAL variable", name) + case 's': + global, globalMsg = 1238, fmt.Sprintf("Variable '%s' is a SESSION variable", name) + } + check("SELECT @@session."+name, session, sessionMsg) + check("SELECT @@global."+name, global, globalMsg) + } +} + +// The read's own placement and spelling, pinned: the scope error fires wherever the +// read sits (a dead branch, a subquery), @@local means +// @@session, an unqualified read never scope-fails, and an unknown or component +// variable is not judged (the server's 1193 / 1238 there is a plugin's business). +func TestSysVarPlacementServer(t *testing.T) { + cases := []struct { + sql string + want int + }{ + {"SELECT @@SESSION.Thread_Stack", 1238}, + {"SELECT @@local.thread_stack", 1238}, + {"SELECT COUNT(@@session.port)", 1238}, + {"SELECT IF(1, 1, @@session.port)", 1238}, + {"SELECT 1 FROM users WHERE FALSE AND @@session.port", 1238}, + {"SELECT (SELECT @@session.port)", 1238}, + {"SELECT @@thread_stack", 0}, + {"SELECT @@timestamp", 0}, + {"SELECT @@session.sql_mode", 0}, + {"SELECT @@global.sql_mode", 0}, + {"SELECT @@session.gtid_owned", 0}, + {"SELECT @@global.gtid_owned", 0}, + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, testSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + s := load(t) + for _, c := range cases { + _, serr := conn.ExecContext(ctx, c.sql) + var me *driver.MySQLError + scode := 0 + if errors.As(serr, &me) { + scode = int(me.Number) + } else if serr != nil { + t.Errorf("%s: %v", c.sql, serr) + continue + } + if scode != c.want { + t.Errorf("%s: the server says %d, the checker %d", c.sql, scode, c.want) + continue + } + _, aerr := Analyze(s, c.sql) + var e *Error + acode := 0 + if errors.As(aerr, &e) { + acode = e.Code + } else if aerr != nil { + t.Errorf("%s: analyzer %v", c.sql, aerr) + continue + } + if acode != c.want { + t.Errorf("%s: analyzer says %d, want %d", c.sql, acode, c.want) + continue + } + if c.want != 0 && me != nil && e.Message != me.Message { + t.Errorf("%s: analyzer says %q, the server %q", c.sql, e.Message, me.Message) + } + } +} + +// A function call argument carrying an alias (`f(x AS a)`, the loadable function +// syntax), pinned: a native function refuses it with 1583 (the name lowercased), after +// its own argument count (1582, the name as written); any other name -- a stored +// function, even one that does not exist -- is 1584 before the function is looked up. +// A data dictionary function (catalog.Function.Internal) is 3566 before either check. +func TestFunctionAliasServer(t *testing.T) { + const ddl = "CREATE TABLE t (a INT, b INT);\nCREATE FUNCTION sf(x INT) RETURNS INT DETERMINISTIC RETURN x + 1;\n" + cases := []struct { + sql string + want int + msg string + }{ + {"SELECT ABS(3 AS three)", 1583, "Incorrect parameters in the call to native function 'abs'"}, + {"SELECT abs(3 three)", 1583, "Incorrect parameters in the call to native function 'abs'"}, + {"SELECT instr(a 'p1', 'bar') FROM t", 1583, "Incorrect parameters in the call to native function 'instr'"}, + {"SELECT instr('foobar', 'bar' AS p2)", 1583, "Incorrect parameters in the call to native function 'instr'"}, + {"SELECT abs(nosuchcol AS x) FROM t", 1583, "Incorrect parameters in the call to native function 'abs'"}, + {"SELECT length(concat('a' AS x, 'b'))", 1583, "Incorrect parameters in the call to native function 'concat'"}, + {"SELECT upper(sf(1) AS x)", 1583, "Incorrect parameters in the call to native function 'upper'"}, + {"SELECT 1 UNION SELECT abs(1 AS x)", 1583, "Incorrect parameters in the call to native function 'abs'"}, + {"INSERT INTO t VALUES (abs(1 AS x), 1)", 1583, "Incorrect parameters in the call to native function 'abs'"}, + {"SELECT Abs(1 AS x, 2)", 1582, "Incorrect parameter count in the call to native function 'Abs'"}, + {"SELECT Abs()", 1582, "Incorrect parameter count in the call to native function 'Abs'"}, + {"SELECT conv(255 AS p1, 10)", 1582, "Incorrect parameter count in the call to native function 'conv'"}, + {"SELECT sf(1 AS x)", 1584, "Incorrect parameters in the call to stored function `sf`"}, + {"SELECT sf(1 x)", 1584, "Incorrect parameters in the call to stored function `sf`"}, + {"SELECT sf(1 AS x, 2)", 1584, "Incorrect parameters in the call to stored function `sf`"}, + {"SELECT nosuchfn(1 AS x)", 1584, "Incorrect parameters in the call to stored function `nosuchfn`"}, + {"SELECT INTERNAL_TABLE_ROWS(NULL, NULL, NULL, NULL)", 3566, "Access to native function 'INTERNAL_TABLE_ROWS' is rejected."}, + {"SELECT internal_table_rows()", 3566, "Access to native function 'internal_table_rows' is rejected."}, + {"SELECT get_dd_create_options(1 AS x)", 3566, "Access to native function 'get_dd_create_options' is rejected."}, + {"SELECT 1 FROM t WHERE FALSE AND INTERNAL_TABLE_ROWS(NULL, NULL, NULL, NULL)", 3566, "Access to native function 'INTERNAL_TABLE_ROWS' is rejected."}, + {"SELECT ICU_VERSION()", 0, ""}, + {"SELECT sf(1)", 0, ""}, + {"SELECT abs(1)", 0, ""}, + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, "-- sqlshape: mysql 8.4\n"+ddl) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn := db.Conn() + s, err := schema.Load("-- sqlshape: mysql 8.4\n" + ddl) + if err != nil { + t.Fatal(err) + } + for _, c := range cases { + _, serr := conn.ExecContext(ctx, c.sql) + var me *driver.MySQLError + scode, smsg := 0, "" + if errors.As(serr, &me) { + scode, smsg = int(me.Number), me.Message + } else if serr != nil { + t.Errorf("%s: %v", c.sql, serr) + continue + } + if scode != c.want || smsg != c.msg { + t.Errorf("%s: the server says %d %q, the checker %d %q", c.sql, scode, smsg, c.want, c.msg) + continue + } + _, aerr := Analyze(s, c.sql) + if c.want == 0 { + if aerr != nil { + t.Errorf("%s: analyzer %v, the server accepts", c.sql, aerr) + } + continue + } + var e *Error + if !errors.As(aerr, &e) || e.Code != c.want || e.Message != c.msg { + t.Errorf("%s: analyzer %v, want %d %q", c.sql, aerr, c.want, c.msg) + } + } +} diff --git a/check/mysql/internal/analyze/sysvar_test.go b/check/mysql/internal/analyze/sysvar_test.go new file mode 100644 index 0000000..7626fb0 --- /dev/null +++ b/check/mysql/internal/analyze/sysvar_test.go @@ -0,0 +1,75 @@ +package analyze + +import ( + "errors" + "testing" +) + +// The checker's side of TestSysVarScopeServer / TestSysVarPlacementServer / +// TestFunctionAliasServer: the same rules without a server. +func TestSysVarScope(t *testing.T) { + s := load(t) + cases := []struct { + sql string + want int + msg string + }{ + {"SELECT @@session.thread_stack", 1238, "Variable 'thread_stack' is a GLOBAL variable"}, + {"SELECT @@LOCAL.Thread_Stack", 1238, "Variable 'thread_stack' is a GLOBAL variable"}, + {"SELECT @@global.timestamp", 1238, "Variable 'timestamp' is a SESSION variable"}, + {"SELECT COUNT(@@session.port)", 1238, "Variable 'port' is a GLOBAL variable"}, + {"SELECT IF(1, 1, @@session.port)", 1238, "Variable 'port' is a GLOBAL variable"}, + {"SELECT (SELECT @@session.port)", 1238, "Variable 'port' is a GLOBAL variable"}, + {"SELECT 1 FROM users WHERE FALSE AND @@session.port", 1238, "Variable 'port' is a GLOBAL variable"}, + {"SELECT @@thread_stack", 0, ""}, // unqualified: never a scope error + {"SELECT @@timestamp", 0, ""}, // unqualified session-only + {"SELECT @@session.sql_mode", 0, ""}, // both scopes + {"SELECT @@global.sql_mode", 0, ""}, // both scopes + {"SELECT @@session.nosuchvar", 0, ""}, // unknown: a plugin's business (server: 1193) + {"SELECT @@global.dragnet.log_error_filter_rules", 0, ""}, // a component's variable + } + for _, c := range cases { + _, err := Analyze(s, c.sql) + if c.want == 0 { + if err != nil { + t.Errorf("%s: %v, want ok", c.sql, err) + } + continue + } + var e *Error + if !errors.As(err, &e) || e.Code != c.want || e.Message != c.msg { + t.Errorf("%s: %v, want %d %q", c.sql, err, c.want, c.msg) + } + } +} + +func TestFunctionAlias(t *testing.T) { + s := load(t) + cases := []struct { + sql string + want int + msg string + }{ + {"SELECT ABS(3 AS three)", 1583, "Incorrect parameters in the call to native function 'abs'"}, + {"SELECT instr('foobar', 'bar' p2)", 1583, "Incorrect parameters in the call to native function 'instr'"}, + {"SELECT abs(nosuchcol AS x) FROM users", 1583, "Incorrect parameters in the call to native function 'abs'"}, + {"INSERT INTO metrics (id, small, tiny, y) VALUES (abs(1 AS x), 1, 1, 2024)", 1583, "Incorrect parameters in the call to native function 'abs'"}, + {"SELECT Abs(1 AS x, 2)", 1582, "Incorrect parameter count in the call to native function 'Abs'"}, + {"SELECT Internal_Table_Rows()", 3566, "Access to native function 'Internal_Table_Rows' is rejected."}, + {"SELECT nosuchfn(1 AS x)", 1584, "Incorrect parameters in the call to stored function `nosuchfn`"}, + {"SELECT abs(1)", 0, ""}, + } + for _, c := range cases { + _, err := Analyze(s, c.sql) + if c.want == 0 { + if err != nil { + t.Errorf("%s: %v, want ok", c.sql, err) + } + continue + } + var e *Error + if !errors.As(err, &e) || e.Code != c.want || e.Message != c.msg { + t.Errorf("%s: %v, want %d %q", c.sql, err, c.want, c.msg) + } + } +} diff --git a/check/mysql/internal/analyze/target_subquery_test.go b/check/mysql/internal/analyze/target_subquery_test.go new file mode 100644 index 0000000..2c2eaca --- /dev/null +++ b/check/mysql/internal/analyze/target_subquery_test.go @@ -0,0 +1,89 @@ +package analyze + +import ( + "context" + "errors" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// Found by x/stmtprobe (the facts oracle, first run): the server refuses an UPDATE or +// DELETE whose subquery reads the table being written -- 1093 for the table itself, in +// WHERE, EXISTS or IN alike, 1443 for a view over it -- and the analyzer accepted both. A +// derived table over the target is materialized and accepted, INSERT ... SELECT from its own +// table is accepted, a SELECT is unconcerned (all measured, TestTargetInSubqueryServer). +const targetSubquerySchema = `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY, a INT, b INT NOT NULL); +CREATE TABLE u (id INT PRIMARY KEY, a INT); +CREATE VIEW v AS SELECT id, a FROM t WHERE a = 1; +CREATE VIEW w AS SELECT id, a FROM v; +` + +var targetSubqueryCases = []struct { + sql string + code int +}{ + {"UPDATE t AS t0 SET a = 5 WHERE t0.id IN (SELECT s0.id FROM t AS s0 WHERE s0.b = 1)", 1093}, + {"UPDATE t AS t0 SET a = 5 WHERE EXISTS (SELECT 1 FROM t AS s0 WHERE s0.b = t0.a)", 1093}, + {"UPDATE t AS t0 SET a = 5 WHERE EXISTS (SELECT 1 FROM u AS s0 WHERE s0.a = t0.a)", 0}, + {"UPDATE t AS t0 SET a = 5 WHERE t0.id IN (SELECT x.id FROM (SELECT s0.id FROM t AS s0) AS x)", 0}, + {"UPDATE t AS t0 SET a = (SELECT MAX(s0.a) FROM u AS s0) WHERE t0.id = 1", 0}, + {"UPDATE t AS t0 SET a = 5 WHERE t0.id IN (SELECT s0.id FROM v AS s0)", 1443}, + {"UPDATE t AS t0 SET a = 5 WHERE t0.id IN (SELECT s0.id FROM w AS s0)", 1443}, + {"DELETE FROM t AS t0 WHERE t0.id IN (SELECT s0.id FROM t AS s0 WHERE s0.b = 9)", 1093}, + {"DELETE FROM t AS t0 WHERE EXISTS (SELECT 1 FROM t AS s0 WHERE s0.b = t0.a + 9)", 1093}, + {"DELETE FROM t AS t0 WHERE EXISTS (SELECT 1 FROM v AS s0 WHERE s0.a = t0.a + 9)", 1443}, + {"DELETE FROM t AS t0 WHERE t0.id IN (SELECT s0.id FROM u AS s0)", 0}, + {"INSERT INTO u (id, a) SELECT s0.id + 10, s0.a FROM u AS s0", 0}, + {"SELECT t0.id FROM t AS t0 WHERE EXISTS (SELECT 1 FROM t AS s0 WHERE s0.b = t0.a)", 0}, +} + +func TestTargetInSubquery(t *testing.T) { + s, err := schema.Load(targetSubquerySchema) + if err != nil { + t.Fatal(err) + } + for _, c := range targetSubqueryCases { + _, err := Analyze(s, c.sql) + if errCode(err) != c.code { + t.Errorf("%s: %v, want %d", c.sql, err, c.code) + } + } + // the message names the target's alias, and 1443 the view's + if _, err := Analyze(s, targetSubqueryCases[0].sql); err == nil || err.Error() != "You can't specify target table 't0' for update in FROM clause (MySQL error 1093)" { + t.Errorf("1093 message: %v", err) + } + if _, err := Analyze(s, targetSubqueryCases[9].sql); err == nil || err.Error() != "The definition of table 's0' prevents operation DELETE on table 't0'. (MySQL error 1443)" { + t.Errorf("1443 message: %v", err) + } +} + +// The measurements above, on mysqld 8.4. +func TestTargetInSubqueryServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, targetSubquerySchema+"INSERT INTO t VALUES (1, 1, 1), (2, 2, 2);\nINSERT INTO u VALUES (1, 1);\n") + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + for _, c := range targetSubqueryCases { + _, err := db.Conn().ExecContext(ctx, c.sql) + var me *driver.MySQLError + got := 0 + if errors.As(err, &me) { + got = int(me.Number) + } + if got != c.code { + t.Errorf("server: %s => %v, want %d", c.sql, err, c.code) + } + } +} diff --git a/check/mysql/internal/analyze/terms_test.go b/check/mysql/internal/analyze/terms_test.go new file mode 100644 index 0000000..a346337 --- /dev/null +++ b/check/mysql/internal/analyze/terms_test.go @@ -0,0 +1,64 @@ +package analyze + +import ( + "testing" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/facts" +) + +// `col = NULL` is never true: no equality fact, so nothing is pinned or proved single by it. +func TestNullEqualityIsNoFact(t *testing.T) { + s, err := schema.Load("-- sqlshape: mysql 8.4\nCREATE TABLE orders (id INT PRIMARY KEY, tenant_id INT NOT NULL);") + if err != nil { + t.Fatal(err) + } + for _, sql := range []string{"SELECT id FROM orders WHERE tenant_id = NULL", "SELECT id FROM orders WHERE id = NULL"} { + r, err := Analyze(s, sql) + if err != nil { + t.Fatal(err) + } + if n := len(r.Facts.Top.Fixed); n != 0 { + t.Errorf("%s: %d fixed columns, want none (%+v)", sql, n, r.Facts.Top.Preds) + } + } + r, _ := Analyze(s, "SELECT id FROM orders WHERE tenant_id = 1") + if len(r.Facts.Top.Fixed) != 1 { + t.Errorf("= 1: %+v", r.Facts.Top) + } +} + +// SET col = DEFAULT stores the column's default: a literal default is a Const value, any +// other default the expression DEFAULT. +func TestSetDefaultStoresTheLiteralDefault(t *testing.T) { + s, err := schema.Load("-- sqlshape: mysql 8.4\nCREATE TABLE orders (id INT PRIMARY KEY, status VARCHAR(10) NOT NULL DEFAULT 'draft', n INT DEFAULT 3, ts DATETIME DEFAULT CURRENT_TIMESTAMP, e VARCHAR(10) DEFAULT (LOWER('A')));") + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatal(s.Problems) + } + r, err := Analyze(s, "UPDATE orders SET status = DEFAULT, n = DEFAULT, ts = DEFAULT, e = DEFAULT WHERE id = 1") + if err != nil { + t.Fatal(err) + } + w := r.Facts.Writes[0] + want := map[string]facts.Term{ + // constText's tagged spelling (facts.go), not the source's own quoting: a Const + // must compare equal to a literal written in the statement (x/obligation's Const + // vs Const, and its transitions/stateOf), whatever DEFAULT's own text looked like. + "status": {Kind: facts.Const, Const: "sdraft"}, + "n": {Kind: facts.Const, Const: "i3"}, + "ts": {Kind: facts.Known, Text: "DEFAULT"}, + "e": {Kind: facts.Known, Text: "DEFAULT"}, + } + for i, col := range w.Assigned { + if got := w.Values[i]; got != want[col] { + t.Errorf("%s: %+v, want %+v", col, got, want[col]) + } + } + r, _ = Analyze(s, "INSERT INTO orders (id, status) VALUES (1, DEFAULT)") + if v := r.Facts.Writes[0].Values[1]; v != want["status"] { + t.Errorf("INSERT DEFAULT: %+v", v) + } +} diff --git a/check/mysql/internal/analyze/testdata/corpus_baseline.txt b/check/mysql/internal/analyze/testdata/corpus_baseline.txt new file mode 100644 index 0000000..6aa5732 --- /dev/null +++ b/check/mysql/internal/analyze/testdata/corpus_baseline.txt @@ -0,0 +1,2527 @@ +# corpus probe: the statements on which the analyzer and MySQL knowingly disagree. +# file class key sha1(sql) first line. Rewrite with: go test ./check/mysql/internal/analyze -run TestCorpusReplay -corpus-update +alter_table STRICT 1146 d66e6614cdd6 INSERT INTO `#sql2` VALUES (1) +alter_table STRICT 1146 d3321e3a4a2f INSERT INTO `@0023sql1` VALUES (2) +alter_table LENIENT 1054 9fbfbadbdf9d UPDATE t2 SET f = f + 10 +alter_table-big CODE 1137->1146 b637156ed63b SELECT * FROM t4 AS a, t4 AS b +alter_table-big STRICT 1146 9a4fdcaa85b1 INSERT INTO t4 VALUES (2) +alter_table-big CODE 1137->1146 e48892bbd50f DELETE a FROM t4 AS a, t4 AS b +alter_table-big CODE 1137->1146 f4171e8fd27d DELETE b FROM t4 AS a, t4 AS b +alter_table_myisam LENIENT 1054 9fbfbadbdf9d UPDATE t2 SET f = f + 10 +archive DIFF column type smallint->smallint unsigned a6cdfbf8e239 select period FROM t1 +archive DIFF column type smallint->smallint unsigned b81b3e8c36c0 select * FROM t1 +archive DIFF column type smallint->smallint unsigned c6e5e9e77827 select t1.* FROM t1 +archive DIFF column type tinyint->tinyint unsigned 2c317a26cbce select t2.fld3,companynr FROM t2 where companynr = 57+1 order by fld3 +archive DIFF column type tinyint->tinyint unsigned 26a7b6ab6f9a select fld3,companynr FROM t2 where companynr = 58 order by fld3 +archive DIFF column type int->int unsigned 86d0eefb9842 select fld1,fld3 FROM t2 where fld3="Colombo" or fld3 = "nondecreasing" order by fld3 +archive DIFF column type int->int unsigned 1270d64e9993 select fld1,fld3 FROM t2 where companynr = 37 and fld3 like 'f%' +archive DIFF column type int->int unsigned 6d969ed642a3 select fld1,fld3 FROM t2 where fld1 like "25050%" +archive DIFF column type int->int unsigned 3e472c98a772 select fld1,fld3 FROM t2 where fld1 like "25050_" +archive DIFF column type int->int unsigned 790d26546ab9 select * FROM t3 where fld3='bonfire' +archive DIFF column type int->int unsigned 20b84309b4df select * FROM t4 where fld3='bonfire' +archive DIFF column type int->int unsigned 4e8f61654bca SELECT * FROM t2 +archive LENIENT 1031 c2422558d986 DELETE FROM t2 +archive DIFF column type int->int unsigned 7a3e82da2c6e SELECT * FROM t2 WHERE auto != 100000 +archive LENIENT 1022 1c858b33c23f INSERT INTO t5 VALUES (23, "foo") +archive LENIENT 1022 c91321bd7756 INSERT INTO t5 VALUES (3, "foo") +archive LENIENT 1031 2b6933d1b616 UPDATE t SET j = '[1]' +archive LENIENT 1031 f4a84fd0c683 DELETE FROM archive_table +archive LENIENT 1031 f3037b0cf55c DELETE FROM v +archive LENIENT 1031 cae29f4a53e0 DELETE t1, t2 FROM archive_table AS t1, innodb_table AS t2 +archive LENIENT 1031 3c423823545d DELETE t1 FROM v AS t1, innodb_table AS t2 WHERE t1.id = t2.id +archive LENIENT 1031 78d9626149c6 UPDATE archive_table SET x = 1000 +archive LENIENT 1031 37c0cda45f4f UPDATE archive_table AS t1, innodb_table AS t2 +archive LENIENT 1031 b96a0a80f90d UPDATE v SET x = 1000 +archive LENIENT 1031 d7fa2540a276 UPDATE v AS t1, innodb_table AS t2 +archive LENIENT 1031 d92141ef1366 UPDATE archive_table SET x = 1 WHERE id = 1 +archive LENIENT 1031 230bbccb532a UPDATE archive_table SET x = x + 1 WHERE FALSE +archive LENIENT 1031 ba685283396c UPDATE archive_table AS t1, innodb_table AS t2 +archive LENIENT 1031 d6ed662b4c80 UPDATE v SET x = 1 WHERE id = 1 +archive LENIENT 1031 c25b362f5a67 UPDATE v AS t1, innodb_table AS t2 +archive LENIENT 1031 a7bd1fa58c41 UPDATE archive_table SET x = x + 1 +archive LENIENT 1031 86adcff5b44a UPDATE archive_table SET x = NULL +archive LENIENT 1031 f87f328697c1 UPDATE archive_table AS t1, innodb_table AS t2 +archive LENIENT 1031 e84d19fd270b UPDATE v SET x = x + 1 WHERE id = 1 +archive LENIENT 1031 edac3f614a05 UPDATE v AS t1, innodb_table AS t2 +auto_increment RUNTIME 1264 bb8ef6641894 INSERT INTO t1 VALUES (NULL), (NULL), (NULL) +auto_increment LENIENT 1467 bf8404596e9e INSERT INTO t1 VALUES (NULL), (18446744073709551615-1), (NULL) +bigint DIFF column type bigint->decimal 15c3444b1c69 select +9999999999999999999,-9999999999999999999 +bigint DIFF column type bigint->decimal 354eb45aa3be select -(0-3),round(-(0-3)), round(9999999999999999999) +bigint DIFF column type bigint->decimal 0d2847957d01 select -1,-11,-101,-1001,-10001,-100001,-1000001,-10000001,-100000001,-1000000001,-10000000001,-1000 +bigint DIFF column type decimal unsigned->decimal 055b08222849 select * from t1 +bigint DIFF column type bigint->decimal fefd134ec714 select -(-(9223372036854775808)) +bigint DIFF column type bigint->decimal 16abe0dd104f select --9223372036854775808, ---9223372036854775808, ----9223372036854775808 +bigint DIFF column type bigint->decimal 6b713dd8c12b select -(-9223372036854775808), -(-(-9223372036854775808)) +case DIFF column type bigint->int 92afc20d74cb select case a when 1 then 2 when 2 then 3 else 0 end as fcase, count(*) from t1 group by fcase +case LENIENT 1267 48ee974fddc8 SELECT CASE +case LENIENT 1270 c95dbc13f467 SELECT CASE _latin1'a' COLLATE latin1_general_ci +case RUNTIME 1690 f07c1edd3068 SELECT col_int FROM t1 +cast DIFF column type datetime->string 4970e6a913ae SELECT CONVERT(TIMESTAMP "2004-01-22 21:45:33" USING latin1) +cast DIFF nullable true->false 1ac03ccb6893 select +cast DIFF nullable true->false b2e2112ee317 SELECT +cast LENIENT 1439 f061f06b390e SELECT CAST( 'a' AS BINARY(4294967296)) +cast LENIENT 1439 0a8bb4be5bb3 SELECT CAST( 'a' AS BINARY(4294967296784564)) +cast DIFF column type float->double d28f8b03f06f SELECT CAST(1/3 AS FLOAT(10)), CAST(1/3 AS FLOAT(53)) +cast LENIENT 1426 1aac47be91b3 SELECT CAST(1/3 AS FLOAT(54)) +cast RUNTIME 1690 57d992d6655c SELECT PERIOD_ADD(200905, CAST(3.14e19 AS DOUBLE)) +cast LENIENT 1210 dbec57f35267 SELECT CAST(ST_PointFromText('POINT(10 10)') AS YEAR) +cast RUNTIME 3854 64b92e1161a0 SELECT CAST(x <= 0x4891f642 AS UNSIGNED) FROM t +cast RUNTIME 3854 6d143811b25d SELECT CAST(STRCMP(x, 0x4891f642) AS UNSIGNED) FROM t +cast LENIENT 1426 8dc617bf79de select CAST( 1 AS FLOAT ( 54 ) ) and CAST( 1 AS FLOAT ( 54 ) ) +cast CODE 1426->1146 9e4c030b4f97 SELECT * from v0 WHERE v1 LIKE v1 ESCAPE CONVERT ( TRUE, DATETIME (100 ) ) +charset LENIENT 1115 69aeb3c49e95 SELECT CONVERT(_latin1'abcd' USING filename) +charset LENIENT 1115 47e640d0cbe8 SELECT CAST(_latin1'abcd' AS CHAR CHARACTER SET filename) +charset RUNTIME 3854 0c484c408f19 SELECT 'x' AS '🐬' +check_constraints RUNTIME 3819 b8787742eb7a INSERT INTO t1 VALUES(1) +check_constraints RUNTIME 3819 0cfc54392caf INSERT INTO t1 VALUES(10) +check_constraints RUNTIME 3819 3b1af52db195 INSERT INTO t1 VALUES() +check_constraints LENIENT 13 dd5c5b1614c5 LOAD DATA INFILE '../../std_data/loaddata3.dat' IGNORE INTO TABLE t1 +check_constraints RUNTIME 3819 84ad7d42af66 INSERT INTO v1(f1) VALUES(3) +check_constraints RUNTIME 3819 187189ac231b REPLACE INTO v1(f1) VALUES(3) +check_constraints RUNTIME 3819 93e7a4d7f7af UPDATE v1 SET f1 = 8 WHERE f1 = 6 +clear_diag_area LENIENT 1051 94b5cea3e05d CALL p4_unhandled_exception_returned +comments DIFF column name 9848ceb3faf2 select 1 /*!32301 +1 */ +comments DIFF column name 1b4cf10a7540 select 1 + /*!00000 2 */ + 3 /*!99999 noise*/ + 4 +commit LENIENT 1792 c6852b774c9e INSERT INTO t1 VALUES (1), (2) +commit LENIENT 1792 95efa0eecccd UPDATE t1 SET a= 3 +commit LENIENT 1792 cd655d1ff73b DELETE FROM t1 +commit LENIENT 1792 3fc1e2aa76a6 SELECT f1() +commit LENIENT 1792 ae2cd589953d CALL p1() +commit LENIENT 1792 6cb8c187ceaa INSERT INTO v1 VALUES (1), (2) +commit LENIENT 1792 b84b6160630e LOCK TABLE t1 WRITE +commit LENIENT 1792 60f251c8bd12 INSERT INTO t1 VALUES (1) +commit LENIENT 1792 dd17d6fe1ff7 UPDATE t1 SET a=2 +compare DIFF column type int->int unsigned 9b8e478af5eb SELECT b,c FROM t1 WHERE b = 1 AND CONCAT(b,c) = '0101' +compression SCHEMA no database selected 087ab405b00a INSERT INTO wl13292.t1 VALUES (1) +conflicting_variables_plugin LENIENT 1193 b89d5e7be10e SELECT @@sql_mode2 +connection_compression_zstd LENIENT 13 cf643f11fe03 LOAD DATA INFILE '../../std_data/inconsistent_scan.csv' INTO TABLE t1 COLUMNS TERMINATED BY "," IGNO +constraints LENIENT 3941 71c6b2ad44aa CALL alter_constraint_proc() +count_distinct DIFF nullable false->true b04b7bf844e7 SELECT '' FROM t GROUP BY 1 WITH ROLLUP HAVING GROUP_CONCAT('') +create DIFF nullable true->false 3b6c27afc3da select a, +create SCHEMA no database selected 9e2cb4e10724 select database() +create SCHEMA no database selected ddbd71e144a8 select database(), user() +create_select_atomic LENIENT 1050 649668e0be20 CALL proc1() +create_select_atomic SCHEMA session: Error N (HYN): Only BINLOG INSERT, COMMIT and ROLLBACK state 60f251c8bd12 INSERT INTO t1 VALUES (1) +csv DIFF column type smallint->smallint unsigned f03475fa0f23 select period from t1 +csv DIFF column type smallint->smallint unsigned 055b08222849 select * from t1 +csv DIFF column type smallint->smallint unsigned 4c1877dbac57 select t1.* from t1 +csv DIFF column type tinyint->tinyint unsigned 28d175eef713 select t2.fld3,companynr from t2 where companynr = 57+1 order by fld3 +csv DIFF column type tinyint->tinyint unsigned d5a654516d39 select fld3,companynr from t2 where companynr = 58 order by fld3 +csv DIFF column type int->int unsigned fca878159cf2 SELECT * FROM test_repair_table4 +csv DIFF column type int->int unsigned 63d219934bf6 SELECT * FROM test_repair_table5 +csv_not_null RUNTIME 1364 01f74cae3e59 INSERT INTO t1 VALUES(default(a),default(b), default(c), default(d), +ctype_ascii RUNTIME 1366 ee487ad92119 INSERT INTO t1 VALUES(0xe5) +ctype_binary_myisam DIFF column type string->binary c0e5175c556d SELECT GROUP_CONCAT(IFNULL(a,'')) FROM t1 +ctype_binary_myisam DIFF column type string->binary 4cea8dcea3a3 SELECT GROUP_CONCAT(IF(a,a,'')) FROM t1 +ctype_binary_myisam DIFF column type string->binary 7b1ecc89d278 SELECT GROUP_CONCAT(CASE WHEN a THEN a ELSE '' END) FROM t1 +ctype_binary_myisam DIFF column type string->binary da6aba2f4f8c SELECT COALESCE(a,'') FROM t1 GROUP BY 1 +ctype_collate LENIENT 1253 50ea31c28b6c SELECT latin1_f FROM t1 ORDER BY latin1_f COLLATE koi8r_general_ci +ctype_collate LENIENT 1253 b8890264a97a SELECT latin1_f COLLATE koi8r_general_ci AS latin1_f_as FROM t1 ORDER BY latin1_f_as +ctype_collate CODE 1253->1055 88014095a461 SELECT latin1_f,count(*) FROM t1 GROUP BY latin1_f COLLATE koi8r_general_ci +ctype_collate LENIENT 1273 5bbc8f32732f SELECT DISTINCT latin1_f COLLATE koi8r FROM t1 +ctype_collate LENIENT 1267 b8d5e523cb36 SELECT * FROM t1 WHERE s1 = s2 +ctype_collate DIFF nullable true->false a25b406bc65f select least(_latin1'a',_latin2'b',_latin5'c' collate latin5_turkish_ci) +ctype_collate DIFF nullable true->false 50539db48f90 select concat(_latin1'a',_latin2'b',_latin5'c' collate latin5_turkish_ci) +ctype_gb18030 LENIENT 1300 748ca83028b9 SELECT HEX(CONVERT(_gb18030 0xA13941 USING ucs2)) +ctype_gb18030 LENIENT 1300 f6da1c43e2d9 SELECT HEX(CONVERT(_gb18030 0xFFFE USING ucs2)) +ctype_gb18030 LENIENT 1300 fc7761787250 SELECT HEX(CONVERT(_gb18030 0x81308140 USING ucs2)) +ctype_gb18030 LENIENT 13 bc6c181a50a7 LOAD DATA INFILE '../../std_data/bug21542698.dat' INTO TABLE t1 CHARACTER SET gb18030 +ctype_gb18030_encoding_utf8 DIFF nullable true->false af29a85f42e4 SELECT * FROM t1b RIGHT JOIN t1a USING (c) +ctype_gb18030_encoding_utf8 DIFF nullable true->false 22b6b9415942 SELECT * FROM t2b RIGHT JOIN t2a USING (c) +ctype_latin1_de RUNTIME 1366 543b6ae49818 insert into t1 (word) values ('ss'),(0xDF),(0xE4),('ae') +ctype_latin2_ch_myisam DIFF column type smallint->smallint unsigned cb5110a4668f SELECT ID, Post, HEX(WEIGHT_STRING(Post)) FROM t1 +ctype_ldml LENIENT 1273 9ce313c8f9e1 SELECT hex(weight_string(_utf8mb4'a' collate utf8mb4_test_ci)) +ctype_ldml LENIENT 1273 ad2733c34f67 SELECT hex(weight_string(convert(_utf32 0x10002 using utf8mb4) collate utf8mb4_test_ci)) +ctype_ldml LENIENT 1273 9dae6543c933 SELECT hex(@a:=convert(_utf32 0x10400 using utf8mb4) collate utf8mb4_test_ci), hex(lower(@a)) +ctype_ldml LENIENT 1273 f79c6fadf3a8 SELECT hex(@a:=convert(_utf32 0x10428 using utf8mb4) collate utf8mb4_test_ci), hex(upper(@a)) +ctype_ldml LENIENT 1273 bde511a89d1d SELECT hex(@a:=convert(_utf32 0x2C00 using utf8mb4) collate utf8mb4_test_ci), hex(lower(@a)) +ctype_ldml LENIENT 1273 18e042f8c130 SELECT hex(@a:=convert(_utf32 0x2C30 using utf8mb4) collate utf8mb4_test_ci), hex(upper(@a)) +ctype_ldml LENIENT 1273 25c5acbc7b00 SELECT hex(weight_string(convert(_utf32 0x61 using utf8mb4) collate utf8mb4_test_ci)) +ctype_ldml LENIENT 1273 c0b50b0c500c SELECT hex(weight_string(convert(_utf32 0x62 using utf8mb4) collate utf8mb4_test_ci)) +ctype_ldml LENIENT 1273 4488bbe69776 SELECT hex(weight_string(convert(_utf32 0x10062 using utf8mb4) collate utf8mb4_test_ci)) +ctype_ldml LENIENT 1273 933d4e0ba3ed SELECT hex(weight_string(convert(_utf32 0x10400 using utf8mb4) collate utf8mb4_test_ci)) +ctype_ldml LENIENT 1273 2b0ec886cfef SELECT hex(weight_string(convert(_utf32 0x100400 using utf8mb4) collate utf8mb4_test_ci)) +ctype_ldml LENIENT 1273 ad3cc08142ed SELECT hex(weight_string(_utf8mb4 0x64 collate utf8mb4_test_ci)) +ctype_ldml LENIENT 1273 2517f7c74fd0 SELECT hex(weight_string(convert(_ucs2 0x0064017e using utf8mb4) collate utf8mb4_test_ci)) +ctype_ldml LENIENT 1273 86b0971b91da SELECT hex(weight_string(convert(_ucs2 0x0044017e using utf8mb4) collate utf8mb4_test_ci)) +ctype_ldml LENIENT 1273 dfa5fcbb39f6 SELECT hex(weight_string(convert(_ucs2 0x0044017d using utf8mb4) collate utf8mb4_test_ci)) +ctype_ldml LENIENT 1273 80aff5c5f0c8 select "foo" = "foo " collate latin1_test +ctype_ldml LENIENT 1273 a2fdf08e3754 SELECT hex(weight_string(_utf8mb4'a' collate utf8mb4_test_400_ci)) +ctype_ldml LENIENT 1273 acb2b686ad03 SELECT hex(weight_string(convert(_utf32 0x10002 using utf8mb4) collate utf8mb4_test_400_ci)) +ctype_ldml LENIENT 1273 2adc19e24dd0 SELECT hex(@a:=convert(_utf32 0x10400 using utf8mb4) collate utf8mb4_test_400_ci), hex(lower(@a)) +ctype_ldml LENIENT 1273 20527db579f4 SELECT hex(@a:=convert(_utf32 0x10428 using utf8mb4) collate utf8mb4_test_400_ci), hex(upper(@a)) +ctype_ldml LENIENT 1273 039a3cb4b7e8 SELECT hex(@a:=convert(_utf32 0x2C00 using utf8mb4) collate utf8mb4_test_400_ci), hex(lower(@a)) +ctype_ldml LENIENT 1273 ce31e0cbf566 SELECT hex(@a:=convert(_utf32 0x2C30 using utf8mb4) collate utf8mb4_test_400_ci), hex(upper(@a)) +ctype_many DIFF column type string->binary 3abd07b0a7e7 SELECT * FROM t1 +ctype_many DIFF column type string->binary e323a3538ceb SELECT min(comment),count(*) FROM t1 GROUP BY ucs2_f +ctype_many LENIENT 1267 d8bb7239dfe8 SELECT CHARSET(CONCAT(utf8mb3, ucs2)) FROM t1 +ctype_many LENIENT 1267 beb71b6ec97d SELECT CHARSET(CONCAT(utf8mb3, utf16)) FROM t1 +ctype_many LENIENT 1267 dbd382f8ea24 SELECT CHARSET(CONCAT(ucs2, utf8mb3)) FROM t1 +ctype_many LENIENT 1267 b436d43bf1fb SELECT CHARSET(CONCAT(ucs2, utf16)) FROM t1 +ctype_many LENIENT 1267 935002dd9ca6 SELECT CHARSET(CONCAT(utf16, utf8mb3)) FROM t1 +ctype_many LENIENT 1267 772637feb2d4 SELECT CHARSET(CONCAT(utf16, ucs2)) FROM t1 +ctype_many RUNTIME 1242 b3eb00932b23 SELECT * FROM t2 WHERE a=(SELECT a FROM t1) AND a=_LATIN1'x' +ctype_recoding DIFF column type string->binary 9a3e25e51ef8 SELECT 'тест' as s +ctype_ucs DIFF column type binary->string 8d31ece50a98 SELECT LPAD(_ucs2 X'0420',10,_ucs2 X'0421') +ctype_ucs DIFF column type binary->string 3626c98cf8b6 SELECT LPAD(_ucs2 X'0420',10,_ucs2 X'04210422') +ctype_ucs DIFF column type binary->string 1bace42f8081 SELECT LPAD(_ucs2 X'0420',10,_ucs2 X'042104220423') +ctype_ucs DIFF column type binary->string 01c391039faf SELECT LPAD(_ucs2 X'0420042104220423042404250426042704280429042A042B',10,_ucs2 X'042104220423') +ctype_ucs DIFF column type binary->string f0b7d173359c SELECT RPAD(_ucs2 X'0420',10,_ucs2 X'0421') +ctype_ucs DIFF column type binary->string 6a9a6798cc84 SELECT RPAD(_ucs2 X'0420',10,_ucs2 X'04210422') +ctype_ucs DIFF column type binary->string eb5007a692e5 SELECT RPAD(_ucs2 X'0420',10,_ucs2 X'042104220423') +ctype_ucs DIFF column type binary->string 5de56368bb9b SELECT RPAD(_ucs2 X'0420042104220423042404250426042704280429042A042B',10,_ucs2 X'042104220423') +ctype_ucs DIFF column count 6b5e2a563541 SELECT '00' UNION SELECT '10' INTO OUTFILE 'tmpp.txt' +ctype_ucs DIFF column count 482172a0d82b SELECT '00' UNION SELECT '10' INTO OUTFILE 'tmpp2.txt' CHARACTER SET ucs2 +ctype_ucs DIFF column type binary->string 592299046777 select insert(_ucs2 0x006100620063,10,2,_ucs2 0x006400650066) +ctype_ucs DIFF column type binary->string 158c150f21d4 select insert(_ucs2 0x006100620063,1,2,_ucs2 0x006400650066) +ctype_ucs DIFF column type binary->string 13fd5f872954 select concat(a,if(b<10,_ucs2 0x0061,_ucs2 0x0062)) from t1 +ctype_ucs DIFF column type binary->string 14a46bc8dab0 select concat(a,if(b>10,_ucs2 0x0061,_ucs2 0x0062)) from t1 +ctype_ucs LENIENT 1267 794d84c70b8b select concat(a,if(b<10,_ucs2 0x00C0,_ucs2 0x0062)) from t1 +ctype_ucs LENIENT 1267 d793b2cb42e2 select concat(a,if(b>10,_ucs2 0x00C0,_ucs2 0x0062)) from t1 +ctype_ucs LENIENT 1267 9713b28168fd select concat(a,if(b<10,_ucs2 0x0062,_ucs2 0x00C0)) from t1 +ctype_ucs LENIENT 1267 ea7182b3ffd2 select concat(a,if(b>10,_ucs2 0x0062,_ucs2 0x00C0)) from t1 +ctype_ucs RUNTIME 3854 9397b1259878 select * from t1 where a=if(b<10,_ucs2 0x00C0,_ucs2 0x0062) +ctype_ucs RUNTIME 1366 8457d5b4064f INSERT INTO t1 VALUES (CONVERT('9e99999999' USING ucs2)) +ctype_unicode900 LENIENT 1300 ee77ec0e3e16 SELECT HEX(CONVERT(_utf8mb4 0x8F USING ucs2)) +ctype_unicode900 LENIENT 1300 f415338f5333 SELECT HEX(CONVERT(_utf8mb4 0xC230 USING ucs2)) +ctype_unicode900 LENIENT 1300 dd7b5237ff0d SELECT HEX(CONVERT(_utf8mb4 0xE234F1 USING ucs2)) +ctype_unicode900 LENIENT 1300 aa9c8ff3672e SELECT HEX(CONVERT(_utf8mb4 0xF4E25634 USING ucs2)) +ctype_unicode900_as_cs LENIENT 1300 ee77ec0e3e16 SELECT HEX(CONVERT(_utf8mb4 0x8F USING ucs2)) +ctype_unicode900_as_cs LENIENT 1300 f415338f5333 SELECT HEX(CONVERT(_utf8mb4 0xC230 USING ucs2)) +ctype_unicode900_as_cs LENIENT 1300 dd7b5237ff0d SELECT HEX(CONVERT(_utf8mb4 0xE234F1 USING ucs2)) +ctype_unicode900_as_cs LENIENT 1300 aa9c8ff3672e SELECT HEX(CONVERT(_utf8mb4 0xF4E25634 USING ucs2)) +ctype_utf16 DIFF column type binary->string 5f1334a8359c select insert(_utf16 0x006100620063,10,2,_utf16 0x006400650066) +ctype_utf16 DIFF column type binary->string a7511903304f select insert(_utf16 0x006100620063,1,2,_utf16 0x006400650066) +ctype_utf16 LENIENT 1300 3b9c0278f41a insert into t1 values (_utf16 0xD800) +ctype_utf16 LENIENT 1300 db6ec22b84df insert into t1 values (_utf16 0xDC00) +ctype_utf16 LENIENT 1300 c5bd6c4568fd insert into t1 values (_utf16 0xD800D800) +ctype_utf16 LENIENT 1300 6f86ed3fec5b insert into t1 values (_utf16 0xD800E800) +ctype_utf16 LENIENT 1300 3b79580ce63e insert into t1 values (_utf16 0xD8000800) +ctype_utf16le LENIENT 1300 13be27c9d24a INSERT INTO t1 VALUES (_utf16le 0x00D8) +ctype_utf16le LENIENT 1300 dc0a0b893b9a INSERT INTO t1 VALUES (_utf16le 0x00DC) +ctype_utf16le LENIENT 1300 d7932067d895 INSERT INTO t1 VALUES (_utf16le 0x00D800D8) +ctype_utf16le LENIENT 1300 9dac15290666 INSERT INTO t1 VALUES (_utf16le 0x00D800E8) +ctype_utf16le LENIENT 1300 5ab82445b504 INSERT INTO t1 VALUES (_utf16le 0x00D80008) +ctype_utf32 DIFF column type binary->string af205213042e select insert(_utf32 0x000000610000006200000063,10,2,_utf32 0x000000640000006500000066) +ctype_utf32 DIFF column type binary->string 1e2f9466fcb9 select insert(_utf32 0x000000610000006200000063,1,2,_utf32 0x000000640000006500000066) +ctype_utf32 LENIENT 1300 735637d42955 insert into t1 values (_utf32 0x00110000) +ctype_utf32 LENIENT 1300 c0fd1a3a6a5e insert into t1 values (_utf32 0x00110101) +ctype_utf32 LENIENT 1300 6f29685f1cc0 insert into t1 values (_utf32 0x01000101) +ctype_utf32 LENIENT 1300 569af37e0ee8 insert into t1 values (_utf32 0x11000101) +ctype_utf32 RUNTIME 1366 31abfd6ffea5 INSERT INTO t1 VALUES(x'D800') +ctype_utf32 RUNTIME 1366 8524068502ca INSERT INTO t1 VALUES(x'D83C') +ctype_utf32 RUNTIME 1366 a76f35b85c40 INSERT INTO t1 VALUES(x'DFFF') +ctype_utf32 RUNTIME 1366 f63c1c7a4719 INSERT INTO t1 VALUES(x'110000') +ctype_utf8 DIFF column type binary->string f7018eb92fca select left(_utf8mb3 0xD0B0D0B1D0B2,1) +ctype_utf8 DIFF column type binary->string f05fff01bad3 select right(_utf8mb3 0xD0B0D0B2D0B2,1) +ctype_utf8 LENIENT 3685 4546edd6cecc select 'вася' rlike '[[:<:]]вася[[:>:]]' +ctype_utf8 LENIENT 3685 019a1ad752f7 select 'вася ' rlike '[[:<:]]вася[[:>:]]' +ctype_utf8 LENIENT 3685 2faa777f848a select ' вася' rlike '[[:<:]]вася[[:>:]]' +ctype_utf8 LENIENT 3685 b9abb1dfeb7f select ' вася ' rlike '[[:<:]]вася[[:>:]]' +ctype_utf8 LENIENT 3685 2fe445f88563 select 'васяz' rlike '[[:<:]]вася[[:>:]]' +ctype_utf8 LENIENT 3685 0589d31cfe46 select 'zвася' rlike '[[:<:]]вася[[:>:]]' +ctype_utf8 LENIENT 3685 1183dfa6f990 select 'zвасяz' rlike '[[:<:]]вася[[:>:]]' +ctype_utf8 DIFF column type binary->string 6ce1f92e7508 select soundex(_utf8mb3 0xE99885E8A788E99A8FE697B6E69BB4E696B0E79A84E696B0E997BB) +ctype_utf8 DIFF column type binary->string eee62906d3e8 select soundex(_utf8mb3 0xD091D092D093) +ctype_utf8 DIFF column type binary->string c8e55918ecb4 select char(0xd1,0x8f using utf8mb3) +ctype_utf8 DIFF column type binary->string 85b1d2826621 select char(0xd18f using utf8mb3) +ctype_utf8 DIFF column type binary->string d3ef2615afc2 select char(53647 using utf8mb3) +ctype_utf8 DIFF column type binary->string 360b4849842a select char(0xff,0x8f using utf8mb3) +ctype_utf8 DIFF column type binary->string 28b84cd5d139 select convert(char(0xff,0x8f) using utf8mb3) +ctype_utf8 DIFF column type binary->string 314db0207028 select char(195 using utf8mb3) +ctype_utf8 DIFF column type binary->string c41b760e86d3 select char(196 using utf8mb3) +ctype_utf8 DIFF column type binary->string ba943ae3ac24 select char(2557 using utf8mb3) +ctype_utf8 LENIENT 1267 0b8572bac9cc select concat(a, if(b>10, N'æ', N'ß')) from t1 +ctype_utf8 LENIENT 1267 e48be7032e4d select concat(a, if(b>10, _utf8mb3'æ', _utf8mb3'ß')) from t1 +ctype_utf8 DIFF column type binary->string 97c7b8ca1f3a select concat(a, if(b>10, _utf8mb3 0x78, _utf8mb3 0x79)) from t1 +ctype_utf8 LENIENT 1267 cf7fb6011f21 select concat(a, if(b>10, _utf8mb3 0xC3A6, _utf8mb3 0xC3AF)) from t1 +ctype_utf8 LENIENT 1267 11640519c38d select concat(a, if(b>10, 'x' 'æ', 'y' 'ß')) from t1 +ctype_utf8 DIFF column type string->binary 31530f66c022 SELECT 'н1234567890' UNION SELECT _binary '1' +ctype_utf8 LENIENT 1300 90b3d995bb6d select hex(_utf8mb3 0x616263FF) +ctype_utf8 LENIENT 1300 452fd1fe6304 select hex(_utf8mb3 X'616263FF') +ctype_utf8 LENIENT 1300 d73b70c22a95 select hex(_utf8mb3 B'001111111111') +ctype_utf8 LENIENT 1300 c44c4f2d7167 select (_utf8mb3 X'616263FF') +ctype_utf8 RUNTIME 1406 2bbeb4497e52 INSERT INTO t1 (c1) VALUES(REPEAT('A', 256)) +ctype_utf8 RUNTIME 1406 90dd291bb354 INSERT INTO t1 (c1) VALUES(REPEAT(X'C385', 128)) +ctype_utf8 RUNTIME 1406 dbfcc52b520a INSERT INTO t1 (c1) VALUES(REPEAT(X'C385', 129)) +ctype_utf8 RUNTIME 1406 d840f94cb9af INSERT INTO t1 (c1) VALUES(CONCAT('a', REPEAT(X'C385', 128))) +ctype_utf8mb4 DIFF column type binary->string 328b6109eb55 select left(_utf8mb4 0xD0B0D0B1D0B2,1) +ctype_utf8mb4 DIFF column type binary->string 0f2bdc35a06d select right(_utf8mb4 0xD0B0D0B2D0B2,1) +ctype_utf8mb4 DIFF column type binary->string 07e0446c087e select soundex(_utf8mb4 0xE99885E8A788E99A8FE697B6E69BB4E696B0E79A84E696B0E997BB) +ctype_utf8mb4 DIFF column type binary->string b62ae6f715b2 select soundex(_utf8mb4 0xD091D092D093) +ctype_utf8mb4 DIFF column type binary->string 25e643eb9df9 select char(0xd1,0x8f using utf8mb4) +ctype_utf8mb4 DIFF column type binary->string 828c37f56878 select char(0xd18f using utf8mb4) +ctype_utf8mb4 DIFF column type binary->string 8886c759184a select char(53647 using utf8mb4) +ctype_utf8mb4 DIFF column type binary->string b852a159e0b7 select char(0xff,0x8f using utf8mb4) +ctype_utf8mb4 DIFF column type binary->string dc3f067d44cd select convert(char(0xff,0x8f) using utf8mb4) +ctype_utf8mb4 DIFF column type binary->string f559111f4e31 select char(195 using utf8mb4) +ctype_utf8mb4 DIFF column type binary->string 6837ab02cf87 select char(196 using utf8mb4) +ctype_utf8mb4 DIFF column type binary->string f72ac685140f select char(2557 using utf8mb4) +ctype_utf8mb4 LENIENT 1267 0b8572bac9cc select concat(a, if(b>10, N'æ', N'ß')) from t1 +ctype_utf8mb4 LENIENT 1267 e12fef97b242 select concat(a, if(b>10, _utf8mb4'æ', _utf8mb4'ß')) from t1 +ctype_utf8mb4 DIFF column type binary->string fc9c90a6f5ef select concat(a, if(b>10, _utf8mb4 0x78, _utf8mb4 0x79)) from t1 +ctype_utf8mb4 LENIENT 1267 f5d138c80fb1 select concat(a, if(b>10, _utf8mb4 0xC3A6, _utf8mb4 0xC3AF)) from t1 +ctype_utf8mb4 LENIENT 1267 11640519c38d select concat(a, if(b>10, 'x' 'æ', 'y' 'ß')) from t1 +ctype_utf8mb4 DIFF column type string->binary 31530f66c022 SELECT 'н1234567890' UNION SELECT _binary '1' +ctype_utf8mb4 LENIENT 1300 5ed017d47506 select hex(_utf8mb4 0x616263FF) +ctype_utf8mb4 LENIENT 1300 9e1c663c73d5 select hex(_utf8mb4 X'616263FF') +ctype_utf8mb4 LENIENT 1300 d53365731630 select hex(_utf8mb4 B'001111111111') +ctype_utf8mb4 LENIENT 1300 14e1d5239f9a select (_utf8mb4 X'616263FF') +ctype_utf8mb4 DIFF column type binary->string 9b502491a126 SELECT CONCAT(utf8mb4, _utf8mb3 x'ea9da8') FROM t1 LIMIT 0 +date_formats DIFF column type datetime->date d9c8c571d6c4 select str_to_date('15-01-2001 12:59:59', GET_FORMAT(DATE,'USA')) +date_formats DIFF column type datetime->date 5b15fe1bfbb4 select str_to_date( NULL, 1 ) +ddl_i18n_koi8r SCHEMA no database selected 102c4650f61e CALL mysqltest2.p3(@a, @b) +ddl_i18n_utf8 SCHEMA no database selected 102c4650f61e CALL mysqltest2.p3(@a, @b) +default RUNTIME 1364 74fda82808c4 insert into bug20691 values (7, DEFAULT, DEFAULT), (7, '1975-07-10 07:10:03', '1978-01-13 14:08:51') +default_as_expr RUNTIME 1406 05c4d69658f6 INSERT INTO t1 VALUES (),(),() +default_as_expr LENIENT 3773 7875912a4791 SELECT DEFAULT(d) from t3 +default_as_expr LENIENT 3773 72e22ef8a41d SELECT DEFAULT(c) from t3 +default_as_expr RUNTIME 1406 23be96788536 INSERT INTO t values(256, DEFAULT) +default_as_expr RUNTIME 1364 d6bbe2b9a776 INSERT INTO t(i) values(128) +default_as_expr RUNTIME 1406 35d66d49142d INSERT INTO t values(16777216, DEFAULT) +default_as_expr RUNTIME 1406 af6d55a03140 INSERT INTO t values(65536, DEFAULT) +default_as_expr LENIENT 1301 3a795b8d59d7 INSERT INTO t values(4294967295, DEFAULT) +default_as_expr RUNTIME 1406 42027691890c INSERT INTO t1(a) VALUES (now()) +default_as_expr RUNTIME 1265 5e3cd6309878 INSERT INTO t1 values (DEFAULT) +default_as_expr RUNTIME 1265 45f4d9e94bff INSERT INTO t1 values () +default_as_expr RUNTIME 1265 d008ead59432 INSERT INTO t2 values (DEFAULT) +default_as_expr RUNTIME 1265 a402541dbc63 INSERT INTO t2 values () +delete RUNTIME 1242 8be2b2cb60c5 delete t11.*, t12.* from t11,t12 where t11.a = t12.a and t11.b <> (select b from t2 where t11.a < t2 +delete RUNTIME 1242 80cf9d44afcb delete from t11 where t11.b <> (select b from t2 where t11.a < t2.a) +delete LENIENT 1175 5abd6e9add4b DELETE FROM t +delete LENIENT 1175 bfb57a3fceac DELETE FROM t ORDER BY a +delete LENIENT 1175 e70c4d50719b DELETE FROM t WHERE a = 6 +delete LENIENT 1175 fcce9ba5b8a0 DELETE t2 FROM t1 JOIN t2 WHERE t1.a = 10 +delete LENIENT 1109 49046288b82b DELETE FROM db2.alias USING db1.t1, db2.t1 alias WHERE db1.t1.a = alias.a +delete CODE 1066->1146 0e7bc23f69ab DELETE a1 FROM a1, db1.t1 AS a1 +delete LENIENT 1109 b6261609e496 DELETE t1 FROM db1.t1, db2.t1 AS a1 +delete CODE 1109->1066 b6e6b934ab14 DELETE t1 FROM db1.t1, db2.t1 +delete LENIENT 1146 ef25e210a7af DELETE t1, t2, t3 FROM t1, t2, t3 +delete LENIENT 1442 15ba89d25e6a DELETE IGNORE FROM t1 +delete RUNTIME 1242 9308895fb05c DELETE OUTR1.* +delete RUNTIME 1242 7fe4ef483069 DELETE OUTR1.* FROM e3 AS OUTR1 INNER JOIN d3 AS OUTR2 ON +delete RUNTIME 1292 675d39c577f8 DELETE d2 FROM t AS d1, t AS d2 WHERE d1.a > d2.a +delete RUNTIME 1292 b81eb3f68bc3 DELETE FROM outr1, outr2 +delete_myisam RUNTIME 1242 25417f9da4e3 DELETE FROM OUTR1.* USING t1 AS OUTR1 +deprecate_eof LENIENT 1298 925db6abe915 CALL t1_inout(@a,@b,@c,@d,@e,@f,@g,@h,@i,@j) +deprecate_user_defined_collations CODE 1273->1146 fdb5eb6baa69 insert into t values ("a" collate utf8mb4_test_ci), ("a" collate utf8mb4_test_ci) +deprecated_features LENIENT 1193 2f0790b2527e select @@table_type='MyISAM' +deprecated_features DIFF column type string->binary d93760d227bb select convert("123" using binary) +derived LENIENT 1052 1c3b06575415 SELECT a,b as a FROM (SELECT '1' as a,'2' as b) b HAVING a=1 +derived DIFF column type int->bigint 021d76adff69 SELECT * FROM (SELECT (SELECT * FROM (SELECT 1 as a) as a )) as b +derived RUNTIME 1242 0612a6733495 SELECT * FROM (SELECT (SELECT a.a FROM t1 AS a WHERE a.a = b.a) FROM t1 AS b) AS c +derived LENIENT 1060 9ea26760cbe7 select * from (select * from t1,t2) foo +derived STRICT 1055 3c043cdfe782 SELECT * +derived DIFF column type bigint->int 8f90cf02b279 SELECT * FROM t1 LEFT JOIN ( SELECT 1 FROM t1 ) d1 ON TRUE ORDER BY a +derived_ci LENIENT 1054 27f9218dfd2f select QN.a from (select 1 as a) as qn +derived_ci LENIENT 1054 f633e599218b select qn.a from (select 1 as a) as QN +derived_ci STRICT 1066 37b50d11be03 select 3 from (select 1) as qn, (select 2) as QN +derived_ci LENIENT 1146 e12d58713ce0 with qn as (select 1) select * from QN +derived_ci LENIENT 1146 2603e4dbaa82 with QN as (select 1) select * from qn +derived_ci LENIENT 1054 903e89f619d5 with qn as (select 1 as a) select QN.a from qn +derived_condition_pushdown DIFF column type int->bigint 4d852a9e4b66 SELECT f1, f2 FROM (SELECT f1, f2 FROM t1 GROUP BY f1, f2 WITH ROLLUP) as dt +derived_correlated CODE 1109->1054 581a749bcf45 with cte as (select t1.a) select (select * from cte) from t1 +derived_correlated DIFF column type int->bigint 3a9631a8feeb select * from t1, +derived_correlated DIFF column type int->bigint 5c84aaa8d089 SELECT * FROM t1, LATERAL ( SELECT MAX(1) FROM t2 GROUP BY t1.f1 ) AS l1 +derived_correlated LENIENT 1054 32360023ff66 SELECT 1 FROM +derived_cs LENIENT 1054 27f9218dfd2f select QN.a from (select 1 as a) as qn +derived_cs LENIENT 1054 f633e599218b select qn.a from (select 1 as a) as QN +derived_cs STRICT 1066 37b50d11be03 select 3 from (select 1) as qn, (select 2) as QN +derived_cs LENIENT 1146 e12d58713ce0 with qn as (select 1) select * from QN +derived_cs LENIENT 1146 2603e4dbaa82 with QN as (select 1) select * from qn +derived_cs LENIENT 1054 903e89f619d5 with qn as (select 1 as a) select QN.a from qn +disable_drop_rename_user LENIENT 1449 2d44f19af813 SELECT * FROM v1 +disable_drop_rename_user LENIENT 1356 2d44f19af813 SELECT * FROM v1 +disable_drop_rename_user LENIENT 1449 ae2cd589953d CALL p1() +disable_drop_rename_user LENIENT 1370 ae2cd589953d CALL p1() +disable_drop_rename_user LENIENT 1449 3fc1e2aa76a6 SELECT f1() +disable_drop_rename_user LENIENT 1370 3fc1e2aa76a6 SELECT f1() +disable_drop_rename_user LENIENT 1449 850248433df7 INSERT INTO t1 VALUES (10) +disable_drop_rename_user LENIENT 1142 850248433df7 INSERT INTO t1 VALUES (10) +distinct DIFF column type bigint->int ca15e7488698 select id >= 0 and id <= 5 as grp,count(*) from t1 group by grp +distinct LENIENT 1 b5e2dc018a33 SELECT DISTINCT fruit_id, fruit_name INTO OUTFILE +distinct LENIENT 13 8ee5fdf05664 LOAD DATA INFILE '../../tmp/data1.tmp' INTO TABLE t2 +distinct LENIENT 1 aba6cc621999 SELECT DISTINCT @v19:= fruit_id, @v20:= fruit_name INTO OUTFILE +distinct LENIENT 13 42808d5f11b7 LOAD DATA INFILE '../../tmp/data2.tmp' INTO TABLE t2 +distinct DIFF column type int->bigint 7d16e03a32cb SELECT DISTINCT f1 FROM t1 GROUP BY f1 WITH ROLLUP ORDER BY f1, ANY_VALUE(GROUPING(f1)) +distinct_innodb LENIENT 3065 25bb497ad39a select distinct t1_outer.a from t1 t1_outer +drop LENIENT 1051 c952b86a3953 INSERT INTO table1 VALUES (1) +errors LENIENT 1054 8fa3f08ec3e0 select count(not_existing_database.t1.a) from t1 +errors LENIENT 1054 a3131c01159a SELECT '' AS b FROM t1 GROUP BY VALUES(b) +errors LENIENT 1054 82758128e360 REPLACE t2(b) SELECT '' AS b FROM t1 GROUP BY VALUES(b) +errors LENIENT 1054 8fa9bb5764df UPDATE t2 SET a=(SELECT '' AS b FROM t1 GROUP BY VALUES(b)) +errors LENIENT 1054 acd43bbe32cd INSERT INTO t2 VALUES (1,0) ON DUPLICATE KEY UPDATE +events_2 LENIENT 1415 94094473477e insert into t1 (a) values (1) +events_2 STRICT 1146 3aae31860bef select * from tmp +events_2 LENIENT 1422 94094473477e insert into t1 (a) values (1) +events_bugs LENIENT 1525 112d8151900e call p_16() +events_bugs SCHEMA problems: server sql_mode = ?: sql_mode: ? is not a mode of MySQL N.N d325095ea8ca select @@sql_mode into @full_mode +events_restart SCHEMA no database selected 89b067bb657c SELECT @@event_scheduler +events_time_zone LENIENT 1210 e2bc9f8126c5 SELECT SLEEP(@step / 2) +explain CODE 1048->1292 5cecdb2dcf3e INSERT INTO t2 VALUES (NULL), (0) +filesort LENIENT 3037 dcbf912f5a26 SELECT +filesort_debug LENIENT 1038 9e94f8ab4797 DELETE IGNORE FROM t1 ORDER BY c26,c7,c23,c4,c25,c5,c20, +filesort_debug LENIENT 1038 b67bd5113372 DELETE FROM t1 ORDER BY c26,c7,c23,c4,c25,c5,c20, +flush LENIENT 1100 3f0ae4aca767 select * from t3 +flush LENIENT 1100 648061759d0e insert into t3 (a) values (1) +flush LENIENT 1100 f752c3b7cad4 insert into t3 (a) values (2) +flush_read_lock LENIENT 1223 8c48daa916a4 lock tables t1_base write +flush_read_lock LENIENT 1399 03281429c6d2 insert into t3_trans values (1) +flush_read_lock LENIENT 1399 6e31fcc9ad79 delete from t3_trans +flush_read_lock SCHEMA dump: SHOW CREATE TABLE tm_base_temp: Error N (HYN): Unable to ope 33a2b1d97db6 select * from tm_base_temp +foreign_key LENIENT 1100 604eba08cee3 SELECT * FROM child +foreign_key LENIENT 1100 110ac4f30b4a INSERT INTO child VALUES (NULL) +foreign_key LENIENT 1100 6a9fb01e4dc6 DELETE FROM parent1 +foreign_key LENIENT 1099 81f78db3fa23 UPDATE t1 SET pk = 10 +fulltext LENIENT 1191 4dac848f94d6 select * from t1 where MATCH a AGAINST ("search" IN BOOLEAN MODE) +fulltext LENIENT 1191 32ecb65961e3 select * from t1 where MATCH b AGAINST ("sear*" IN BOOLEAN MODE) +fulltext LENIENT 1191 cdbf25598a91 select * from t2 where MATCH ticket AGAINST ('foobar') +fulltext LENIENT 1191 b0f2b52aee23 select * from t2 where match name against ('*a*b*c*d*e*f*' in boolean mode) +fulltext LENIENT 1191 ad9adb717737 SELECT * FROM t1 WHERE MATCH(a) AGAINST ('"aaaa"' IN BOOLEAN MODE) +fulltext LENIENT 1191 f6d0cd1ba764 SELECT MATCH(a) AGAINST('aaa1* aaa14 aaa16' IN BOOLEAN MODE) FROM t1 +fulltext LENIENT 1191 3651e802dc35 SELECT MATCH(a) AGAINST('aaa1* aaa14 aaa15 aaa16' IN BOOLEAN MODE) FROM t1 +fulltext LENIENT 1191 d2dd0e193cf7 SELECT count(*) FROM t1 WHERE +fulltext LENIENT 1191 7ba63484dee1 SELECT * FROM t WHERE MATCH(x) AGAINST('abc' IN BOOLEAN MODE) AND x = 1 +fulltext3 LENIENT 1210 1cc4bec65181 SELECT (SELECT 1 FROM (SELECT 1) foo WHERE MATCH(`a`) AGAINST ('')) FROM t1 +fulltext_left_join LENIENT 1191 babe53ddb946 select * from t1 where match(d, e) against ('+aword +bword' in boolean mode) +fulltext_left_join LENIENT 1191 723c38a166bc SELECT * FROM t1 LEFT JOIN t2 ON a=b WHERE MATCH(c) AGAINST('+castle' IN BOOLEAN MODE) +fulltext_order_by CODE 1250->1054 14967c1f8841 select a.text, b.id, b.betreff +fulltext_order_by CODE 1250->1054 29554239caaf select a.text, b.id, b.betreff +fulltext_order_by LENIENT 1214 7f55d064a7c3 select a.text, b.id, b.betreff +fulltext_var LENIENT 1191 c8491230ce25 select * from t1 where match b against ('+aaaaaa bbbbbb' in boolean mode) +fulltext_var LENIENT 1191 fa260f79ff60 select * from t1 where match b against ('+aaaaaa @bbbbbb' in boolean mode) +func_aes_kdf_hkdf LENIENT 3235 b6ad5942337a select aes_encrypt("foo",repeat("x",16),NULL,'hKdF') +func_aes_kdf_pbkdf2_hmac LENIENT 3235 01bc4dbd9e40 select AES_ENCRYPT('my_text','my_key_string', '', 'invalid') +func_aes_kdf_pbkdf2_hmac LENIENT 3236 d6e026f12f06 select AES_ENCRYPT('my_text','my_key_string', '', 'pbkdf2_hmac', 'salt', '100') +func_aes_kdf_pbkdf2_hmac LENIENT 3236 7cd3b63540e4 select AES_ENCRYPT('my_text','my_key_string', '', 'pbkdf2_hmac', 'salt', 'aa') +func_aes_kdf_pbkdf2_hmac LENIENT 3238 333c41b3ddd5 select aes_encrypt("foo",repeat("x",16),NULL,repeat("1",10000000000)) +func_aes_kdf_pbkdf2_hmac LENIENT 3238 24990062acfe select aes_encrypt("foo",repeat("x",16),NULL,repeat("1",300)) +func_aes_kdf_pbkdf2_hmac LENIENT 3235 3e3ac79e7f5d select aes_encrypt("foo",repeat("x",16),NULL,'pbkdf2_HMac') +func_bitwise_ops DIFF column type bigint unsigned->binary 8ff25a696191 SELECT vbin1 & vbin2, vbin1 | vbin2, vbin1 ^ vbin2, ~vbin1, vbin1 << 3, vbin2 >> 3 +func_bitwise_ops LENIENT 3513 6b397f121b21 SELECT HEX(vbin1 & vbin2) FROM t WHERE id=7 +func_bitwise_ops LENIENT 3513 18936108ad26 SELECT HEX(vbin1 | vbin2) FROM t WHERE id=7 +func_bitwise_ops LENIENT 3513 d51dbac601d7 SELECT HEX(vbin1 ^ vbin2) FROM t WHERE id=7 +func_bitwise_ops LENIENT 3513 da4045c02c8f SELECT HEX(vbin1 & vbin2) FROM t WHERE id=8 +func_bitwise_ops LENIENT 3513 a5f60348e9c6 SELECT HEX(vbin1 | vbin2) FROM t WHERE id=8 +func_bitwise_ops LENIENT 3513 e0b8cd05d41b SELECT HEX(vbin1 ^ vbin2) FROM t WHERE id=8 +func_bitwise_ops DIFF column name 3b50cd961234 SELECT hex(_bin | x'CAFEBABE000000000000000000000000000000000000000000000000000000000000000000000000 +func_bitwise_ops LENIENT 3513 06fffd367648 SELECT _bin | x'cafebabe' FROM at +func_bitwise_ops DIFF column name b95f915505e5 SELECT hex(_vbn | x'CAFEBABE000000000000000000000000000000000000000000000000000000000000000000000000 +func_bitwise_ops LENIENT 3513 5b6d0d752b7f SELECT _vbn | x'cafebabe' FROM at +func_bitwise_ops DIFF column name be3059f512a9 SELECT hex(_tbl | x'CAFEBABE000000000000000000000000000000000000000000000000000000000000000000000000 +func_bitwise_ops LENIENT 3513 5836d1839dbd SELECT _tbl | x'cafebabe' FROM at +func_bitwise_ops DIFF column name c6d6ecb2566c SELECT hex(_bin & x'CAFEBABE000000000000000000000000000000000000000000000000000000000000000000000000 +func_bitwise_ops LENIENT 3513 8e642e3e92f7 SELECT _bin & x'cafebabe' FROM at +func_bitwise_ops DIFF column name 356a94769a58 SELECT hex(_vbn & x'CAFEBABE000000000000000000000000000000000000000000000000000000000000000000000000 +func_bitwise_ops LENIENT 3513 028b84e3616c SELECT _vbn & x'cafebabe' FROM at +func_bitwise_ops DIFF column name d3e915000e83 SELECT hex(_tbl & x'CAFEBABE000000000000000000000000000000000000000000000000000000000000000000000000 +func_bitwise_ops LENIENT 3513 178aec4b74eb SELECT _tbl & x'cafebabe' FROM at +func_bitwise_ops DIFF column name cd73e81b9517 SELECT hex(_bin ^ x'CAFEBABE000000000000000000000000000000000000000000000000000000000000000000000000 +func_bitwise_ops LENIENT 3513 dd3d37117586 SELECT _bin ^ x'cafebabe' FROM at +func_bitwise_ops DIFF column name 3eb3b92b6583 SELECT hex(_vbn ^ x'CAFEBABE000000000000000000000000000000000000000000000000000000000000000000000000 +func_bitwise_ops LENIENT 3513 5d1517a6639e SELECT _vbn ^ x'cafebabe' FROM at +func_bitwise_ops DIFF column name a3ef1148084a SELECT hex(_tbl ^ x'CAFEBABE000000000000000000000000000000000000000000000000000000000000000000000000 +func_bitwise_ops LENIENT 3513 fee33b51d9f9 SELECT _tbl ^ x'cafebabe' FROM at +func_bitwise_ops DIFF column type bigint unsigned->binary ffe2ca10cbb2 SELECT BIT_AND(a) FROM t +func_bitwise_ops DIFF column type bigint unsigned->binary cdf316e84ae4 SELECT BIT_OR(a) FROM t +func_bitwise_ops DIFF column type bigint unsigned->binary e9f5eec20b38 SELECT BIT_XOR(a) FROM t +func_bitwise_ops LENIENT 3513 ffe2ca10cbb2 SELECT BIT_AND(a) FROM t +func_bitwise_ops LENIENT 3513 cdf316e84ae4 SELECT BIT_OR(a) FROM t +func_bitwise_ops LENIENT 3513 e9f5eec20b38 SELECT BIT_XOR(a) FROM t +func_bitwise_ops DIFF column type bigint unsigned->binary c99173404613 SELECT group_id, HEX(BIT_AND(a)), BIT_AND(192), BIT_AND(0x303233), BIT_AND(binary "foo") +func_bitwise_ops DIFF column type string->binary 38aa86ada281 SELECT CONCAT("M" ,BIT_OR(a)) FROM t2 +func_bitwise_ops DIFF column type string->binary 1676c6edb6a9 SELECT CONCAT("M" ,BIT_OR(a)) FROM t2 GROUP BY gid +func_bitwise_ops DIFF column type decimal->double b5d27cd38f6b SELECT +func_bitwise_ops DIFF column type string->binary 6b97f5b4e266 SELECT +func_bitwise_ops DIFF column type decimal->double 4ecb789b5720 SELECT +func_bitwise_ops DIFF column type decimal->double f2aad462f0d2 SELECT +func_bitwise_ops DIFF column type decimal->double 041350649cd7 SELECT +func_bitwise_ops DIFF column type decimal->double 40fc2af4be23 SELECT +func_bitwise_ops LENIENT 3514 6a83fa3cf4f1 SELECT HEX(BIT_OR(a)) FROM t GROUP BY gid +func_bitwise_ops LENIENT 3514 4c9b087a8e3c SELECT concat(BIT_COUNT(BIT_AND(b))) FROM t GROUP BY b +func_bitwise_ops LENIENT 3514 40933434353c SELECT concat("" , BIT_COUNT(BIT_AND(b))) FROM t GROUP BY b +func_bitwise_ops LENIENT 3513 03c15fc724ba SELECT id, bit_or(a) FROM t where id=3 GROUP BY id +func_bitwise_ops LENIENT 3513 9e773880f95f SELECT id, bit_or(a) FROM t where id=4 GROUP BY id +func_bitwise_ops DIFF column type bigint unsigned->binary 033fe4cc5cb8 SELECT id, bit_or(a) FROM t where id in (1,2) GROUP BY id +func_bitwise_ops LENIENT 3513 e6006aeecaaa SELECT SQL_BIG_RESULT DISTINCT a&b FROM t +func_bitwise_ops LENIENT 3513 6c8b741f1b79 SELECT bit_count(a & 0x2438) FROM t +func_bitwise_ops LENIENT 3513 1b0f791438f8 SELECT 1 FROM t WHERE ~reverse(a & 0x111111) +func_bitwise_ops LENIENT 3513 34f959b9c72a SELECT 1 FROM t WHERE insert(a & 0x111111,'','','') +func_bitwise_ops LENIENT 3513 b415cea287c8 SELECT 1 FROM t WHERE rtrim(a & 0x111111) +func_bitwise_ops LENIENT 3513 3babc3787a51 SELECT (SELECT a & 0xff FROM t) +func_bitwise_ops DIFF column type bigint unsigned->binary cc75e7aea6e8 SELECT (SELECT a & NULL from t) +func_date_add RUNTIME 1441 9ab6de35799e insert into t1 (d) select date_sub('2000-01-01', INTERVAL 2001 YEAR) +func_date_add RUNTIME 1441 c0251b841020 insert into t1 (d) select date_add('2000-01-01',interval 8000 year) +func_date_add DIFF column type datetime->date d5eb54e494e8 SELECT CAST('2006-09-26' AS DATE) + INTERVAL 1 DAY +func_date_add DIFF column type datetime->date 1aaf1c26e99a SELECT CAST('2006-09-26' AS DATE) + INTERVAL 1 MONTH +func_date_add DIFF column type datetime->date 63735dddc83a SELECT CAST('2006-09-26' AS DATE) + INTERVAL 1 YEAR +func_date_add DIFF column type datetime->date d562e8f9f398 SELECT CAST('2006-09-26' AS DATE) + INTERVAL 1 WEEK +func_date_add DIFF column type datetime->date 79c599a0e951 SELECT ADDDATE(DATE'2021-01-01', INTERVAL 1 DAY) +func_default RUNTIME 1364 d7cd7203ca57 SELECT s, 32 AS mi FROM t1 GROUP BY s HAVING DEFAULT(mi) IS NULL +func_digest DIFF column type string->binary afda871f6abb SELECT sha2('1',224) +func_gconcat DIFF column type string->binary b27635114b08 select grp,group_concat(null) from t1 group by grp +func_gconcat DIFF column type string->binary 3c70473bfdc1 select group_concat(null) from t1 +func_gconcat LENIENT 1111 8f3defb104f1 select group_concat(sum(c)) from t1 group by grp +func_gconcat LENIENT 1054 0745f12b840f select grp,group_concat(c order by 2) from t1 group by grp +func_gconcat LENIENT 1267 5139250d365c select collation(group_concat(a,_koi8r 0xC1C2)) from t1 +func_gconcat LENIENT 1267 078b90a49e56 select collation(group_concat(a,b)) from t1 +func_gconcat RUNTIME 1366 08ea21a899aa insert into t1 values (1,repeat('a',255)),(2,repeat('b',255)) +func_gconcat DIFF column type string->binary 56c4342b4697 select group_concat(distinct a) from t1 +func_gconcat DIFF column type string->binary 7bbdfa899d6f select group_concat(distinct a order by a) from t1 +func_gconcat DIFF column type string->binary 39e5e5ccd445 select group_concat(distinct a order by a desc) from t1 +func_gconcat DIFF column type string->binary e41b9ff20cd8 select group_concat(distinct a, c) from t1 +func_gconcat DIFF column type string->binary 33ae1de6d2d7 select group_concat(distinct a, c order by a) from t1 +func_gconcat DIFF column type string->binary 562cb32f9bc6 select group_concat(distinct a, c order by a, c) from t1 +func_gconcat DIFF column type string->binary 1fd04755c96e select group_concat(distinct a, c order by a desc, c desc) from t1 +func_gconcat LENIENT 1367 c4a42332e61f SELECT POLYGON((SELECT 1 FROM (SELECT 1 IN (GROUP_CONCAT(t1.f1)) FROM t1, t1 t GROUP BY t.f1 ) d)) +func_gconcat LENIENT 1260 b14834c3460b INSERT INTO t2 SELECT GROUP_CONCAT(a), b FROM t1 GROUP BY b +func_group DIFF column name d0c9ea712c24 SELECT ( +func_group RUNTIME 1690 9d2e4b8744ac SELECT STDDEV(value) from t1 +func_group RUNTIME 1690 525def8207c9 SELECT VARIANCE(value) FROM t2 GROUP BY grp +func_if DIFF column type string->binary 3c598c7da80e select if(u=1,binary st,st) s from t1 order by s +func_if DIFF column type string->binary e984dec5ec6b select if(u=1,st,binary st) s from t1 where st like "%a%" order by s +func_if DIFF column type string->null fcaae5831ea1 select NULLIF(NULL,NULL), NULLIF(NULL,1), NULLIF(NULL,1.0), NULLIF(NULL,"test") +func_if STRICT 1064 788ebff45159 SELECT t1.a, +func_if DIFF column type string->binary ca03b1ec25f0 SELECT IF(b, (SELECT a FROM t1 LIMIT 1), b) c FROM t1 GROUP BY c +func_if DIFF column type decimal->double 8b86b6aea25b SELECT AVG(NULLIF(YEAR('2001-01-01'),10)) +func_if DIFF column type decimal->double 35524635e94f SELECT AVG(NULLIF(y, 10)) FROM t1 +func_in_none LENIENT 1271 abf88c6777db SELECT i8 FROM t1 WHERE vc IN (@str_one, @str_two, @str_five) +func_isnull DIFF column type bigint->int 4fc79ba635e8 SELECT t1.x IS NULL = t2.x AS col FROM t AS t1, t AS t2 ORDER BY col +func_like LENIENT 1210 5ed7e5d39921 SELECT '' LIKE '1' ESCAPE COUNT(1) +func_like LENIENT 1210 9d3f271ef7d6 SELECT ('a%b' LIKE 'ax%b' ESCAPE (SELECT 'xy' FROM t1)) +func_like RUNTIME 1242 2b70d63e2936 SELECT ('a%b' LIKE 'ax%b' ESCAPE (SELECT x FROM t1)) +func_like LENIENT 1241 2b1788ce01fc SELECT ('a%b' LIKE 'ax%b' ESCAPE ('x', 'y')) +func_like RUNTIME 1690 51d55944de2b SELECT 1 FROM t1 HAVING (SELECT 1 FROM t1) LIKE EXP(NOW()) +func_like LENIENT 3141 710a5fdc0ffd SELECT 'a' LIKE '%' ESCAPE CAST('' AS JSON) +func_like CODE 1292->1096 b2e02fcc4680 DELETE FROM t1 WHERE +func_math DIFF column type bigint->int unsigned 1670936464d8 SELECT c DIV 900 y FROM t1 GROUP BY y +func_math RUNTIME 1690 ab0facba41d4 SELECT -a FROM t1 +func_math RUNTIME 1690 103b176d8aea SELECT -b FROM t1 +func_math RUNTIME 1690 3d54a2c2c45e SELECT @a + @a +func_math RUNTIME 1690 7819a97cc355 SELECT @a * @a +func_math RUNTIME 1690 06897a9e5963 SELECT -@a - @a +func_math RUNTIME 1690 317dfd94f2bd SELECT @a / 0.5 +func_math LENIENT 3020 7cdc1a7e0bda INSERT INTO t1 VALUES (ln(0)) +func_math LENIENT 3020 752c5a8a67fa INSERT INTO t1 VALUES (ln(-1)) +func_math LENIENT 3020 6c66672ee5a0 INSERT INTO t1 VALUES (log(0)) +func_math LENIENT 3020 33d6ab7ee805 INSERT INTO t1 VALUES (log(1,0)) +func_math LENIENT 3020 8b6fb7b92b87 INSERT INTO t1 VALUES (log2(0)) +func_math LENIENT 3020 c40dca3eed8f INSERT INTO t1 VALUES (log10(0)) +func_math LENIENT 3020 dcc7bfe2dfce UPDATE t1 SET a = ln(0) +func_math LENIENT 3020 af9e74b99485 DELETE FROM t1 WHERE a <=> ln(0) +func_math LENIENT 13 784072fcd2d2 LOAD DATA INFILE '../../std_data/loaddata_utf8.dat' INTO TABLE t1 CHARACTER SET utf8mb3 +func_math RUNTIME 1264 5dde931a6d5e INSERT INTO t VALUES(hex(rtrim(1.040739e+308))) +func_math RUNTIME 1264 b4538efbcc51 CALL test_round(9223372036854775808) +func_math RUNTIME 1690 62d79c4f163d SELECT a, 0-a FROM t1 +func_math RUNTIME 1690 11eb49c027f0 SELECT a, 1-a FROM t1 +func_math DIFF column type bigint->int 42d218a0b50a SELECT * FROM (SELECT -TRUE AS a, --TRUE AS b) AS dt +func_math DIFF column type bigint->int 35ddc7e2e9a9 SELECT * FROM v +func_misc RUNTIME 1242 67ba1b836c25 select benchmark(100, (select (a) from table_26093)) +func_misc DIFF column name c1e199b1dd3b SELECT NAME_CONST('test', NULL) +func_misc DIFF column name 962c51041c68 SELECT NAME_CONST('test', 1) +func_misc DIFF column name 235db940beda SELECT NAME_CONST('test', -1) +func_misc DIFF column name d764689df77c SELECT NAME_CONST('test', 1.0) +func_misc DIFF column name ca7ea687fb58 SELECT NAME_CONST('test', -1.0) +func_misc DIFF column name 1b7537119527 SELECT NAME_CONST('test', 'test') +func_misc DIFF column type string->binary 2c3d91662011 SELECT DISTINCT LEAST(a, (SELECT b FROM t1 LIMIT 1)) FROM t1 UNION SELECT 1 +func_misc DIFF column type string->binary 1894448a655c SELECT DISTINCT GREATEST(a, (SELECT b FROM t1 LIMIT 1)) FROM t1 UNION SELECT 1 +func_misc DIFF nullable true->false 5157b74e5589 SELECT IS_IPV4(BINARY('1.2.3.4')) +func_misc DIFF nullable true->false 33b9cebc6808 SELECT IS_IPV6(BINARY('0000:0000:0000:0000:0000:0000:0000:0001')) +func_prefix_key LENIENT 3141 0045a109253c SELECT 1 FROM t1 WHERE f1 NOT LIKE json_merge('' ,'+' ) +func_prefix_key LENIENT 3141 d170099c489e SELECT 1 FROM t1 WHERE f1 LIKE json_contains('key2' ,'key4' ) +func_regexp LENIENT 3696 8f5f64449925 select * from t1 where xxx REGEXP '^this is some text: to test - out\\.reg exp [[(][0-9]+[/\\][0-9]+ +func_regexp LENIENT 3995 43af6aae6c7f SELECT '' REGEXP BINARY NULL +func_regexp LENIENT 3995 19d7b7725117 SELECT 'A' REGEXP BINARY NULL +func_regexp LENIENT 3995 6fc8067d0f67 SELECT "ABC" REGEXP BINARY NULL +func_regexp LENIENT 3687 b02cd6462e58 SELECT '1' RLIKE RPAD('1', 10000, '(') +func_rollback LENIENT 1146 fe5ced7823c7 SELECT f1_simple_insert(1) +func_rollback LENIENT 1146 e475bfa125f8 SELECT f1_simple_insert(1) FROM t1_select +func_rollback LENIENT 1146 4f4c3194b397 SELECT f1_simple_insert(NULL) +func_rollback LENIENT 1146 33385287937b SELECT f1_simple_insert(NULL) FROM t1_select +func_rollback LENIENT 1146 3895a009efa6 SELECT f1_simple_insert(f2) FROM t1_select +func_rollback LENIENT 1146 fa880c32beda SELECT f1_simple_insert(1),f1_simple_insert(NULL) +func_rollback LENIENT 1146 9d74537d9540 SELECT f1_simple_insert(NULL),f1_simple_insert(1) +func_rollback LENIENT 1146 fab114019fcc SELECT f1_simple_insert(f1),f1_simple_insert(f2) FROM t1_select +func_rollback LENIENT 1146 20a76cd4aeae SELECT f1_simple_insert(f2),f1_simple_insert(f1) FROM t1_select +func_rollback LENIENT 1146 34f91274c29d SELECT f1_simple_insert(f1_simple_insert(NULL)) FROM t1_select +func_rollback LENIENT 1146 9999e574877c SELECT f1_simple_insert(f1_simple_insert(1) + NULL) FROM t1_select +func_rollback LENIENT 1146 ab167a988482 SELECT f1_two_inserts() +func_rollback LENIENT 1146 90c64ae1554b SELECT 1 +func_rollback LENIENT 1146 7d5572493d29 SELECT * FROM t1_select ORDER BY f1,f1_two_inserts() +func_rollback LENIENT 1146 37edb3725e1d SELECT AVG(f1_two_inserts()) FROM t1_select +func_rollback LENIENT 1146 d0aff0c833e9 SELECT 1 FROM t1_select HAVING AVG(f1) = f1_two_inserts() + 2 +func_rollback LENIENT 1146 2a79de334080 SELECT f1_two_inserts_v1() +func_sapdb DIFF column type string->datetime 5590a275e75f select timestamp("2001-12-01", "01:01:01.999999") +func_sapdb DIFF column type string->datetime b044f46b3e82 select timestamp("2001-13-01", "01:01:01.000001") +func_sapdb DIFF column type string->datetime a2a90c10b661 select timestamp("2001-12-01", "25:01:01") +func_set LENIENT 1267 4294d7cf8dd8 SELECT 'using string ' as param, FIND_IN_SET('foxtrot', s) as f, s FROM t1 +func_set LENIENT 1267 68b0e16e1bb9 SELECT 'invalid string ' as param, FIND_IN_SET('zulu', s) as f, s FROM t1 +func_str DIFF column name 63f2cd94ccbe select 'hello' 'monty' +func_str RUNTIME 3854 55ad3b141785 SELECT LOCATE(x'ff', _utf8mb4'abcdef') +func_str RUNTIME 3854 dfc5d2fea622 SELECT POSITION(x'ff' IN _utf8mb4'abcdef') +func_str RUNTIME 3854 8b84dcf00b45 SELECT INSTR(_utf8mb4'abcdef', x'ff') +func_str DIFF nullable true->false 4a3ce50a4df0 select position("0" in "baaa" in (1)),position("0" in "1" in (1,2,3)),position("sql" in ("mysql")) +func_str LENIENT 1267 db859c609b43 select _latin1'1'=_latin2'1' +func_str DIFF nullable true->false f5e49fd8d8b4 select row('a','b','c') = row('a','b','c') +func_str DIFF nullable true->false 7e584947e236 select row('A','b','c') = row('a','b','c') +func_str LENIENT 1267 89394a45f09d select row('A' COLLATE latin1_general_ci,'b','c') = row('a' COLLATE latin1_bin,'b','c') +func_str LENIENT 1267 891431fa491b select concat(_latin1'a',_latin2'a') +func_str LENIENT 1270 ba7266d7600f select concat(_latin1'a',_latin2'a',_latin5'a') +func_str LENIENT 1271 673dc3643315 select concat(_latin1'a',_latin2'a',_latin5'a',_latin7'a') +func_str LENIENT 1267 91edeb8df22b select concat_ws(_latin1'a',_latin2'a') +func_str RUNTIME 3854 6932c6bb8b1f SELECT FIELD(_utf8mb4'abc', x'ff') +func_str LENIENT 1267 8e2f782236d9 select FIND_IN_SET(_latin1'B' COLLATE latin1_general_ci,_latin1'a,b,c,d' COLLATE latin1_bin) +func_str LENIENT 1267 2c91e1877314 select FIND_IN_SET(_latin1'B',_latin2'a,b,c,d') +func_str RUNTIME 3854 58111e77f0e0 select SUBSTRING_INDEX(_latin1'abcdabcdabcd',_latin2 0xa3, 2) +func_str LENIENT 1270 20ec865bf11f select _latin2'B' between _latin1'a' and _latin1'b' +func_str LENIENT 1270 d1924c1aefc4 select _latin1'B' between _latin2'a' and _latin1'b' +func_str LENIENT 1270 847a99f1f634 select _latin1'B' between _latin1'a' and _latin2'b' +func_str LENIENT 1270 d147eacf2acf select _latin1'B' collate latin1_general_ci between _latin1'a' collate latin1_bin and _latin1'b' +func_str LENIENT 1270 09c96241d9c0 select _latin2'B' in (_latin1'a',_latin1'b') +func_str LENIENT 1270 6de71b739d7a select _latin1'B' in (_latin2'a',_latin1'b') +func_str LENIENT 1270 074356047a0b select _latin1'B' in (_latin1'a',_latin2'b') +func_str LENIENT 1270 b6152a78fe4d select _latin1'B' COLLATE latin1_general_ci in (_latin1'a' COLLATE latin1_bin,_latin1'b') +func_str LENIENT 1270 41e05cdc3a9d select _latin1'B' COLLATE latin1_general_ci in (_latin1'a',_latin1'b' COLLATE latin1_bin) +func_str LENIENT 1267 14d503543bee select * from t7 +func_str DIFF column type string->binary dd3195599dc2 select trim('xyz' from null) as "must_be_null" +func_str DIFF column type string->binary b17af7522f2b select ifnull(load_file("lkjlkj"),"it is null") +func_str DIFF column type binary->string 76e0d5e73c93 SELECT CHAR(0xff,0x8f USING utf8mb3) +func_str RUNTIME 1690 802f293cf1c6 SELECT +func_str DIFF column type bigint->int 17c5d77f32ab SELECT (SELECT (SELECT 1 FROM t1 a WHERE t1.a <_ucs2'a')) AS d5 FROM t1 GROUP BY 1 +func_str DIFF column type binary->string 4b4dbe789ad6 SELECT REPLACE( 'a', binary 'b', NULL ) as xxx +func_str DIFF column type binary->string 92bbbdbf94ac SELECT REPLACE( 'a', binary '', NULL ) as xxx +func_str DIFF column type string->binary 2adce7364001 SELECT REPLACE( NULL, 'b', 'bravo' ) as xxx +func_str DIFF column type string->binary 5f01a94f69c3 SELECT REPLACE( NULL, '', 'bravo' ) as xxx +func_str DIFF column type binary->string cfd5a68b21db SELECT REPLACE( 'a', binary NULL, 'bravo' ) as xxx +func_str DIFF column type string->binary 926dd4befb9b select left('hello',null),left(null,1),left(null,null) +func_str DIFF column type string->binary e99776a8a5c5 select right('hello',null),right(null,1),right(null,null) +func_str DIFF column type string->binary c3c28e4bbe08 select mid('hello',1,null),mid('hello',null,1),mid(null,1,1) +func_str DIFF column type string->binary 0afc473dc0b7 select substr('hello',null,2),substr('hello',2,null),substr(null,1,2) +func_str DIFF column type string->binary a1bfd6ef623d select substr(null,null,null),mid(null,null,null) +func_str DIFF column type string->binary b7b7bdd7d83c select substring_index(null,'the',3) +func_str DIFF column type string->binary 638bdd298af6 select substring_index(null,null,3) +func_str DIFF column type string->binary c2c18d05f184 select substring_index(null,null,null) +func_str DIFF column type string->binary b10daf93209c select insert(null,2,1,'hi'),insert('txs',null,1,'hi') +func_str DIFF column type string->binary a58bbe924e0f select insert('txs',null,null,'hi'),insert(null,null,null,null) +func_str DIFF column type bigint unsigned->binary 3353c54202aa SELECT REPEAT(0x1111, 40000000) >> 1 +func_str DIFF column type bigint unsigned->binary 437ae66d6822 SELECT (~(REPEAT(0xb822, 0x5C9C380))) +func_str DIFF nullable false->true b9eea417c926 SELECT COUNT(*), CONCAT(x, 'x') FROM (SELECT * FROM t) AS tt +func_str DIFF nullable true->false ebe7aae496e4 SELECT +func_str DIFF nullable true->false 83e62894d455 SELECT +func_str DIFF nullable true->false 1c1410213bd3 SELECT +func_str RUNTIME 3854 92bbd0d4ab20 SELECT HEX(LPAD('', 42, REPLACE(COMPRESS(42),c1,''))) FROM t1 +func_str RUNTIME 3854 bdc1bad6fefa SELECT HEX(LPAD('', 42, SUBSTRING_INDEX(COMPRESS(42),c1,1))) FROM t1 +func_str RUNTIME 3854 8aa79c1a4aa8 SELECT LPAD('', 42, REPLACE(AES_ENCRYPT(c1, 'key'),c1,'')) FROM t1 +func_str RUNTIME 3854 8b2d08aa530e SELECT HEX(LPAD('', 42, TRIM(BOTH c1 FROM x'ff'))) FROM t1 +func_str RUNTIME 3854 75531bcb9fd1 SELECT HEX(LPAD('', 42, REPLACE(x'ffff',c1,''))) FROM t1 +func_str RUNTIME 3854 c3e084cb945e SELECT HEX(LPAD('', 42, TRIM(x'ffff' FROM c1))) FROM t1 +func_str RUNTIME 3854 9d2af6f9a7cf SELECT LPAD('', 42, REPLACE(c1, 'las', x'ff')) FROM t1 +func_str RUNTIME 3854 2f8a70663e55 SELECT LPAD('', 42, REPLACE(c1, x'ff', 'las')) FROM t1 +func_str RUNTIME 3854 bc2191e2b2d3 SELECT HEX(SUBSTRING_INDEX(_utf8mb4 x'41F09F8DA3F09F8DA3' , x'f0', 2)) FROM t1 +func_str RUNTIME 3854 fad067ab3f07 SELECT TRIM(BOTH x'f0' FROM _utf8mb4 x'F09F8DA3F09F8DA3') +func_str DIFF column type binary->string 5ceeea250637 SELECT TRIM(BOTH x'F09F8DA3' FROM _utf8mb4 x'F09F8DA3F09F8DA3') +func_str RUNTIME 3854 256e0b875a84 SELECT TRIM(_latin2 x'a3' from _latin1 "hello") +func_str RUNTIME 3854 a48a8baa3653 SELECT REPLACE(_latin1 "hello", _latin2 x'a3', "hei") +func_str RUNTIME 3854 a671dfa48ec2 SELECT REPLACE(_latin1 "hello", "hei", _latin2 x'a3') +func_str RUNTIME 3854 9fcc904b78de SELECT SUBSTRING_INDEX(_latin1 "hello", _latin2 x'a3', 42) +func_str DIFF column type binary->string 08b0af31b6b1 SELECT TRIM(leading _utf8mb4 x'F09F8DA3' from _gb18030 x'9439B9376181308B33') +func_str DIFF column type binary->string 38e7a8f3bb68 SELECT SUBSTRING_INDEX(_utf8mb4 x'C3A6C3B8F09F8DA361C3A6C3B8F09F8DA362', +func_str DIFF column type binary->string cf8fac4cb820 SELECT REPLACE('æøåæøå',_utf16 x'00e5', _gb18030 x'9439B937') +func_str DIFF column type binary->string ca017a9f4868 SELECT REPLACE(_latin1 x'E6F8E5E6F8E5',_utf16 x'00e5', _utf32 x'00000061') +func_str DIFF column type binary->string 65be7efd91f8 SELECT +func_str DIFF column type binary->string b08621f57abe SELECT INSERT(1, 1, 2, _utf8mb4 x'E4B8ADE69687') +func_str RUNTIME 3854 474496a2a69d SELECT CONCAT(a, x'ff') FROM t1 +func_str RUNTIME 3854 b5d42f966e7c SELECT CONCAT_WS(',', a, x'ff') FROM t1 +func_str RUNTIME 3854 d3c1eaf741af SELECT CONCAT_WS(x'ff', a, a) FROM t1 +func_str RUNTIME 3854 d9a6d1a38de2 SELECT INSERT(a, 1, 1, x'ff') FROM t1 +func_str RUNTIME 3854 98fb3b408202 SELECT EXPORT_SET(7, a, b, x'ff', 4) FROM t1 +func_str RUNTIME 3854 09614e5e61fd SELECT EXPORT_SET(7, a, x'ff', b, 4) FROM t1 +func_str RUNTIME 3854 37e85f6219d5 SELECT MAKE_SET(7, a, b, x'ff') FROM t1 +func_str RUNTIME 3854 9ef9cc6df32c SELECT (SELECT 1 +func_str DIFF column type datetime->date 35ddc7e2e9a9 SELECT * FROM v +func_str LENIENT 1267 f0446eadc74a SELECT _latin1 'ß' COLLATE latin1_general_ci = _utf8mb4 'ss' COLLATE utf8mb4_0900_ai_ci AS c +func_str RUNTIME 3854 17c501dfb76e SELECT +func_str RUNTIME 3854 83366d6cc5b3 SELECT QUOTE(x'80') +func_str LENIENT 1300 de09a372d253 SELECT QUOTE(_utf8mb4 x'80') +func_test LENIENT 1267 99cb3c3e7318 select _koi8r'a' COLLATE koi8r_bin = _koi8r'A' COLLATE koi8r_general_ci +func_test LENIENT 1267 47ba54d6d8f3 select _koi8r'a' = _latin1'A' +func_test LENIENT 1267 aac1b4066d7c select strcmp(_koi8r'a' COLLATE koi8r_general_ci, _koi8r'A' COLLATE koi8r_bin) +func_test LENIENT 1267 c49a66a2ffd6 select strcmp(_koi8r'a', _latin1'A') +func_test LENIENT 1267 d3d648202e01 select _koi8r'a' COLLATE koi8r_general_ci LIKE _koi8r'A' COLLATE koi8r_bin +func_test LENIENT 1267 f652800ed6cc select _koi8r'a' LIKE _latin1'A' +func_test LENIENT 1270 f24502f781af select greatest(1,_utf16'.',_utf8mb3'') +func_test DIFF column type string->binary 5f0a81d9d921 SELECT GREATEST(a, b), GREATEST(a, c), GREATEST(a, d), GREATEST(a, e), +func_test RUNTIME 1690 220c959d0728 SELECT +func_test LENIENT 1411 22098442ee9d SELECT +func_time DIFF column type decimal->bigint ee0f0568d9ba select unix_timestamp('1969-12-01 19:00:01') +func_time DIFF column type datetime->date 893c3fb428f0 select date_add(date,INTERVAL 1 DAY) from t1 +func_time DIFF column type datetime->date 13c44b892077 select date_add(date,INTERVAL 1 MONTH) from t1 +func_time DIFF column type datetime->date bf86a1de7d52 select date_add(date,INTERVAL 1 YEAR) from t1 +func_time DIFF column type datetime->date e9bb6e2f1583 select date_add(date,INTERVAL "1 1" YEAR_MONTH) from t1 +func_time DIFF column type datetime->date ba553f5d9c9b select date_add(date,INTERVAL "1" WEEK) from t1 +func_time DIFF column type datetime->date 7ff8446c1369 select date_add(date,INTERVAL "1" QUARTER) from t1 +func_time DIFF column type datetime->date 5159d2889345 select timestampadd(WEEK, 1, date) from t1 +func_time DIFF column type datetime->date 965da59a4fdf select date_add(last_day("1997-12-1"), INTERVAL 1 DAY) +func_time LENIENT 1305 abbf0ba65cbe insert into t1 set a = sysdate() +func_time CODE 1267->1140 73b3636e060f select concat(a,ifnull(min(date_format(now(), '%Y-%m-%d')),' ull')) from t1 +func_time DIFF column type datetime->date c804874e72b7 SELECT min(timestampadd(month, 1>'', from_days('%Z'))) +func_time DIFF column type decimal->bigint c262b2f37b36 SELECT a, UNIX_TIMESTAMP(a), UNIX_TIMESTAMP('5000-01-01 00:00:00') FROM t1 +func_time LENIENT 1525 d559304fc831 WITH cte1 AS +func_unixtime DIFF column type decimal->bigint 44a6d6cda70e SELECT UNIX_TIMESTAMP('3001-01-18 23:59:59') +func_unixtime DIFF column type decimal->bigint 52f3dad5d75c SELECT UNIX_TIMESTAMP('3001-01-19 00:00:00') +func_unixtime DIFF column type decimal->bigint 4ffc542f5a6d SELECT UNIX_TIMESTAMP('3001-01-19 00:59:59') +func_unixtime DIFF column type decimal->bigint 0ebc7c738808 SELECT UNIX_TIMESTAMP('3001-01-19 01:00:00') +func_unixtime DIFF column type decimal->bigint 3f50326cc816 SELECT UNIX_TIMESTAMP("3001-01-19 08:59:59") +func_unixtime DIFF column type decimal->bigint 8b4df314881d SELECT UNIX_TIMESTAMP("3001-01-19 09:00:00") +func_unixtime DIFF column type decimal->bigint 13e1f6fe4889 SELECT UNIX_TIMESTAMP('3001-01-18 22:59:59') +func_unixtime DIFF column type decimal->bigint 36785ecb8b4c SELECT UNIX_TIMESTAMP('3001-01-18 15:59:59') +func_unixtime DIFF column type decimal->bigint f0007a4ef0ec SELECT UNIX_TIMESTAMP('3001-01-18 16:00:00') +func_weight_string LENIENT 1064 a004d59c56a7 select hex(weight_string('a' as char(0))) +functional_index LENIENT 3752 951ca7511025 INSERT INTO t1 (f1) VALUES (CAST(1000 AS JSON)) +functional_index LENIENT 3751 3f86c17a619b INSERT INTO t1 (f2) VALUES ("lorem ipsum") +functional_index LENIENT 1050 ae2cd589953d CALL p1() +get_diagnostics STRICT 1146 ae2cd589953d CALL p1() +get_diagnostics LENIENT 1000 ae2cd589953d CALL p1() +get_diagnostics LENIENT 9999 ae2cd589953d CALL p1() +get_diagnostics LENIENT 3004 ae2cd589953d CALL p1() +get_diagnostics LENIENT 3004 3fc1e2aa76a6 SELECT f1() +gis DIFF column type bigint->int ea7656a3ea67 SELECT g1.fid as first, g2.fid as second, +gis LENIENT 13 a9ccb34051ff load data infile '../../std_data/bad_gis_data.dat' into table t1 +gis LENIENT 1367 f3e52c825985 select +gis LENIENT 3037 238f434c3fdb select +gis LENIENT 1367 317bff974bbc select +gis LENIENT 3037 679a7d28fde7 select ST_geomfromtext(col9,col89) as a from t1 +gis LENIENT 3673 418f54622d20 insert into t1 values (null,null) +gis RUNTIME 1416 8456877e7bee insert into t1 values(default) +gis LENIENT 1210 acd7ac0a53ea select min(`col002`) from t1 union select `col002` from t1 +gis LENIENT 1367 4501261ee18f SELECT MultiPoint(12345,'') +gis CODE 1367->1210 b74b899288b2 SELECT 1 FROM (SELECT GREATEST(1,GEOMETRYCOLLECTION('00000','00000')) b FROM DUAL) AS d WHERE (LINES +gis DIFF nullable false->true 81008058e542 select * from (select polygon(t1.a) as p from t1 order by t1.a) d +gis LENIENT 3055 be8d213af16f SELECT ST_ISCLOSED(CONVERT(CONCAT(' ', 0x2), BINARY(20))) +gis LENIENT 1367 9e2de8e977cc SELECT GEOMETRYCOLLECTION((SELECT @@CORE_FILE)) +gis LENIENT 3055 d687e3770389 SELECT ST_ASTEXT(0x0100000000030000000100000000000010) +gis LENIENT 3055 9325c4e47aa9 SELECT ST_ENVELOPE(0x0100000000030000000100000000000010) +gis LENIENT 3055 d57780ab9bac SELECT +gis LENIENT 3055 54af26e60a4d SELECT +gis LENIENT 3033 06699d2f11c3 SELECT ST_Equals(boundary, +gis SCHEMA no database selected beb0f86f06b3 SELECT ST_Union('', ''), md5(1) +gis RUNTIME 1416 a4909ed4fcb4 INSERT INTO t1 VALUES ( +gis RUNTIME 1416 83faf4e9f683 INSERT INTO t1 VALUES ( +gis RUNTIME 1416 f62c04ad8865 INSERT INTO t1 VALUES ( +gis RUNTIME 1416 29a448739cbd INSERT INTO t1 VALUES ( +gis RUNTIME 1416 c79646dcb632 INSERT INTO t1 VALUES ( +gis RUNTIME 1416 73d165052c9a INSERT INTO t1 VALUES ( +gis RUNTIME 1416 d623fa472ece INSERT INTO t1 VALUES ( +gis RUNTIME 1416 a5cee47f8664 INSERT INTO t1 VALUES ( +gis RUNTIME 1416 a4b8fa416376 INSERT INTO t1 VALUES ( +gis RUNTIME 1416 26385155bed5 INSERT INTO t1 VALUES ( +gis RUNTIME 1416 1d77fe3a9cf7 INSERT INTO t1 VALUES ( +gis RUNTIME 1416 2566222c6f4d INSERT INTO t1 VALUES ( +gis RUNTIME 1416 d0dd21bac753 INSERT INTO t1 VALUES ( +gis RUNTIME 1416 f81ab5e6eefe INSERT INTO t1 VALUES ( +gis RUNTIME 1416 46df268b1d3e INSERT INTO t1 VALUES ( +gis RUNTIME 1416 cce1def0f48e INSERT INTO t1 VALUES ( +gis RUNTIME 1416 f3113d4ee470 INSERT INTO t1 VALUES ( +gis RUNTIME 1416 5ff4ab8b3f8f INSERT INTO t1 VALUES ( +gis RUNTIME 1416 b6a770bb291e INSERT INTO t1 VALUES ( +gis RUNTIME 1416 7732df0c6dfa INSERT INTO t1 VALUES ( +gis RUNTIME 1416 4315b9b0515d INSERT INTO t1 VALUES ( +gis RUNTIME 1416 097ed4f66e60 INSERT INTO t1 VALUES ( +gis RUNTIME 1416 997ee191338e INSERT INTO t1 VALUES ( +gis RUNTIME 1416 80743202dc3d INSERT INTO t1 VALUES ( +gis RUNTIME 1416 93fcc64b4571 INSERT INTO t1 VALUES ( +gis RUNTIME 1416 eed67913f1b3 INSERT INTO t1 VALUES ( +gis RUNTIME 1416 6567d3ed8382 INSERT INTO t1 VALUES ( +gis RUNTIME 1416 41262041d643 INSERT INTO t1 VALUES ( +gis RUNTIME 1416 789de56415a5 INSERT INTO t1 VALUES ( +gis RUNTIME 1416 58b91e31b152 INSERT INTO t1 VALUES ( +gis RUNTIME 1416 e54cf07cd7cd INSERT INTO t1 VALUES ( +gis RUNTIME 1416 bc7b7ab907ec INSERT INTO t1 VALUES ( +gis RUNTIME 1416 c58560e1faba INSERT INTO t1 VALUES ( +gis RUNTIME 1416 61b6339bf930 INSERT INTO t1 VALUES ( +gis RUNTIME 1416 1e50d046d46f INSERT INTO t1 VALUES ( +gis RUNTIME 1416 24bd30f6332b INSERT INTO t1 VALUES ( +gis RUNTIME 1416 275f2931708b INSERT INTO t1 VALUES ( +gis DIFF column name c32f45801a51 SELECT ST_ISVALID(ST_UNION(ST_GEOMFROMTEXT('MULTIPOLYGON(((0 0,0 40,40 +gis DIFF column name 6936d50043e2 SELECT ST_ISVALID(ST_UNION(ST_GEOMFROMTEXT('MULTIPOLYGON(((0 0,0 40,40 +gis LENIENT 1210 3f1388213f63 SELECT ST_GeomFromText('POINT(0 0)') > (SELECT b FROM t) AS result +gis LENIENT 1210 dcfff3e49f9a SELECT ST_GeomFromText('POINT(0 0)') > (SELECT b FROM t1) AS result +gis DIFF column name 3fc0bfcf9da4 SELECT ST_ISVALID(ST_UNION(ST_GEOMFROMTEXT('LINESTRING(12 6,9 4,-9 +gis DIFF nullable false->true 0cf85a140226 SELECT point(1,1) IN ('1',1,'1') AS res +gis LENIENT 1367 7a17e9e2d2b0 SELECT (SELECT MULTILINESTRING(d.a, d.a, d.a) FROM t1) +gis LENIENT 3673 2802ad3f68bd INSERT INTO t1 VALUES (NULL), (NULL) +gis LENIENT 3673 31cc9a46f15a INSERT IGNORE INTO t1 VALUES (NULL) +gis LENIENT 3673 2408057819ce INSERT INTO t1 VALUES (NULL) +gis LENIENT 3673 3a4e6eb3c86b UPDATE t1 SET g=NULL +gis LENIENT 3673 68802a88a2df UPDATE IGNORE t1 SET g=NULL +gis RUNTIME 1364 324f3481b2f0 INSERT IGNORE INTO t1 VALUES () +gis RUNTIME 1364 d1977bd73a60 LOAD DATA INFILE 'bug26412713.csv' INTO TABLE t1 (x) +gis RUNTIME 1423 bd861e55558c INSERT IGNORE INTO v1(id) VALUES(0) +gis LENIENT 3727 121ccbf072f4 SELECT ST_Contains( +gis LENIENT 3727 f7408ff9eeca SELECT ST_Touches( +gis LENIENT 3616 fc4da108bcac INSERT t(a) VALUES( +gis RUNTIME 1364 ec8546c0599a INSERT IGNORE INTO t1 VALUES() +gis RUNTIME 1416 b69d5ddacf9a INSERT IGNORE INTO t1 VALUES(DEFAULT) +gis RUNTIME 1416 b6c5c492322c INSERT INTO t1 VALUES(DEFAULT) +gis RUNTIME 1416 fefb3b4d8137 UPDATE t1 SET geom = DEFAULT +gis-precise LENIENT 3037 50a377a84452 SELECT ST_ASTEXT(ST_TOUCHES(@a, ST_GEOMFROMTEXT('point(0 0)'))) t +gis-precise LENIENT 3037 fc284aacc74b select st_union((cast(linestring(point(6,-68), point(-22,-4)) as binary(13))), +gis-precise DIFF column name 5a83363db37f select ST_astext(st_union( +gis-precise LENIENT 3037 0a76a5b10fc2 select ST_astext(ST_centroid(ST_PolyFromWKB(ST_AsWKB(Polygon(LineString(Point(0, 0), Point(30, 0), P +gis-precise LENIENT 3037 dec402d77a27 select ST_astext(ST_convexhull(ST_PolyFromWKB(ST_AsWKB(Polygon(LineString(Point(0, 0), Point(30, 0), +gis-precise LENIENT 3037 1bdd04bf13d5 SELECT ST_AsText(ST_Centroid(ST_GeomFromText('polygon((0 0, 1 1, 2 2, 0 0), (3 3, 4 4, 5 5, 3 3))')) +gis-precise LENIENT 3037 d8a688ddbcf8 SELECT ST_AsText(ST_Centroid(ST_GeomFromText('multipolygon(((0 0, 1 1, 2 2, 0 0), (3 3, 4 4, 5 5, 3 +gis-precise LENIENT 3037 2b5a4b982b25 SELECT ST_AsText(ST_Convexhull(ST_GeomFromText('polygon((0 0, 1 1, 2 2, 0 0), (3 3, 4 4, 5 5, 3 3))' +gis-precise LENIENT 3037 4a27106dc655 SELECT ST_AsText(ST_Convexhull(ST_GeomFromText('multipolygon(((0 0, 1 1, 2 2, 0 0), (3 3, 4 4, 5 5, +gis-precise LENIENT 3037 90b816c68466 Select ST_AsText(ST_Centroid(ST_PolyFromText('POLYGON((1 0, 2 0, 3 0, 4 0, 5 0, 1 0))'))) as Result +gis-precise LENIENT 3037 a1e787ba87ad select (ST_aswkb(cast(st_union(multipoint( +gis-precise DIFF column name a623e0227e9d SELECT ST_Distance(ST_GeomFromText('GEOMETRYCOLLECTION(LINESTRING(0 0,10 10,20 20),GEOMETRYCOLLECTIO +gis-precise LENIENT 3037 40097a08f0cd select ST_Length(ST_MLineFromWKB(0x0000000005000000020000000002000000035FB317E5EF3AB327E3A4B378469B6 +gis-precise LENIENT 3037 10f87f3476c5 select st_distance(linestring(point(26,87),point(13,95)), +gis-precise LENIENT 3037 7d3a01eabff0 select st_distance(linestring(point(26,87),point(13,95)), +gis-precise DIFF column name 0e4473f35535 SELECT +gis-precise LENIENT 3033 5deecb5c0f13 SELECT ST_ASTEXT(ST_MAKEENVELOPE(ST_GEOMFROMTEXT('POINT(0 1)', 4236), +gis-precise LENIENT 3033 96a0ed0e40a8 SELECT ST_ASTEXT(ST_MAKEENVELOPE(ST_GEOMFROMTEXT('POINT(0 1)', 4145), +gis-precise LENIENT 3033 40eae9240316 SELECT ST_ASTEXT(ST_MAKEENVELOPE(ST_GEOMFROMTEXT('POINT(0 1)', 0), +gis-precise LENIENT 3548 2ca1e08c9a24 SELECT ST_ISVALID(ST_GEOMFROMTEXT('POINT(0 0)', 1)) +gis-precise LENIENT 3548 08c4cc759300 SELECT ST_ISVALID(ST_GEOMFROMTEXT('POINT(0 0)', 1000)) +gis-precise LENIENT 3037 c956c4912cc1 SELECT ST_ASTEXT(ST_SIMPLIFY(0x000000000200000000000000000000000000000000, 1)) as result +gis-precise LENIENT 3072 51f4d6f3023f SELECT HEX(ST_GeomFromGeoJSON('{"type":"LineString","coordinates":[[0,0]]}')) +gis-precise LENIENT 3134 a4a32fbd566d select st_astext(st_buffer(point(-5,0),8772, +gis-precise LENIENT 1210 855cd5f13a4f SELECT HEX(POINT(POINT(0,0),0)) +gis-precise LENIENT 1210 256a9e73c85e SELECT ST_ASTEXT(POINT(LINESTRING(POINT(0,0),POINT(1,1)),LINESTRING(POINT(2,2),POINT(3,3)))) +gis-precise LENIENT 3037 dc5f28982aa8 select st_astext(st_union(cast(point(1,1)as binary(15)),point(1,1))) as res +gis-precise LENIENT 3037 7ac4c72ee4d8 SELECT ST_GeomFromText(col_1) FROM t1 +gis-rtree LENIENT 3037 0295d78c1b0e INSERT INTO t2 SELECT ST_GeomFromText(st) FROM t1 +gis-rtree LENIENT 3673 76d09b8acd42 INSERT INTO t1(foo) VALUES (NULL) +gis-rtree LENIENT 1210 d841bc94d238 SELECT 1 FROM t1 WHERE a < ST_GEOMFROMTEXT('LINESTRING(-1 -1, 1 -1, -1 -1, -1 1, 1 1)') +gis-rtree LENIENT 1210 b2662b65c8f8 SELECT 1 FROM t1 WHERE a <= ST_GEOMFROMTEXT('LINESTRING(-1 -1, 1 -1, -1 -1, -1 1, 1 1)') +gis-rtree LENIENT 1210 b12bfd2e84f0 SELECT 1 FROM t1 WHERE a > ST_GEOMFROMTEXT('LINESTRING(-1 -1, 1 -1, -1 -1, -1 1, 1 1)') +gis-rtree LENIENT 1210 69a4e341eff6 SELECT 1 FROM t1 WHERE a >= ST_GEOMFROMTEXT('LINESTRING(-1 -1, 1 -1, -1 -1, -1 1, 1 1)') +gis-rtree LENIENT 3673 5d3d31a887d5 INSERT IGNORE INTO t1 SET a=1, b=NULL +grant STRICT 1054 473d683c553a update mysqltest_2.t1, mysqltest_2.t2 set c=500,d=600 +grant STRICT 1054 309e6fc14605 update mysqltest_1.t1, mysqltest_1.t2 set a=100,b=200 +grant STRICT 1054 41d1600fbcb6 update mysqltest_2.t1, mysqltest_1.t2 set c=100,b=200 +grant STRICT 1054 269f9b279be3 update mysqltest_1.t1, mysqltest_2.t2 set a=100,d=200 +grant DIFF column name 024eee5d764a select t1.*,t2.* from mysqltest_1.t1,mysqltest_1.t2 +grant SCHEMA dump: SHOW CREATE TABLE ADMINISTRABLE_ROLE_AUTHORIZATIONS: sql: ex 6827cdef3cf4 SELECT TABLE_SCHEMA, TABLE_NAME, GROUP_CONCAT(PRIVILEGE_TYPE ORDER BY +grant2 DIFF column type string->binary a42d37b8c8e5 select current_user() +grant2 DIFF column type string->binary 055b08222849 select * from t1 +grant2 DIFF column type string->binary 78869acd3759 SELECT user,host,length(authentication_string),insert_priv FROM user WHERE user=@u AND host=@h +group_by DIFF column type bigint->int 7c1da931e0bb select count(*), interval(qty,2,3,4,5,6,7,8) as category from t1 group by category +group_by CODE 1241->1140 dfd2cb8a6108 SELECT t1.a FROM t1 ORDER BY (SELECT SUM(t2.a), t2.a FROM t2) +group_by STRICT 3029 c8a2b6142e61 SELECT t1.a FROM t1 GROUP BY t1.a +group_by CODE 1055->1054 3e3d58cfd8ee SELECT 1 FROM t1 GROUP BY t1.a +group_by CODE 1055->1054 10e775189d04 SELECT 1 FROM t1 GROUP BY t1.a +group_by STRICT 1241 d4d9c5c6b89f SELECT t1.a FROM t1 GROUP BY t1.a +group_by RUNTIME 1242 36c7452a24f2 SELECT b c, (SELECT a FROM t1 WHERE b = c) +group_by DIFF column type int->bigint d9a219914fbd SELECT (SELECT t2.a FROM t2 WHERE t2.a = t1.a) aa, b, COUNT(DISTINCT b) +group_by DIFF column type int->bigint 69493037adef SELECT (SELECT t2.a FROM t2 WHERE t2.a = t1.a) aa, b, COUNT( b) +group_by DIFF column type int->bigint 34c889ad4574 SELECT (SELECT t2.a FROM t2 WHERE t2.a = t1.a) aa, b, COUNT(DISTINCT b) +group_by DIFF column type int->bigint db5fb8c2eb4b SELECT (SELECT t2.a FROM t2 WHERE t2.a = t1.a) aa, b, COUNT( b) +group_by STRICT 3029 c447f2ca0f65 SELECT (SELECT 1 AS foo ORDER BY COUNT(a)) AS x +group_by STRICT 3029 67ffe40885ef SELECT (SELECT 1 AS foo ORDER BY COUNT(t1.a)) AS x +group_by LENIENT 1247 4b4bcc4077c4 SELECT COUNT(*) AS c1, (SELECT 1 HAVING c1) AS c2 +group_by LENIENT 1247 76b00c654e76 SELECT COUNT(*) + 1 AS c1, (SELECT 1 HAVING c1) + 1 AS c2 +group_by STRICT 1247 220e21450d11 SELECT 1 FROM t t1 HAVING (SELECT SUM(t1.a) s FROM t t2 +group_by LENIENT 1111 a700d4c01beb SELECT * FROM t t1 WHERE (SELECT SUM(t1.a) s FROM t t2 HAVING s = 0) +group_by LENIENT 1111 bcf29f86b553 SELECT 1 FROM t t1 GROUP BY (SELECT SUM(t1.a) s FROM t t2 ORDER BY s) +group_by LENIENT 1111 60d25b95f2e4 SELECT 1 FROM t t1 GROUP BY (SELECT SUM(t1.a) s FROM t t2 ORDER BY s + 1) +group_by LENIENT 1111 c5c81b60503f SELECT 1 FROM t t1 GROUP BY (SELECT SUM(t1.a) s) +group_by LENIENT 1111 65a9ffdbec37 SELECT 1 FROM t WHERE (SELECT SUM(a)) > 0 +group_by LENIENT 1111 4ba05ca0d607 SELECT 1 FROM t GROUP BY (SELECT SUM(a)) +group_by RUNTIME 1690 f6fe09799fd2 SELECT MAX(FROM_UNIXTIME(1536999161)), +group_by RUNTIME 1242 86fcfb75b8e7 SELECT AVG(a2.a) +group_by DIFF column type int->bigint 740f6acce28a SELECT DISTINCT a, COUNT(*) FROM t1 GROUP BY a WITH ROLLUP +group_by DIFF column type int->bigint 90e44ff4c93c SELECT t1.x +group_min_max_ps_protocol RUNTIME 1242 0abe929386c4 DELETE FROM t3 WHERE (SELECT (SELECT MAX(b) FROM t1 GROUP BY a HAVING a < 2) x +having DIFF column type int->bigint 2c7e71422766 select a as foo, +having DIFF column type int->bigint 2a46ce0c8b22 select a as foo, +having STRICT 1054 4bddf331d669 SELECT a FROM (SELECT 1 FROM t1 AS From1) AS From2 +having STRICT 1054 45a403334906 SELECT a FROM (SELECT 1 FROM t1 AS From1) AS From2 +having STRICT 1054 c1481c71e932 SELECT Outjoin3.a FROM (SELECT 1 FROM t1 AS From1) AS From2 +having LENIENT 1052 a701e0ec62ce SELECT * +having_myisam RUNTIME 1242 9324da2a48fb SELECT col_int_key +histograms STRICT 1406 3981813324b7 WITH cte AS ( +histograms_debug LENIENT 1792 d93129d8e95a INSERT INTO t1 (col1) VALUES (1) +ignore_strict LENIENT 1748 10d625c4db18 INSERT INTO v1 VALUES (10),(11) +ignore_strict LENIENT 1526 ddb6edcb0f0c INSERT INTO t1 VALUES (2),(4) +ignore_strict LENIENT 1526 7ecb0b391ecf UPDATE t1 SET a=a+1 +ignore_strict LENIENT 1748 0d3f2b84efaf UPDATE v1 SET a=11 WHERE a=2 +ignore_strict LENIENT 1526 acd092d63e7d UPDATE t1 SET a=4 WHERE a=5 +ignore_strict RUNTIME 1242 5d4a49f0e467 DELETE FROM t11 WHERE t11.b = (SELECT b FROM t2 WHERE t11.a < t2.a) +ignore_strict RUNTIME 1441 6ff24bdce819 INSERT INTO t1 (d) SELECT DATE_SUB('2000-01-01', INTERVAL 2001 YEAR) +ignore_strict RUNTIME 1365 42674097b74b INSERT INTO t1 VALUES(2/0) +ignore_strict LENIENT 1210 316901552400 INSERT INTO t1(SELECT SLEEP(NULL)) +ignore_strict LENIENT 13 9d2beecfea02 LOAD DATA INFILE '../../std_data/wl6030_2.dat' INTO TABLE t1 FIELDS TERMINATED BY ',' ENCLOSED BY '" +ignore_strict LENIENT 13 b8a6ff7ffb9a LOAD DATA INFILE '../../std_data/wl6030_2.dat' IGNORE INTO TABLE t1 FIELDS TERMINATED BY ',' ENCLOSE +ignore_strict LENIENT 1814 e8e388b2ecce DELETE IGNORE FROM t +ignore_strict LENIENT 1814 1c900727bb14 DELETE IGNORE FROM t ORDER BY 0 + 1 +ignore_strict LENIENT 3020 182f7c9f0778 DELETE FROM t1 where a <=> ln(0) +ignore_strict LENIENT 3020 2d93e0a5d68b DELETE t1, t2 FROM t1 INNER JOIN t2 WHERE t1.a <=> ln(0) AND t2.b <=> ln(0) +ignore_strict LENIENT 13 544a0076d878 LOAD DATA INFILE '../../std_data/rpl_loaddata.dat' INTO TABLE t2 (a, @b) SET b= @b + 2 +ignore_strict LENIENT 1175 58970e964977 DELETE IGNORE FROM t1 WHERE a=1 +inconsistent_scan LENIENT 13 8f05689f6bdb load data infile '../../std_data/inconsistent_scan.csv' into table bureau columns terminated by "," +information_schema_cs SCHEMA dump: SHOW CREATE TABLE ADMINISTRABLE_ROLE_AUTHORIZATIONS: sql: ex 069d554d2787 select table_name from tables where table_name='user' +information_schema_cs SCHEMA dump: SHOW CREATE TABLE ADMINISTRABLE_ROLE_AUTHORIZATIONS: sql: ex 27703f47abf2 select trigger_schema, trigger_name from triggers where +information_schema_cs SCHEMA dump: SHOW CREATE TABLE ADMINISTRABLE_ROLE_AUTHORIZATIONS: sql: ex 4e02cf1f54a1 select column_name, column_default from columns +information_schema_cs SCHEMA dump: SHOW CREATE TABLE ADMINISTRABLE_ROLE_AUTHORIZATIONS: sql: ex ad5c361faa4e select ROUTINE_NAME from routines where ROUTINE_SCHEMA='information_schema' +information_schema_cs SCHEMA dump: SHOW CREATE TABLE ADMINISTRABLE_ROLE_AUTHORIZATIONS: sql: ex 3ff83bb877ee select 1 from (select 1 from test.t1) a +information_schema_cs SCHEMA dump: SHOW CREATE TABLE ADMINISTRABLE_ROLE_AUTHORIZATIONS: sql: ex 07941e7c0a6e SELECT * +information_schema_cs SCHEMA dump: SHOW CREATE TABLE ADMINISTRABLE_ROLE_AUTHORIZATIONS: sql: ex 16d6afaf0d99 INSERT IGNORE INTO ubig VALUES (0xFFFFFFFFFFFFFFFF,0xFFFFFFFFFFFFFFFF) +init_file DIFF column type bigint->bigint unsigned d136e7b5a743 SELECT YEAR(@X)-YEAR(@Y) +insert RUNTIME 1265 97cdab2cb371 insert into t1 values(null,@value,@value,@value,@value,@value,@value,@value,@value,@value) +insert RUNTIME 1264 97cdab2cb371 insert into t1 values(null,@value,@value,@value,@value,@value,@value,@value,@value,@value) +insert LENIENT 1110 e8eaf135de55 insert into t1 (a,b,b) values (1,1,1) +insert CODE 1110->1136 10ac39acf8ec insert into t1 (a,a) values (1,1,1) +insert LENIENT 1110 dfa7fd752939 insert into t1 (a,a) values (1,1) +insert LENIENT 1110 256fe3852e2b insert into t1 SET a=1,b=2,a=1 +insert LENIENT 1110 015d473bdff8 insert into t1 (b,b) select 1,2 +insert LENIENT 1110 0afb03236f07 INSERT INTO t1 (b,b) SELECT 0,0 ON DUPLICATE KEY UPDATE a = a + VALUES (a) +insert LENIENT 1393 290e9170123f insert into v1 (f1) values (3) on duplicate key update f1= f3 + 10 +insert RUNTIME 1406 9e870d5e685c INSERT INTO t1(a,b,c) VALUES (1.225e-05, 1.225e-05, 1.225e-05) +insert RUNTIME 1406 3524fc138f78 INSERT INTO t1(a,c) VALUES (1.87e-3, 1.87e-3) +insert LENIENT 1110 85971cc28ddb INSERT INTO t1 (a, a) VALUES (1, 1) +insert LENIENT 1110 1211b98723eb INSERT IGNORE t1 (a, a) VALUES (1, 1) +insert LENIENT 1110 e127c04f2b96 INSERT IGNORE t1 (a, a) SELECT 1,1 +insert LENIENT 1110 7b6dedd4fb2c INSERT IGNORE t1 (a, a) SELECT 1,1 UNION SELECT 2,2 +insert LENIENT 1136 2b3a22600926 INSERT INTO t1(a) values() +insert RUNTIME 1292 53d527013025 INSERT INTO default_date VALUES() +insert RUNTIME 1292 1144c1a7cf0a INSERT INTO default_date VALUES(DEFAULT) +insert LENIENT 1110 3588de707114 INSERT INTO t1(a,a) VALUES (1,1) +insert LENIENT 1110 40ca0f9540a6 INSERT INTO t1(a, a, b, c) VALUES (1, 1, DEFAULT, DEFAULT) +insert LENIENT 1110 86483264e825 INSERT INTO t1(b, b) VALUES (DEFAULT, DEFAULT) +insert_select_myisam DIFF column name 9bc52dc15a9f Select null, Field, Count From t1 Where Month=20030901 and Type=2 +insert_update RUNTIME 1048 02f8285d9aab INSERT INTO t1 (id,c1) SELECT 1,NULL +insert_update RUNTIME 1366 30c3f0c893ac INSERT INTO t1 values(x'8142', x'8142', x'8142') +insert_update RUNTIME 1366 9c29be3c9601 UPDATE t1 SET a=x'8243' where a=x'6162' +insert_update RUNTIME 1366 78e199f9e95f INSERT INTO t2 values(x'8142', x'8142', x'8142') +insert_update RUNTIME 1366 57d6f3081206 UPDATE t2 SET a=x'8243' where a=x'6162' +insert_update RUNTIME 1366 7931dbbe8aa4 INSERT INTO t3 values(x'8142', x'8142', x'8142') +insert_update RUNTIME 1366 60c152d4247e UPDATE t3 SET a=x'8243' where a=x'6162' +insert_update RUNTIME 1366 1004eb1c3fb6 INSERT INTO v1 values(x'8142', x'8142', x'8142') +insert_update RUNTIME 1366 42b5d52a584c UPDATE v1 SET a=x'8243' where a=x'6162' +insert_update RUNTIME 1366 54b404f8c267 INSERT INTO v2 values(x'8142', x'8142', x'8142') +insert_update RUNTIME 1366 291bd33b1821 UPDATE v2 SET a=x'8243' where a=x'6162' +insert_update RUNTIME 1366 40ed9f5eb5e0 INSERT INTO v3 values(x'8142', x'8142', x'8142') +insert_update RUNTIME 1366 1d676bd3b203 UPDATE v3 SET a=x'8243' where a=x'6162' +insert_update RUNTIME 1366 ae86deb766f7 INSERT INTO t_utf8 values(x'f4b8ad', x'f4b8ad', x'f4b8ad') +insert_update RUNTIME 1366 56a59f2b970f UPDATE t_utf8 SET a=x'f69687' where a=x'e69687' +insert_update RUNTIME 1366 288a3d726d7b INSERT INTO v_utf8 values(x'f4b8ad', x'f4b8ad', x'f4b8ad') +insert_update RUNTIME 1366 29b4e6529980 UPDATE v_utf8 SET a=x'f69687' where a=x'e69687' +ipv6_debug LENIENT 1193 4d2fbb345136 SELECT @@global.debug, @@global.bind_address +join LENIENT 1116 df4d8bbaf36b select t1.a from t1 as t1 left join t1 as t2 using (a) left join t1 as t3 using (a) left join t1 as +join LENIENT 1116 8e206ee9afb2 select a from t1 as t1 left join t1 as t2 using (a) left join t1 as t3 using (a) left join t1 as t4 +join DIFF column name d474e4fd99ab select * from (t3 natural join t4) natural right join (t1 natural join t2) +join DIFF column name 0a980f42bb04 select * from (t4 natural right join t3) natural right join (t2 natural right join t1) +join DIFF column name 90b625c925f6 select * from t1 natural join t2 natural join t3 natural join t4 +join DIFF column name 5fbc487415a2 select * from ((t1 natural join t2) natural join t3) natural join t4 +join DIFF column name f02f8c87d35b select * from t5 natural right join (t4 natural right join ((t2 natural right join t1) natural right +join DIFF column name 161639309162 select * from (t1 natural join t2), (t3 natural join t4) +join DIFF column name 0c5d929fcf90 select * from t5 natural join ((t1 natural join t2), (t3 natural join t4)) +join DIFF column name 647cc25e3368 select * from ((t1 natural join t2), (t3 natural join t4)) natural join t5 +join DIFF column name 869245b11abf select * from t5 natural join ((t1 natural join t2) cross join (t3 natural join t4)) +join DIFF column name 5d49cf7d7179 select * from ((t1 natural join t2) cross join (t3 natural join t4)) natural join t5 +join DIFF column name 48926441eda6 select * from (t1 join t2 using (b)) join (t3 join t4 using (c)) using (c) +join DIFF column name 9b07ee5b5b4a select * from (t3 natural join t4) natural right join (t1 natural join t2) +join DIFF column name fec3b0cd5f11 select * from t1 natural left join (t4 natural join t5) where t5.z is not NULL +join STRICT 1052 813bdd95bf5b select * from t1 join (t2 join t4 on b + 1 = y) on t1.c = t4.c +join STRICT 1052 ac46b1aee24c select * from t1 natural join (t2 join t4 on b + 1 = y) +join DIFF column name 6751e0ce5912 select * from (t1 natural join t2) join (t3 natural join t4) on a = y +join STRICT 1052 c9c1503f2097 select * from ((t3 join (t1 join t2 on c > a) on t3.b < t2.a) join t4 on y > t1.c) join t5 on z = t1 +join DIFF column name 39c2de289ee6 select * from t1 natural join (t4 natural join t5) where t4.y > 7 +join DIFF column name 0f8e5516e136 select * from (t4 natural join t5) natural join t1 where t4.y > 7 +join DIFF column name 193292eb6635 select * from t1 natural left join (t4 natural join t5) where t4.y > 7 +join DIFF column name c61867909abf select * from (t4 natural join t5) natural right join t1 where t4.y > 7 +join DIFF column name 78d37dc38011 select * from (t1 natural join t2) join (t3 natural join t4) on t1.b = t3.b +join DIFF column name ea75ffb1bb7d select * from (select * from t3 natural join t4) as t34 +join DIFF column name 155732deba7c select * from v1a join (t3 natural join t4) on a = y +join LENIENT 1052 5dbf6517295f select * from t1 natural join (t3 cross join t4) +join LENIENT 1052 aa1eeb059acd select * from (t3 cross join t4) natural join t1 +join LENIENT 1052 e1cf84783bad select * from t1 join (t2, t3) using (b) +join LENIENT 1052 d571c5e6c839 select * from ((t1 natural join t2), (t3 natural join t4)) natural join t6 +join LENIENT 1052 e974ecd20bc6 select * from t6 natural join ((t1 natural join t2), (t3 natural join t4)) +join LENIENT 1052 af6811b99228 select * from (t1 join t2 on t1.b=t2.b) natural join (t3 natural join t4) +join LENIENT 1052 d155ae96503e select * from (t3 natural join t4) natural join (t1 join t2 on t1.b=t2.b) +join LENIENT 1052 3b1c05fbd6b8 select * from (t3 join (t4 natural join t5) on (b < z)) +join DIFF column name 797c807d624b select * from t1 join t2 using (a1) join t3 on b=c1 join t4 using (c2) +join DIFF column name cf46bfb9d756 select * from t3 join (t1 join t2 using (a1)) on b=c1 join t4 using (c2) +join DIFF column name ee3c9468523f select * from ((t3 natural join (t1 natural join t2)) natural join t4) +join DIFF column name 0615bfa3094b select 'The cost of accessing t1 (dont care if it changes' '^' +join LENIENT 1054 8ced3ee45cfe select t1.* from t t0 cross join (t t1 join t t2 on 100=t0.a) +join LENIENT 1054 c0171bff8184 select t1.* from t t0 cross join (t t1 join t t2 +join RUNTIME 1690 663313088c03 SELECT t2.b FROM t AS t1 +join DIFF column type int->bigint cf1f66dda339 SELECT * +join LENIENT 3141 76ea9acef8b5 SELECT ra1.k +join_nested LENIENT 1052 39c16e1fcecd SELECT * FROM t1 JOIN (t2 JOIN t3 USING (b)) USING (a) +join_outer DIFF column name 041b148c3281 select * from t1 +join_outer DIFF column type bigint unsigned->int unsigned 911afbb589b2 select s.*, '*', m.*, (s.match_1_h - m.home) UUX from +join_outer DIFF column type bigint unsigned->int unsigned 1672ee401e63 select s.*, '*', m.*, (s.match_1_h - m.home) UUX from +join_outer DIFF column type int->bigint c8f51a6987af SELECT t1.a, COUNT( t2.b ), SUM( t2.b ), MAX( t2.b ) +join_outer DIFF column type int->bigint 21a46121b71b SELECT t1.a, COUNT( t2.b ), SUM( t2.b ), MAX( t2.b ) +join_outer DIFF nullable true->false 2507ec1d5da7 SELECT * +join_outer DIFF nullable true->false f4b0b18322b4 SELECT * +join_outer DIFF nullable true->false 288d1bf2f17a SELECT * FROM big_view +join_outer DIFF nullable true->false 4f2f299b3f67 SELECT big.*, small.id AS small_id, small.is_odd +join_outer DIFF nullable true->false 8f0a3cf09156 SELECT big.*, dt.* +join_outer DIFF nullable true->false 59f88253d493 SELECT big.*, dt.* +join_outer DIFF nullable true->false 5474b8a443d9 SELECT big.*, dt.* +join_outer DIFF nullable true->false 65a725c98737 SELECT je +join_outer DIFF column type bigint->int d6469f033388 SELECT je +key_cache LENIENT 1193 894e3271e01f SELECT @@skr.default_storage_engine="test" +lead_lag LENIENT 1235 2485612c6dda SELECT id, sex, LEAD(id, 1, 10) IGNORE NULLS OVER () FROM t1 +lead_lag LENIENT 1267 c4ec44cc179c SELECT c1, c2, LEAD(c1, 0, c2) OVER () l0 FROM t +lead_lag DIFF column type string->binary 2907f8f9ebc2 SELECT LEAD(c1, 100, b1) OVER () lcb, IFNULL(c1, b1) ifn_cb FROM t +lead_lag DIFF column type string->binary 86ff77570304 SELECT LEAD(j1, 100, b1) OVER () ljb, IFNULL(j1, b1) ifn_jb FROM t +lead_lag DIFF column type string->binary 5643816b6ac7 SELECT LEAD(i1, 100, b1) OVER () lib, IFNULL(i1, b1) ifn_ib FROM t +lead_lag DIFF column type string->binary ce7f15c7f622 SELECT LEAD(b1, 100, c1) OVER () lbc, IFNULL(b1, c1) ifn_bc FROM t +lead_lag DIFF column type string->binary f2d0c3c5304d SELECT LEAD(b1, 100, j1) OVER () lbj, IFNULL(b1, j1) ifn_bj FROM t +lead_lag DIFF column type string->binary ba2877465fcb SELECT LEAD(b1, 100, i1) OVER () lbi, IFNULL(b1, i1) ifn_bi FROM t +lead_lag DIFF column type string->binary 41123bf636cf SELECT LEAD(b1, 100, d1) OVER () lbd, IFNULL(b1, d1) ifn_bd FROM t +lead_lag DIFF column type string->binary c6a4ec73d085 SELECT LEAD(b1, 100, e1) OVER () lbe1, IFNULL(b1, e1) ifn_be1 FROM t +lead_lag DIFF column type string->binary da137e1fcbb6 SELECT LEAD(b1, 100, e2) OVER () lbe2, IFNULL(b1, e2) ifn_be2 FROM t +lead_lag DIFF column type string->binary 2b8e00a6ea4f SELECT LEAD(d1, 100, b1) OVER () ldd, IFNULL(d1, b1) ifn_db FROM t +lead_lag DIFF column type string->binary 6e3be798cf99 SELECT LEAD(e1, 100, b1) OVER () le1d, IFNULL(e1, b1) ifn_e1d FROM t +lead_lag DIFF column type string->binary 168a848ba712 SELECT LEAD(e2, 100, b1) OVER () le2d, IFNULL(e2, b1) ifn_e2d FROM t +lead_lag DIFF column type datetime->date 4333cc88c3c1 SELECT ADDDATE(LEAD(NULL, 1, date'1955-05-15') OVER (ORDER BY NULL), 1) +lead_lag DIFF column type datetime->date abbd6e91001b SELECT ADDDATE(LEAD(date'1955-05-15', 0) OVER (ORDER BY NULL), 1) +lead_lag LENIENT 3146 8bb7d4663924 SELECT ((LAG(JSON_MERGE_PATCH(1.755913e+308,'{ }'),0)OVER())<=(1)) +lead_lag LENIENT 3146 e9cdd15bb536 SELECT ((QUOTE(JSON_KEYS(LEAD(JSON_KEYS(EXP(-15676),ABS('d0')), +limit LENIENT 3158 6f996f547742 SELECT JSON_OBJECTAGG(NULL, '') FROM t1, t1 AS t2 GROUP BY t1.pk LIMIT 2 OFFSET 5 +loaddata LENIENT 13 e34d01f6a4f5 load data infile '../../std_data/loaddata1.dat' ignore into table t1 fields terminated by ',' +loaddata LENIENT 13 e1c990a214ed load data infile '../../std_data/loaddata1.dat' into table t1 fields terminated by ',' IGNORE 2 LINE +loaddata LENIENT 13 7364d44bb85c load data from infile '../../std_data/loaddata1.dat' +loaddata LENIENT 13 d5bc036847ed load data infile '../../std_data/loaddata1.dat' in primary key order +loaddata LENIENT 1221 2356afd2b2ca load data url '../../std_data/loaddata1.dat' into table t1 +loaddata LENIENT 13 99363bc660f8 load data infile '../../std_data/loaddata1.dat' ignore into table t1 fields terminated by ',' LINES +loaddata LENIENT 13 5b651ddf288e load data infile '../../std_data/loaddata2.dat' ignore into table t1 fields terminated by ',' enclos +loaddata LENIENT 13 7782955f14b9 load data infile '../../std_data/loaddata3.dat' ignore into table t1 fields terminated by '' enclose +loaddata LENIENT 13 9b05a29784c8 load data infile '../../std_data/loaddata4.dat' ignore into table t1 fields terminated by '' enclose +loaddata LENIENT 13 5d85a801cbec load data infile '../../std_data/loaddata_dq.dat' into table t1 fields terminated by ',' enclosed by +loaddata LENIENT 13 aa90dfff8631 load data infile '../../std_data/rpl_loaddata.dat' into table t1 (a, @b) set b=@b+10, c=concat("b=", +loaddata LENIENT 13 abc6251422f3 load data infile '../../std_data/rpl_loaddata.dat' into table t1 (a, @b) set c= if(a is null,"oops", +loaddata LENIENT 13 da52555048e7 load data infile '../../std_data/rpl_loaddata.dat' into table t1 (@a, b) set c= if(@a is null,@c,b) +loaddata LENIENT 13 85cdd13be425 load data infile '../../std_data/rpl_loaddata.dat' into table t1 (@a, @b) +loaddata LENIENT 13 5f276bbc5cfa load data infile '../../std_data/rpl_loaddata.dat' ignore into table t1 set c=b +loaddata LENIENT 13 b4a6647ad9bc load data infile '../../std_data/loaddata5.dat' into table t1 fields terminated by '' enclosed by '' +loaddata LENIENT 13 fb225b9bd939 load data infile '../../std_data/loaddata5.dat' into table t1 fields terminated by '' enclosed by '' +loaddata LENIENT 1409 ea4d042c4ead load data infile '../../std_data/loaddata5.dat' into table t1 fields terminated by '' enclosed by '' +loaddata LENIENT 13 bb900a1cdee8 load data infile '../../std_data/rpl_loaddata.dat' into table t1 (@dummy,@n) set a= @n, c= (select s +loaddata CODE 13->1288 5e34c4e29a7a LOAD DATA INFILE '../../std_data/bug35469.dat' INTO TABLE v1 +loaddata CODE 13->1288 07f14566a95f LOAD DATA INFILE '../../std_data/bug35469.dat' INTO TABLE v2 +loaddata CODE 1348->1288 136fb0821631 LOAD DATA INFILE '../../std_data/bug35469.dat' INTO TABLE v2 +loaddata DIFF column name 8d2c6ca54fd4 select load_file(0x0A9FB76C661B409C4BEC88098C5DD71B1072F9691F2E827D7EC8F092B299868A3CE196C04F0FB18CA +loaddata LENIENT 13 a91ab2451817 LOAD DATA INFILE 't1.dat' IGNORE INTO TABLE t1 CHARACTER SET utf8mb3 +loaddata CODE 3948->1288 8be0ca843b20 LOAD DATA LOCAL INFILE '' REPLACE INTO TABLE v1 +loaddata LENIENT 13 b67b01ba86b2 LOAD DATA INFILE '../../std_data/bug20683959loaddata.txt' INTO TABLE t1 +loaddata CODE 1348->1288 819a7f546e41 LOAD DATA INFILE 'test.dat' INTO TABLE v(a, d) +loaddata CODE 1348->1288 65c3539c5b86 LOAD DATA INFILE 'test.dat' INTO TABLE v(@a, @d) SET a= @a, d= @d +loaddata LENIENT 13 68aed30eef13 LOAD DATA INFILE '../../std_data/loaddata_utf8.dat' INTO TABLE t1 CHARACTER SET utf8mb4 +loaddata LENIENT 13 45493d64da1e LOAD DATA INFILE '../../std_data/loaddata7.dat' INTO TABLE t1 CHARACTER SET gb18030 +loaddata CODE 1059->1146 c30c60f3298e LOAD DATA INFILE '../../std_data/loaddata_utf8.dat' INTO TABLE t123456789012345678901234567890123456 +loaddata LENIENT 13 ddc19ebe5687 LOAD DATA INFILE '../../std_data/loaddata_utf8.dat' INTO TABLE t1 () +loaddata LENIENT 13 2b69440afc1b LOAD DATA INFILE '../../std_data/loaddata_incomplete_mb.dat' INTO TABLE t1 IGNORE 20 LINES +loaddata LENIENT 13 00b31f8db502 LOAD DATA INFILE '../../std_data/loaddata_incomplete_escape.dat' INTO TABLE t1 IGNORE 20 LINES +loaddata LENIENT 3948 bd805d5121e6 LOAD DATA LOCAL INFILE '../../std_data/loaddata_utf8.dat' INTO TABLE t1 +loaddata LENIENT 1084 4c6865315083 LOAD DATA INFILE '../../std_data/loaddata_utf8.dat' INTO TABLE t2 FIELDS TERMINATED BY '' LINES TERM +loaddata LENIENT 13 9d9ee23ce36f LOAD DATA INFILE '../../std_data/loaddata_utf8.dat' INTO TABLE t3 (a) SET i = NULL +loaddata RUNTIME 1364 67799767905e LOAD DATA INFILE '../../std_data/x_y_data.csv' +loaddata LENIENT 13 d0e9d2cf7888 LOAD DATA INFILE '../../std_data/x_y_data.csv' +loaddata_special LENIENT 1085 9d98c16945e9 select load_file("/proc/self/fd/0") +loadxml LENIENT 13 78bb177af90a load xml infile '../../std_data/loadxml.dat' into table t1 +loadxml LENIENT 13 57c6cd1cee14 load xml infile '../../std_data/loadxml.dat' into table t1 +loadxml LENIENT 13 f0f92665d54f load xml infile '../../std_data/loadxml2.dat' into table t1 +loadxml LENIENT 13 ca8761889c9b LOAD XML INFILE '../../std_data/loadxml.dat' INTO TABLE t1 +loadxml LENIENT 13 e4f8a95dca41 LOAD XML INFILE '../../std_data/bug16171518_1.dat' INTO TABLE t1 +loadxml LENIENT 13 26c7392b18a0 LOAD XML INFILE '../../std_data/bug16171518_2.dat' INTO TABLE t1 +loadxml CODE 13->1288 ff26d0d69bf0 LOAD XML INFILE '../../std_data/bug16171518_2.dat' INTO TABLE v3 +loadxml LENIENT 13 cb656f077b48 LOAD XML INFILE '../../std_data/bug16171518_2.dat' INTO TABLE t4 (col1, col2, col3) SET col4 = NULL +loadxml LENIENT 13 e8582bfb37e3 LOAD XML INFILE '../../std_data/bug30753708.dat' INTO TABLE t1 ROWS IDENTIFIED BY '
' +lock_multi LENIENT 1099 1ac6c4f2faa8 INSERT INTO v1 VALUES (1) +lock_myisam LENIENT 1099 2c881bd2655d update t1 set id=1 where id=1 +lock_myisam LENIENT 1100 e1d9ebdfa20b insert into t1 select index1,nr from t1 +locking_clause LENIENT 3568 c77081f2bb5c SELECT * FROM t1 FOR SHARE OF t2 +locking_clause LENIENT 3568 c73a09247730 SELECT * FROM t1 t1a FOR SHARE OF t1 +locking_clause LENIENT 3568 727f4aa65910 SELECT * FROM t1 t1a, t2 t2a FOR SHARE OF t1a, t2 +locking_clause LENIENT 3568 a68782aad9ac SELECT * FROM t1 STRAIGHT JOIN t2 FOR SHARE OF t1 +locking_clause LENIENT 3568 0ecd0b883576 SELECT * FROM t1 STRAIGHT JOIN t2 FOR SHARE OF t1 FOR UPDATE OF t2 +locking_clause LENIENT 3568 989f715cc4a4 SELECT * FROM t1 STRAIGHT JOIN t2 AS t12 FOR UPDATE OF t12, t1 +locking_clause LENIENT 3568 493d4f52e7d4 SELECT * FROM no_such_database.t1 t1a FOR SHARE OF no_such_database.t1 +locking_clause LENIENT 3568 0e167d021c6f SELECT * FROM t1 FOR SHARE OF no_such_database.t1 +locking_clause LENIENT 3568 308ea3d15db1 SELECT * FROM t1 FOR SHARE OF db1.t1 +locking_clause LENIENT 3569 b5aab7590692 SELECT * FROM t1 FOR UPDATE OF t1 FOR SHARE OF t1 +locking_clause LENIENT 3569 1cdf60baa1a7 SELECT * FROM t1, t2 FOR UPDATE OF t2 FOR SHARE OF t1 FOR UPDATE OF t2 +locking_clause LENIENT 3569 f2c9db97d24d SELECT * FROM t1, t2 FOR UPDATE FOR SHARE OF t1 +locking_clause LENIENT 3569 3d0a5b7d49a1 SELECT * FROM t1, t2 FOR UPDATE FOR SHARE OF t2 +locking_clause LENIENT 3569 0608f9856f45 SELECT * FROM t1, t2 FOR SHARE FOR UPDATE OF t1 +locking_clause LENIENT 3569 5ed09183fd35 SELECT * FROM t1, t2 FOR SHARE FOR UPDATE OF t2 +locking_clause LENIENT 3569 facc91082108 SELECT * FROM t1, t2 FOR UPDATE OF t1 FOR SHARE +locking_clause LENIENT 3569 a7eac3f359be SELECT * FROM t1, t2 FOR UPDATE OF t2 FOR SHARE +locking_clause DIFF column count dce937bbb9fc SELECT 1 UNION SELECT 1 INTO @var FOR UPDATE +log_components_eventlog LENIENT 1193 6cebb597af1e SELECT @@syseventlog.tag +log_components_filter LENIENT 1238 6b899b0a6566 SELECT @@session.dragnet.log_error_filter_rules +log_components_implicit LENIENT 1193 b922d4f54976 SELECT @@global.dragnet.log_error_filter_rules +log_components_split DIFF column name 38d5df48fe32 SELECT "Too long: ⟼λ⮞γζ←⤆↕↑↓怪獣⟼λ⮞γζ←⤆↕↑↓A7Uη)R|5怪獣⟼λâ® +log_components_split DIFF column name c6ab2591c7f8 SELECT "Too long as well: +log_components_syslog LENIENT 1193 6cebb597af1e SELECT @@syseventlog.tag +log_components_syslog LENIENT 1193 4e0ff6514315 SELECT @@syseventlog.include_pid +log_components_syslog LENIENT 1193 0adf1ce1e2be SELECT @@syseventlog.facility +log_options_cmdline LENIENT 1193 cf2ff6fdd263 SELECT @@dragnet.log_error_filter_rules +log_options_cmdline LENIENT 1193 b922d4f54976 SELECT @@global.dragnet.log_error_filter_rules +log_tables LENIENT 1556 eb8275a73df3 lock tables general_log read local, help_category read local +log_tables STRICT 1146 914d90b24e93 call `db_17876.archiveSlowLog`() +log_tables STRICT 1146 d40228310b76 call `db_17876.archiveGeneralLog`() +lowercase_table LENIENT 1054 2afab712b8cc select count(T1.a) from t1 +lowercase_table LENIENT 1054 895ee2305e55 select count(bags.a) from t1 as Bags +lowercase_table LENIENT 1109 3e7ae8d41a9f delete p1.*,P2.* from t1 as p1, t2 as p2 where p1.a=P2.a +lowercase_table LENIENT 1109 67680c8e4b48 delete P1.*,p2.* from t1 as P1, t2 as P2 where P1.a=p2.a +lowercase_table LENIENT 1054 744b98130a5e update t1 as p1, t2 as p2 SET p1.a=1,P2.a=1 where p1.a=P2.a +lowercase_table LENIENT 1054 08aaf54b11a9 update t1 as P1, t2 as P2 SET P1.a=1,p2.a=1 where P1.a=p2.a +lowercase_table STRICT 1066 d0191aaeeaeb select * from t1 c, t2 C +lowercase_table STRICT 1066 8df8de6742d4 select C.a, c.a from t1 c, t2 C +lowercase_view CODE 1109->1146 62b8fda8a797 delete v2Aa from v2aA,t2Aa where (select max(col1) from v1aA) > 0 and v2Aa.col1 = t2aA.col1 +lowercase_view CODE 1109->1146 ef64808106bd delete t1aA from t1Aa,t2Aa where (select max(col1) from v1Aa) > 0 and t1aA.col1 = t2aA.col1 +lowercase_view CODE 1109->1146 e1d8a3b6a0b3 delete v1aA from v1Aa,t2Aa where (select max(col1) from v1aA) > 0 and v1Aa.col1 = t2aA.col1 +lowercase_view CODE 1109->1146 e6fc4c308e4f delete v2aA from v2Aa,t2Aa where (select max(col1) from t1Aa) > 0 and v2aA.col1 = t2aA.col1 +lowercase_view CODE 1109->1146 0abcd7cba603 delete t1aA from t1Aa,t2Aa where (select max(col1) from t1aA) > 0 and t1Aa.col1 = t2aA.col1 +lowercase_view CODE 1109->1146 321f6e43c0ac delete v1aA from v1Aa,t2Aa where (select max(col1) from t1aA) > 0 and v1aA.col1 = t2aA.col1 +lowercase_view CODE 1109->1146 3ebc63c9bc65 delete v2Aa from v2aA,t2Aa where (select max(col1) from v2Aa) > 0 and v2aA.col1 = t2aA.col1 +lowercase_view CODE 1109->1146 db7a5a4011b7 delete t1Aa from t1aA,t2Aa where (select max(col1) from v2Aa) > 0 and t1Aa.col1 = t2aA.col1 +lowercase_view CODE 1109->1146 dcd84944c320 delete v1Aa from v1aA,t2Aa where (select max(col1) from v2aA) > 0 and v1Aa.col1 = t2aA.col1 +lowercase_view LENIENT 1054 e4e634961074 select Aaa.col1 from t1Aa as AaA +lowercase_view CODE 1054->1055 353c61d93c2c select X.a from t1 AS X group by X.b having (x.a = 1) +lowercase_view CODE 1054->1055 c6c918f4ffd1 select X.a from t1 AS X group by X.b having (x.b = 1) +lpad DIFF column type string->binary 129a642ec32e SELECT LPAD(NULL, 5, 'x') AS result +lpad DIFF column type string->binary 45e31d752915 SELECT LPAD(NULL, NULL, 'x') AS result +lpad DIFF column type string->binary 85e89b9057e6 SELECT LPAD(NULL, NULL, NULL) AS result +lpad DIFF column type string->binary 26dfaf692377 SELECT LPAD(NULL, 5, NULL) AS result +mem_cnt_sql_keys LENIENT 1 f754a353fea6 SELECT 1 INTO OUTFILE '../../tmp/f1.txt' +merge_innodb SCHEMA dump: SHOW CREATE TABLE tN: Error N (HYN): Unable to open underlyi c64899c9e086 SELECT * FROM t5 +merge_innodb SCHEMA dump: SHOW CREATE TABLE tN: Error N (HYN): Unable to open underlyi acf996ee627f SELECT * FROM t4 +merge_mmap SCHEMA dump: SHOW CREATE TABLE mN: Error N (HYN): Unable to open underlyi 60f251c8bd12 INSERT INTO t1 VALUES (1) +merge_myisam SCHEMA dump: SHOW CREATE TABLE tN: Error N (HYN): Unable to open underlyi 3f0ae4aca767 select * from t3 +merge_myisam SCHEMA dump: SHOW CREATE TABLE tN: Error N (HYN): Unable to open underlyi 4ef74ccab0fa select * from t3 where a = 10 +merge_myisam SCHEMA dump: SHOW CREATE TABLE tN: Error N (HYN): Unable to open underlyi 185c84490988 select * from t4 +merge_myisam SCHEMA dump: SHOW CREATE TABLE tN: Error N (HYN): Unable to open underlyi b1297265730b SELECT * from t3 +merge_myisam SCHEMA dump: SHOW CREATE TABLE tN: Error N (HYN): Unable to open underlyi 51d406754c06 insert into t1 values (1,2),(2,1),(0,0),(4,4),(5,5),(6,6) +merge_myisam SCHEMA dump: SHOW CREATE TABLE tN: Error N (HYN): Unable to open underlyi 7ee68ff17c32 select * from t3 where a=1 order by b limit 2 +merge_myisam SCHEMA dump: SHOW CREATE TABLE tN: Error N (HYN): Unable to open underlyi 731f6f2b664e insert into t1 values (1,NULL),(1,NULL),(1,NULL),(1,NULL) +merge_myisam SCHEMA dump: SHOW CREATE TABLE tN: Error N (HYN): Unable to open underlyi c016f42d7ee2 INSERT INTO t1 VALUES (1,1), (2,1) +merge_myisam SCHEMA dump: SHOW CREATE TABLE tN: Error N (HYN): Unable to open underlyi 60f251c8bd12 INSERT INTO t1 VALUES (1) +merge_myisam SCHEMA dump: SHOW CREATE TABLE tN: Error N (HYN): Unable to open underlyi 5ef2c34b1dee UNLOCK TABLES +merge_myisam SCHEMA dump: SHOW CREATE TABLE tN: Error N (HYN): Unable to open underlyi 2206a4b64943 SELECT * FROM tm1 +merge_myisam SCHEMA dump: SHOW CREATE TABLE mN: Error N (HYN): Unable to open underlyi 7874b8083a1f SELECT * FROM m1 +merge_myisam SCHEMA dump: SHOW CREATE TABLE tN: Error N (HYN): Unable to open underlyi e81a70766e13 select * from tm +merge_myisam SCHEMA dump: SHOW CREATE TABLE tN: Error N (HYN): Table ? is differently 5ef2c34b1dee UNLOCK TABLES +merge_myisam SCHEMA dump: SHOW CREATE TABLE tN: Error N (HYN): Unable to open underlyi 7486f092c2b1 SELECT @a +merge_myisam SCHEMA dump: SHOW CREATE TABLE tN: Error N (HYN): Unable to open underlyi c5e8bf48a8bf INSERT INTO m1 VALUES () +merge_myisam SCHEMA dump: SHOW CREATE TABLE tN: Error N (HYN): Unable to open underlyi 6722317f2f3a insert into m1 (col1) values (1) +merge_myisam SCHEMA dump: SHOW CREATE TABLE tN: Error N (HYN): Unable to open underlyi 539413a02b8a INSERT INTO m1 VALUES (666) +merge_myisam SCHEMA dump: SHOW CREATE TABLE mN: Error N (HYN): Unable to open underlyi f1fa61f903a8 INSERT INTO m1 VALUES (1,2) +merge_myisam SCHEMA dump: SHOW CREATE TABLE tN: Error N (HYN): Unable to open underlyi 7874b8083a1f SELECT * FROM m1 +merge_myisam SCHEMA dump: SHOW CREATE TABLE mN: Error N (HYN): Table ? is differently b3fdf7b0d859 SELECT 1 FROM m1 +merge_myisam SCHEMA dump: SHOW CREATE TABLE mN: Error N (N): Unknown database ? 2d44f19af813 SELECT * FROM v1 +merge_myisam SCHEMA dump: SHOW CREATE TABLE mN: Error N (HYN): Unable to open underlyi b8b2035d7842 SELECT f1() FROM (SELECT 1) AS c1 +merge_myisam SCHEMA dump: SHOW CREATE TABLE mN: Error N (HYN): Unable to open underlyi 8b2a694bd002 SELECT f1() FROM (SELECT 1 UNION SELECT 1) c1 +merge_myisam SCHEMA dump: SHOW CREATE TABLE mN: Error N (N): Unknown database ? 3fc1e2aa76a6 SELECT f1() +merge_myisam SCHEMA dump: SHOW CREATE TABLE mN: Error N (N): Unknown database ? 5ef2c34b1dee UNLOCK TABLES +merge_myisam SCHEMA dump: SHOW CREATE TABLE mN: Error N (HYN): Unable to open underlyi 3fc1e2aa76a6 SELECT f1() +merge_myisam SCHEMA dump: SHOW CREATE TABLE mN: Error N (HYN): Unable to open underlyi 5ef2c34b1dee UNLOCK TABLES +merge_myisam STRICT 1146 cf6e2144fd34 SELECT * FROM m2 +metadata DIFF column type int->bigint e1aebc79eda2 select a.* from (select 214748364 as v_small) a +metadata DIFF column type decimal unsigned->decimal 055b08222849 select * from t1 +multi_update LENIENT 1099 15c8d56d7069 DELETE t1.*, t2.* FROM t1,t2 where t1.n=t2.n +multi_update LENIENT 1099 36c681ddaedb UPDATE t1,t2 SET t1.d=t2.d,t2.d=30 WHERE t1.n=t2.n +multi_update LENIENT 1175 84eddf058ec2 UPDATE t1,t2 SET t1.d=t2.d WHERE t1.n=t2.n +multi_update LENIENT 1175 a58893ff6499 UPDATE IGNORE t1, t1 t1a SET t1.a = 1 WHERE t1a.a = 1 +multi_update LENIENT 1221 195c1774c182 UPDATE v SET a=1 ORDER BY a +multi_update LENIENT 1221 6c75a2a863db UPDATE v SET a=1 LIMIT 3 +multi_update_innodb LENIENT 1706 8cf7ade0a568 UPDATE t1 AS A, t1 AS B SET A.pk = 1, B.a = 2 +multi_update_innodb LENIENT 1706 a99c64e50648 UPDATE v1 AS A, t1 AS B SET A.pk = 1, B.a = 2 +multi_update_innodb LENIENT 1706 d218e52237ee UPDATE t1 AS A NATURAL JOIN t1 B SET A.pk=5,B.pk=7 +multi_update_innodb LENIENT 1706 10aa024f10a4 UPDATE t2 AS A NATURAL JOIN t2 B SET A.pk_1=5,B.pk_1=7 +multi_update_innodb LENIENT 1706 b2143adb2aa1 UPDATE t2 AS A NATURAL JOIN t2 B SET A.pk_2=10,B.pk_2=11 +multi_update_innodb RUNTIME 1264 401a13a3cdfa UPDATE (SELECT 128 as col1) x, table_11757486 SET field1=x.col1 +multi_update_myisam RUNTIME 1264 401a13a3cdfa UPDATE (SELECT 128 as col1) x, table_11757486 SET field1=x.col1 +myisam LENIENT 1099 5b86cc54b9aa INSERT INTO t2 VALUES ('test000001'), ('test000005') +myisam LENIENT 1099 c6770912870a insert into t1 values(6),(7) +myisam LENIENT 1099 e59075683b72 insert into t1 (a) values(6),(7) +mysqlbinlog LENIENT 13 22b4c8780b8e load data infile '../../std_data/words.dat' into table t1 +mysqlbinlog DIFF column name 479fc60d958f select "--- Local --" as "" +mysqlbinlog LENIENT 13 205d7bbcb79c load data infile '../../std_data/loaddata6.dat' into table t1 CHARACTER SET latin1 +mysqlbinlog LENIENT 13 02bd2bbd560b load data infile '../../std_data/loaddata6.dat' into table t1 +mysqlbinlog LENIENT 13 5c7fe6b9eec8 load data infile '../../std_data/loaddata6.dat' into table t1 character set koi8r +mysqldump STRICT 1136 c3b45014f5c5 INSERT INTO t1 VALUES (1), (3), (7), (8), (4) +mysqldump STRICT 1136 cb1e052f8b3d INSERT INTO t2 VALUES (1), (3), (7), (8), (4) +mysqldump_restore RUNTIME 1366 96d5216ecc51 INSERT INTO t1 VALUES (_koi8r x'C1C2C3C4C5'), (NULL) +no_binlog_gtid_empty_transaction LENIENT 1781 ae2cd589953d CALL p1() +nth DIFF column type int->bigint 13775ae0be3c SELECT NTH_VALUE(6,1) OVER () +nth DIFF column type int->bigint e93172c7ee70 SELECT NTH_VALUE(6,2) OVER () +nth DIFF column type int->bigint 243ce1628136 SELECT NTH_VALUE(6,1) OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) +nth DIFF column type int->bigint eb17ab2a3dda SELECT NTH_VALUE(6,2) OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) +nth LENIENT 1235 09a821505a0d SELECT id, sex, NTH_VALUE(id, 1) FROM LAST OVER () FROM t1 +nth LENIENT 1235 8181fd370123 SELECT id, sex, NTH_VALUE(id, 1) IGNORE NULLS OVER () FROM t1 +nth LENIENT 1210 e5360ce8fd89 SELECT id, sex, NTH_VALUE(id, NULL) OVER () FROM t1 +nth DIFF column type null->binary e66004afec75 SELECT id, sex, NTH_VALUE(NULL, 1) RESPECT NULLS OVER () FROM t1 +nth LENIENT 3146 758b2b79f749 SELECT ((NTH_VALUE(JSON_MERGE_PATCH(1.755913e+308,'{ }'),1)OVER())<=(1)) +nth LENIENT 3146 54c529df196d SELECT ((QUOTE(JSON_KEYS(NTH_VALUE(JSON_KEYS(EXP(-15676),ABS('d0')), +null DIFF column name ab1058d85cc9 select null,isnull(null),isnull(1/0),isnull(1/0 = null),ifnull(null,1),ifnull(null,"TRUE"),ifnull("T +null DIFF column type string->binary 3214cf885a9f select concat("a",NULL),replace(NULL,"a","b"),replace("string","i",NULL),replace("string",NULL,"i"), +null DIFF column type string->binary f2e71e701944 select repeat("a",0),repeat("ab",5+5),repeat("ab",-1),reverse(NULL) +odbc DIFF column name 2e2960df7b01 select {fn length("hello")}, { date "1997-10-20" } +olap DIFF column type int->bigint 68f4f8ee335f SELECT product, country_id , SUM(profit) FROM t1 +olap DIFF column type int->bigint 0bb42771106c SELECT product, country , year, SUM(profit) FROM t1,t2 WHERE +olap LENIENT 3889 a9fc2b9b9143 SELECT product, country_id , year, SUM(profit) FROM t1 +olap LENIENT 3889 6ed8870dfffe SELECT product, country_id , year, SUM(profit) FROM t1 +olap DIFF column type int->bigint fe6695f08dbf SELECT t1.i, t2.i, COUNT(*) FROM t1,t2 GROUP BY t1.i,t2.i WITH ROLLUP +olap DIFF column type int->bigint 2adcd92d1e1a SELECT DISTINCT a, SUM(b) FROM t1 GROUP BY a,b WITH ROLLUP +olap DIFF column type int->bigint b15bf641b92c SELECT DISTINCT b,a, SUM(b) FROM t1 GROUP BY a,b WITH ROLLUP +olap DIFF column type int->bigint 4839c5717221 SELECT distinct a,b,SUM(c) FROM t1 GROUP BY a,b,c WITH ROLLUP +olap DIFF column type int->bigint f471e9499016 SELECT * FROM ( SELECT a, SUM(a) m FROM t1 GROUP BY a WITH ROLLUP ) t2 +olap DIFF column type int->bigint 4132cb177dde SELECT * FROM (SELECT a, a + 1, COUNT(*) FROM t1 GROUP BY a WITH ROLLUP) t +olap DIFF column type int->bigint 59feb1289204 SELECT * FROM (SELECT a, LENGTH(a), COUNT(*) FROM t1 GROUP BY a WITH ROLLUP) t +olap DIFF column type int->bigint cd1d84d81806 SELECT DISTINCT a, SUM(b) FROM t1 GROUP BY a WITH ROLLUP +olap DIFF column type int->bigint 1f9e3e0a5638 SELECT DISTINCT a, b, COUNT(*) FROM t1 GROUP BY a,b WITH ROLLUP +olap DIFF nullable false->true 6e4d4dbee2da SELECT 'x', a, SUM(b) FROM t1 GROUP BY 1,2 WITH ROLLUP +olap DIFF nullable false->true b12921070310 SELECT DISTINCT 'x', a, SUM(b) FROM t1 GROUP BY 1,2 WITH ROLLUP +olap DIFF column type int->bigint 2d44f19af813 SELECT * FROM v1 +olap DIFF column type int->bigint 50454a4f14dd SELECT * FROM (SELECT a, SUM(a) FROM t1 GROUP BY a WITH ROLLUP) as t +olap DIFF column type int->bigint 1ae433aec9f4 SELECT a, b FROM t1, t2 GROUP BY a, b WITH ROLLUP +olap DIFF column type int->bigint 48a19a85d448 SELECT DISTINCT b FROM t1, t2 GROUP BY a, b WITH ROLLUP +olap DIFF column type int->bigint c1a6856a28cf SELECT b FROM t1, t2 GROUP BY a, b WITH ROLLUP +olap DIFF column type bigint->int e24706215d71 SELECT NOT EXISTS (SELECT 1) FROM t1 JOIN t1 a USING(a) +olap LENIENT 3602 951f63f7ccf0 SELECT a, b, GROUPING(c) FROM t1 GROUP BY a,b WITH ROLLUP +olap CODE 3602->1055 27d162113c56 SELECT a, b, c, GROUPING(a, b, c) FROM t1 GROUP BY a,b WITH ROLLUP +olap LENIENT 3602 f14516653ed6 SELECT a, GROUPING(SUM(a)) FROM t1 GROUP BY (a) WITH ROLLUP +olap LENIENT 1111 dfc1fadab562 SELECT a, b, GROUPING(a) FROM t1 GROUP BY a,b +olap CODE 1111->1140 06bd63224832 SELECT a, b, GROUPING(a) FROM t1 +olap LENIENT 1111 681dbdf4f397 SELECT a, b FROM t1 WHERE GROUPING(a)=1 GROUP BY a,b WITH ROLLUP +olap LENIENT 1210 7ff09624c10e SELECT a, b, c, GROUPING(1) FROM t1 GROUP BY a,b,c WITH ROLLUP +olap LENIENT 1210 67e132a65433 SELECT a, GROUPING(1) FROM t1 GROUP BY 1 WITH ROLLUP +olap LENIENT 1210 f6e922da61bc SELECT GROUPING(1) FROM t1 GROUP BY 1 WITH ROLLUP +olap LENIENT 3602 b78f7d89a149 SELECT a, b, GROUPING(c + c) FROM t1 GROUP BY a,b WITH ROLLUP +olap LENIENT 3602 edee437cbfa9 SELECT GROUPING((SELECT MAX(b) FROM t1)) FROM t1 +olap LENIENT 3602 c460f7926c30 SELECT (SELECT MAX(b) FROM t1) FROM t1 GROUP BY (SELECT MAX(b) FROM t1) +olap LENIENT 3602 ef0a6a706de1 SELECT (SELECT MAX(b) FROM t1) FROM t1 GROUP BY (SELECT MAX(b) FROM t1) +olap DIFF column type int->bigint 6d930ee5ad73 WITH qn AS (SELECT a, b, c, GROUPING(a) as grouping_a FROM t1 +olap STRICT 1056 482c6247d13f WITH qn AS (SELECT (SELECT MAX(a) FROM t1) as field1 FROM t1 +olap LENIENT 3602 d313ac80b1f2 WITH qn AS(SELECT GROUPING((SELECT MAX(b) FROM t1)) as field1 +olap DIFF column type int->bigint db94d19ec0c6 SELECT a, b, a + COALESCE(b, 0), AVG(b) OVER () FROM t1 GROUP BY a, b WITH ROLLUP +olap DIFF column type int->bigint c0ab4bc0f4dd SELECT i, j, AVG(k) FROM t1 GROUP BY i,j WITH ROLLUP ORDER BY i,j +olap DIFF column type int->bigint 51e75a5aa5a8 SELECT i, j, AVG(k) FROM t1 GROUP BY i,j WITH ROLLUP ORDER BY i,j LIMIT 5 +olap DIFF column type int->bigint 6fbcf54819fa SELECT i, j, SUM(k) FROM t1 GROUP BY i,j WITH ROLLUP +olap DIFF column type int->bigint b23812522611 SELECT i, j, SUM(k)/COUNT(*) as avg FROM t1 GROUP BY i,j WITH ROLLUP +olap DIFF column type int->bigint 5938e98ee9bc SELECT i, sum FROM (SELECT i, SUM(j) AS 'sum' FROM t1 GROUP BY i WITH ROLLUP +olap DIFF column type int->bigint 76da58bb4e14 SELECT * FROM (SELECT i, j, SUM(k) FROM t1 GROUP BY i, j WITH ROLLUP) as tmp +olap DIFF column type int->bigint a16d3c468d42 SELECT i, j, i+j, AVG(j) over () from t1 GROUP BY i,j WITH ROLLUP +olap DIFF column type int->bigint cd48057bc13d SELECT i, j, i+j, FIRST_VALUE(i) over () from t1 GROUP BY i,j WITH ROLLUP +olap DIFF column type int->bigint 49af3f3ec154 SELECT i,j FROM t1 GROUP BY i,j WITH ROLLUP HAVING i=111 ORDER BY i +olap DIFF column type int->bigint cefd6932cf9a SELECT i,j FROM t1 GROUP BY i,j WITH ROLLUP HAVING i+j < 200 ORDER BY i +olap DIFF column type int->bigint 86c7f63b4179 SELECT i, j, i+j, AVG(k) from t1 GROUP BY i,j WITH ROLLUP +olap DIFF column type int->bigint 78334039cd33 SELECT i, j, i+j, AVG(k) from t1 GROUP BY i,j WITH ROLLUP +olap DIFF column type int->bigint a56effae473e SELECT i, GROUPING(i+j), AVG(k) from t1 GROUP BY i,i+j WITH ROLLUP +olap DIFF column type int->bigint ba7590684f6d SELECT i, GROUPING(i+j), AVG(k) from t1 GROUP BY i,i+j WITH ROLLUP +olap DIFF column type int->bigint 8ff2957d5e65 SELECT i, GROUPING(i+j), AVG(k) from t1 GROUP BY i,i+j WITH ROLLUP +olap DIFF column type int->bigint d958e8cdb287 SELECT DISTINCT i,j FROM t1 GROUP BY i,j WITH ROLLUP HAVING i=111 ORDER BY i +olap DIFF column type int->bigint cea79c4af9fb SELECT DISTINCT i FROM t1 GROUP BY i,j WITH ROLLUP HAVING i=111 ORDER BY i +olap DIFF column type int->bigint ce9c41bcbf4d SELECT DISTINCT i,j FROM t1 GROUP BY i,j WITH ROLLUP +olap DIFF column type int->bigint 7f229f45e648 SELECT DISTINCT i, j, i+j, AVG(k) from t1 GROUP BY i,j WITH ROLLUP +olap DIFF column type int->bigint 97ce5bc4469a SELECT DISTINCT i, j, i+j, AVG(k) from t1 GROUP BY i,j WITH ROLLUP +olap DIFF column type int->bigint ffea1b51db37 SELECT DISTINCT i, i+j, GROUPING(i+j), AVG(k) from t1 GROUP BY i,i+j WITH ROLLUP +olap DIFF column type int->bigint e88adbffe6af SELECT DISTINCT i, GROUPING(i+j), AVG(k) from t1 GROUP BY i,i+j WITH ROLLUP +olap DIFF column type int->bigint 6d22692847b8 SELECT DISTINCT i, GROUPING(i+j), AVG(k) from t1 GROUP BY i,i+j WITH ROLLUP +olap DIFF column type int->bigint 8e1f2ae221aa SELECT i, j, AVG(k) FROM t1 GROUP BY i,j WITH ROLLUP +olap DIFF column type int->bigint f9e890716429 SELECT i, j, AVG(k) FROM t1 GROUP BY i,j WITH ROLLUP +olap DIFF column type int->bigint 1360dbe4541a SELECT i, j, SUM(k) FROM t1 GROUP BY i,j WITH ROLLUP +olap DIFF column type int->bigint ced7dc06eae1 SELECT i, 'sum' FROM (SELECT i, SUM(j) AS 'sum' FROM t1 GROUP BY i +olap DIFF column type int->bigint 2e980ae92e6d SELECT i, j, i AS k, COUNT(*) AS count FROM t1 GROUP BY i, j, k WITH ROLLUP +olap DIFF column type int->bigint 983288a3b229 SELECT * FROM (SELECT i, j, SUM(k) FROM t1 GROUP BY i, j WITH ROLLUP +olap DIFF column type int->bigint 398a50e68e22 SELECT i, j, i+j, AVG(j) over () from t1 GROUP BY i,j WITH ROLLUP +olap DIFF column type int->bigint f1bfb4a9cc7a SELECT i, j, i+j, FIRST_VALUE(i) over () from t1 GROUP BY i,j WITH ROLLUP +olap DIFF column type int->bigint 5cedbb1b6fcd SELECT i, j, i+j, FIRST_VALUE(i) over () from t1 GROUP BY i,j WITH ROLLUP +olap DIFF column type int->bigint b278f6cc5698 SELECT i, j, i+j, FIRST_VALUE(i) over () from t1 GROUP BY i,j WITH ROLLUP +olap DIFF column type int->bigint 97ab38d12e4f SELECT i, j, i+j, AVG(j), AVG(j) over () from t1 GROUP BY i,j WITH ROLLUP +olap DIFF column type int->bigint b4d876d4be9c SELECT i,j, SUM(k), GROUPING(i), GROUPING(j) FROM t1 GROUP BY i,j +olap DIFF column type int->bigint 9508714437ba SELECT DISTINCT j, GROUPING(j) FROM t1 GROUP BY i,j WITH ROLLUP +olap DIFF column type int->bigint e6ace04bad5c SELECT alias1.f1 AS field1, alias1.f1 AS field2, (alias1.f1 +1) AS field3 FROM +olap DIFF column type int->bigint 20e6bdfa137a SELECT f1 FROM t1 WHERE pk = 3 GROUP BY f1 WITH ROLLUP ORDER BY f1 +olap DIFF column type bigint->int 0ef2e1964a47 SELECT (((@e:=`b`)) NOT BETWEEN 0x0b5f09 AND (CHAR(md5(@pub1), +olap DIFF column type int->bigint d63b36f5f0fd SELECT a, b, a + SUM(a) OVER () FROM t GROUP BY a,b WITH ROLLUP +olap DIFF nullable false->true da6f1c276203 SELECT a AS f1, 'w' AS f2 FROM t GROUP BY f1, f2 WITH ROLLUP HAVING ISNULL(f2) +olap DIFF nullable false->true b8bc77c99e14 SELECT a AS f1, 'w' AS f2 FROM t GROUP BY f1, f2 WITH ROLLUP HAVING ISNULL(f1) +olap DIFF nullable false->true f308160171b4 SELECT a AS f1, 'w' AS f2 FROM t GROUP BY f1, f2 WITH ROLLUP HAVING GROUPING(f2)=1 +olap DIFF nullable false->true b769d3ec4d71 SELECT a AS f1, 'w' AS f2 FROM t GROUP BY f1, f2 WITH ROLLUP HAVING GROUPING(f2)=0 +olap DIFF nullable false->true 2d36780beaa3 SELECT a AS f1, 'w' AS f2 FROM t GROUP BY f1, f2 WITH ROLLUP HAVING GROUPING(f1)=0 +olap DIFF column type int->bigint 345a566c6df4 SELECT a, b, 2*a+b, SUM(a+b) OVER () FROM t1 GROUP BY a, b WITH ROLLUP +olap DIFF nullable false->true d6d0d5a9fde0 SELECT ( SELECT 'a' ) AS f1 +olap DIFF column type int->bigint e469e6f9fd11 SELECT DISTINCT i FROM t1 WHERE i = 1 GROUP BY i WITH ROLLUP +olap DIFF column type int->bigint 7dd2b3afcaba SELECT a, GROUPING(a) AS ga +olap DIFF column type int->bigint 6ed8b38fa457 SELECT DISTINCT x AS a, x AS b FROM t GROUP BY a, b WITH ROLLUP +olap DIFF column type int->bigint b26d5c415fb6 SELECT DISTINCT t1.i1, t1.i2 FROM t AS t1 JOIN t AS t2 ON t1.i3 = t2.i2 GROUP BY t1.i1, t1.i2 WITH R +olap DIFF column type int->bigint 20e2d07e7709 SELECT DISTINCT i1, i2, GROUPING(i1), GROUPING(i2), GROUPING(i1+i2), GROUPING(i1) + GROUPING(i2) FRO +olap LENIENT 3066 cec9f915088c SELECT DISTINCT i1, i2, GROUPING(i1), GROUPING(i2) FROM t GROUP BY i1, i2 WITH ROLLUP ORDER BY i1, i +olap LENIENT 3889 8ab95e211a16 SELECT 1 +olap_myisam DIFF column type int->bigint e46f6a501e43 SELECT c, a, SUM(c) OVER () FROM t1,t2 GROUP BY c,a WITH ROLLUP +opt_hints SCHEMA dump: SHOW CREATE TABLE testN?: Error N (N): You have an error in b332ff4e3371 SELECT /*+ BKA(`test1``test2```) */ 1 +opt_hints SCHEMA dump: SHOW CREATE TABLE testN?: Error N (N): You have an error in 86a0d925f933 SELECT /*+ BKA(" quoted name тест") */ 1 FROM t1 +opt_hints_set_var LENIENT 3134 b8d199b1961e SELECT /*+ SET_VAR(max_points_in_geometry=1048576) */ +opt_hints_set_var LENIENT 1175 dd3407422e92 UPDATE /*+ SET_VAR(sql_safe_updates=1) */ IGNORE v1 SET a = 1 +opt_hints_set_var LENIENT 3636 be3ea5cab56e WITH RECURSIVE qn AS ( +packet LENIENT 1301 fd7415e85b50 UPDATE t1 +packet LENIENT 1301 68a02475ffda UPDATE t1, t2 +packet LENIENT 1301 d381fc7ebda1 INSERT INTO t1 +packet LENIENT 1301 ec2e15c272f7 INSERT INTO t1 +packet LENIENT 1301 c103c0559542 REPLACE INTO t1 +packet LENIENT 1301 1ec0b16a58b7 REPLACE INTO t1 +packet LENIENT 1301 501ec72d15c7 REPLACE INTO t1 +packet LENIENT 1301 e2269c463b7d DELETE FROM t1 +packet LENIENT 1301 e917c2331c36 DELETE FROM t1, t2 +packet_myisam LENIENT 1301 b02bbd7b5d7c UPDATE t3 +parser LENIENT 1210 7c9de647673a select source_pos_wait('binlog.999999', 4, -1) +parser LENIENT 3953 81460d4850d5 SELECT 1 INTO @var17727401_1 FROM t1 INTO @var17727401_2 +parser LENIENT 3953 fbe6d1c708e2 SELECT 1 INTO @var17727401_1 FROM DUAL +parser LENIENT 3953 0189d894428f SELECT 1 INTO @var17727401_1 +parser LENIENT 1426 4fa39cdd729a SELECT CONVERT(CONVERT('',DECIMAL(65,0)), DECIMAL(66,0)) +parser LENIENT 1221 0b5af4203588 INSERT INTO t1 SELECT DISTINCT ALL * FROM t1 +parser CODE 1103->1146 42779f23977e REPLACE INTO `` SELECT * FROM `` +parser LENIENT 1052 7bae98a2b835 SELECT * FROM t1 JOIN ( t2, t3 ) USING ( a ) +parser LENIENT 3954 52d8aaa489e6 SELECT 1 INTO @v UNION SELECT 1 +parser LENIENT 3954 74fba14457ca SELECT 1 INTO @v UNION (SELECT 1) +parser RUNTIME 1172 22ee1cebad26 SELECT 1 UNION SELECT 2 INTO @v +parser RUNTIME 1172 e0385571a789 SELECT 1 UNION SELECT 2 INTO @v FROM t1 +parser DIFF column count a9e8cc52ec1d SELECT 1 UNION SELECT 1 INTO @v FROM t1 +parser DIFF column count 9a161e058bec SELECT 1 UNION SELECT 2 INTO OUTFILE 'parser.test.file1' +parser DIFF column count b872f33a92d9 SELECT 1 UNION (SELECT 2 INTO OUTFILE 'parser.test.file2') +parser LENIENT 3954 51c7309f68b1 SELECT * FROM (SELECT a INTO @v FROM t1) t1a +parser CODE 3954->1054 3caa9bf65b6e SELECT * FROM (SELECT a INTO @v) t1a +parser DIFF column name 37daa86859f9 SELECT * FROM t1 NATURAL RIGHT JOIN t2 +parser DIFF column name 04f8b3ec5939 SELECT * FROM t1 NATURAL RIGHT OUTER JOIN t2 +parser DIFF column name b66f6da76f84 SELECT * FROM t1 NATURAL LEFT JOIN t2 NATURAL RIGHT JOIN t3 +parser DIFF column name 27ac38eb0c6d SELECT * FROM (t1 NATURAL LEFT JOIN t2) NATURAL RIGHT JOIN t3 +parser DIFF column name f41c85ec0685 SELECT * FROM t1 NATURAL LEFT JOIN (t2 NATURAL RIGHT JOIN t3) +parser LENIENT 1193 7daf2387bcfd SELECT 1 +parser SCHEMA no database selected f28b2cefbe9a SELECT @@default_collation_for_utf8mb4 +parser DIFF column count 11de417971d6 SELECT 1 UNION SELECT 1 INTO @var +parser DIFF column count 06f0142186bc SELECT 1 UNION SELECT 1 UNION SELECT 1 INTO @var +parser DIFF column count 0e13bc263540 SELECT 1 UNION SELECT 1 INTO @var FROM DUAL +parser DIFF column count 0a61630c00cf SELECT 1 UNION (SELECT 1 INTO @var FROM DUAL) +parser DIFF column count dce937bbb9fc SELECT 1 UNION SELECT 1 INTO @var FOR UPDATE +parser DIFF column count ce0de435d715 SELECT 1 UNION SELECT 1 UNION SELECT 1 INTO @var FROM DUAL +parser DIFF column count 21b089db5c9f SELECT 1 UNION SELECT 1 UNION (SELECT 1 INTO @var FROM DUAL) +parser DIFF column count 8f352edbd0ba SELECT 1 UNION SELECT 1 UNION SELECT 1 INTO @var FOR UPDATE +parser LENIENT 3954 e4a75238d571 SELECT 1 UNION (SELECT 1 INTO @var FROM DUAL) UNION SELECT 1 +parser LENIENT 3954 da01b90a27f6 SELECT 1 UNION SELECT 1 UNION SELECT * FROM (SELECT 2 UNION SELECT 1 INTO @var FROM DUAL) t +parser LENIENT 3954 4b054d945f15 SELECT 1 UNION SELECT 1 UNION SELECT * FROM (SELECT 1 UNION SELECT 1 UNION SELECT 1 INTO @var FROM D +parser LENIENT 1235 f3709bf90ed7 SELECT cast( DATE'2019-10-10' AT LOCAL AS DATETIME ) +parser LENIENT 1235 7933fb19162b SELECT cast( TIME'10:10' AT LOCAL AS DATETIME ) +parser LENIENT 1235 a9dd582f3ca0 SELECT cast( TIMESTAMP'2019-10-10 10:11:12' AT LOCAL AS DATETIME ) +parser_precedence DIFF column type double->bigint unsigned 37ddb8943548 select 0xF0 + 0x0F | 0x55, (0xF0 + 0x0F) | 0x55, 0xF0 + (0x0F | 0x55) +parser_precedence DIFF column type double->bigint unsigned e2e053cd6831 select 0x55 | 0xF0 + 0x0F, (0x55 | 0xF0) + 0x0F, 0x55 | (0xF0 + 0x0F) +parser_precedence DIFF column type double->bigint unsigned ac68754b28a0 select 0xF0 + 0x0F & 0x55, (0xF0 + 0x0F) & 0x55, 0xF0 + (0x0F & 0x55) +parser_precedence DIFF column type double->bigint unsigned 531f873119ff select 0x55 & 0xF0 + 0x0F, (0x55 & 0xF0) + 0x0F, 0x55 & (0xF0 + 0x0F) +parser_precedence DIFF column type double->bigint unsigned 2705d14f4be1 select 0xFF - 0x0F | 0x55, (0xFF - 0x0F) | 0x55, 0xFF - (0x0F | 0x55) +parser_precedence DIFF column type double->bigint unsigned 2f144c089617 select 0x55 | 0xFF - 0xF0, (0x55 | 0xFF) - 0xF0, 0x55 | (0xFF - 0xF0) +parser_precedence DIFF column type double->bigint unsigned a5f479a6fc4c select 0xFF - 0xF0 & 0x55, (0xFF - 0xF0) & 0x55, 0xFF - (0xF0 & 0x55) +parser_precedence DIFF column type double->bigint unsigned 6e29db0b9493 select 0x55 & 0xFF - 0x49, (0x55 & 0xFF) - 0x49, 0x55 & (0xFF - 0x49) +parser_precedence DIFF column type double->bigint unsigned 233c605adb9b select 0x0F | 2 * 0x40, (0x0F | 2) * 0x40, 0x0F | (2 * 0x40) +parser_precedence DIFF column type double->bigint unsigned a137dd2e7090 select 0xF0 & 2 * 0x40, (0xF0 & 2) * 0x40, 0xF0 & (2 * 0x40) +parser_precedence DIFF column type double->decimal 82a1558bb937 select 0x80 / 4 << 2, (0x80 / 4) << 2, 0x80 / (4 << 2) +parser_precedence DIFF column type double->decimal dd6d01a27965 select 0x80 / 4 >> 2, (0x80 / 4) >> 2, 0x80 / (4 >> 2) +parser_precedence DIFF column type double->decimal 96e39e7c78ed select 0x80 / 2 + 2, (0x80 / 2) + 2, 0x80 / (2 + 2) +parser_precedence DIFF column type double->decimal ad591905191a select 0x80 + 2 / 2, (0x80 + 2) / 2, 0x80 + (2 / 2) +parser_precedence DIFF column type double->decimal b57cde669f31 select 0x80 / 4 - 2, (0x80 / 4) - 2, 0x80 / (4 - 2) +parser_precedence DIFF column type double->decimal bd3403760c39 select 0x80 - 4 / 2, (0x80 - 4) / 2, 0x80 - (4 / 2) +parser_precedence DIFF column type double->bigint unsigned 5f53111b42b4 select 0xFF ^ 0xF0 + 0x0F, (0xFF ^ 0xF0) + 0x0F, 0xFF ^ (0xF0 + 0x0F) +parser_precedence DIFF column type double->bigint unsigned cab0a8d058a8 select 0x0F + 0xFF ^ 0xF0, (0x0F + 0xFF) ^ 0xF0, 0x0F + (0xFF ^ 0xF0) +parser_precedence DIFF column type double->bigint unsigned 6181234eabff select 0x65 - 0x0F ^ 0x55, (0x65 - 0x0F) ^ 0x55, 0x65 - (0x0F ^ 0x55) +parser_precedence DIFF column type double->decimal c8524406a829 select 0xF2 / 2 ^ 0xF0, (0xF2 / 2) ^ 0xF0, 0xF2 / (2 ^ 0xF0) +parser_precedence DIFF column type double unsigned->bigint unsigned 6bfacdd6c866 select 0xFF ^ 0xF0 % 0x20, (0xFF ^ 0xF0) % 0x20, 0xFF ^ (0xF0 % 0x20) +parser_precedence DIFF column type double->bigint unsigned 3dad14fe691f select 0xFF % 0x20 ^ 0xF0, (0xFF % 0x20) ^ 0xF0, 0xFF % (0x20 ^ 0xF0) +parser_precedence DIFF column type double unsigned->bigint unsigned f3f70b361905 select 0xFF ^ 0xF0 MOD 0x20, (0xFF ^ 0xF0) MOD 0x20, 0xFF ^ (0xF0 MOD 0x20) +parser_precedence DIFF column type double->bigint unsigned c26071cbc3be select 0xFF MOD 0x20 ^ 0xF0, (0xFF MOD 0x20) ^ 0xF0, 0xFF MOD (0x20 ^ 0xF0) +parser_stack STRICT 1064 c8917a22dadf SELECT +parser_stack SCHEMA problems: memory exhausted a4941c44300d call proc_overflow() +parser_stack SCHEMA problems: memory exhausted bd2e9c4fb5b6 select func_overflow() +parser_stack SCHEMA problems: memory exhausted 75c71e66d1d9 insert into table_overflow set a=10 +partition LENIENT 1526 865578b63230 REPLACE t1 SET a = 4 +partition LENIENT 1526 933dc9414f7f insert into t1 values (4) +partition LENIENT 1526 10f9cbe4f579 insert into t1 values (1) +partition LENIENT 1526 a9fb66e9e931 update t1 set a = 5 +partition LENIENT 1526 453ac14b447a insert into t1 values (10,1) +partition LENIENT 1526 65b3e39e1c10 insert into t1 values (NULL,1,1) +partition LENIENT 1526 3b9bdd157531 insert into t1 values (0) +partition LENIENT 1706 7dd35d7a082c UPDATE t1_part AS A NATURAL JOIN t1_part B SET A.partkey = 2, B.nokey = 3 +partition LENIENT 1706 00c1f90c5025 UPDATE t1_part AS A NATURAL JOIN t1_part B SET A.nokey = 2, B.partkey = 3 +partition LENIENT 1706 ddfe86b94923 UPDATE t1_part AS A NATURAL JOIN v1 as B SET A.nokey = 2 , B.partkey = 3 +partition LENIENT 1706 e36aa6ff13f9 UPDATE v1 AS A NATURAL JOIN t1_part as B SET A.nokey = 2 , B.partkey = 3 +partition RUNTIME 1690 14deccabadf8 INSERT v0 SET c1 = 0 +partition_column LENIENT 1526 912a76efb92c INSERT INTO t1 VALUES(3, 'Dan', 'dep2', 'Croatia') +partition_column SCHEMA problems: PARTITION LIST: definition not understood 8620676461c1 insert into t1 values (NULL),(0),(1),(2),(2),(4),(4),(4),(8),(8) +partition_column LENIENT 1526 edb56143a04c insert into t1 values (5) +partition_column SCHEMA problems: PARTITION LIST: definition not understood 10f9cbe4f579 insert into t1 values (1) +partition_column SCHEMA problems: PARTITION LIST: definition not understood 609cc83d26c6 insert into t1 (a) values ('a') +partition_column LENIENT 1526 ee02e2d9f482 insert into t1 values (11,0) +partition_datatype SCHEMA problems: PARTITION BY HASH: an explicit partition list is not support 416d710a6c00 insert into t1 values (1),(4),(7),(10),(13),(16),(19),(22),(25),(28),(31),(34) +partition_debug LENIENT 1814 7e7ce3adb2fd SELECT COUNT(*)FROM t1 +partition_error LENIENT 1526 983a85dcd1fd insert into t1 values (10) +partition_error LENIENT 1526 72f567be6eab INSERT INTO t1 VALUES (2) +partition_error LENIENT 1748 3b392a6beb7b INSERT INTO t1 PARTITION (p1) VALUES (3) +partition_error LENIENT 1526 1b02dbf51044 UPDATE t1 SET a = 3 WHERE (a % 3) != 0 +partition_error LENIENT 1526 2b92959f9371 UPDATE t1 PARTITION (p1) SET a = 3 WHERE (a % 3) != 0 +partition_error LENIENT 1748 2ce1d6ccac0d UPDATE t1 PARTITION (p1) SET a = 5 WHERE (a % 3) = 0 +partition_error LENIENT 1526 d8759194b2a7 UPDATE t1 PARTITION (p1) SET a = 3 WHERE (a % 5) > 3 +partition_error LENIENT 1863 905d4c047937 UPDATE t1 PARTITION (p1,p6) SET a = 7 WHERE (a % 7) > 5 +partition_error LENIENT 1748 49f97a04752e DELETE FROM t1 PARTITION (p1) WHERE (a % 7) > 2 +partition_error LENIENT 1863 571ce7599f72 DELETE FROM t1 PARTITION (p1,p6) WHERE (a % 7) > 5 +partition_error LENIENT 1526 0b1162498b9e DELETE FROM t1 WHERE a > 0 +partition_exchange LENIENT 1735 e4361b76acb6 SELECT * FROM tsp PARTITION (sp2) +partition_exchange LENIENT 1735 5a4b83eb7bf8 SELECT * FROM tsp PARTITION (sp3) +partition_explicit_prune LENIENT 1748 79bd9e49999b INSERT INTO v1 VALUES (11) +partition_explicit_prune LENIENT 1748 416a783de229 INSERT INTO v1 VALUES (21) +partition_explicit_prune LENIENT 1735 aef98e10ab62 INSERT INTO t1 PARTITION (pNonExisting) VALUES (1, "error") +partition_explicit_prune LENIENT 1735 c47dfc2f7a6c INSERT INTO t1 PARTITION (pNeg, pNonExisting) VALUES (1, "error") +partition_explicit_prune LENIENT 1748 e6ce9f366d5c INSERT INTO t1 PARTITION (pNeg, pNeg) VALUES (1, "error") +partition_explicit_prune LENIENT 1748 9b1021ec2643 INSERT INTO t1 PARTITION (pNeg, subp0) VALUES (1, "error") +partition_explicit_prune LENIENT 1748 17f76aae433d INSERT INTO t1 PARTITION (`p100-99999`) VALUES (1, "error"), (10, "error") +partition_explicit_prune LENIENT 1526 66cf3f2727a3 INSERT INTO t1 VALUES (1000000, "error"), (9999999, "error") +partition_explicit_prune LENIENT 1526 201130422444 INSERT INTO t1 PARTITION (`p100-99999`) VALUES (1000000, "error"), (9999999, "error") +partition_explicit_prune LENIENT 1735 dbca92e5dc81 SELECT * FROM t1 PARTITION (pNonexistent) +partition_explicit_prune LENIENT 1748 8df57a859582 REPLACE INTO t1 PARTITION (subp0) VALUES (-21, 'Should fail!') +partition_explicit_prune LENIENT 1748 0b0112bde7d9 LOAD DATA INFILE 'loadtest.txt' INTO TABLE t1 PARTITION (pNeg) +partition_explicit_prune LENIENT 1748 59f47b5d11db UPDATE t1 PARTITION(`p100-99999`) SET a = -2, b = concat(b, ', Updated from a = 100') +partition_explicit_prune LENIENT 1748 10aa7fbe9169 INSERT INTO t2 PARTITION (subp3) SELECT * FROM t1 PARTITION (subp3, `p10-99`, `p100-99999`) +partition_explicit_prune LENIENT 1747 64710022c04e SELECT * FROM t3 PARTITION (pNeg) +partition_explicit_prune LENIENT 1735 be1cfda5e511 SELECT * FROM t1 PARTITION (foo) +partition_explicit_prune LENIENT 1735 5aea9c711310 SELECT * FROM t2 PARTITION (foo) +partition_hash SCHEMA problems: PARTITION BY HASH: an explicit partition list is not support 5d1e5c3be183 insert into t1 values (1,1,1) +partition_hash SCHEMA problems: PARTITION BY HASH: an explicit partition list is not support 055b08222849 select * from t1 +partition_innodb LENIENT 1526 5ad1b7ac8cf5 INSERT INTO t1 VALUES (90) +partition_innodb LENIENT 1526 8fdbb70d5005 INSERT INTO t1 VALUES (100) +partition_innodb LENIENT 1526 b31fcc9ebd50 insert INTO t1 VALUES (110) +partition_innodb LENIENT 1814 2db13ad39624 SELECT COUNT(*)FROM t1,t1 AS b WHERE t1.c1=''AND t1.c2=b.c1 +partition_innodb LENIENT 1814 a8cf8a4cf32d SELECT COUNT(*)FROM t2,t2 AS b WHERE t2.c1=''AND t2.c2=b.c1 +partition_innodb_plugin SCHEMA dump: SHOW CREATE TABLE t`\?e: Error N (N): You have an error in y fa98c66044e2 INSERT INTO `t``\""e` VALUES (0), (2), (6), (10), (14), (18), (22) +partition_innodb_plugin SCHEMA dump: SHOW CREATE TABLE t`\?e: Error N (N): You have an error in y 5d3ad3b1b483 UPDATE `t``\""e` SET a = 16 WHERE a = 0 +partition_innodb_plugin SCHEMA dump: SHOW CREATE TABLE t`\?e: Error N (N): You have an error in y c7287d6b991f UPDATE `t``\""e` SET a = 4 WHERE a = 22 +partition_list LENIENT 1526 230f998490d1 INSERT into t1 VALUES (2,1,1) +partition_list LENIENT 1526 7513c99b0d81 INSERT into t1 VALUES (3,1,1) +partition_list LENIENT 1526 d5940a8b3a13 INSERT into t1 VALUES (9,1,1) +partition_list LENIENT 1526 2d6a2f21ddbe INSERT into t1 VALUES (2,2,1) +partition_list SCHEMA problems: PARTITION LIST: definition not understood 66fb98de9ea8 INSERT into t1 VALUES (1,1,1) +partition_list SCHEMA problems: PARTITION LIST: definition not understood 7d1bbde90ed9 INSERT INTO t1 VALUES (0x98000) +partition_locking DIFF column type decimal->bigint d1e7f6658ca3 SELECT UNIX_TIMESTAMP('2011-01-01 00:00:00') as time_t, +partition_locking RUNTIME 1242 d13d4388d9d5 SELECT * FROM t4 WHERE a = (SELECT a FROM t3) +partition_locking LENIENT 1748 cd14dbdea3f7 INSERT INTO v1_25 VALUES (17, "Insert in v1_25 fail") +partition_locking LENIENT 1748 99afe43bd698 INSERT INTO v1_25_check VALUES (30, "Insert in v1_25_check fail") +partition_locking LENIENT 1748 613395965e1c CALL sp_insert_partition("p8", 111, "Test111") +partition_locking LENIENT 1146 60f251c8bd12 INSERT INTO t1 VALUES (1) +partition_order DIFF column type double unsigned->double 64d695038816 select * from t1 force index (b) where b > 0 order by b +partition_order DIFF column type float unsigned->float 64d695038816 select * from t1 force index (b) where b > 0 order by b +partition_pruning SCHEMA problems: PARTITION LIST: definition not understood 36c5f35d164e INSERT INTO t1 VALUES (1, 1, 1, 1), (2, 3, 1, 1) +partition_range LENIENT 1526 1cc48b89efd3 INSERT into t1 values (15, 1, 1) +partition_range SCHEMA problems: PARTITION RANGE: definition not understood d7728f67202e SELECT * from t1 +partition_range SCHEMA problems: PARTITION RANGE: definition not understood 66fb98de9ea8 INSERT into t1 VALUES (1,1,1) +partition_range LENIENT 1526 78c33eccab42 INSERT into t1 VALUES (5,1,1) +partition_range LENIENT 1526 6f3280098489 insert into t1 values (0xFFFFFFFFFFFFFFFF) +partition_utf8 SCHEMA problems: PARTITION LIST: definition not understood 3de32f5db32e insert into t1 values ('') +persisted_variables LENIENT 1193 218ba6d24410 SELECT @@global.test_component.str_sys_var +persisted_variables_bugs SCHEMA no database selected 7592012d41ac SELECT @@max_binlog_cache_size +persisted_variables_bugs SCHEMA no database selected 7486f092c2b1 SELECT @a +persisted_variables_bugs SCHEMA no database selected acc812931d84 SELECT @@global.optimizer_trace_offset, @@global.activate_all_roles_on_login, +persisted_variables_bugs SCHEMA no database selected ec46562af698 SELECT @@global.optimizer_trace_offset, @@global.activate_all_roles_on_login, +persisted_variables_bugs SCHEMA no database selected 88ae8b53680f SELECT @@global.binlog_cache_size +persisted_variables_bugs SCHEMA no database selected bdc451dbe88a SELECT @@global.innodb_tmpdir +persisted_variables_bugs SCHEMA no database selected 85222da33c50 SELECT @@global.innodb_ft_user_stopword_table +persisted_variables_bugs SCHEMA no database selected 65db9a11e19a SELECT @@global.innodb_tmpdir, @@global.innodb_ft_user_stopword_table +persisted_variables_bugs SCHEMA no database selected 1dbb6a0b7b2c SELECT @@global.innodb_strict_mode, @@global.innodb_lock_wait_timeout +persisted_variables_bugs SCHEMA no database selected 0c47eb1166c5 SELECT @@global.innodb_lock_wait_timeout +persisted_variables_bugs SCHEMA no database selected 904e1479c2cf SELECT @@global.innodb_strict_mode +persisted_variables_bugs SCHEMA no database selected 0091afb0427f SELECT @@global.skip_name_resolve +persisted_variables_bugs SCHEMA no database selected ae486f6dd568 call mtr.add_suppression("Duplicate variable name 'test_component.int_sys_var'") +persisted_variables_for_plugin LENIENT 1193 9ea2f9605d92 SELECT @@global.validate_password_policy, @@global.validate_password_length +persisted_variables_for_plugin LENIENT 1193 90741dd3309d SELECT @@global.validate_password_policy +persisted_variables_for_plugin LENIENT 1193 eec21290a56c SELECT @@global.validate_password_length +persisted_variables_for_plugin LENIENT 1193 d71dc96f2582 SELECT @@GLOBAL.validate_password_length +plugin LENIENT 1193 8a18738438ce select @@global.example_ulong_var +plugin LENIENT 1193 d339dfe0deef SELECT @@GLOBAL.example_double_var +plugin LENIENT 1193 42b9d5669774 SELECT @@SESSION.example_double_thdvar +plugin LENIENT 1193 d4d69d031658 SELECT @@SESSION.example_create_count_thdvar +plugin LENIENT 1193 f9c6c890254a SELECT @@SESSION.example_last_create_thdvar +plugin LENIENT 1193 b06e044afc21 SELECT @@GLOBAL.example_signed_int_var +plugin LENIENT 1193 1d04f26b3cf3 SELECT @@SESSION.example_signed_int_thdvar +plugin LENIENT 1193 c4fc30c2e73f SELECT @@GLOBAL.example_signed_long_var +plugin LENIENT 1193 3af171393cc2 SELECT @@GLOBAL.example_signed_long_var IN (-2147483648, -9223372036854775808) +plugin LENIENT 1193 8976270b9605 SELECT @@GLOBAL.example_signed_long_var IN (2147483647, 9223372036854775807) +plugin LENIENT 1193 e2b305a12b77 SELECT @@SESSION.example_signed_long_thdvar +plugin LENIENT 1193 e2366c8dbb50 SELECT @@SESSION.example_signed_long_thdvar IN (-2147483648, -9223372036854775808) +plugin LENIENT 1193 4e41bed82967 SELECT @@SESSION.example_signed_long_thdvar IN (2147483647, 9223372036854775807) +plugin LENIENT 1193 4e04464f879b SELECT @@GLOBAL.example_signed_longlong_var +plugin LENIENT 1193 a57c32df254b SELECT @@SESSION.example_signed_longlong_thdvar +plugin_load LENIENT 1193 54c9fbddc9c3 SELECT @@global.example_enum_var = 'e2' +plugin_load_early LENIENT 1193 54c9fbddc9c3 SELECT @@global.example_enum_var = 'e2' +ps STRICT 1066 b4687d93ee1f select * from t1, mysqltest1.t1 +ps LENIENT 1064 7ae509fc5e11 select ? +ps LENIENT 1336 ad71164e93ea select func_1(), func_1(), func_1() from dual +ps LENIENT 1094 9970b4a11d25 call proc_1(0) +ps LENIENT 1094 e6f07092518c select func_1() from dual +ps LENIENT 1126 97ae6d0cd549 call proc_1() +ps LENIENT 1305 97ae6d0cd549 call proc_1() +ps LENIENT 1007 97ae6d0cd549 call proc_1() +ps LENIENT 1396 97ae6d0cd549 call proc_1() +ps LENIENT 1539 97ae6d0cd549 call proc_1() +ps RUNTIME 1265 56ef3fdd2cc0 INSERT INTO dbls VALUES ('') +ps RUNTIME 1265 da22aaa67523 INSERT INTO flts VALUES ('') +ps RUNTIME 1265 053481428bbb INSERT INTO dbls VALUES (' ') +ps RUNTIME 1265 a8dc9222aa38 INSERT INTO flts VALUES (' ') +ps RUNTIME 1366 698719f97022 INSERT INTO ints VALUES (@empty) +ps RUNTIME 1366 efffe60993cd INSERT INTO deci VALUES (@empty) +ps RUNTIME 1265 dacc043cbccc INSERT INTO dbls VALUES (@empty) +ps RUNTIME 1265 2c30292618ac INSERT INTO flts VALUES (@empty) +ps RUNTIME 1366 7957fd0e5d2c INSERT INTO ints VALUES (@spaces) +ps RUNTIME 1366 7a7339ec721d INSERT INTO deci VALUES (@spaces) +ps RUNTIME 1265 9dd59e0f1c86 INSERT INTO dbls VALUES (@spaces) +ps RUNTIME 1265 4c3ea92d1e80 INSERT INTO flts VALUES (@spaces) +ps_1general DIFF column type string->bigint eeca0b160f57 select '2' || '3' +ps_1general DIFF column name 9886c44114c9 select @string as "" +ps_type_deduction STRICT 1146 c1212a6eb675 select length(a), a=@b from t4 +query_attributes STRICT 1305 9f82c0fdcdd7 SELECT mysql_query_attribute_string('a') +query_attributes STRICT 1305 6ed2af8c6116 SELECT mysql_query_attribute_string('a'), mysql_query_attribute_string('c') +query_attributes STRICT 1305 5ee56225711f SELECT mysql_query_attribute_string('') +query_attributes STRICT 1305 d2af08324d74 SELECT mysql_query_attribute_string('a'), mysql_query_attribute_string('c') +query_expression-bugs LENIENT 13 d6762aaa16ef LOAD DATA INFILE '../../std_data/t1_2cols.csv' INTO TABLE t1 FIELDS TERMINATED BY ',' LINES TERMINAT +read_only_persisted_plugin_variables LENIENT 1193 69b4a1ea58eb SELECT @@version_tokens_session_number +regular_expressions_func LENIENT 3697 4497319edfa3 select regexp_like('-','a[b-a]') /* Result: c */ +regular_expressions_func LENIENT 3696 b62a3f0238a3 select regexp_like('-','a[]b') /* Result: ci */ +regular_expressions_func LENIENT 3696 aebeae73be58 select regexp_like('-','a[') /* Result: c */ +regular_expressions_func LENIENT 3688 d1bee1d76ffd select regexp_like('-','*a') /* Result: c */ +regular_expressions_func LENIENT 3688 fc0a20a54fe6 select regexp_like('-','(*)b') /* Result: c */ +regular_expressions_func LENIENT 3689 ae95d2089796 select regexp_like('-','a\\') /* Result: c */ +regular_expressions_func LENIENT 3691 a2f55396aab6 select regexp_like('-','abc)') /* Result: c */ +regular_expressions_func LENIENT 3691 5e779b0cbffd select regexp_like('-','(abc') /* Result: c */ +regular_expressions_func LENIENT 3688 c54f0d424b26 select regexp_like('-','a**') /* Result: c */ +regular_expressions_func LENIENT 3691 3e0edb7e6a2f select regexp_like('-',')(') /* Result: c */ +regular_expressions_func LENIENT 3694 dfb6b0baf644 select regexp_like('-','\\1') /* Result: c */ +regular_expressions_func LENIENT 3694 fa5f7d94aa10 select regexp_like('-','\\2') /* Result: c */ +regular_expressions_func LENIENT 3694 106a30f51db9 select regexp_like('-','(a)|\\2') /* Result: c */ +regular_expressions_func LENIENT 3697 a7841bc9d302 select regexp_like('-','(?i)a[b-a]') /* Result: c */ +regular_expressions_func LENIENT 3696 337827e3064d select regexp_like('-','(?i)a[]b') /* Result: ci */ +regular_expressions_func LENIENT 3696 8d53562c9e46 select regexp_like('-','(?i)a[') /* Result: c */ +regular_expressions_func LENIENT 3688 2375f4fe8790 select regexp_like('-','(?i)*a') /* Result: c */ +regular_expressions_func LENIENT 3688 c8fdfde8e24e select regexp_like('-','(?i)(*)b') /* Result: c */ +regular_expressions_func LENIENT 3689 d8d22d11a31c select regexp_like('-','(?i)a\\') /* Result: c */ +regular_expressions_func LENIENT 3691 a013580b2da7 select regexp_like('-','(?i)abc)') /* Result: c */ +regular_expressions_func LENIENT 3691 8a308e71548a select regexp_like('-','(?i)(abc') /* Result: c */ +regular_expressions_func LENIENT 3688 dd8dd39fe68d select regexp_like('-','(?i)a**') /* Result: c */ +regular_expressions_func LENIENT 3691 7df0de535dd4 select regexp_like('-','(?i))(') /* Result: c */ +regular_expressions_func LENIENT 3691 9dfb157875e1 select regexp_like('-',':(?:') /* Result: c */ +regular_expressions_func LENIENT 3690 9a584ac53751 select regexp_like('aaaaaaaaaa','^(a(?(1)\\1)){4}$') /* Result: y */ +regular_expressions_func LENIENT 3690 c5017c35a5ba select regexp_like('aaaaaaaaa','^(a(?(1)\\1)){4}$') /* Result: n */ +regular_expressions_func LENIENT 3690 b40ef4da9ce2 select regexp_like('aaaaaaaaaaa','^(a(?(1)\\1)){4}$') /* Result: n */ +regular_expressions_func LENIENT 3688 8c6f8e9ff252 select regexp_like('-','(?<%)b') /* Result: c */ +regular_expressions_func LENIENT 3690 f18bfb518da9 select regexp_like('cabd','a(?{})b') /* Result: y */ +regular_expressions_func LENIENT 3690 e402fca078c8 select regexp_like('-','a(?{)b') /* Result: c */ +regular_expressions_func LENIENT 3690 0fd48c04e11a select regexp_like('-','a(?{{})b') /* Result: c */ +regular_expressions_func LENIENT 3690 a1c1252c9e90 select regexp_like('-','a(?{}})b') /* Result: c */ +regular_expressions_func LENIENT 3690 aa9f800c09df select regexp_like('-','a(?{"{"})b') /* Result: c */ +regular_expressions_func LENIENT 3690 113ef166e413 select regexp_like('cabd','a(?{"\\{"})b') /* Result: y */ +regular_expressions_func LENIENT 3690 500b984f99b2 select regexp_like('-','a(?{"{"}})b') /* Result: c */ +regular_expressions_func LENIENT 3690 1699631b88bb select regexp_like('a','(?(1)a|b)') /* Result: n */ +regular_expressions_func LENIENT 3690 e661a378c28d select regexp_like('a','(?(1)b|a)') /* Result: y */ +regular_expressions_func LENIENT 3690 5e6f9b8503d0 select regexp_like('a','(x)?(?(1)a|b)') /* Result: n */ +regular_expressions_func LENIENT 3690 0be536655f77 select regexp_like('a','(x)?(?(1)b|a)') /* Result: y */ +regular_expressions_func LENIENT 3690 2e756da81a53 select regexp_like('a','()?(?(1)b|a)') /* Result: y */ +regular_expressions_func LENIENT 3690 3f04164a3723 select regexp_like('a','()(?(1)b|a)') /* Result: n */ +regular_expressions_func LENIENT 3690 56740afc4dcc select regexp_like('a','()?(?(1)a|b)') /* Result: y */ +regular_expressions_func LENIENT 3690 fc0985f8004d select regexp_like('(blah)','^(\\()?blah(?(1)(\\)))$') /* Result: y */ +regular_expressions_func LENIENT 3690 dc2cab9a64bb select regexp_like('blah','^(\\()?blah(?(1)(\\)))$') /* Result: y */ +regular_expressions_func LENIENT 3690 8291853e35e7 select regexp_like('blah)','^(\\()?blah(?(1)(\\)))$') /* Result: n */ +regular_expressions_func LENIENT 3690 b657115eb4bb select regexp_like('(blah','^(\\()?blah(?(1)(\\)))$') /* Result: n */ +regular_expressions_func LENIENT 3690 a8f8eec03f5e select regexp_like('(blah)','^(\\(+)?blah(?(1)(\\)))$') /* Result: y */ +regular_expressions_func LENIENT 3690 5149b70feebe select regexp_like('blah','^(\\(+)?blah(?(1)(\\)))$') /* Result: y */ +regular_expressions_func LENIENT 3690 231012eea443 select regexp_like('blah)','^(\\(+)?blah(?(1)(\\)))$') /* Result: n */ +regular_expressions_func LENIENT 3690 e6858f1dfa04 select regexp_like('(blah','^(\\(+)?blah(?(1)(\\)))$') /* Result: n */ +regular_expressions_func LENIENT 3690 c63eff337351 select regexp_like('a','(?(1?)a|b)') /* Result: c */ +regular_expressions_func LENIENT 3690 4e95b2c04fa8 select regexp_like('a','(?(1)a|b|c)') /* Result: c */ +regular_expressions_func LENIENT 3690 28821e604ac7 select regexp_like('a','(?(?{0})a|b)') /* Result: n */ +regular_expressions_func LENIENT 3690 ce1fa84908d3 select regexp_like('a','(?(?{0})b|a)') /* Result: y */ +regular_expressions_func LENIENT 3690 6b59e00fee0d select regexp_like('a','(?(?{1})b|a)') /* Result: n */ +regular_expressions_func LENIENT 3690 7ed390396737 select regexp_like('a','(?(?{1})a|b)') /* Result: y */ +regular_expressions_func LENIENT 3690 933287c9fea6 select regexp_like('a','(?(?!a)a|b)') /* Result: n */ +regular_expressions_func LENIENT 3690 67ba79f27e15 select regexp_like('a','(?(?!a)b|a)') /* Result: y */ +regular_expressions_func LENIENT 3690 da4df97fd261 select regexp_like('a','(?(?=a)b|a)') /* Result: n */ +regular_expressions_func LENIENT 3690 a3cc78430480 select regexp_like('a','(?(?=a)a|b)') /* Result: y */ +regular_expressions_func LENIENT 3696 b88e33a5b5c8 select regexp_like('-','[a[:xyz:') /* Result: c */ +regular_expressions_func LENIENT 3685 b0be326e1f63 select regexp_like('-','[a[:xyz:]') /* Result: c */ +regular_expressions_func LENIENT 3685 204e55868b53 select regexp_like('pbaq','([a[:xyz:]b]+)') /* Result: c */ +regular_expressions_func LENIENT 3685 2ce6e747a733 select regexp_like('-','[[:foo:]]') /* Result: c */ +regular_expressions_func LENIENT 3685 4635f8340226 select regexp_like('-','[[:^foo:]]') /* Result: c */ +regular_expressions_func LENIENT 3695 5ecbea5651c7 select regexp_like('-','(?<=x+)y') /* Result: c */ +regular_expressions_func LENIENT 3693 e10a2eb5d3ce select regexp_like('-','a{37,17}') /* Result: c */ +regular_expressions_func LENIENT 3690 f1f59901fc5f select regexp_like('a','^(a)?(?(1)a|b)+$') /* Result: n */ +regular_expressions_func LENIENT 3688 9fa45f89046d select regexp_like('ab','^(a(??{"(?!)"})|(a)(?{1}))b') /* Result: yi */ +regular_expressions_func LENIENT 3690 73aaf08fd16d select regexp_like('CD','(A|B)*(?(1)(CD)|(CD))') /* Result: y */ +regular_expressions_func LENIENT 3690 b2a48c4f36ea select regexp_like('ABCD','(A|B)*(?(1)(CD)|(CD))') /* Result: y */ +regular_expressions_func LENIENT 3690 b4905832fe63 select regexp_like('CD','(A|B)*?(?(1)(CD)|(CD))') /* Result: y */ +regular_expressions_func LENIENT 3690 1f164a7014aa select regexp_like('ABCD','(A|B)*?(?(1)(CD)|(CD))') /* Result: y */ +regular_expressions_func LENIENT 3688 006dae4136c9 select regexp_like('x','(??{})') /* Result: yi */ +regular_expressions_func_icu_54 LENIENT 3699 72eecae8ec9b select regexp_like('bbbbXcXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','.X(.+)+X') /* Result: yi */ +regular_expressions_func_icu_54 LENIENT 3699 9777539071b8 select regexp_like('bbbbXcXXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','.X(.+)+XX') /* Result: yi */ +regular_expressions_func_icu_54 LENIENT 3699 dcf13b375e0a select regexp_like('bbbbXXcXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','.XX(.+)+X') /* Result: yi */ +regular_expressions_func_icu_54 LENIENT 3699 040b58e10293 select regexp_like('bbbbXXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','.X(.+)+X') /* Result: ni */ +regular_expressions_func_icu_54 LENIENT 3699 287466a09e5a select regexp_like('bbbbXXXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','.X(.+)+XX') /* Result: ni */ +regular_expressions_func_icu_54 LENIENT 3699 ff67ab45991e select regexp_like('bbbbXXXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','.XX(.+)+X') /* Result: ni */ +regular_expressions_func_icu_54 LENIENT 3699 0b5ebe03f434 select regexp_like('bbbbXcXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','.X(.+)+[X]') /* Result: yi */ +regular_expressions_func_icu_54 LENIENT 3699 3d71559df3c2 select regexp_like('bbbbXcXXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','.X(.+)+[X][X]') /* Result: yi */ +regular_expressions_func_icu_54 LENIENT 3699 4b580dd5285f select regexp_like('bbbbXXcXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','.XX(.+)+[X]') /* Result: yi */ +regular_expressions_func_icu_54 LENIENT 3699 840cffd8aa55 select regexp_like('bbbbXXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','.X(.+)+[X]') /* Result: ni */ +regular_expressions_func_icu_54 LENIENT 3699 e5dddd7b9aff select regexp_like('bbbbXXXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','.X(.+)+[X][X]') /* Result: ni */ +regular_expressions_func_icu_54 LENIENT 3699 401154b68d4c select regexp_like('bbbbXXXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','.XX(.+)+[X]') /* Result: ni */ +regular_expressions_func_icu_54 LENIENT 3699 b55cb0c1474d select regexp_like('bbbbXcXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','.[X](.+)+[X]') /* Result: yi */ +regular_expressions_func_icu_54 LENIENT 3699 74c2d85b0f1a select regexp_like('bbbbXcXXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','.[X](.+)+[X][X]') /* Result: yi */ +regular_expressions_func_icu_54 LENIENT 3699 4384b232a6ee select regexp_like('bbbbXXcXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','.[X][X](.+)+[X]') /* Result: yi */ +regular_expressions_func_icu_54 LENIENT 3699 685ae5dba1ce select regexp_like('bbbbXXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','.[X](.+)+[X]') /* Result: ni */ +regular_expressions_func_icu_54 LENIENT 3699 550743a282ae select regexp_like('bbbbXXXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','.[X](.+)+[X][X]') /* Result: ni */ +regular_expressions_func_icu_54 LENIENT 3699 9feef50fc01c select regexp_like('bbbbXXXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','.[X][X](.+)+[X]') /* Result: ni */ +regular_expressions_utf-8 DIFF column type string->binary 3da2d84c9967 SELECT hex( concat(regexp_instr( 'a', 'a' )) ) +regular_expressions_utf-8 LENIENT 3685 2f361ea3b238 SELECT regexp_instr( 'a', '[[:invalid_bracket_expression:]]' ) +regular_expressions_utf-8 LENIENT 3686 fa6c5cf984f8 SELECT regexp_instr( 'a', 'a+', 3 ) +regular_expressions_utf-8 LENIENT 1210 8ba4d0911987 SELECT regexp_instr( 'a', 'a+', 1, 1, 2 ) +regular_expressions_utf-8 LENIENT 1210 e83e8da449d1 SELECT regexp_instr( 'a', 'a+', 1, 1, -1 ) +regular_expressions_utf-8 LENIENT 1210 a5119d6708a9 SELECT regexp_instr( 'a', 'a+', 1, 1, 1, 'x' ) +regular_expressions_utf-8 DIFF column type string->binary 3143f762a5d7 SELECT hex( concat(regexp_like( 'a', 'a' )) ) +regular_expressions_utf-8 LENIENT 1210 0ecfdab01f48 SELECT regexp_like( 'a', 'a+', 'x' ) +regular_expressions_utf-8 LENIENT 1210 6e7e7a8a0fb1 SELECT regexp_like( 'a', 'a+', 'cmnux' ) +regular_expressions_utf-8 LENIENT 3685 5fa35bcf0b7f SELECT regexp_like('a', '[[:invalid_bracket_expression:]]') +regular_expressions_utf-8 LENIENT 1583 540b2f990e48 SELECT regexp_replace( 'aaa', 'a', 'X', 0 ) +regular_expressions_utf-8 LENIENT 3686 1a2c6be5a5fd SELECT regexp_replace( 'a', 'a+', 'b', 3 ) +regular_expressions_utf-8 LENIENT 1583 d5824fc7389f SELECT regexp_replace( 'aaabbccbbddaa', 'b+', 'X', 0, 1 ) +regular_expressions_utf-8 LENIENT 1583 535ac757bb17 SELECT regexp_substr( 'ab ac ad', 'a.', 0 ) +regular_expressions_utf-8 LENIENT 3685 56ba457fc52b SELECT regexp_substr('a', '[[:invalid_bracket_expression:]]') +regular_expressions_utf-8 DIFF column type string->binary 75b906a8df5d SELECT concat( regexp_like(g, g), 'x' ) FROM t2 +regular_expressions_utf-8 LENIENT 3692 485529199766 SELECT regexp_instr( '', '((((((((){80}){}){11}){11}){11}){80}){11}){4}' ) +regular_expressions_utf-8 LENIENT 3699 209781473ada SELECT regexp_instr( '', '(((((((){120}){11}){11}){11}){80}){11}){4}' ) +regular_expressions_utf-8 LENIENT 3698 cc22eb31df28 SELECT regexp_instr( '', '((((((){11}){11}){11}){160}){11}){160}' ) +regular_expressions_utf-8 LENIENT 3698 97bb76c89c02 SELECT regexp_instr( '', '(((((){150}){11}){160}){11}){160}' ) +regular_expressions_utf-8 LENIENT 3698 0e77e05ac458 SELECT regexp_instr( '', '((((){255}){255}){255}){255}' ) +regular_expressions_utf-8 LENIENT 3699 ef710f9ceaf6 SELECT regexp_instr( 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC', '(A+)+B' ) +regular_expressions_utf-8 LENIENT 1210 c28cea02b271 SELECT regexp_like( 'aaa', 'a+', 1 ) +regular_expressions_utf-8 LENIENT 1210 574abedec6bd SELECT regexp_substr( 'aaa', 'a+', 1, 1, 1 ) +regular_expressions_utf-8 LENIENT 3688 843d0d091ec7 SELECT regexp_substr( 'aaa', '+' ) +regular_expressions_utf-8 RUNTIME 1265 92b10d66aa01 INSERT INTO t1 ( a ) VALUES ( regexp_replace('a', 'a', 'a') ) +regular_expressions_utf-8 RUNTIME 1366 20da78399ec6 INSERT INTO t1 ( b ) VALUES ( regexp_replace('a', 'a', 'a') ) +regular_expressions_utf-8 RUNTIME 1366 cdec0327a5b3 INSERT INTO t1 ( d ) VALUES ( regexp_replace('a', 'a', 'a') ) +regular_expressions_utf-8 RUNTIME 1265 f6d3f9b9a7ae INSERT INTO t1 ( a ) VALUES ( regexp_substr('a', 'a') ) +regular_expressions_utf-8 RUNTIME 1366 8e000cf4ed07 INSERT INTO t1 ( b ) VALUES ( regexp_substr('a', 'a') ) +regular_expressions_utf-8 RUNTIME 1366 7d3d5c0e2dcd INSERT INTO t1 ( d ) VALUES ( regexp_substr('a', 'a') ) +regular_expressions_utf-8 DIFF nullable true->false 01198210cf30 SELECT cast( regexp_replace('a', 'a', 'a') AS SIGNED INTEGER ) +regular_expressions_utf-8 LENIENT 3685 ae01e54fb002 SELECT regexp_like( 'a', '[[:<:]]a' ) +regular_expressions_utf-8 LENIENT 3688 3c26ef7aee6e SELECT regexp_like( 'a', ' **' ) +regular_expressions_utf-8 LENIENT 3688 345f743b038e SELECT regexp_like( 'a', ' \n **' ) +regular_expressions_utf-8 LENIENT 3688 43f85ec46830 SELECT regexp_like( 'a', ' +++' ) +regular_expressions_utf-8 LENIENT 3689 6d91204b50c6 SELECT regexp_like( 'a', '\\' ) +regular_expressions_utf-8 LENIENT 3690 8b28149ffd54 SELECT regexp_like('a','(?{})') +regular_expressions_utf-8 LENIENT 3691 e70c4886bde6 SELECT regexp_like('a','(') +regular_expressions_utf-8 LENIENT 3692 edf08aa9d8f0 SELECT regexp_like('a','a{}') +regular_expressions_utf-8 LENIENT 3693 f27dd8cf3df2 SELECT regexp_like('a','a{2,1}') +regular_expressions_utf-8 LENIENT 3694 fd2ac898ccd0 SELECT regexp_like('a','\\1') +regular_expressions_utf-8 LENIENT 3695 b131ef658868 SELECT regexp_substr( 'ab','(?<=a+)b' ) +regular_expressions_utf-8 LENIENT 3696 23fe0ed861f4 SELECT regexp_like( 'a', 'a[' ) +regular_expressions_utf-8 LENIENT 3697 b354f4e80010 SELECT regexp_substr( 'ab','[b-a]' ) +regular_expressions_utf-8 LENIENT 3887 133b4903b9ab SELECT regexp_replace( ' F' , '^ ', '[,$' ) +regular_expressions_utf-8 LENIENT 3900 2b18243fb84a SELECT regexp_instr( 'abc', '(?-' ) +regular_expressions_utf-8 LENIENT 3686 a8374dd09c09 SELECT regexp_instr( '🍣🍣a', 'a', 4 ) +regular_expressions_utf-8 LENIENT 3995 e197a22fbdca SELECT regexp_like('1', x'01') +regular_expressions_utf-8 LENIENT 3995 1054f0fa1b9e SELECT regexp_like(x'01', '1') +regular_expressions_utf-8 LENIENT 3995 db74a07cf28e SELECT regexp_replace('01', '01', x'02') +regular_expressions_utf-8 LENIENT 3995 2b96a817b378 SELECT regexp_replace('01', x'01', '02') +regular_expressions_utf-8 LENIENT 3995 c67611763688 SELECT regexp_replace('01', x'01', x'02') +regular_expressions_utf-8 LENIENT 3995 6c0d523330d9 SELECT regexp_replace(x'01', '01', '02') +regular_expressions_utf-8 LENIENT 3995 d4c05bfce56e SELECT regexp_replace(x'01', '01', x'02') +regular_expressions_utf-8 LENIENT 3995 c935d458197d SELECT regexp_replace(x'01', x'01', '02') +regular_expressions_utf-8 LENIENT 3995 6fa9a2d395c1 SELECT regexp_like(a, x'01') FROM t1 +regular_expressions_utf-8 LENIENT 3995 8e0c6adf09e7 SELECT regexp_like(a, x'01') FROM t3 +regular_expressions_utf-8 LENIENT 3995 8bc150152f7a SELECT regexp_like(x'01', a) FROM t1 +regular_expressions_utf-8 LENIENT 3995 08c6ae7e833c SELECT regexp_like(x'01', a) FROM t3 +regular_expressions_utf-8 DIFF nullable true->false 62e32139aa96 SELECT regexp_replace(1, 1, 'a') +regular_expressions_utf-8 DIFF column type string->binary 73fef62ef900 SELECT regexp_replace(NULL, NULL, 'a') +regular_expressions_utf-8 RUNTIME 3854 6b138610c605 select ( _utf32', ,*') regexp 13946 +regular_expressions_utf-8 RUNTIME 3854 8fb26c8c7564 select regexp_instr( ( _utf32', ,*'), 13946) +regular_expressions_utf-8 RUNTIME 3854 d79df3c7ed38 select regexp_replace( ( _utf32', ,*'), 13946, 42) +regular_expressions_utf-8 RUNTIME 3854 ddaa93a266d9 select regexp_substr( ( _utf32', ,*'), 13946) +regular_expressions_utf-8 CODE 1210->1292 46434b4c64ec UPDATE t1 SET col_datetime = 8 +regular_expressions_utf-8_icu_58 LENIENT 3688 b5d723ef23a7 SELECT regexp_like( 'a\nb', '(*CR)a.b' ) +regular_expressions_utf-8_icu_59 LENIENT 3699 2b4267891000 SELECT regexp_like('', "(((((((){120}){11}){11}){11}){80}){11}){4}" ) +rename_myisam LENIENT 1100 b22000f51e8b SELECT * FROM mysqltest.t4 +rename_myisam LENIENT 1100 82cc001b3ffa SELECT * FROM t3 +replace RUNTIME 1062 0b8cf136f3ce replace into t1 values (0,"error") +roles STRICT 1146 2dd5329bdedb CALL db1.sp1() +roles STRICT 1146 3a49aa09988d CALL test.sp1() +roles SCHEMA no database selected 58589964075d SELECT CURRENT_ROLE() +roles SCHEMA no database selected 624c801df910 SELECT ExtractValue(ROLES_GRAPHML(),'count(//node)') as num_nodes +roles SCHEMA no database selected 253ef47ce129 SELECT current_role() +roles SCHEMA no database selected 55645c0bdab1 SELECT USER(), CURRENT_USER(), CURRENT_ROLE() +roles SCHEMA no database selected db11a90c64d5 SELECT CURRENT_USER() +roles SCHEMA no database selected 771841405a06 SELECT CURRENT_USER(),CURRENT_ROLE() +roles-sp STRICT 1146 2dd5329bdedb CALL db1.sp1() +roles-sp STRICT 1146 ca5fd40b96d6 CALL db2.sp1() +row DIFF nullable true->false d3b8ddecf516 select (1,2,3) IN ((3,2,3), (1,2,3), (1,3,3)) +row DIFF nullable true->false a306cec42a97 select row(10,2,3) IN (row(3,2,3), row(1,2,3), row(1,3,3)) +row DIFF nullable true->false 60287669cb1e select row('a',1.5,3) IN (row(1,2,3), row('a',1.5,3), row('a','a','a')) +row DIFF nullable true->false bb569a01b92c select row('a',0,3) IN (row(3,2,3), row('a','a','3'), row(1,3,3)) +row DIFF nullable true->false f1bc122f1bfc select row('a',0,3) IN (row(3,2,3), row('a','0','3'), row(1,3,3)) +row DIFF nullable true->false 036a61406afc select (1,2,(3,4)) IN ((3,2,(3,4)), (1,2,(3,4))) +row LENIENT 1241 3eaf1dd24b29 select row(1,2,row(3,4)) IN (row(3,2,row(3,4)), row(1,2,4)) +row DIFF nullable true->false 2d20b2e771b9 SELECT ROW(1,2,3)=ROW(1,2,3) +row DIFF nullable true->false 84162a7fe4cd SELECT ROW(2,2,3)=ROW(1+1,2,3) +row DIFF nullable true->false 2a88314d3db8 SELECT ROW(1,2,3)=ROW(1+1,2,3) +row DIFF nullable true->false ce2ae0226328 SELECT ROW(1,2,3)false 75b4c5fe2f42 SELECT ROW(1,2,3)>ROW(1+1,2,3) +row DIFF nullable true->false 175c5e47ea11 SELECT ROW(1,2,3)<=ROW(1+1,2,3) +row DIFF nullable true->false 3fd2a053b115 SELECT ROW(1,2,3)>=ROW(1+1,2,3) +row DIFF nullable true->false 20e4b7e26ea2 SELECT ROW(1,2,3)<>ROW(1+1,2,3) +row DIFF nullable true->false a153534b3d73 SELECT ROW(1,2,ROW(3,4,5))=ROW(1,2,ROW(3,4,5)) +row DIFF nullable true->false 61237f11179f SELECT ROW('test',2,3.33)=ROW('test',2,3.33) +row LENIENT 1241 2007e83bb870 SELECT ROW('test',2,3.33)=ROW('test',2,3.33,4) +row DIFF nullable true->false 30a773f87593 SELECT ROW('test',2,ROW(3,33))=ROW('test',2,ROW(3,33)) +row DIFF nullable true->false edfdcaced7b6 SELECT ROW('test',2,ROW(3,33))=ROW('test',2,ROW(3,3)) +row LENIENT 1241 63bbaec0dcff SELECT ROW('test',2,ROW(3,33))=ROW('test',2,4) +row LENIENT 1241 2a286eed5f7a select ROW(1,1) +row LENIENT 1241 4a31c5133659 select 1 from t1 where ROW(1,1) +row LENIENT 1241 fb2a809f8840 select count(*) from t1 order by ROW(1,1) +row LENIENT 1241 87f2e75f8889 select count(*) from t1 having (1,1) order by i +row LENIENT 1241 b089bbba130e SELECT ROW(1,ROW(2,3)) IN (ROW(1,ROW(2,3)),ROW(1,1)) +row LENIENT 1241 e7fb9b706317 SELECT ROW(1,ROW(2,3)) IN (ROW(1,ROW(2,3)),ROW(1,1),ROW(1,ROW(2,3))) +row LENIENT 1241 92f831a83428 SELECT ROW(1,ROW(2,3)) IN (ROW(1,ROW(2,3)),ROW(1,ROW(2,2,2))) +row LENIENT 1241 d776902a1b21 SELECT ROW(1,ROW(2,3,4)) IN (ROW(1,ROW(2,3,4)),ROW(1,ROW(2,2))) +row LENIENT 1241 08e64a433f4f SELECT ROW(2,1) IN (ROW(21,2),ROW(ROW(1,1,3),0)) +row LENIENT 1241 f773451a9c46 SELECT ROW(2,1) IN (ROW(ROW(1,1,3),0),ROW(21,2)) +row DIFF nullable true->false 4c04abd67878 SELECT ROW(1,1,1) = ROW(1,1,1) as `1`, ROW(1,1,1) = ROW(1,2,1) as `0`, ROW(1,NULL,1) = ROW(2,2,1) as +row STRICT 1241 cf575d5d1867 SELECT ROW(1,2) = (SELECT 1,2 FROM DUAL WHERE 1 = 0) +row STRICT 1241 3ca707056038 SELECT ROW(1,2) = (SELECT 1,3 FROM DUAL WHERE 1 = 0) +row STRICT 1241 6ba5ad06600c SELECT ROW(1,2) = (SELECT 1,2 FROM t1 WHERE 1 = 0) +row STRICT 1241 369a6b0f9408 SELECT ROW(1,2) = (SELECT 1,3 FROM t1 WHERE 1 = 0) +row STRICT 1241 efdd1304fbc8 SELECT i FROM t1 WHERE ROW(1,2) = (SELECT 1,2 FROM DUAL WHERE 1 = 0) +row STRICT 1241 bbd64e4d8357 SELECT i FROM t1 WHERE ROW(1,2) = (SELECT 1,3 FROM DUAL WHERE 1 = 0) +row LENIENT 1241 5c58849aee2f SELECT row( 1, 1, 1 ) = row( 1, 1 ) FROM t1 GROUP BY a +row LENIENT 3037 c78bf40d3569 SELECT ROW(ST_Intersection(POINT(1, 1), 1), (SELECT 1)) +row LENIENT 3037 d23538ec2869 SELECT ROW(1, ST_Intersection(POINT(1, 1), 1), (SELECT 1)) +rpad DIFF column type string->binary 2658193b5384 SELECT RPAD(NULL, 5, 'x') AS result +rpad DIFF column type string->binary 03e3f28eeab3 SELECT RPAD(NULL, NULL, 'x') AS result +rpad DIFF column type string->binary 31aff7eeccac SELECT RPAD(NULL, NULL, NULL) AS result +rpad DIFF column type string->binary efa1e6c90013 SELECT RPAD(NULL, 5, NULL) AS result +schema SCHEMA no database selected a839a2833b36 CALL mtr.add_suppression("Problem while dropping database. Can't remove database directory .* Please +schema SCHEMA no database selected 48350c9f94b9 SELECT db1.f1() +schema SCHEMA no database selected 83ea08dfa592 call mtr.add_suppression("\\[Warning\\] .*MY-\\d+.* Tablespace .*, name '.*', file '.*' is missing!" +sdi_tablespace LENIENT 1814 308298691a0c SELECT * FROM dst_t2 ORDER BY c1 +select_count STRICT 1054 070361768ba9 SELECT 1 AS c1, (SELECT COUNT(*) FROM t_innodb HAVING c1 > 0) FROM DUAL +select_distinct_debug LENIENT 1193 8e9108365e57 SELECT @@innodb_purge_stop_now INTO @old_val +select_safe LENIENT 1175 0ccc0ee46c0d update t1 set b="a" +select_safe LENIENT 1175 edce4c5cb893 update t1 set b="a" where b="test" +select_safe LENIENT 1175 e023afd06714 delete from t1 +select_safe LENIENT 1175 df75f6dfae03 delete from t1 where b="test" +select_safe LENIENT 1175 5e5bbf2e78aa delete from t1 where a+0=1 +select_safe LENIENT 1104 0cc713d75549 select 1 from t1,t1 as t2,t1 as t3,t1 as t4,t1 as t5, t1 as t6 +select_safe LENIENT 1104 a314fb9f43bb select /*+ SET_VAR(max_join_size = 10) */ +select_safe LENIENT 1104 45913020a393 SELECT * from t1 order by a +select_safe LENIENT 1104 d7728f67202e SELECT * from t1 +select_safe LENIENT 1104 8f4c3acee63a select * from (select * from t1) x +select_safe LENIENT 1104 507c312633f3 select * from (select a.a as aa, b.a as ba from t1 a, t1 b) x +select_safe LENIENT 1104 00d40b507a26 SELECT /*+ SET_VAR(max_join_size = 109) */ +select_safe LENIENT 1104 e9f814c2dfdf SELECT /*+ SET_VAR(max_join_size = 19) */ +select_safe LENIENT 1104 0e4410a87c3f SELECT /*+ SET_VAR(max_join_size = 109) */ COUNT(x) FROM t1 +select_safe LENIENT 1104 ab26ba68ef08 SELECT /*+ SET_VAR(max_join_size = 109) */ COUNT(x) FROM t1 +select_safe LENIENT 1104 68ebe7dd8ea2 SELECT /*+ SET_VAR(max_join_size = 1109) */ COUNT(*) FROM t1, t2 +select_safe LENIENT 1104 e2bd64988843 SELECT COUNT(*) FROM t1 LEFT JOIN t2 +select_safe LENIENT 1104 14a158093c22 SELECT /*+ SET_VAR(max_join_size=109) */ COUNT(*) FROM +select_safe LENIENT 1104 a320c6340c89 SELECT 1 FROM t LIMIT 10 +select_safe LENIENT 1104 08280fbf9c3a SELECT x FROM t ORDER BY x LIMIT 10 +select_safe LENIENT 1104 9957fd4be564 SELECT * FROM t ORDER BY id LIMIT 10 +select_safe LENIENT 1104 a7e5bc4037d7 SELECT /*+ SET_VAR(max_join_size = 79) */ y FROM t ORDER BY y LIMIT 10 +select_safe LENIENT 1104 7d31b37e61bc SELECT SUM(y) FROM t LIMIT 10 +select_safe LENIENT 1104 ce816691856e SELECT SUM(y) FROM t GROUP BY x LIMIT 10 +select_safe LENIENT 1104 998d81865d1b SELECT SUM(x) FROM t GROUP BY y LIMIT 10 +select_safe LENIENT 1104 cf607a4aecb6 SELECT 1 FROM t AS t1, t AS t2 WHERE t1.x = t2.x LIMIT 16 +select_safe LENIENT 1104 9e3f3224627b SELECT 1 FROM t AS t1, t AS t2 WHERE t1.y = t2.y LIMIT 16 +sensitive_variables_permission LENIENT 1238 9798c58ac3cf SELECT @@session.test_component.sensitive_string_1 +sensitive_variables_permission LENIENT 1238 7d93dd6771f4 SELECT @@session.test_component.sensitive_ro_string_1 +sensitive_variables_permission LENIENT 1193 26b926df1519 SELECT @@session.debug_sensitive_session_string INTO @save_debug_sensitive_session_string +sensitive_variables_rewrite LENIENT 1193 26b926df1519 SELECT @@session.debug_sensitive_session_string INTO @save_debug_sensitive_session_string +session_tracker_trx_state LENIENT 1792 b8787742eb7a INSERT INTO t1 VALUES(1) +signal LENIENT 65535 1938582414b8 call test_signal() +signal LENIENT 1231 1938582414b8 call test_signal() +signal LENIENT 9999 1938582414b8 call test_signal() +signal LENIENT 1325 1938582414b8 call test_signal() +signal LENIENT 8888 1938582414b8 call test_signal() +signal LENIENT 1111 1938582414b8 call test_signal() +signal LENIENT 45678 1938582414b8 call test_signal() +signal LENIENT 1234 1938582414b8 call test_signal() +signal LENIENT 1231 53e82b5bab93 call test_signal("Parameter string", NULL) +signal LENIENT 1231 ba27110d9044 call test_signal(NULL, 1234) +signal LENIENT 45678 1e3b29583f42 call test_signal("Parameter string", 45678) +signal LENIENT 12 1938582414b8 call test_signal() +signal LENIENT 1012 03431e0a0bd6 select test_signal_func() +signal LENIENT 1012 f89d802751a6 insert into t1 values (3), (4) +signal LENIENT 1012 2371e43fa60b insert into t1 values (5), (6) +signal LENIENT 1012 28642f573458 call test_resignal() +signal LENIENT 1051 28642f573458 call test_resignal() +signal LENIENT 55555 28642f573458 call test_resignal() +signal RUNTIME 1264 28642f573458 call test_resignal() +signal LENIENT 9999 95650935644d CALL peter_p2() +signal LENIENT 2 01e4699da14b call peter_p3() +signal LENIENT 18 179403f2ed3a call test_signal +signal LENIENT 65 179403f2ed3a call test_signal +signal LENIENT 1231 179403f2ed3a call test_signal +signal LENIENT 3 179403f2ed3a call test_signal +signal LENIENT 1000 179403f2ed3a call test_signal +signal LENIENT 1008 ae2cd589953d CALL p1() +signal STRICT 1054 cd60ebbb256c CALL p2() +signal STRICT 1054 2bcc825f0ad0 CALL p3() +signal STRICT 1054 8bea613ff489 CALL p5() +signal STRICT 1054 96b63b4c3197 CALL p6() +signal LENIENT 1146 ae2cd589953d CALL p1() +signal_demo1 LENIENT 10000 1ea0f973f7ee call po_add_order_line (@my_po, 1, 100, 1) +signal_demo1 LENIENT 10000 5e4bdde72f03 call po_add_order_line (@my_po, 2, 101, 4) +signal_demo1 LENIENT 10000 208ac91d5205 call po_add_order_line (@my_po, 1, 200, 1) +signal_demo1 LENIENT 10000 0beef62d4c48 call po_add_order_line (@my_po, 1, 999, 1) +signal_demo1 LENIENT 10000 5d4f63c43afd call po_add_order_line (99, 1, 100, 1) +signal_demo1 LENIENT 10000 8a1cb5e5793d call po_create_order("M", 7, @my_po) +signal_demo1 LENIENT 50000 8c4b9b1e8b20 call po_create_order("X", 1, @my_po) +signal_demo1 LENIENT 10000 69ab5a2190a2 call po_add_order_line (@my_po, 1, 300, 10) +signal_demo1 LENIENT 10000 e0287eae3f27 call po_add_order_line (@my_po, 2, 301, 20) +signal_demo2 LENIENT 1051 fd470ac366d6 call proc_top_a(2) +signal_demo2 LENIENT 1050 f8439b0af8b9 call proc_top_a(3) +signal_demo2 LENIENT 1050 26c2cb48cbd0 call proc_top_a(1) +signal_demo2 LENIENT 1051 a66ae40fc778 call proc_top_b(2) +signal_demo3 SCHEMA no database selected fa59ffc71dd3 SELECT @@global.max_error_count +signal_demo3 SCHEMA no database selected 378479c5b33e SELECT @@session.max_error_count +signal_sqlmode RUNTIME 1406 4c4fb09e54f4 call p +signal_sqlmode LENIENT 1648 9537a00334f9 call p2 +signal_sqlmode LENIENT 1648 55670a79d079 call p3 +sp STRICT 1146 70edbe46b408 select * from t1, t3 +sp STRICT 1136 98d9c1fc7eff call hndlr1(42) +sp STRICT 1136 ffed74595d30 call hndlr2(42) +sp STRICT 1136 15022c310bc0 call hndlr3(3) +sp STRICT 1324 1d3d4ef6fbae call cur2() +sp SCHEMA problems: syntax error 8ea53b93fef1 call modes(@c1, @c2) +sp SCHEMA problems: syntax error fd831ddf3345 select @c1, @c2 +sp LENIENT 1424 2fb825111f41 select f5(2) +sp LENIENT 1424 7055b09c5c17 select f5(3) +sp LENIENT 1424 1cf9a2f6a741 select f1() +sp LENIENT 1424 c9be3035bd25 select * from v1 +sp LENIENT 1424 ce4b09fd45f2 select * from v2 +sp LENIENT 1100 64ff07da4e87 select * from v0 +sp LENIENT 1100 4b6ab2c66ce8 select *, f0() from v0, (select 123) as d1 +sp LENIENT 1100 582c5e86d6a4 select id, f3() from t1 +sp LENIENT 1100 469d40619e43 select f4() +sp LENIENT 1146 4d55d2abf169 select f10() +sp LENIENT 1137 cb6d1b12dc76 select f11() +sp LENIENT 1137 25cd9f3fa312 select f11() from t1 +sp STRICT 1146 3f0ae4aca767 select * from t3 +sp LENIENT 1146 143140f04a38 call bug4904() +sp LENIENT 1424 ed453b746984 select bug6022(5) +sp STRICT 1136 e56b72323b10 call bug6029() +sp STRICT 1136 a23a5e6dd393 call bug6900() +sp STRICT 1136 e4b38d376467 call bug6900_9074(0) +sp STRICT 1136 9310b8b37222 call bug6900_9074(1) +sp STRICT 1096 c70773b0acfd call bug10961() +sp STRICT 1146 7fd8e9e56632 CALL bug13095('bug13095_t1') +sp LENIENT 1424 b41eab92f626 select bug10100f(3) +sp LENIENT 1424 2b83197c31d5 select bug10100f(6) +sp LENIENT 1424 2f736af0270d select bug10100f(5) +sp LENIENT 1456 e207c9f3bed6 call bug10100t(5) +sp STRICT 1054 c5fd0afb7a8a call bug14643_1() +sp STRICT 1054 cbe2b4991728 call bug14643_2() +sp LENIENT 1054 df00d246b304 call bug14376() +sp LENIENT 1054 c23ec8fa9a27 call bug15441('county') +sp STRICT 1054 407c877052d0 call bug14498_1() +sp STRICT 1054 7fe57e264882 call bug14498_2() +sp STRICT 1054 ca2d975b593a call bug14498_3() +sp STRICT 1054 b0145e721277 call bug14498_4() +sp STRICT 1054 7a0c454f8f24 call bug14498_5() +sp STRICT 1305 7a96b67fdc07 call bug18787() +sp SCHEMA no database selected 9e2cb4e10724 select database() +sp RUNTIME 1365 8905fb1dc28f select func_20028_c() +sp RUNTIME 1365 201031cde26e call proc_20028_c() +sp SCHEMA no database selected bf86ca0f3bde select database(), database () +sp SCHEMA no database selected d47683fccb22 CALL mysqltest1.p1() +sp RUNTIME 1690 e840ab8af5d7 select bug20777(-9223372036854775808) as 'lower bounds signed bigint' +sp RUNTIME 1690 84e1ebc363ab select bug20777(0) as 'lower bounds unsigned bigint' +sp RUNTIME 1690 4b4dd1897f5a select bug20777(-1) as 'lower bounds unsigned bigint - 1' +sp SCHEMA no database selected 91cab21a58a4 insert into test.t1 (id) values (1) +sp SCHEMA no database selected 8a688547b3b2 call test.sp_bug29050() +sp DIFF column type int->int unsigned a932d6b9a59a SELECT DISTINCT t1.barcode, f1(t1.barcode) +sp STRICT 1054 e4b54091151f CALL bug29770() +sp SCHEMA problems: server sql_mode = ?: sql_mode: ? is not a mode of MySQL N.N d325095ea8ca select @@sql_mode into @full_mode +sp SCHEMA problems: server sql_mode = ?: sql_mode: ? is not a mode of MySQL N.N 2dc4dbfaf39b call p() +sp LENIENT 1146 2666bc39ac38 SELECT * FROM t1 WHERE a = f1() +sp LENIENT 1054 e9c063cb85bd SELECT f1(1) +sp LENIENT 1436 0953eea26970 call bug10100p(255, @var) +sp LENIENT 1424 e9c063cb85bd SELECT f1(1) +sp-bugs LENIENT 1360 ba78fe295e7d CALL p1 () +sp-bugs RUNTIME 1264 abbc34877c70 SELECT f1(-25) +sp-bugs RUNTIME 1264 4be85828c3d2 CALL f1(-25) +sp-bugs RUNTIME 1264 33689334dc0b CALL sp2() +sp-bugs RUNTIME 1292 e70dab3d9e63 SELECT fn1(25) +sp-bugs RUNTIME 1264 40d6dac268a9 SELECT fn1(-25) +sp-bugs RUNTIME 1264 3e2f8947f65e SELECT fn2() +sp-bugs RUNTIME 1292 40d6dac268a9 SELECT fn1(-25) +sp-bugs RUNTIME 1292 b8787742eb7a INSERT INTO t1 VALUES(1) +sp-bugs LENIENT 1318 142eec0f69c8 INSERT INTO t1 VALUES (1,10) +sp-bugs RUNTIME 1292 0641f1fe3744 SELECT myfunc('2019-01-01 00:00:71') +sp-bugs STRICT 1054 4318da0a467f CALL p() +sp-dynamic LENIENT 1444 2b71e30aba06 call p1() +sp-dynamic LENIENT 1456 2b71e30aba06 call p1() +sp-dynamic LENIENT 1295 2b71e30aba06 call p1() +sp-dynamic LENIENT 1336 4d6df68263d9 select f1(0) +sp-dynamic LENIENT 1336 73ecf7f52b51 select f1(f1(0)) +sp-dynamic LENIENT 1336 166b75f5b70e select f1(f1(f1(0))) +sp-dynamic LENIENT 1243 2b71e30aba06 call p1() +sp-dynamic LENIENT 1054 2b71e30aba06 call p1() +sp-error LENIENT 1321 aba656fcde2d select f(10) +sp-error LENIENT 1325 2dc4dbfaf39b call p() +sp-error LENIENT 1326 2dc4dbfaf39b call p() +sp-error LENIENT 1327 233b547812a1 select 1 into a +sp-error CODE 1326->1054 066f5812d228 call bug2259() +sp-error LENIENT 1339 1f12b75f2525 select bug3287() +sp-error LENIENT 1339 4ad453117340 call bug3287(2) +sp-error LENIENT 1051 f7efe5e3eea5 call bug3294() +sp-error RUNTIME 1329 a2a3ef010847 call bug7299() +sp-error LENIENT 1415 8f66d79cded5 select bug8408_f() +sp-error LENIENT 1326 9f45b8b21610 call SP001() +sp-error LENIENT 1424 a9e1a880f8e8 select bug11394(2) +sp-error LENIENT 1424 f024419fc8c8 select bug11394_1(2) +sp-error LENIENT 1456 7b051e7d1405 call bug11394(2, 1) +sp-error RUNTIME 1329 aa74f26181b0 call bug9367() +sp-error LENIENT 1415 0afdecd0a3b4 SELECT bug13012_2() +sp-error LENIENT 1356 c9be3035bd25 select * from v1 +sp-error LENIENT 1356 ce4b09fd45f2 select * from v2 +sp-error LENIENT 1356 48bbdf97a2aa select * from v3 +sp-error LENIENT 1146 7560a5f1ce40 insert into v1 values (1) +sp-error LENIENT 1146 810bc153521e insert into v1 values (2) +sp-error CODE 1109->1054 cbc8bb43a503 call bug15091() +sp-error LENIENT 1422 60f251c8bd12 INSERT INTO t1 VALUES (1) +sp-error LENIENT 1422 819d361c1fd8 SELECT bug18914_f1() +sp-error LENIENT 1422 4493654b9320 SELECT bug18914_f2() +sp-error LENIENT 1415 42ef9880000b select func_25345() +sp-error STRICT 1146 1b7a67a51691 call proc_25345() +sp-error SCHEMA no database selected b9c7150ce962 SELECT inexistent(), 1 + , +sp-error LENIENT 1436 005a9d50d197 call p1(1) +sp-error CODE 1059->1305 4fba41624cba SELECT very_long_fn_name_111111111111111111111111111111111111111111111111111111111111111111111111122 +sp-error CODE 1059->1305 6808fa3523e0 CALL very_long_pr_name_11111111111111111111111111111111111111111111111111111111111111111111111112222 +sp-error RUNTIME 1644 96b63b4c3197 CALL p6() +sp-error SCHEMA problems: memory exhausted e37244dc78d0 CALL p10() +sp-error SCHEMA problems: memory exhausted 943d2514b51e CALL p11() +sp-error SCHEMA problems: memory exhausted e93f66f92ec8 CALL p12() +sp-error SCHEMA problems: memory exhausted 40ff3b8e2950 CALL p13() +sp-error SCHEMA no database selected 4bc38ef88999 SELECT DATABASE() +sp-external SCHEMA problems: CREATE FUNCTION foo: No RETURN found in FUNCTION foo 52bc6376ced5 SELECT foo(2) +sp-external SCHEMA problems: CREATE FUNCTION JAVASCRIPT: No RETURN found in FUNCTION JAVA 709bda967868 SELECT JAVASCRIPT(2) +sp-external CODE 6001->1324 469d480e8772 CALL bar(2) +sp-external CODE 6001->1324 db4a32946ade CALL JAVASCRIPT(2) +sp-lock LENIENT 1456 2b71e30aba06 call p1() +sp-lock LENIENT 1422 2b71e30aba06 call p1() +sp-prelocking LENIENT 1146 15d72988e96b insert into bug_27907_t1(a) values (1) +sp-prelocking LENIENT 1100 fdb9a6497787 INSERT INTO t2 values(4) +sp-threads LENIENT 1100 0c3029289915 call bug11158() +sp-ucs2 LENIENT 1305 3fc1e2aa76a6 SELECT f1() +sp-vars SCHEMA problems: statement not applied to the schema: sql_statement ae2cd589953d CALL p1() +sp-vars SCHEMA problems: syntax error 8b35368d951f CALL p1(10) +sp-vars SCHEMA problems: statement not applied to the schema: sql_statement d7294a8521a0 CALL p1(18446744073709551614) +sp-vars SCHEMA problems: statement not applied to the schema: sql_statement f8a53516729b CALL p1('alpha', 'abc') +sp-vars SCHEMA problems: statement not applied to the schema: sql_statement a557aaf06a9c CALL p1(NOW()) +sp-vars SCHEMA problems: syntax error 2b71e30aba06 call p1() +sp-vars SCHEMA problems: syntax error a887f8c3bcbb INSERT table_12976_a VALUES (0) +sp-vars RUNTIME 1264 ae2cd589953d CALL p1() +sp-vars RUNTIME 1264 cd60ebbb256c CALL p2() +sp-vars RUNTIME 1406 2bcc825f0ad0 CALL p3() +sp-vars RUNTIME 1406 3a834edc25b7 CALL p4('aaa') +sp-vars RUNTIME 1406 edf7a9067d30 CALL p5('aa') +sp-vars RUNTIME 1264 12b0ed6df146 CALL p6(10) +sp-vars SCHEMA problems: statement not applied to the schema: sql_statement b4277507d77e CALL p1(1929) +sp-vars SCHEMA problems: statement not applied to the schema: sql_statement fde1102df480 SELECT f1(-2500) +sp-vars SCHEMA problems: syntax error fde1102df480 SELECT f1(-2500) +sp-vars RUNTIME 1264 fde1102df480 SELECT f1(-2500) +sp-vars SCHEMA problems: statement not applied to the schema: sql_statement c403d0efe2ae SELECT f1(8388699) +sp-vars SCHEMA problems: syntax error c403d0efe2ae SELECT f1(8388699) +sp-vars RUNTIME 1264 c403d0efe2ae SELECT f1(8388699) +sp-vars SCHEMA problems: statement not applied to the schema: sql_statement 266b570599fc SELECT f1('_aBcDe_') +sp-vars SCHEMA problems: syntax error a88787013bdc CALL p1('c') +sp-vars SCHEMA problems: syntax error e1e0b25e8820 CALL p1('t') +sp-vars SCHEMA problems: statement not applied to the schema: sql_statement bf288f04ea81 CALL p1(0x41, 0x42) +sp-vars SCHEMA problems: statement not applied to the schema: sql_statement 337049977391 CALL p1((1, 2)) +sp-vars SCHEMA problems: syntax error b48aebf2c77d CALL p1('abcdef') +sp-vars SCHEMA problems: statement not applied to the schema: sql_statement 3fc1e2aa76a6 SELECT f1() +sp-vars SCHEMA problems: syntax error 1dffb4bd5390 CALL bug27415_text_test('a,b,c') +sp-vars SCHEMA problems: statement not applied to the schema: sql_statement c1d034ef2600 insert into t1 (a) values (1), (2) +sp-vars SCHEMA problems: statement not applied to the schema: sql_statement 003de6e8c726 select f1(), @b from t1 +sp-vars SCHEMA problems: statement not applied to the schema: sql_statement 5bc13fffe181 CALL ctest() +sp-vars SCHEMA problems: statement not applied to the schema: sql_statement 8d485a7916ae CALL vctest() +sp-vars SCHEMA problems: statement not applied to the schema: sql_statement d52590c3777c SELECT @@GLOBAL.innodb_ft_aux_table, f() +sp_trans LENIENT 1422 0aa43256e267 select *, bug10015_7() from t1 +sp_trans LENIENT 1305 4ecdfb97d306 insert into t2 values (2) +sp_trans LENIENT 1305 94b44a1af4cc update t2 set i = 2 +sp_validation STRICT 1146 2bcc825f0ad0 CALL p3() +sp_validation LENIENT 1241 3fc1e2aa76a6 SELECT f1() +sp_validation LENIENT 1241 9777483d1789 SELECT f2() +sp_validation LENIENT 1241 47395f84d85f UPDATE t2 SET a = 20 +sp_validation STRICT 1146 a782e71a13d9 SELECT * FROM t1_result_set +ssl_dynamic LENIENT 1193 5fb33d2b441a SELECT @@global.mysqlx_ssl_ca = @orig_mysqlx_ssl_ca, +strict RUNTIME 1292 a50b69bcf9f4 INSERT INTO t1 (col3) VALUES(CAST('0000-10-31 15:30' AS DATETIME)) +strict RUNTIME 1292 23f441ac4438 INSERT INTO t1 (col3) VALUES(CONVERT('0000-10-31 15:30',DATETIME)) +strict RUNTIME 1264 9459755d7792 UPDATE t1 SET col1 = col1 - 50 WHERE col1 < 0 +strict RUNTIME 1264 72d02d62891f UPDATE t1 SET col2=col2 + 50 WHERE col2 > 0 +strict RUNTIME 1365 7453bb70d6ed UPDATE t1 SET col1=col1 / 0 WHERE col1 > 0 +strict RUNTIME 1264 f4e4656d665d UPDATE t1 SET col2 = col2 + 50 WHERE col2 > 0 +strict RUNTIME 1365 67f41798288f UPDATE t1 SET col1 = col1 / 0 WHERE col1 > 0 +strict RUNTIME 1365 24447d735955 UPDATE t1 SET col1= MOD(col1,0) WHERE col1 > 0 +strict RUNTIME 1365 cb1227d44414 UPDATE t1 SET col1 =col1 / 0 WHERE col1 > 0 +strict RUNTIME 1264 8f5a2b9cd559 UPDATE t1 SET col2 =col2 + 50 WHERE col2 > 0 +strict CODE 1366->1265 e9a11dd6664b INSERT INTO t1 VALUES ('-100E') +strict RUNTIME 1264 70433801726e UPDATE t1 SET col1 =col1 * 50000 WHERE col1 =11 +strict CODE 1366->1265 d88f00bac2c7 INSERT INTO t1 (col1) VALUES ('1a') +strict RUNTIME 1264 94b8c0b6b2fb INSERT INTO t1 (col2) VALUES (-1.1E-3) +strict RUNTIME 1264 0c5ae1112743 INSERT INTO t1 (col1) VALUES ('+3.4E+39') +strict RUNTIME 1264 3317eeb4aa6a INSERT INTO t1 (col2) VALUES ('-1.1E-3') +strict RUNTIME 1264 dc99e6e41dff UPDATE t1 SET col1 =col1 * 5000 WHERE col1 > 0 +strict RUNTIME 1365 50358c320648 UPDATE t1 SET col2 =col2 / 0 WHERE col2 > 0 +strict RUNTIME 1365 69e0a7a8f7cb UPDATE t1 SET col2= MOD(col2,0) WHERE col2 > 0 +strict RUNTIME 1265 7b69f4cfdef3 INSERT INTO t1 (col1) VALUES ('') +strict RUNTIME 1265 9566cb122f62 INSERT INTO t1 (col1) VALUES ('a59b') +strict RUNTIME 1265 d88f00bac2c7 INSERT INTO t1 (col1) VALUES ('1a') +strict DIFF column type float unsigned->float 3abd07b0a7e7 SELECT * FROM t1 +strict LENIENT 1367 2fcb00155e47 INSERT INTO t1 (col1) VALUES (+1.7E+309) +strict RUNTIME 1264 d54f4b180e2d INSERT INTO t1 (col1) VALUES ('+1.8E+309') +strict RUNTIME 1264 4fbcd8333cdc INSERT INTO t1 (col2) VALUES ('-1.2E-3') +strict RUNTIME 1690 dc99e6e41dff UPDATE t1 SET col1 =col1 * 5000 WHERE col1 > 0 +strict LENIENT 1367 57dc48de276e INSERT IGNORE INTO t1 VALUES (+1.9E+309,-1.9E+309) +strict DIFF column type double unsigned->double 3abd07b0a7e7 SELECT * FROM t1 +strict RUNTIME 1364 4ddb6c423c41 INSERT INTO t1 VALUES(default(col1),default(col2)) +strict RUNTIME 1406 a0ca69f51784 insert into t1 (charcol) values (repeat('x',256)) +strict RUNTIME 1406 cb2688ae23b0 insert into t1 (varcharcol) values (repeat('x',256)) +strict RUNTIME 1406 a04f70539c24 insert into t1 (binarycol) values (repeat('x',256)) +strict RUNTIME 1406 a0d64b947738 insert into t1 (varbinarycol) values (repeat('x',256)) +strict RUNTIME 1406 5162a4320d94 insert into t1 (tinytextcol) values (repeat('x',256)) +strict RUNTIME 1406 74fe77b0fac3 insert into t1 (tinyblobcol) values (repeat('x',256)) +strict RUNTIME 1292 f1c4aafe1e5f insert into t1 select * from t1 +strict RUNTIME 1364 3eb4d0be2024 INSERT INTO t1 VALUES (DEFAULT) +strict RUNTIME 1364 935d23867fc8 INSERT INTO t1 VALUES (DEFAULT(i)) +strict RUNTIME 1364 5094a5d00f8d INSERT INTO t1 SET j = 1, i = DEFAULT +strict RUNTIME 1364 83e9a7161f65 INSERT INTO t1 SET j = 1, i = DEFAULT(i) +strict RUNTIME 1364 697bbd3e3a5e INSERT INTO t1 VALUES (DEFAULT,1) +strict RUNTIME 1406 df6c1304674d insert into t1 values(b'101') +strict RUNTIME 1292 6c039840d7ff INSERT INTO t1 SELECT * FROM t1 +strict RUNTIME 1365 b7b506a57eb8 INSERT INTO t1 VALUES(1/0) +strict RUNTIME 1365 f72f62263064 UPDATE t1 SET a = (1/0) +strict RUNTIME 1365 ae2cd589953d CALL p1() +strict RUNTIME 1365 cd60ebbb256c CALL p2() +strict RUNTIME 1406 73af8d3f0b6b INSERT INTO t1 VALUES(1000) +strict RUNTIME 1406 720a73b21378 INSERT INTO t1 VALUES(cast(1000 as char(4))) +strict RUNTIME 1264 e64d1aaecf90 INSERT INTO t1 VALUES (18) +subquery_bugs RUNTIME 1242 0364a61b89e4 SELECT 1 FROM a WHERE (SELECT f FROM b WHERE (SELECT 1 FROM c)) <=> e GROUP BY d +subquery_bugs LENIENT 3141 4717ddaad6f9 SELECT DISTINCT * FROM t1 +subquery_bugs LENIENT 3057 10ae90466d48 SELECT STD(IS_FREE_LOCK(0x2ADA5C38)),1 FROM t1 WHERE a+(EXISTS(SELECT 1)) +subquery_bugs RUNTIME 1242 a5cb988fce2a SELECT 1 FROM t1 WHERE +subquery_bugs STRICT 3065 0406ecdc563e CALL p1 +subquery_bugs DIFF column type bigint->int 3a00b9c99762 SELECT +subquery_bugs RUNTIME 1242 8b2fae53f66e SELECT 1 = (SELECT 1 +subquery_bugs STRICT 3029 b47001337db8 SELECT ( SELECT b FROM t2 ORDER BY a, MIN(a) LIMIT 1 ) FROM t1 GROUP BY a +subquery_bugs LENIENT 3037 1f492e8a06e0 SELECT 1 FROM t1 GROUP BY ( +subquery_bugs LENIENT 1235 c0a3fedfb619 SELECT * FROM ( +subquery_hypergraph RUNTIME 1242 a12add7a707b SELECT * FROM t WHERE (SELECT x FROM t) +subquery_scalar_to_derived DIFF nullable false->true 948f4e47a94d SELECT derived_1_0.countrycount AS countrycount, +subquery_scalar_to_derived RUNTIME 1406 ecc5831cd04d INSERT INTO t1 VALUES +subquery_scalar_to_derived STRICT 1056 a41f072579bf SELECT +subquery_scalar_to_derived STRICT 1056 8f942d2f6017 SELECT (SELECT MIN(f1) AS min FROM t1 ) AS field1 FROM t1 GROUP BY +subquery_scalar_to_derived DIFF column type bigint->int bb9e1cbb8837 SELECT COUNT(*), (SELECT 1 FROM t) +subquery_scalar_to_derived_correlated STRICT 1056 f67fbb65b73e SELECT /*+ SET_VAR(optimizer_switch='subquery_to_derived=OFF') */ +subquery_scalar_to_derived_correlated STRICT 1056 45a3b9dad918 SELECT +subquery_table_to_derived LENIENT 1267 0786c04b7fea SELECT col +subselect LENIENT 3037 854cce12c5a5 SELECT * FROM (SELECT ST_EXTERIORRING(1)) AS t1, (SELECT 1) AS t2 +subselect_debug RUNTIME 1242 bcc587010314 SELECT ((SELECT 1 FROM t1) IN (SELECT 1 FROM t1)) - (11111111111111111111) +tablelock LENIENT 1422 3fc1e2aa76a6 SELECT f1() +tablesample LENIENT 3889 caff43402cf2 SELECT * FROM t3 TABLESAMPLE SYSTEM (100) +tablesample LENIENT 6107 09d4783143ee SELECT * FROM t3 TABLESAMPLE SYSTEM (101) +temp_table STRICT 1146 4af7b8be5573 select * from t2 +temporal_literal LENIENT 1525 cde151717647 SELECT TIME'0000-00-00 00:00:00' +temporal_literal LENIENT 1525 05cd7eb86c05 SELECT TIMESTAMP'2001-00-00 00:00:00.9999999' +temporal_literal LENIENT 1525 5f9046242542 SELECT TIMESTAMP'2001-00-01 00:00:00.9999999' +temporal_literal LENIENT 1525 6e47fc83538a SELECT TIMESTAMP'2001-01-00 00:00:00.9999999' +test_mysql_thd_store_service STRICT 1305 7c67cc66902d SELECT test_thd_store_service_function() +time_zone DIFF column type string->datetime 16188fdefd66 SELECT TIMESTAMP( '2003-12-31 23:59:59+02:00', '12:00:00' ) +time_zone DIFF column type decimal->bigint c8860906aabb SELECT UNIX_TIMESTAMP( '2015-11-13 23:59:59+02:00' ) +time_zone2 DIFF column type decimal->bigint fd90f292d696 SELECT UNIX_TIMESTAMP('2003-03-30 01:59:59'), 'Before the gap' AS b +time_zone2 DIFF column type decimal->bigint 74158e31c95f SELECT UNIX_TIMESTAMP('2003-03-30 02:30:00'), 'Inside the gap' AS b +time_zone2 DIFF column type decimal->bigint a942e6513c73 SELECT UNIX_TIMESTAMP('2003-03-30 03:00:00'), 'After the gap' AS b +time_zone2 DIFF column type decimal->bigint bb7fd5650d9b SELECT UNIX_TIMESTAMP("1969-12-31 15:59:59") +time_zone2 DIFF column type decimal->bigint 81a88602c9c6 SELECT UNIX_TIMESTAMP("1969-12-31 16:00:00") +time_zone2 DIFF column type decimal->bigint 27b45dadbed4 SELECT UNIX_TIMESTAMP("1969-12-31 16:00:01") +time_zone2 DIFF column type decimal->bigint 60adde2cc528 SELECT UNIX_TIMESTAMP("1970-01-01 00:00:01") +time_zone2 DIFF column type decimal->bigint 12a10beac06c SELECT UNIX_TIMESTAMP("2022-01-01 16:00:01") +time_zone4 DIFF column type decimal->bigint 773665e6cb92 select unix_timestamp('1969-12-31 14:00:01') +transactional_acl_tables LENIENT 1445 9777483d1789 SELECT f2() +trigger RUNTIME 1048 6599452e796b insert into t1 values (4, "four", 1), (5, "five", 2) +trigger SCHEMA problems: syntax error ec44976332ee select @a +trigger LENIENT 1051 83dcb9ca07a0 insert into table_25411_a values (1) +trigger_before_not_null RUNTIME 1048 0aabdfae5863 INSERT INTO t1 VALUES (1, 2, 3) +trigger_before_not_null RUNTIME 1048 667eca376330 INSERT INTO t1(a, b, c) VALUES (1, 2, 3) +trigger_before_not_null RUNTIME 1048 9a2c50f2ab29 INSERT INTO t1(b, c) VALUES (2, 3) +trigger_before_not_null RUNTIME 1048 9e31954d97ff INSERT INTO t1(a) VALUES (1) +trigger_before_not_null RUNTIME 1048 dbe088207397 REPLACE INTO t1 VALUES (1, 2, 3) +trigger_before_not_null RUNTIME 1048 33c9590f3e2d REPLACE INTO t1(a, b, c) VALUES (1, 2, 3) +trigger_before_not_null RUNTIME 1048 e7fb05636b35 REPLACE INTO t1(b, c) VALUES (2, 3) +trigger_before_not_null RUNTIME 1048 ed87adaa734f REPLACE INTO t1(a) VALUES (1) +trigger_before_not_null RUNTIME 1048 c948db7472f3 INSERT INTO t1 VALUES (1, 2, 3), (10, 20, 30), (100, 200, 300) +trigger_before_not_null RUNTIME 1048 f42598b34630 INSERT INTO t1(a, b, c) VALUES (1, 2, 3), (10, 20, 30), (100, 200, 300) +trigger_before_not_null RUNTIME 1048 17db18f1c3d8 INSERT INTO t1(b, c) VALUES (2, 3), (20, 30), (200, 300) +trigger_before_not_null RUNTIME 1048 2df8520f8dbe INSERT INTO t1(a) VALUES (1), (10), (100) +trigger_before_not_null RUNTIME 1048 1a97021ccd2d REPLACE INTO t1 VALUES (1, 2, 3), (10, 20, 30), (100, 200, 300) +trigger_before_not_null RUNTIME 1048 cfdac9385ca5 REPLACE INTO t1(a, b, c) VALUES (1, 2, 3), (10, 20, 30), (100, 200, 300) +trigger_before_not_null RUNTIME 1048 99cd5129df19 REPLACE INTO t1(b, c) VALUES (2, 3), (20, 30), (200, 300) +trigger_before_not_null RUNTIME 1048 823d2b4fdf5b REPLACE INTO t1(a) VALUES (1), (10), (100) +trigger_before_not_null RUNTIME 1048 0be2988fd3d7 INSERT INTO t1(b, c) SELECT b, c FROM t2 +trigger_before_not_null RUNTIME 1048 e8a32630350e REPLACE INTO t1(b, c) SELECT b, c FROM t2 +trigger_before_not_null RUNTIME 1048 446ddf2ee418 UPDATE t1 SET a = 99 WHERE a = 1 +trigger_before_not_null RUNTIME 1048 cc561cc664eb UPDATE t1 SET a = 99, b = 99 WHERE a = 1 +trigger_before_not_null RUNTIME 1048 0bc2472b42a7 UPDATE t1, t3 SET t1.a = 99, t3.a = -10 WHERE t1.a = t3.a AND t3.a = 10 +trigger_before_not_null LENIENT 13 2f695bca0fbf LOAD DATA INFILE '../../std_data/wl6030.dat' INTO TABLE t1 FIELDS +trigger_before_not_null LENIENT 13 db32c2a5e29d LOAD DATA INFILE '../../std_data/wl6030_2.dat' INTO TABLE t2 FIELDS +trigger_before_not_null CODE 13->1288 060da8e98224 LOAD DATA INFILE '../../std_data/wl6030_2.dat' INTO TABLE v2 FIELDS +trigger_before_not_null LENIENT 1442 2408057819ce INSERT INTO t1 VALUES (NULL) +trigger_before_not_null RUNTIME 1048 743f2362f5c5 INSERT INTO t1 VALUES (2, 2) +trigger_before_not_null RUNTIME 1048 227d5ecdfa3a INSERT INTO t1 VALUES (2, 2),(3,3) +trigger_before_not_null RUNTIME 1048 9162c109cde5 INSERT INTO t1 VALUES ('2012-12-12','2012-12-12 12:12:12','1980-01-01 01:01:01') +trigger_before_not_null RUNTIME 1048 ccc0b9e94c21 INSERT INTO t1 VALUES ('MySQL','MySQL Testing', 1) +trigger_before_not_null RUNTIME 1048 800b497b601d INSERT INTO t1 VALUES ('0x101','0x101', 1) +trigger_before_not_null RUNTIME 1048 a0ed78a993e6 UPDATE t1 SET a1 = 2 +trigger_before_not_null RUNTIME 1048 1a517b445c4b UPDATE t1 SET a1 = 3 +trigger_before_not_null_myisam RUNTIME 1048 a71cc3ba29a9 UPDATE t1, t2 SET t1.a1 = 1 , t2.a1 = 2 +trigger_myisam LENIENT 1054 dbad7954fcc4 insert into t1 values (2, 1) +trigger_myisam LENIENT 1054 d87cd141cf85 update t1 set k = 2 where i = 2 +trigger_myisam LENIENT 1054 cd6d56ed8feb delete from t1 where i = 2 +trigger_myisam LENIENT 13 bf6af884069b load data infile '../../std_data/loaddata5.dat' into table t1 fields terminated by '' enclosed by '' +trigger_myisam LENIENT 1054 7cd527a33807 insert into t1 select 3, 3 +trigger_myisam LENIENT 1054 2165d3b2aa0e update t1, t2 set k = k + 10 where t1.i = t2.i +trigger_myisam LENIENT 1054 ffc977e1eb13 delete t1, t2 from t1 straight_join t2 where t1.i = t2.i +trigger_myisam LENIENT 1054 50df00c6cc90 delete t2, t1 from t2 straight_join t1 where t1.i = t2.i +trigger_myisam LENIENT 1054 b23f2b2067f7 insert into t1 values (3, 4) on duplicate key update k= k + 10 +trigger_myisam LENIENT 1054 94739cc72ba5 replace into t1 values (3, 3) +truncate LENIENT 1100 2d44f19af813 SELECT * FROM v1 +type_binary RUNTIME 1406 8df716ded1e0 insert into t1 values(0x412020, NULL) +type_binary RUNTIME 1406 4829f08730c7 insert into t1 values(NULL, 0x412020) +type_binary DIFF column type double->bigint unsigned bac20e469ecc select 0x9999999999999999999999999999999999999999999 + 0 +type_binary DIFF column type double->bigint unsigned 1ed956f23687 select 0x9999999999999999999999999999999999999999999888888888888888888888888888888888888888888888888 +type_binary DIFF column type double->bigint unsigned c898aefaedb7 SELECT 0x20000000000000+0 +type_binary DIFF column type double->bigint unsigned 832d8aeec3e5 SELECT 0x20000000000000+1 +type_binary DIFF column type double->bigint unsigned 60362e819a68 SELECT 0x20000000000000+2 +type_binary DIFF column type double->bigint unsigned 40d2951e63ce SELECT 0x20000000000000+3 +type_binary DIFF column type double->bigint unsigned b169bcc64a8a SELECT 0xfffffffffffff+2 +type_binary DIFF column type double->bigint unsigned 6b4e49c9de5d SELECT 0xfffffffffffff+1 +type_bit_innodb DIFF column type double->bigint 01f53fe722dd select 0 + b'1' +type_bit_innodb DIFF column type double->bigint c2d000043a4c select 0 + b'0' +type_bit_innodb DIFF column type double->bigint ef516e780534 select 0 + b'000001' +type_bit_innodb DIFF column type double->bigint 8604845d2fa9 select 0 + b'000011' +type_bit_innodb DIFF column type double->bigint b6beb3b54426 select 0 + b'000101' +type_bit_innodb DIFF column type double->bigint e423a56cbb9a select 0 + b'000000' +type_bit_innodb DIFF column type double->bigint 0655f6d2a0e1 select 0 + b'10000000' +type_bit_innodb DIFF column type double->bigint 1af398ed4e6c select 0 + b'11111111' +type_bit_innodb DIFF column type double->bigint 1c70e40bc9c8 select 0 + b'10000001' +type_bit_innodb DIFF column type double->bigint 9f87287324b4 select 0 + b'1000000000000000' +type_bit_innodb DIFF column type double->bigint 0832b67b75f8 select 0 + b'1111111111111111' +type_bit_innodb DIFF column type double->bigint 44cacfc8b6d7 select 0 + b'1000000000000001' +type_bit_myisam DIFF column type double->bigint 01f53fe722dd select 0 + b'1' +type_bit_myisam DIFF column type double->bigint c2d000043a4c select 0 + b'0' +type_bit_myisam DIFF column type double->bigint ef516e780534 select 0 + b'000001' +type_bit_myisam DIFF column type double->bigint 8604845d2fa9 select 0 + b'000011' +type_bit_myisam DIFF column type double->bigint b6beb3b54426 select 0 + b'000101' +type_bit_myisam DIFF column type double->bigint e423a56cbb9a select 0 + b'000000' +type_bit_myisam DIFF column type double->bigint 0655f6d2a0e1 select 0 + b'10000000' +type_bit_myisam DIFF column type double->bigint 1af398ed4e6c select 0 + b'11111111' +type_bit_myisam DIFF column type double->bigint 1c70e40bc9c8 select 0 + b'10000001' +type_bit_myisam DIFF column type double->bigint 9f87287324b4 select 0 + b'1000000000000000' +type_bit_myisam DIFF column type double->bigint 0832b67b75f8 select 0 + b'1111111111111111' +type_bit_myisam DIFF column type double->bigint 44cacfc8b6d7 select 0 + b'1000000000000001' +type_bit_myisam RUNTIME 1406 08231a9c31a8 insert into t1 values (b'0'), (b'1'), (b'000'), (b'100'), (b'001') +type_bit_myisam RUNTIME 1406 6db666ee1216 insert into t1 values (b'00'), (b'01'), (b'10'), (b'100') +type_bit_myisam DIFF column type bigint unsigned->int unsigned ab1f7aa24e51 select a1, a2, b1+0, b2+0 from t1 join t2 on a1 = a2 order by a1 +type_bit_myisam DIFF column type bigint unsigned->int unsigned 77a6e893b24e select sum(a1), b1+0, b2+0 from t1 join t2 on b1 = b2 group by b1 order by 1 +type_bit_myisam DIFF column type bigint unsigned->int unsigned 81e67a81ef80 select b1+0,sum(b1), sum(b2) from t1 join t2 on b1 = b2 group by b1 order by 1 +type_blob LENIENT 1210 d4acb9c118a0 SELECT c1,ST_Astext(c4) FROM tab WHERE c4<@g1 +type_blob LENIENT 1210 12bb33f442a0 SELECT c1,ST_Astext(c4) FROM tab WHERE c4>@g1+3 +type_blob LENIENT 1210 ba041455db75 select min(c2) from tab +type_blob LENIENT 1210 9c5cf6236866 select max(c2) from tab +type_blob LENIENT 1210 34ad6df79447 select @g1 > ST_GeomFromText('POLYGON((30 30,40 40,50 50,30 50,30 40,30 30))') +type_blob LENIENT 1210 d981309eed26 select @g1 >= ST_GeomFromText('POLYGON((30 30,40 40,50 50,30 50,30 40,30 30))') +type_blob LENIENT 1210 b9faf0b8dec4 select @g1 < ST_GeomFromText('POLYGON((30 30,40 40,50 50,30 50,30 40,30 30))') +type_blob LENIENT 1210 d69696fca1e2 select @g1 <= ST_GeomFromText('POLYGON((30 30,40 40,50 50,30 50,30 40,30 30))') +type_date DIFF column type datetime->date 60bd094a62f4 select date_add(date,INTERVAL 1 DAY),date_add(date,INTERVAL 1 SECOND) from t1 +type_date RUNTIME 1441 ff192b999e89 INSERT INTO t1 VALUES +type_date RUNTIME 1292 a1845d652cd3 INSERT INTO t1 VALUE(15.00+adddate('2011-00-00', 1)) +type_datetime RUNTIME 1292 49ecae2e588f UPDATE test.t SET `col_datetime_key` = `col_datetime_key` WHERE +type_decimal DIFF column type decimal unsigned->decimal 055b08222849 select * from t1 +type_float DIFF column type double unsigned->double 055b08222849 select * from t1 +type_float LENIENT 1367 5d965805b0cf INSERT INTO d1 VALUES (1.79769313486232e+308) +type_float RUNTIME 1264 ed5544271f4d INSERT INTO t5u VALUES('1e+52') +type_float RUNTIME 1264 a0a58131d325 INSERT INTO t5s VALUES('-1e+52') +type_float DIFF column type float unsigned->float 9b794a16a9ed SELECT * from t5u +type_newdecimal DIFF column type decimal unsigned->decimal 1e57b24662bf select a from t1 +type_newdecimal DIFF column type bigint->decimal b670f07380ca select floor(-999999999999999999999.999) +type_newdecimal RUNTIME 1264 05cebec9db9a UPDATE Sow6_2f SET col1 = col1 * 50 WHERE col1 = 11 +type_newdecimal RUNTIME 1365 1e7d25224eb8 UPDATE Sow6_2f SET col1 = col1 / 0 WHERE col1 > 0 +type_newdecimal LENIENT 1427 9a3d33be9ec8 select convert(ln(14000),decimal(2,3)) c1 +type_newdecimal LENIENT 1427 d9c1ffee9b38 select cast(ln(14000) as decimal(2,3)) c1 +type_newdecimal DIFF column type decimal unsigned->decimal 055b08222849 select * from t1 +type_newdecimal RUNTIME 1690 56979ce70317 insert into t1 values( +type_newdecimal LENIENT 1426 d898af9f0999 SELECT CAST(1 AS decimal(66,10)) +type_newdecimal LENIENT 1425 33d42bcec451 SELECT CAST(1 AS decimal(65,31)) +type_newdecimal LENIENT 1425 7c9512375129 SELECT a+CAST(1 AS decimal(65,31)) AS aa, SUM(b) FROM t1 GROUP BY aa +type_newdecimal DIFF column type decimal unsigned->decimal 3abd07b0a7e7 SELECT * FROM t1 +type_newdecimal RUNTIME 1690 a400e3397f17 SELECT a - b FROM t1 +type_newdecimal RUNTIME 1690 9983964035f6 SELECT a - b FROM t2 +type_newdecimal RUNTIME 1690 c3391a99c59e SELECT a - b FROM t3 +type_newdecimal RUNTIME 1690 ca47f1f29490 SELECT * FROM t ORDER BY (a-b) +type_newdecimal LENIENT 3146 da82ef831078 SELECT -1 DIV LEAST(1,JSON_INSERT(1, '$[1]','','','')) +type_newdecimal RUNTIME 3854 9e5721866407 WITH RECURSIVE cte1 AS ( +type_ranges DIFF column type smallint->smallint unsigned c98e583a7222 select auto,string,tiny,short,medium,long_int,longlong,real_float,real_double,utiny,ushort,umedium,u +type_set STRICT 1265 62a8d6c492f6 INSERT INTO bar VALUES ("100") +type_temporal_fractional LENIENT 1426 3d1d317ee62a SELECT CURTIME(7) +type_temporal_fractional LENIENT 1426 4645616c2c3e SELECT CURTIME(31) +type_temporal_fractional RUNTIME 1292 a36e0a9f2cd7 INSERT INTO t1 VALUES ('838:59:59.0000009') +type_temporal_fractional RUNTIME 1292 f750f715002a INSERT INTO t1 VALUES ('-838:59:59.0000009') +type_temporal_fractional RUNTIME 1292 84a6a04906ca INSERT INTO t1 VALUES (CAST(0xFFFFFFFF00000000 AS SIGNED)) +type_temporal_fractional RUNTIME 1292 0864244adffa INSERT INTO t1 VALUES (CAST(0xFFFFFFFF00000000 AS UNSIGNED)) +type_temporal_fractional LENIENT 1426 73d28983b06c SELECT CAST(1 AS TIME(7)) +type_temporal_fractional LENIENT 1426 0e004b6a21c1 SELECT CAST(1 AS TIME(31)) +type_temporal_fractional LENIENT 1426 f2d07b4d4828 SELECT SYSDATE(7) +type_temporal_fractional LENIENT 1426 afe2876f7742 SELECT SYSDATE(31) +type_temporal_fractional LENIENT 1426 435dbb708d17 SELECT NOW(7) +type_temporal_fractional LENIENT 1426 d74e6abf3885 SELECT NOW(31) +type_temporal_fractional RUNTIME 1292 60b7fd330a33 INSERT INTO t1 VALUES (NULL, CAST(0xFFFFFFFF00000000 AS SIGNED)) +type_temporal_fractional RUNTIME 1292 24f1ba9e8d0e INSERT INTO t1 VALUES (NULL, CAST(0xFFFFFFFF00000000 AS UNSIGNED)) +type_temporal_fractional DIFF column type bigint->decimal 2c2e47deb9a6 SELECT LEAST(TIMESTAMP'2001-01-01 00:00:00.1', TIMESTAMP'2001-01-01 00:00:00.2') + 1 +type_temporal_fractional LENIENT 1426 cff48813d8f8 SELECT CAST(1 AS DATETIME(7)) +type_temporal_fractional LENIENT 1426 01a5944f8679 SELECT CAST(1 AS DATETIME(31)) +type_temporal_fractional DIFF nullable true->false 6c9acd6e705f SELECT CAST(a AS CHAR) FROM t1 +type_temporal_fractional DIFF nullable true->false ae12a8d91a66 SELECT CAST(a AS CHAR(6)) FROM t1 +type_temporal_fractional DIFF column type string->datetime c10487a4a5df SELECT TIMESTAMP('2011-05-18 16:17:11.2', '11.29') +type_temporal_fractional DIFF nullable true->false a0ae2e39bcbb SELECT CAST(COALESCE(a,a) AS CHAR) FROM t1 +type_temporal_fractional DIFF column type decimal->bigint 4355d4d6608e SELECT UNIX_TIMESTAMP(NULL) +type_temporal_fractional DIFF column type decimal->bigint 809a452a517f SELECT UNIX_TIMESTAMP('0000-01-01 00:00:00') +type_temporal_fractional DIFF column type decimal->bigint cbdafb6a3710 SELECT UNIX_TIMESTAMP('2001-00-01 00:00:00') +type_temporal_fractional DIFF column type decimal->bigint 394b65f0685b SELECT UNIX_TIMESTAMP('2001-01-00 00:00:00') +type_temporal_fractional DIFF column type decimal->bigint 61c31677ccc1 SELECT UNIX_TIMESTAMP('0000-00-00 10:10:20') +type_temporal_fractional DIFF column type bigint->decimal 59431a8d9261 SELECT UNIX_TIMESTAMP(TIMESTAMP('0000-00-00 00:00:00.1')) +type_temporal_fractional LENIENT 3144 9da3064b08d2 SELECT * FROM t1 WHERE +type_time DIFF column type time->datetime 0f119b782f42 SELECT TIMESTAMP(f1,'1') FROM t1 +type_time DIFF nullable true->false 8297621cc801 SELECT CAST(IF(1, TIME'00:00:00',TIME'00:00:00') AS CHAR) +type_time DIFF nullable true->false 1ca1831bc63e SELECT CAST(CASE WHEN 1 THEN TIME'00:00:00' ELSE TIME'00:00:00' END AS CHAR) +type_timestamp DIFF column type double->bigint a4ce02315347 SELECT TIMESTAMP('2000-01-27','23:13:41') - TIMESTAMP('2003-05-16','23:53:29') +type_year RUNTIME 1690 219c347d8fb3 SELECT ((year(QC_DATE) - year(QC_TIMESTAMP))) FROM t +type_year RUNTIME 1690 467c549d1239 SELECT y1-y2 FROM y +type_year DIFF column type year->int unsigned 3f317b9e437c SELECT c0 FROM v1 +udf CODE 1584->1305 9ed0f98b2e7c SELECT myfunc_int(fn(MIN(b) xx)) as c FROM t1 GROUP BY a +udf CODE 1583->1305 a8be87b0b2d8 select myfunc_double(abs(3 AS wrong)) +udf RUNTIME 1366 4de193fd8941 SELECT metaphon('a') +udf LENIENT 1305 d8fb9b46bf81 select f3(NULL) +udf LENIENT 1305 f5dea7304076 select f2(NULL) +udf LENIENT 1305 78604177118b select f1(NULL) +union LENIENT 1234 8ce3da13c84c select * from t1 union select SQL_BUFFER_RESULT * from t2 +union DIFF nullable true->false b78d0bd457b1 SELECT(SELECT 0 AS a UNION SELECT 1 AS a ORDER BY a ASC LIMIT 1) AS dev +union DIFF nullable true->false 208847a65d4f SELECT(SELECT 0 AS a UNION SELECT 1 AS a ORDER BY a DESC LIMIT 1) AS dev +union DIFF nullable true->false 428eb39474c1 SELECT(SELECT 0 AS a FROM dual UNION SELECT 1 AS a FROM dual ORDER BY a ASC LIMIT 1) AS dev +union DIFF nullable true->false 2db65c98b1c9 SELECT(SELECT 0 AS a FROM dual UNION SELECT 1 AS a FROM dual ORDER BY a DESC LIMIT 1) AS dev +union DIFF nullable true->false addf4be22908 SELECT(SELECT 1 AS a LIMIT 1) AS dev +union DIFF nullable true->false 03df8ac45ab2 SELECT(SELECT 1 AS a FROM dual ORDER BY a DESC LIMIT 1) AS dev +union LENIENT 1267 d91df936bde9 SELECT _utf8mb4 0xC3A6 UNION SELECT a FROM t +update LENIENT 1111 b0f0cde6aaf4 update t1 set f1=1 where count(*)=1 +update LENIENT 1111 d6cc1efafa0e delete from t1 where count(*)=1 +update LENIENT 1424 9baab6d9018a UPDATE t1 SET i = 3 WHERE f1() +update LENIENT 1424 20fd608aec7c UPDATE t1 SET i = f1() +update LENIENT 1175 79d05c7a3091 UPDATE IGNORE v1 SET a = 1 +update LENIENT 1356 a52607f91157 UPDATE v1 SET pk = 7 WHERE pk > 0 +update RUNTIME 1292 421451b30fad UPDATE t1 SET a=1 ORDER BY CAST('invalid' AS DATETIME) +update RUNTIME 1406 b90bd1424cd5 UPDATE t1 SET c1 = c2 WHERE i = 0 +user_lock LENIENT 3057 faf54644e6e7 SELECT GET_LOCK(NULL, 0) +user_lock LENIENT 3057 64320e0b2a96 SELECT GET_LOCK("", 0) +user_lock LENIENT 3057 31bbb25c9308 SELECT RELEASE_LOCK(NULL) +user_lock LENIENT 3057 745d6a2caf7c SELECT RELEASE_LOCK("") +user_lock LENIENT 3057 0f3f00c041de SELECT IS_USED_LOCK(NULL) +user_lock LENIENT 3057 593dcb19bac1 SELECT IS_USED_LOCK("") +user_lock LENIENT 3057 9e97b3210d68 SELECT IS_FREE_LOCK(NULL) +user_lock LENIENT 3057 9f3edd0b6afa SELECT IS_FREE_LOCK("") +user_lock LENIENT 4163 1415d17005ee SELECT GET_LOCK(REPEAT('a', 65), 0) +user_lock LENIENT 4163 0eeb6743a5de SELECT IS_USED_LOCK(REPEAT('a', 65)) +user_lock LENIENT 4163 9bfaa0704105 SELECT IS_FREE_LOCK(REPEAT('a', 65)) +user_lock LENIENT 4163 5e8004c1ef2a SELECT RELEASE_LOCK(REPEAT('a', 65)) +user_lock LENIENT 4163 5a8ca50d15a7 SELECT GET_LOCK('12345678901234567890123456789012345678901234567890123456789012345',1) +user_lock LENIENT 3057 5f41528aadef SELECT GET_LOCK('',1) +user_lock LENIENT 3057 1a98b92f4a05 SELECT GET_LOCK(NULL,1) +user_lock LENIENT 3057 5bb2406aac80 SELECT GET_LOCK(@a,1) +validate_password_plugin LENIENT 1193 33ea5579728f SELECT @@validate_password_length, +validate_password_plugin_check_user LENIENT 1193 8d25049d663f SELECT @@global.validate_password_check_user_name +varbinary DIFF column type double->bigint unsigned 3aa901c382ce select 0x41,0x41+0,0x41 | 0x7fffffffffffffff | 0,0xffffffffffffffff | 0 +varbinary DIFF column type double->bigint unsigned df925f1b548a select 0x31+1,concat(0x31)+1,-0xf +varbinary DIFF column type double->bigint unsigned 6d4384b2ab82 select x'31',X'ffff'+0 +varbinary DIFF column type double->bigint unsigned a1ac4188b062 select x'41', 0+x'41' +varbinary DIFF column type double->bigint d6df6bb8b062 select b'', 0+b'' +varbinary DIFF column type double->bigint unsigned 9ed9e27cd210 select x'', 0+x'' +varbinary DIFF column type bigint unsigned->binary 52e3392be8f0 SELECT +varbinary DIFF column type bigint unsigned->binary 79d91092dc9a SELECT +varbinary DIFF column type bigint unsigned->binary 2ab7a5df33b4 SELECT +varbinary DIFF column type bigint unsigned->binary 326c41036b1d SELECT * from v1 +varbinary DIFF column type bigint unsigned->binary 2bb13f31c89c SELECT _binary '12' | _binary '12' +varbinary DIFF column type bigint unsigned->binary 4b8b15506c43 SELECT _binary '12' | 0x0001 +varbinary DIFF column type bigint unsigned->binary ee8e2196d2f4 SELECT binary '12' | binary '12' +varbinary DIFF column type bigint unsigned->binary 51119d3accba SELECT binary '12' | 0x0001 +varbinary DIFF column type bigint unsigned->binary 463f1eb420f1 SELECT CAST('12' AS binary) | 0x0001 +varbinary DIFF column type bigint unsigned->binary 125bb0bacd3b SELECT _binary '12' << 1 +varbinary DIFF column type bigint unsigned->binary 5e17354a00eb SELECT binary '12' << 1 +varbinary DIFF column type bigint unsigned->binary 0e04b90ce515 SELECT CAST('12' AS binary) << 1 +varbinary DIFF column type bigint unsigned->binary 3b54375f7ed0 SELECT (_binary x'31' | x'31') +varbinary DIFF column type bigint unsigned->double 5b9ef774daa5 SELECT (_binary x'31' | x'31') + 0 +varbinary DIFF column type decimal->double febb6e5d5ce4 SELECT 1.0 * (_binary x'312E35' | x'312E35') +varbinary DIFF column type bigint unsigned->double c31ecf472db3 SELECT (_binary x'31' | NULL) + 0 +varbinary DIFF column type decimal->double f489ad6f1115 SELECT +varbinary DIFF column type decimal->double 8773c2fabf91 SELECT +varbinary DIFF column type decimal->double 517bb619ff13 SELECT +varbinary DIFF column type decimal->double 29cb9f240375 SELECT +varbinary DIFF column type decimal->double 9ec03b4ffceb SELECT +varbinary DIFF column type decimal->double f965dd0f1228 SELECT +variables LENIENT 1193 9435362f3716 select @@xxxxxxxxxx +variables LENIENT 1064 8d7d294acf97 SELECT @@global.global.key_buffer_size +variables LENIENT 1064 a2e440f3a0b8 SELECT @@global.session.key_buffer_size +variables LENIENT 1064 83fc7bd29530 SELECT @@global.local.key_buffer_size +variables LENIENT 1238 4427d9383f84 SELECT @@session.myisam_recover_options +variables_debug LENIENT 1193 7ae92f9ec76e select @@debug +variables_debug LENIENT 1193 448b09a42039 SELECT @@session.debug, @@global.debug +variables_debug LENIENT 1193 f100db0cb9da SELECT @@global.debug +variables_debug LENIENT 1193 f99d7cd77af5 SELECT @@debug +version_token LENIENT 1193 f6b5f6e49292 select @@version_tokens_session +view LENIENT 1288 462d774e27ec update v2 set x=x+1 limit 1 +view LENIENT 1288 5b1c1b49f8b7 delete from v2 limit 1 +view SCHEMA dump: SHOW CREATE TABLE tNa`b: Error N (N): You have an error in y c9be3035bd25 select * from v1 +view CODE 1176->1054 af47778aed64 select b from v1 use index (some_index) where b=1 +view SCHEMA dump: SHOW CREATE FUNCTION ?N`: Error N (N): You have an error in c9be3035bd25 select * from v1 +view RUNTIME 1242 9619b9f8e4ba select * from v4 +view CODE 1093->1443 90421ffc5409 update v1,t2 set v1.col1 = (select max(col1) from v1) where v1.col1 = t2.col1 +view CODE 1093->1443 231fac35b6b8 update v2,t2 set v2.col1 = (select max(col1) from v2) where v2.col1 = t2.col1 +view LENIENT 1443 8ae060b39b31 insert into v2 values ((select max(col1) from v1)) +view LENIENT 1093 8eb4aa89b05c insert into v2 values ((select max(col1) from v2)) +view LENIENT 1443 fa52b7bb6d9f insert into v3 (col1) values ((select max(col1) from v1)) +view LENIENT 1443 f4ad6c0c96f1 insert into v3 (col1) values ((select max(col1) from v2)) +view LENIENT 1443 6cab887a803c insert into v3 (col1) values ((select CONVERT_TZ('20050101000000','UTC','MET') from v2)) +view CODE 13->1288 db16fe5dca36 load data infile '../../std_data/loaddata2.dat' into table v1 fields terminated by ',' enclosed by ' +view CODE 13->1288 cea1b4ffd762 load data infile '../../std_data/loaddata2.dat' ignore into table v1 fields terminated by ',' enclos +view CODE 13->1288 8eba43730323 load data infile '../../std_data/loaddata3.dat' into table v1 fields terminated by '' enclosed by '' +view CODE 13->1288 61e553dbf75c load data infile '../../std_data/loaddata3.dat' ignore into table v1 fields terminated by '' enclose +view LENIENT 1288 7607fe612e5c update v2 set a= 10 where a=200 limit 1 +view LENIENT 1288 78614ecaa11c update v1 set f60=2345 +view SCHEMA dump: SHOW CREATE VIEW tN: Error N (HYN): ?.? contains view recurs c9be3035bd25 select * from v1 +view LENIENT 1424 c9be3035bd25 select * from v1 +view DIFF column type bigint->int unsigned 2d44f19af813 SELECT * FROM v1 +view LENIENT 1471 9777483d1789 SELECT f2() +view DIFF column type bigint->int 35ddc7e2e9a9 SELECT * FROM v +view LENIENT 1176 e650e1164e91 SELECT * FROM v1 USE KEY(non_existant) +view LENIENT 1176 d542ba10833b SELECT * FROM v1 FORCE KEY(non_existant) +view LENIENT 1176 0b5c0e0d63cc SELECT * FROM v1 IGNORE KEY(non_existant) +view LENIENT 1176 d5ddd87d0f56 SELECT * FROM v1 USE INDEX (PRIMARY) WHERE c1=2 +view LENIENT 1176 297b733c8d4b SELECT * FROM v1 FORCE INDEX (PRIMARY) WHERE c1=2 +view LENIENT 1176 5131fd68a939 SELECT * FROM v1 IGNORE INDEX (PRIMARY) WHERE c1=2 +view LENIENT 1176 1fbcb6108df3 SELECT * FROM v1 USE INDEX (c2) WHERE c2=2 +view LENIENT 1176 e602425fabed SELECT * FROM v1 FORCE INDEX (c2) WHERE c2=2 +view LENIENT 1176 478fb6cfc941 SELECT * FROM v1 IGNORE INDEX (c2) WHERE c2=2 +view LENIENT 1267 6e0fd12535b3 SELECT * FROM v1 WHERE c1 = 'text1' +view LENIENT 1267 3c999506e0d5 SELECT * FROM v1 WHERE c2 = 'text2' +view SCHEMA dump: SHOW CREATE VIEW vN: Error N (N): Table ? from one of the SE 681594f1015c SELECT * FROM v0 +view_grant SCHEMA dump: SHOW CREATE VIEW vN: Error N (N): Unknown database ? 4a9b814c4204 select @v1def1, @v1def2, @v1def1=@v1def2 +view_grant SCHEMA dump: SHOW CREATE VIEW vN: Error N (N): Unknown database ? 097b84695ed7 INSERT INTO mysqltest1.t1 VALUES (1), (2) +view_grant SCHEMA dump: SHOW CREATE VIEW vN: Error N (N): Unknown database ? 974762f1fc4b SELECT * FROM mysqltest1.v_t1 +view_grant SCHEMA dump: SHOW CREATE VIEW vN: Error N (N): Unknown database ? 1997e5cb5545 INSERT INTO mysqltest1.v_t1 VALUES(4) +view_grant SCHEMA dump: SHOW CREATE VIEW vN: Error N (N): Unknown database ? ff1f7a057b10 SELECT 1 FROM mysqltest1.v_t1 +view_grant SCHEMA dump: SHOW CREATE VIEW vN: Error N (N): Unknown database ? fc1787de5e7b INSERT INTO t1 VALUES (1), (2), (3) +view_grant SCHEMA dump: SHOW CREATE VIEW vN: Error N (N): Unknown database ? 35ddc7e2e9a9 SELECT * FROM v +view_grant SCHEMA no database selected 4e1b462fbf90 SELECT CURRENT_USER() = 'mysqltest_u1@localhost' +view_grant SCHEMA no database selected a6690946cace SELECT * FROM mysqltest_db1.v1, mysqltest_db1.t2 +view_grant SCHEMA no database selected 43763b1cc37b INSERT INTO mysqltest1.t1 VALUES (11), (12), (13), (14) +view_grant SCHEMA no database selected dc276702af88 SELECT * FROM mysqltest1.t1 +view_grant SCHEMA dump: SHOW CREATE VIEW vN: Error N (N): Unknown database ? c9be3035bd25 select * from v1 +view_grant LENIENT 1449 2c694dc433bf LOCK TABLES v1 READ +view_grant LENIENT 1356 180ddce8c027 SELECT cs FROM test1.v1_none +view_grant LENIENT 1356 684c6ba8647c SELECT cs FROM test1.v1_multi +view_grant SCHEMA no database selected c5ca48542825 UPDATE test1.v1_all SET cn='x' +view_myisam CODE 13->1288 8eba43730323 load data infile '../../std_data/loaddata3.dat' into table v1 fields terminated by '' enclosed by '' +view_myisam CODE 13->1288 61e553dbf75c load data infile '../../std_data/loaddata3.dat' ignore into table v1 fields terminated by '' enclose +warnings LENIENT 13 4e7a6b8c321c load data infile '../../std_data/warnings_loaddata.dat' into table t1 fields terminated by ',' +warnings RUNTIME 1406 5be37371a551 insert into t2 values(@c) +warnings RUNTIME 1406 fe25e51d3534 insert into t1 values(@q, NULL, NULL) +warnings RUNTIME 1406 ce3ee4594914 insert into t1 values(NULL, @q, NULL) +warnings RUNTIME 1406 287131cc13bb insert into t1 values(NULL, NULL, @q) +warnings RUNTIME 1406 40ef182e67f7 insert into t2 values(@q) +window_functions LENIENT 1235 ba348099d24b select group_concat('3') over () +window_functions LENIENT 1054 fa437ec69e09 SELECT i, j, SUM(i+j) OVER (ORDER BY jj DESC ROWS UNBOUNDED PRECEDING) foo FROM t +window_functions LENIENT 1054 4e8d18a7f095 SELECT i/SUM(j) OVER (PARTITION BY kk) AS x FROM t +window_functions LENIENT 3592 77427198f28e SELECT i/SUM(j) OVER (PARTITION BY 1) AS x FROM t +window_functions DIFF column type int->bigint 26d1856144cb SELECT k, MIN(i), SUM(j), SUM(k) OVER (ROWS UNBOUNDED PRECEDING) wf FROM t +window_functions DIFF column type int->bigint 2437118e0246 SELECT k, MIN(i), SUM(j), SUM(k) OVER (ROWS UNBOUNDED PRECEDING) wf FROM t +window_functions DIFF column type int->bigint 67428ab5bb3f SELECT k, MIN(i), SUM(j), SUM(k) OVER (ROWS UNBOUNDED PRECEDING) wf FROM t +window_functions STRICT 1056 0370c5fe1a72 SELECT (SELECT SUM(t_b.a) OVER () FROM t_b WHERE t_b.a = t_a.a) aa, b FROM t_a GROUP BY aa, b +window_functions LENIENT 3586 3bc0978a87c2 SELECT t, FIRST_VALUE(t) OVER (ORDER BY t RANGE INTERVAL -1 HOUR PRECEDING) FROM t_time +window_functions LENIENT 3586 0dcfa3dd451f SELECT t, FIRST_VALUE(t) OVER (ORDER BY t RANGE INTERVAL NULL HOUR PRECEDING) FROM t_time +window_functions LENIENT 3586 3f862406e86d SELECT t, FIRST_VALUE(t) OVER (ORDER BY t RANGE BETWEEN INTERVAL -1 HOUR PRECEDING AND INTERVAL 2 HO +window_functions LENIENT 3586 548272380aea SELECT t, FIRST_VALUE(t) OVER (ORDER BY t RANGE BETWEEN INTERVAL 1 HOUR PRECEDING AND INTERVAL -2 HO +window_functions LENIENT 3586 badab61d3ccb SELECT t, FIRST_VALUE(t) OVER (ORDER BY t RANGE BETWEEN INTERVAL 1 HOUR PRECEDING AND INTERVAL '-2:2 +window_functions LENIENT 3586 631e60d73a24 SELECT t, FIRST_VALUE(t) OVER (ORDER BY t RANGE BETWEEN INTERVAL 1 HOUR PRECEDING AND INTERVAL NULL +window_functions LENIENT 3586 3592bb3f3956 SELECT t, FIRST_VALUE(t) OVER (ORDER BY t RANGE BETWEEN INTERVAL NULL HOUR PRECEDING AND INTERVAL 1 +window_functions LENIENT 1054 101a21312a4c SELECT sex, AVG(DISTINCT id), +window_functions LENIENT 1054 05c512018776 SELECT sex, AVG(DISTINCT id), +window_functions LENIENT 1210 d7b731aea57c SELECT NTH_VALUE(id, id) OVER w FROM (SELECT * FROM t1,t2 WHERE t1.id=t2.user_id) t WINDOW w AS (PAR +window_functions LENIENT 1210 ac5699fe2f7e SELECT id, NTILE(@s) OVER w FROM t1 WINDOW w AS (ORDER BY id) +window_functions DIFF column type int->bigint a813611368f0 SELECT FIRST_VALUE(6) OVER () +window_functions DIFF column type int->bigint f5a6de05dc02 SELECT FIRST_VALUE(6) OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) +window_functions DIFF column type int->bigint f761a56861aa SELECT LAST_VALUE(6) OVER () +window_functions DIFF column type int->bigint 36a475727231 SELECT LAST_VALUE(6) OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) +window_functions LENIENT 3587 e5f0a2349b1d SELECT id, FIRST_VALUE(id) OVER w FROM td_str WINDOW w AS (ORDER BY id RANGE 2 PRECEDING) +window_functions LENIENT 3587 108bbd2598dd SELECT id, FIRST_VALUE(id) OVER w FROM td_str WINDOW w AS (ORDER BY id RANGE BETWEEN 2 PRECEDING AND +window_functions LENIENT 3587 eace1197b314 SELECT id, FIRST_VALUE(id) OVER w FROM td_str WINDOW w AS (ORDER BY id RANGE BETWEEN 2 FOLLOWING AND +window_functions LENIENT 3589 0f9e42de7ecd SELECT NTILE(5) OVER w, ROW_NUMBER() OVER w, id, SUM(id) OVER w FROM t1 +window_functions LENIENT 3588 b0ddf0e2de43 SELECT user_id, date, COUNT(*) OVER (ORDER BY date RANGE 1 PRECEDING) FROM t2 +window_functions LENIENT 3587 c07fe35e2cf7 SELECT COUNT(*) OVER (ORDER BY c RANGE 3 PRECEDING) FROM t4 +window_functions LENIENT 3587 730f5f699ef9 SELECT COUNT(*) OVER (ORDER BY i,j RANGE 3 PRECEDING) FROM t4 +window_functions DIFF column type bigint->int 0dc25a6dd3b6 SELECT i, NTH_VALUE(i,3) OVER (ORDER BY i) FROM tj UNION ALL SELECT i, NTH_VALUE(i, 3) OVER (ORDER B +window_functions DIFF column type bigint->int f1e90bd7242f SELECT i, LEAD(i,3) OVER (ORDER BY i) FROM tj UNION ALL SELECT i, LEAD(i, 3) OVER (ORDER BY i) FROM +window_functions DIFF column type bigint->int ef311599b495 SELECT i, FIRST_VALUE(i) OVER (ORDER BY i) FROM tj UNION ALL SELECT i, FIRST_VALUE(i) OVER (ORDER BY +window_functions DIFF column type bigint->int a239d0d7d4f5 SELECT i, LAST_VALUE(i) OVER (ORDER BY i) FROM tj UNION ALL SELECT i, LAST_VALUE(i) OVER (ORDER BY i +window_functions LENIENT 3587 e3c62fffc8c6 SELECT j, JSON_TYPE(j), SUM(j) OVER (ORDER BY j RANGE 3 PRECEDING) FROM tj +window_functions LENIENT 3581 557d59038c2f SELECT t, COUNT(t) OVER w1 FROM t6 +window_functions LENIENT 3583 2b10d01b827f SELECT t, COUNT(t) OVER w1 FROM t6 +window_functions LENIENT 3582 af9e06dfbb75 SELECT t, COUNT(t) OVER w1 FROM t6 +window_functions LENIENT 1054 cb2426a5e40f SELECT 1 AS a, ROW_NUMBER() OVER (ORDER BY a) FROM dual +window_functions LENIENT 3593 36c8f772a48d SELECT * FROM t WHERE 1 = RANK() OVER (ORDER BY a) +window_functions LENIENT 3593 99f9468ed4d5 SELECT * FROM t HAVING 1 = rank() OVER (ORDER BY a) +window_functions CODE 3593->1054 d5b445e77abb SELECT 1 FROM t HAVING 1=(SELECT 1 FROM (SELECT 1) foo) AND COUNT(a) OVER () +window_functions LENIENT 3593 fc58439c0f8a SELECT (SELECT 1 FROM tb WHERE (SELECT 1 FROM tb WHERE FIRST_VALUE(a) OVER())) +window_functions LENIENT 3591 9abfca7cd23d SELECT count(*) OVER w FROM t WINDOW w AS (ORDER BY a), w AS (ORDER BY b) +window_functions LENIENT 3592 8a93354bfbcf SELECT RANK() OVER (ORDER BY 1) FROM t +window_functions LENIENT 3583 a5da106f64c2 SELECT a, b, c, rank() OVER ( w ORDER BY c DESC ) FROM t WINDOW w AS (ORDER BY a) +window_functions LENIENT 1235 22f0b5fcda14 SELECT SUM(DISTINCT b) OVER () FROM t +window_functions LENIENT 1235 1928369c1d30 SELECT SUM(b) OVER (ORDER by a GROUPS 2 PRECEDING) FROM t +window_functions LENIENT 3593 9d5d722dc8de UPDATE t set a= SUM(b) OVER () +window_functions LENIENT 3593 0cbde10809a2 DELETE FROM t WHERE SUM(b) OVER () = 10 +window_functions LENIENT 1235 78a54dff8e2a SELECT SUM(b) OVER (ORDER by a ROWS 2 PRECEDING EXCLUDE CURRENT ROW) FROM t +window_functions LENIENT 1235 f0ee227d1f0a SELECT SUM(b) OVER (ORDER by a ROWS 2 PRECEDING EXCLUDE TIES) FROM t +window_functions LENIENT 1235 36091a7ed290 SELECT SUM(b) OVER (ORDER by a ROWS 2 PRECEDING EXCLUDE GROUP) FROM t +window_functions LENIENT 1235 e2265793c628 SELECT SUM(b) OVER (ORDER by a ROWS 2 PRECEDING EXCLUDE NO OTHERS) FROM t +window_functions LENIENT 3593 dbff7ef602a0 SELECT a, b, FIRST_VALUE(SUM(a+b) OVER()) OVER () AS sum FROM t +window_functions LENIENT 3593 429683574085 SELECT a, b, FIRST_VALUE(1+SUM(a+b) OVER()) OVER () AS sum FROM t +window_functions LENIENT 3593 3909c239e3cf SELECT a, b, SUM(1+SUM(a+b) OVER()) OVER () AS sum FROM t +window_functions LENIENT 3595 04ad30f9f475 SELECT a, b, FIRST_VALUE(a) OVER (PARTITION BY ROW_NUMBER() OVER ()) AS sum FROM t +window_functions LENIENT 3595 3ef887bac5a6 SELECT a, b, FIRST_VALUE(a) OVER (PARTITION BY 1+ROW_NUMBER() OVER ()) AS sum FROM t +window_functions LENIENT 3595 c8732730b6b8 SELECT a, b, FIRST_VALUE(a) OVER (ORDER BY ROW_NUMBER() OVER ()) AS sum FROM t +window_functions LENIENT 3593 6ee3c6dd3a3f SELECT t, FIRST_VALUE(t) OVER (ORDER BY t RANGE INTERVAL ROW_NUMBER() OVER () HOUR PRECEDING) FROM t +window_functions LENIENT 3590 55c8924dedbf SELECT t, FIRST_VALUE(t) OVER (ORDER BY t RANGE INTERVAL ABS(t) HOUR PRECEDING) FROM t_time +window_functions LENIENT 3590 5c91083e9412 SELECT t, FIRST_VALUE(t) OVER (ORDER BY t RANGE BETWEEN INTERVAL 3 HOUR PRECEDING AND INTERVAL ABS(t +window_functions RUNTIME 1242 0b8effda29c0 SELECT (SELECT COUNT(a) OVER (PARTITION BY a) FROM t1) FROM t1 +window_functions LENIENT 1235 8e1ef49322e9 SELECT FIRST_VALUE(i) IGNORE NULLS OVER () FROM t1 +window_functions DIFF column type int->bigint 67aedf3cc3f5 SELECT LAST_VALUE(1) OVER (PARTITION BY t1.j), t2.pk FROM t1 LEFT JOIN +window_functions LENIENT 3594 749c71440df9 SELECT ROW_NUMBER() OVER () AS num FROM t HAVING (num = '2') +window_functions LENIENT 3593 a1dfbac85cfe SELECT ROW_NUMBER() OVER () FROM t HAVING ( ROW_NUMBER() OVER () = '2') +window_functions DIFF column type bigint->int 474c8c02eddc SELECT (SELECT FIRST_VALUE(j) OVER (ORDER BY upper.j) FROM t1 LIMIT 1) +window_functions DIFF column type bigint->int 45ec1343c8bf SELECT (SELECT FIRST_VALUE(j) OVER (ORDER BY upper.j DESC) FROM t1 LIMIT 1) +window_functions DIFF column type bigint->int 620268b418d0 SELECT (SELECT FIRST_VALUE(upper.j) OVER (ORDER BY upper.j DESC) +window_functions DIFF column type bigint->int d334a46ac1d0 SELECT (SELECT LAST_VALUE(j) OVER (ORDER BY upper.j) FROM t1 LIMIT 1) +window_functions DIFF column type bigint->int a2ab3fb54977 SELECT (SELECT LAST_VALUE(j) OVER (ORDER BY upper.j DESC) FROM t1 LIMIT 1) +window_functions LENIENT 3593 a99b78830646 SELECT AVG(SUM(a) OVER ()) FROM t +window_functions LENIENT 3580 f728573f07e2 SELECT ROW_NUMBER() OVER w +window_functions LENIENT 3580 aa72ae3957b6 SELECT ROW_NUMBER() OVER w +window_functions LENIENT 3580 6ab8dfa59793 SELECT ROW_NUMBER() OVER w FROM t1 WINDOW w AS (w) +window_functions LENIENT 3580 1335916d4397 SELECT ROW_NUMBER() OVER w +window_functions LENIENT 3579 3fd3bcd66d3a SELECT ROW_NUMBER() OVER w FROM t1 WINDOW w1 AS () +window_functions LENIENT 3579 c65a731805da SELECT SUM(a) OVER w2, ROW_NUMBER() OVER () FROM t1 WINDOW w AS () +window_functions LENIENT 3579 1b50c80f2534 SELECT SUM(a) OVER w1, ROW_NUMBER() OVER w2 FROM t1 +window_functions LENIENT 3584 c115bccf94c0 SELECT ROW_NUMBER() OVER (ROWS BETWEEN UNBOUNDED FOLLOWING AND CURRENT ROW) +window_functions LENIENT 3585 ed966dee5af3 SELECT ROW_NUMBER() OVER (ROWS BETWEEN CURRENT ROW AND UNBOUNDED PRECEDING) +window_functions LENIENT 3585 a47e5595dd47 SELECT ROW_NUMBER() OVER (ORDER BY a RANGE BETWEEN CURRENT ROW AND UNBOUNDED PRECEDING) +window_functions LENIENT 3596 48f4dfaf3ba3 SELECT ROW_NUMBER() OVER (ROWS BETWEEN INTERVAL 2 DAY PRECEDING AND UNBOUNDED FOLLOWING) +window_functions LENIENT 3596 4356d7e1f592 SELECT ROW_NUMBER() OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND INTERVAL 2 DAY FOLLOWING) +window_functions LENIENT 3593 33889c9eb451 SELECT MAX(row_number() OVER ()) FROM t +window_functions CODE 3593->1140 1d14b4c945e7 SELECT MIN(rank() OVER (ORDER BY a)) FROM t +window_functions CODE 3593->1140 6e76922c1dae SELECT BIT_AND(rank() OVER (ORDER BY a)) FROM t +window_functions LENIENT 3593 d23890d14293 SELECT MIN(a),SUM(rank() OVER (ORDER BY a)) FROM t GROUP BY a +window_functions LENIENT 3590 d95b0d3357c8 SELECT RANK() OVER (ORDER BY a RANGE INTERVAL (SELECT a FROM t2) MINUTE PRECEDING) FROM t1 +window_functions LENIENT 3590 c96158c5933e SELECT RANK() OVER (ORDER BY a RANGE BETWEEN INTERVAL 1 MINUTE PRECEDING AND INTERVAL (SELECT a FROM +window_functions LENIENT 3590 9b087aaf5f43 SELECT RANK() OVER (ORDER BY a RANGE INTERVAL 1+(SELECT a FROM t2) MINUTE PRECEDING) FROM t1 +window_functions DIFF column type int->bigint 19e291618eec SELECT i, i+1, SUM(i) OVER () FROM t1 GROUP BY i WITH ROLLUP +window_functions LENIENT 3587 a6506d3861a8 SELECT a, b, c, SUM(a) OVER +window_functions LENIENT 3587 f23e29b17f5c SELECT a, b, c, SUM(a) OVER +window_functions LENIENT 3588 d8637419d7bd SELECT a, b, c, SUM(a) OVER +window_functions DIFF column type bigint->int 638505fad6b6 SELECT ISNULL(COS(RANK() OVER())) FROM t +window_functions DIFF column type int->bigint c901d7d7cfe8 SELECT * FROM (SELECT MAX( alias1.col_int) AS field1 FROM view_E AS alias1 +window_functions DIFF column type int->bigint bcea1c9cecb0 SELECT * FROM (SELECT MAX( alias1.col_int) AS field1 FROM view_E AS alias1 +window_functions LENIENT 3586 108ad441ba38 SELECT w, SUM(w) OVER (ROWS 3.14 PRECEDING) FROM t +window_functions LENIENT 3586 033bdbaa241a SELECT w, SUM(w) OVER (ROWS BETWEEN 3 PRECEDING AND 3.4 FOLLOWING) FROM t +window_functions LENIENT 3586 9e74ccc18369 SELECT w, SUM(w) OVER (ROWS BETWEEN CURRENT ROW AND 3.4 FOLLOWING) FROM t +window_functions DIFF column type bigint->int b16e7874983a SELECT LEAD(1,1,1) OVER(PARTITION BY a) FROM t +window_functions DIFF column type bigint->int d6dced1ffa4a SELECT MONTH(date), SUM(sale), +window_functions LENIENT 3037 dd6b69adcee8 SELECT ST_BUFFER(ST_ASBINARY(1), ST_ASBINARY(NTH_VALUE('4714-05-04',1)OVER(),'axis-order=long-lat')) +window_functions LENIENT 3146 ab7c935f63d2 SELECT ((FIRST_VALUE(JSON_MERGE_PATCH(1.755913e+308,'{ }'))OVER())<=(1)) +window_functions LENIENT 3146 0124fada5988 SELECT ((QUOTE(JSON_KEYS(FIRST_VALUE(JSON_KEYS(EXP(-15676),ABS('d0')) +window_functions LENIENT 1140 9b0a327241d5 SELECT AVG(i) FROM t1 ORDER BY RANK() OVER (PARTITION BY AVG(i) ORDER BY i) +window_functions LENIENT 3146 4e4301907e46 SELECT IFNULL(JSON_TYPE(CASE WHEN(1) THEN(1) ELSE(1) END), +window_functions DIFF column type bigint->int 796ba8e68343 SELECT PERCENT_RANK() OVER(), +window_functions DIFF column type bigint->int b02bdb03bc76 SELECT PERCENT_RANK() OVER(), +window_functions_bugs DIFF column type int->bigint f86d557dc932 SELECT a, PERCENT_RANK() OVER w1 FROM t GROUP BY b,1 WITH ROLLUP WINDOW w1 AS() +window_functions_bugs DIFF column type int->bigint dccfa60b121e SELECT a, PERCENT_RANK() OVER w1 FROM v GROUP BY b,1 WITH ROLLUP WINDOW w1 AS() +window_functions_bugs RUNTIME 1690 15f8239dce83 SELECT ((EXP(0x6108))DIV(LAG(NULL,21,' 4F')OVER())) +window_functions_bugs RUNTIME 1690 f389d4981be5 SELECT ((FIRST_VALUE(EXP(0x41a129))OVER())DIV(-969)) +window_functions_bugs DIFF nullable false->true 2b01efcf19c8 SELECT EXISTS +window_functions_bugs RUNTIME 1690 a34229f3c3c6 SELECT b, CUME_DIST() OVER ( +window_functions_bugs DIFF column type bigint->int 2977eea0482c SELECT a, COUNT(a) OVER w, LAG(1,13) RESPECT NULLS OVER w +window_functions_bugs DIFF column type int->bigint 2a524fc466ac WITH der AS (SELECT CASE WHEN i IN ('0') THEN i END AS a FROM t1), +window_functions_bugs LENIENT 1054 e3388fdc8ca2 SELECT i AS b, ROW_NUMBER() OVER (ORDER BY b) FROM t1 +window_functions_bugs LENIENT 1054 dcd3adb9a985 SELECT i+1 AS b, ROW_NUMBER() OVER (ORDER BY b) FROM t1 +window_functions_bugs LENIENT 3141 e1fb5f6a08cf SELECT JSON_ARRAY(FIRST_VALUE(CAST('' AS JSON)) OVER (), '') +window_functions_bugs LENIENT 3141 61b2c4b35b4d SELECT JSON_ARRAY(NTH_VALUE(CAST('' AS JSON), 1) OVER (), '') +window_functions_bugs LENIENT 3141 299429a58bcc SELECT JSON_ARRAY(LEAD(CAST('true' AS JSON), 2, CAST('' AS JSON)) OVER (), '') +window_functions_bugs LENIENT 3141 f610bb99955d SELECT JSON_ARRAY(JSON_ARRAYAGG(CAST('' AS JSON)) OVER (), '') +window_functions_bugs DIFF nullable true->false b300708812c6 SELECT +window_functions_bugs DIFF column type bigint->int 1118f232f41b SELECT DISTINCT +window_functions_bugs DIFF column type int->bigint 8b5f344469a3 SELECT f1, f2, DENSE_RANK() OVER (ORDER BY f1), RANK() OVER (ORDER BY f1) +window_jsonaggs DIFF column type int->bigint f9e059b675d8 SELECT i, f, JSON_ARRAYAGG(i) OVER w jarray_i, JSON_ARRAYAGG(f) +window_jsonaggs LENIENT 3158 6be4e496b416 SELECT i, JSON_OBJECTAGG(i,f) OVER w jobject_f FROM t1 GROUP BY i,f +window_min_max DIFF column type bigint->int 44c20d073cb3 SELECT sex, NTILE(2) OVER w , MIN(ASCII(sex)) OVER w min, +window_min_max DIFF column type int->bigint 7b7cdf87ecd7 SELECT k, MIN(i), SUM(j), MIN(k) OVER (ROWS UNBOUNDED PRECEDING) min_wf FROM t +window_min_max DIFF column type int->bigint ce7be1afc5c6 SELECT k, MIN(i), SUM(j), MAX(k) OVER (ROWS UNBOUNDED PRECEDING) max_wf FROM t +window_min_max DIFF column type int->bigint 2f9752ab49d6 SELECT k, MIN(i), SUM(j), MIN(k) OVER (ORDER BY k DESC ROWS UNBOUNDED PRECEDING) min_wf FROM t +window_min_max DIFF column type int->bigint 7ea8dc81948f SELECT k, MIN(i), SUM(j), MIN(k) OVER (ROWS UNBOUNDED PRECEDING) min_wf FROM t +window_min_max LENIENT 3589 fc68f5dbe693 SELECT NTILE(5) OVER w, ROW_NUMBER() OVER w, id, MIN(id) OVER w FROM t1 +window_min_max LENIENT 3587 c7c00642717a SELECT j, JSON_TYPE(j), JSON_TYPE(MIN(j) OVER (ORDER BY j RANGE 3 PRECEDING)) FROM tj +window_min_max DIFF column type bigint->int 2794b67edc05 SELECT i, MIN(i) OVER (ORDER BY i) FROM tj UNION ALL SELECT i, MIN(i) OVER (ORDER BY i) FROM tj +windows LENIENT 1126 97ae6d0cd549 call proc_1() +with_non_recursive_bugs RUNTIME 1292 87ba43ca6159 WITH cte AS ( +with_non_recursive_bugs CODE 1054->1093 93272d9c4dad WITH cte AS ( +with_non_recursive_bugs LENIENT 1054 8d063557f88c SELECT COUNT(sq1_t1.a) AS sq1_field1 +with_non_recursive_bugs LENIENT 1054 1fb88837a32d SELECT (WITH cte1 AS +with_recursive_solver STRICT 1146 a95ba5730a3a call solver("[[2,3,1], [5,-3,10], [6,0,12]]", "[7,21,28]") +with_recursive_solver STRICT 1146 f9bd068029a5 call solver("[[1,2,1,3], [1,0,1,1], [0,1,0,1], [1,3,1,4]]", "[1,3,-1,4]") +with_recursive_solver STRICT 1146 02d7b5dddd26 call solver("[[1,2,1,3], [1,0,1,1], [0,1,0,1], [1,3,1,3]]", "[1,3,-1,4]") +xa_debug LENIENT 1399 3abd07b0a7e7 SELECT * FROM t1 +xa_debug LENIENT 1399 60f251c8bd12 INSERT INTO t1 VALUES (1) +xa_default LENIENT 1399 9875053f6da0 INSERT INTO t1 VALUES (40) +xa_default LENIENT 1399 c56d9b114068 INSERT INTO t1 VALUES (42) +xa_detach_on_prepare_off LENIENT 1399 f25aa4712800 insert t1 values (40) +xml RUNTIME 1105 f7ad2b4ea27d SELECT extractValue(@xml, 'string-length()') +xml RUNTIME 1105 e36ff97be2c0 select extractvalue('A','/') +xml RUNTIME 1105 38dd7651891a select extractvalue('bb!','//b!') +xml RUNTIME 1105 21df1b7df59b select extractValue('1','position()') +xml RUNTIME 1105 4f30b4b971f5 select extractValue('1','last()') +xml RUNTIME 1105 78d95e5ffb9b select extractValue('1','/e/') +xml RUNTIME 1105 b085d5ae016c select extractValue('<01>10:39:15<02>140','/zot/tim0/02') +xml RUNTIME 1105 599b84925011 select UpdateXML('a',repeat('a b ',1000),'') +xml RUNTIME 1105 8d82469f118b select ExtractValue('a', '/a[@x=@y0123456789_0123456789_0123456789_0123456789]') +xml DIFF column type string->binary c20f1fb34d42 select updatexml(NULL, NULL, NULL) +xml RUNTIME 1105 c39782d78f98 select extractvalue('','$@x/a') +xml RUNTIME 1105 0b6f68359c19 select extractvalue('','round(123.4)/a') +xml RUNTIME 1105 e45bf7e609ad select extractvalue('','1/a') +xml RUNTIME 1105 a983b44248e1 select extractvalue('','"b"/a') +xml RUNTIME 1105 1ccbdd31dcd3 select extractvalue('','(1)/a') +xml LENIENT 1367 10d527c03afd SELECT UPDATEXML(NULL, (LPAD(0.1111E-15, '2011', 1)), 1) +xml LENIENT 1367 84146081cd8a SELECT EXTRACTVALUE('', LPAD(0.1111E-15, '2011', 1)) diff --git a/check/mysql/internal/analyze/testdata/probe.golden b/check/mysql/internal/analyze/testdata/probe.golden new file mode 100644 index 0000000..596cc95 --- /dev/null +++ b/check/mysql/internal/analyze/testdata/probe.golden @@ -0,0 +1,5268 @@ +# server 8.4.11: 5267 statements; agree 5033, untyped 0, disagree 0, server rejects 234, analyzer rejects 0 += SELECT ABS(b) FROM t double += SELECT ABS(d) FROM t decimal += SELECT ABS(da) FROM t double += SELECT ABS(dt) FROM t double += SELECT ABS(f) FROM t double += SELECT ABS(i) FROM t bigint += SELECT ABS(j) FROM t double null += SELECT ABS(n) FROM t bigint null += SELECT ABS(s) FROM t double += SELECT ABS(tm) FROM t double += SELECT ABS(u) FROM t bigint unsigned += SELECT ACOS(b) FROM t double null += SELECT ACOS(d) FROM t double null += SELECT ACOS(da) FROM t double null += SELECT ACOS(dt) FROM t double null += SELECT ACOS(f) FROM t double null += SELECT ACOS(i) FROM t double null += SELECT ACOS(j) FROM t double null += SELECT ACOS(n) FROM t double null += SELECT ACOS(s) FROM t double null += SELECT ACOS(tm) FROM t double null += SELECT ACOS(u) FROM t double null += SELECT ADDTIME(b, b) FROM t string null += SELECT ADDTIME(d, d) FROM t string null += SELECT ADDTIME(da, da) FROM t datetime null += SELECT ADDTIME(dt, dt) FROM t datetime null += SELECT ADDTIME(dt, i) FROM t datetime null += SELECT ADDTIME(f, f) FROM t string null += SELECT ADDTIME(i, i) FROM t string null += SELECT ADDTIME(i, s) FROM t string null += SELECT ADDTIME(j, j) FROM t string null += SELECT ADDTIME(n, n) FROM t string null += SELECT ADDTIME(s, i) FROM t string null += SELECT ADDTIME(s, s) FROM t string null += SELECT ADDTIME(tm, tm) FROM t time null += SELECT ADDTIME(u, u) FROM t string null += SELECT AES_DECRYPT(b, b) FROM t binary null += SELECT AES_DECRYPT(b, b, b) FROM t binary null += SELECT AES_DECRYPT(b, b, b, b) FROM t binary null += SELECT AES_DECRYPT(d, d) FROM t binary null += SELECT AES_DECRYPT(d, d, d) FROM t binary null += SELECT AES_DECRYPT(d, d, d, d) FROM t binary null += SELECT AES_DECRYPT(da, da) FROM t binary null += SELECT AES_DECRYPT(da, da, da) FROM t binary null += SELECT AES_DECRYPT(da, da, da, da) FROM t binary null += SELECT AES_DECRYPT(dt, dt) FROM t binary null += SELECT AES_DECRYPT(dt, dt, dt) FROM t binary null += SELECT AES_DECRYPT(dt, dt, dt, dt) FROM t binary null += SELECT AES_DECRYPT(dt, i) FROM t binary null += SELECT AES_DECRYPT(dt, i, i) FROM t binary null += SELECT AES_DECRYPT(dt, i, i, i) FROM t binary null += SELECT AES_DECRYPT(f, f) FROM t binary null += SELECT AES_DECRYPT(f, f, f) FROM t binary null += SELECT AES_DECRYPT(f, f, f, f) FROM t binary null += SELECT AES_DECRYPT(i, i) FROM t binary null += SELECT AES_DECRYPT(i, i, i) FROM t binary null += SELECT AES_DECRYPT(i, i, i, i) FROM t binary null += SELECT AES_DECRYPT(i, s) FROM t binary null += SELECT AES_DECRYPT(i, s, s) FROM t binary null += SELECT AES_DECRYPT(i, s, s, s) FROM t binary null += SELECT AES_DECRYPT(j, j) FROM t binary null += SELECT AES_DECRYPT(j, j, j) FROM t binary null += SELECT AES_DECRYPT(j, j, j, j) FROM t binary null += SELECT AES_DECRYPT(n, n) FROM t binary null += SELECT AES_DECRYPT(n, n, n) FROM t binary null += SELECT AES_DECRYPT(n, n, n, n) FROM t binary null += SELECT AES_DECRYPT(s, i) FROM t binary null += SELECT AES_DECRYPT(s, i, i) FROM t binary null += SELECT AES_DECRYPT(s, i, i, i) FROM t binary null += SELECT AES_DECRYPT(s, s) FROM t binary null += SELECT AES_DECRYPT(s, s, NULL) FROM t binary null += SELECT AES_DECRYPT(s, s, NULL, NULL) FROM t binary null += SELECT AES_DECRYPT(s, s, s) FROM t binary null += SELECT AES_DECRYPT(s, s, s, s) FROM t binary null += SELECT AES_DECRYPT(tm, tm) FROM t binary null += SELECT AES_DECRYPT(tm, tm, tm) FROM t binary null += SELECT AES_DECRYPT(tm, tm, tm, tm) FROM t binary null += SELECT AES_DECRYPT(u, u) FROM t binary null += SELECT AES_DECRYPT(u, u, u) FROM t binary null += SELECT AES_DECRYPT(u, u, u, u) FROM t binary null += SELECT AES_ENCRYPT(b, b) FROM t binary null += SELECT AES_ENCRYPT(b, b, b) FROM t binary null += SELECT AES_ENCRYPT(b, b, b, b) FROM t binary null += SELECT AES_ENCRYPT(d, d) FROM t binary null += SELECT AES_ENCRYPT(d, d, d) FROM t binary null += SELECT AES_ENCRYPT(d, d, d, d) FROM t binary null += SELECT AES_ENCRYPT(da, da) FROM t binary null += SELECT AES_ENCRYPT(da, da, da) FROM t binary null += SELECT AES_ENCRYPT(da, da, da, da) FROM t binary null += SELECT AES_ENCRYPT(dt, dt) FROM t binary null += SELECT AES_ENCRYPT(dt, dt, dt) FROM t binary null += SELECT AES_ENCRYPT(dt, dt, dt, dt) FROM t binary null += SELECT AES_ENCRYPT(dt, i) FROM t binary null += SELECT AES_ENCRYPT(dt, i, i) FROM t binary null += SELECT AES_ENCRYPT(dt, i, i, i) FROM t binary null += SELECT AES_ENCRYPT(f, f) FROM t binary null += SELECT AES_ENCRYPT(f, f, f) FROM t binary null += SELECT AES_ENCRYPT(f, f, f, f) FROM t binary null += SELECT AES_ENCRYPT(i, i) FROM t binary null += SELECT AES_ENCRYPT(i, i, i) FROM t binary null += SELECT AES_ENCRYPT(i, i, i, i) FROM t binary null += SELECT AES_ENCRYPT(i, s) FROM t binary null += SELECT AES_ENCRYPT(i, s, s) FROM t binary null += SELECT AES_ENCRYPT(i, s, s, s) FROM t binary null += SELECT AES_ENCRYPT(j, j) FROM t binary null += SELECT AES_ENCRYPT(j, j, j) FROM t binary null += SELECT AES_ENCRYPT(j, j, j, j) FROM t binary null += SELECT AES_ENCRYPT(n, n) FROM t binary null += SELECT AES_ENCRYPT(n, n, n) FROM t binary null += SELECT AES_ENCRYPT(n, n, n, n) FROM t binary null += SELECT AES_ENCRYPT(s, i) FROM t binary null += SELECT AES_ENCRYPT(s, i, i) FROM t binary null += SELECT AES_ENCRYPT(s, i, i, i) FROM t binary null += SELECT AES_ENCRYPT(s, s) FROM t binary null += SELECT AES_ENCRYPT(s, s, NULL) FROM t binary null += SELECT AES_ENCRYPT(s, s, NULL, NULL) FROM t binary null += SELECT AES_ENCRYPT(s, s, s) FROM t binary null += SELECT AES_ENCRYPT(s, s, s, s) FROM t binary null += SELECT AES_ENCRYPT(tm, tm) FROM t binary null += SELECT AES_ENCRYPT(tm, tm, tm) FROM t binary null += SELECT AES_ENCRYPT(tm, tm, tm, tm) FROM t binary null += SELECT AES_ENCRYPT(u, u) FROM t binary null += SELECT AES_ENCRYPT(u, u, u) FROM t binary null += SELECT AES_ENCRYPT(u, u, u, u) FROM t binary null += SELECT ANY_VALUE(b) FROM t binary += SELECT ANY_VALUE(d) FROM t decimal += SELECT ANY_VALUE(da) FROM t date += SELECT ANY_VALUE(dt) FROM t datetime += SELECT ANY_VALUE(f) FROM t double += SELECT ANY_VALUE(i) FROM t int += SELECT ANY_VALUE(j) FROM t json null += SELECT ANY_VALUE(n) FROM t int null += SELECT ANY_VALUE(s) FROM t string += SELECT ANY_VALUE(tm) FROM t time += SELECT ANY_VALUE(u) FROM t bigint unsigned += SELECT ASIN(b) FROM t double null += SELECT ASIN(d) FROM t double null += SELECT ASIN(da) FROM t double null += SELECT ASIN(dt) FROM t double null += SELECT ASIN(f) FROM t double null += SELECT ASIN(i) FROM t double null += SELECT ASIN(j) FROM t double null += SELECT ASIN(n) FROM t double null += SELECT ASIN(s) FROM t double null += SELECT ASIN(tm) FROM t double null += SELECT ASIN(u) FROM t double null += SELECT ATAN(b) FROM t double null += SELECT ATAN(b, b) FROM t double null += SELECT ATAN(d) FROM t double null += SELECT ATAN(d, d) FROM t double null += SELECT ATAN(da) FROM t double null += SELECT ATAN(da, da) FROM t double null += SELECT ATAN(dt) FROM t double null += SELECT ATAN(dt, dt) FROM t double null += SELECT ATAN(dt, i) FROM t double null += SELECT ATAN(f) FROM t double null += SELECT ATAN(f, f) FROM t double null += SELECT ATAN(i) FROM t double null += SELECT ATAN(i, i) FROM t double null += SELECT ATAN(i, s) FROM t double null += SELECT ATAN(j) FROM t double null += SELECT ATAN(j, j) FROM t double null += SELECT ATAN(n) FROM t double null += SELECT ATAN(n, n) FROM t double null += SELECT ATAN(s) FROM t double null += SELECT ATAN(s, i) FROM t double null += SELECT ATAN(s, s) FROM t double null += SELECT ATAN(tm) FROM t double null += SELECT ATAN(tm, tm) FROM t double null += SELECT ATAN(u) FROM t double null += SELECT ATAN(u, u) FROM t double null += SELECT ATAN2(b) FROM t double null += SELECT ATAN2(b, b) FROM t double null += SELECT ATAN2(d) FROM t double null += SELECT ATAN2(d, d) FROM t double null += SELECT ATAN2(da) FROM t double null += SELECT ATAN2(da, da) FROM t double null += SELECT ATAN2(dt) FROM t double null += SELECT ATAN2(dt, dt) FROM t double null += SELECT ATAN2(dt, i) FROM t double null += SELECT ATAN2(f) FROM t double null += SELECT ATAN2(f, f) FROM t double null += SELECT ATAN2(i) FROM t double null += SELECT ATAN2(i, i) FROM t double null += SELECT ATAN2(i, s) FROM t double null += SELECT ATAN2(j) FROM t double null += SELECT ATAN2(j, j) FROM t double null += SELECT ATAN2(n) FROM t double null += SELECT ATAN2(n, n) FROM t double null += SELECT ATAN2(s) FROM t double null += SELECT ATAN2(s, i) FROM t double null += SELECT ATAN2(s, s) FROM t double null += SELECT ATAN2(tm) FROM t double null += SELECT ATAN2(tm, tm) FROM t double null += SELECT ATAN2(u) FROM t double null += SELECT ATAN2(u, u) FROM t double null += SELECT BENCHMARK(b, b) FROM t bigint null += SELECT BENCHMARK(d, d) FROM t bigint null += SELECT BENCHMARK(da, da) FROM t bigint null += SELECT BENCHMARK(dt, dt) FROM t bigint null += SELECT BENCHMARK(dt, i) FROM t bigint null += SELECT BENCHMARK(f, f) FROM t bigint null += SELECT BENCHMARK(i, i) FROM t bigint null += SELECT BENCHMARK(i, s) FROM t bigint null += SELECT BENCHMARK(j, j) FROM t bigint null += SELECT BENCHMARK(n, n) FROM t bigint null += SELECT BENCHMARK(s, i) FROM t bigint null += SELECT BENCHMARK(s, s) FROM t bigint null += SELECT BENCHMARK(tm, tm) FROM t bigint null += SELECT BENCHMARK(u, u) FROM t bigint null += SELECT BIN(b) FROM t string null += SELECT BIN(d) FROM t string null += SELECT BIN(da) FROM t string null += SELECT BIN(dt) FROM t string null += SELECT BIN(f) FROM t string null += SELECT BIN(i) FROM t string null += SELECT BIN(j) FROM t string null += SELECT BIN(n) FROM t string null += SELECT BIN(s) FROM t string null += SELECT BIN(tm) FROM t string null += SELECT BIN(u) FROM t string null += SELECT BIN_TO_UUID(b) FROM t string null += SELECT BIN_TO_UUID(b, b) FROM t string null += SELECT BIN_TO_UUID(d) FROM t string null += SELECT BIN_TO_UUID(d, d) FROM t string null += SELECT BIN_TO_UUID(da) FROM t string null += SELECT BIN_TO_UUID(da, da) FROM t string null += SELECT BIN_TO_UUID(dt) FROM t string null += SELECT BIN_TO_UUID(dt, dt) FROM t string null += SELECT BIN_TO_UUID(dt, i) FROM t string null += SELECT BIN_TO_UUID(f) FROM t string null += SELECT BIN_TO_UUID(f, f) FROM t string null += SELECT BIN_TO_UUID(i) FROM t string null += SELECT BIN_TO_UUID(i, i) FROM t string null += SELECT BIN_TO_UUID(i, s) FROM t string null += SELECT BIN_TO_UUID(j) FROM t string null += SELECT BIN_TO_UUID(j, j) FROM t string null += SELECT BIN_TO_UUID(n) FROM t string null += SELECT BIN_TO_UUID(n, n) FROM t string null += SELECT BIN_TO_UUID(s) FROM t string null += SELECT BIN_TO_UUID(s, i) FROM t string null += SELECT BIN_TO_UUID(s, s) FROM t string null += SELECT BIN_TO_UUID(tm) FROM t string null += SELECT BIN_TO_UUID(tm, tm) FROM t string null += SELECT BIN_TO_UUID(u) FROM t string null += SELECT BIN_TO_UUID(u, u) FROM t string null += SELECT BIT_COUNT(b) FROM t bigint += SELECT BIT_COUNT(d) FROM t bigint += SELECT BIT_COUNT(da) FROM t bigint += SELECT BIT_COUNT(dt) FROM t bigint += SELECT BIT_COUNT(f) FROM t bigint += SELECT BIT_COUNT(i) FROM t bigint += SELECT BIT_COUNT(j) FROM t bigint null += SELECT BIT_COUNT(n) FROM t bigint null += SELECT BIT_COUNT(s) FROM t bigint += SELECT BIT_COUNT(tm) FROM t bigint += SELECT BIT_COUNT(u) FROM t bigint += SELECT BIT_LENGTH(b) FROM t bigint += SELECT BIT_LENGTH(d) FROM t bigint += SELECT BIT_LENGTH(da) FROM t bigint += SELECT BIT_LENGTH(dt) FROM t bigint += SELECT BIT_LENGTH(f) FROM t bigint += SELECT BIT_LENGTH(i) FROM t bigint += SELECT BIT_LENGTH(j) FROM t bigint null += SELECT BIT_LENGTH(n) FROM t bigint null += SELECT BIT_LENGTH(s) FROM t bigint += SELECT BIT_LENGTH(tm) FROM t bigint += SELECT BIT_LENGTH(u) FROM t bigint += SELECT CEIL(b) FROM t double += SELECT CEIL(d) FROM t bigint += SELECT CEIL(da) FROM t double += SELECT CEIL(dt) FROM t double += SELECT CEIL(f) FROM t double += SELECT CEIL(i) FROM t bigint += SELECT CEIL(j) FROM t double null += SELECT CEIL(n) FROM t bigint null += SELECT CEIL(s) FROM t double += SELECT CEIL(tm) FROM t double += SELECT CEIL(u) FROM t bigint unsigned += SELECT CEILING(b) FROM t double += SELECT CEILING(d) FROM t bigint += SELECT CEILING(da) FROM t double += SELECT CEILING(dt) FROM t double += SELECT CEILING(f) FROM t double += SELECT CEILING(i) FROM t bigint += SELECT CEILING(j) FROM t double null += SELECT CEILING(n) FROM t bigint null += SELECT CEILING(s) FROM t double += SELECT CEILING(tm) FROM t double += SELECT CEILING(u) FROM t bigint unsigned += SELECT CHARACTER_LENGTH(b) FROM t bigint += SELECT CHARACTER_LENGTH(d) FROM t bigint += SELECT CHARACTER_LENGTH(da) FROM t bigint += SELECT CHARACTER_LENGTH(dt) FROM t bigint += SELECT CHARACTER_LENGTH(f) FROM t bigint += SELECT CHARACTER_LENGTH(i) FROM t bigint += SELECT CHARACTER_LENGTH(j) FROM t bigint null += SELECT CHARACTER_LENGTH(n) FROM t bigint null += SELECT CHARACTER_LENGTH(s) FROM t bigint += SELECT CHARACTER_LENGTH(tm) FROM t bigint += SELECT CHARACTER_LENGTH(u) FROM t bigint += SELECT CHAR_LENGTH(b) FROM t bigint += SELECT CHAR_LENGTH(d) FROM t bigint += SELECT CHAR_LENGTH(da) FROM t bigint += SELECT CHAR_LENGTH(dt) FROM t bigint += SELECT CHAR_LENGTH(f) FROM t bigint += SELECT CHAR_LENGTH(i) FROM t bigint += SELECT CHAR_LENGTH(j) FROM t bigint null += SELECT CHAR_LENGTH(n) FROM t bigint null += SELECT CHAR_LENGTH(s) FROM t bigint += SELECT CHAR_LENGTH(tm) FROM t bigint += SELECT CHAR_LENGTH(u) FROM t bigint += SELECT COERCIBILITY(b) FROM t bigint += SELECT COERCIBILITY(d) FROM t bigint += SELECT COERCIBILITY(da) FROM t bigint += SELECT COERCIBILITY(dt) FROM t bigint += SELECT COERCIBILITY(f) FROM t bigint += SELECT COERCIBILITY(i) FROM t bigint += SELECT COERCIBILITY(j) FROM t bigint += SELECT COERCIBILITY(n) FROM t bigint += SELECT COERCIBILITY(s) FROM t bigint += SELECT COERCIBILITY(tm) FROM t bigint += SELECT COERCIBILITY(u) FROM t bigint += SELECT COMPRESS(b) FROM t binary null += SELECT COMPRESS(d) FROM t binary null += SELECT COMPRESS(da) FROM t binary null += SELECT COMPRESS(dt) FROM t binary null += SELECT COMPRESS(f) FROM t binary null += SELECT COMPRESS(i) FROM t binary null += SELECT COMPRESS(j) FROM t binary null += SELECT COMPRESS(n) FROM t binary null += SELECT COMPRESS(s) FROM t binary null += SELECT COMPRESS(tm) FROM t binary null += SELECT COMPRESS(u) FROM t binary null += SELECT CONCAT(b) FROM t binary null += SELECT CONCAT(b, b) FROM t binary null += SELECT CONCAT(d) FROM t string null += SELECT CONCAT(d, d) FROM t string null += SELECT CONCAT(da) FROM t string null += SELECT CONCAT(da, da) FROM t string null += SELECT CONCAT(dt) FROM t string null += SELECT CONCAT(dt, dt) FROM t string null += SELECT CONCAT(dt, i) FROM t string null += SELECT CONCAT(f) FROM t string null += SELECT CONCAT(f, f) FROM t string null += SELECT CONCAT(i) FROM t string null += SELECT CONCAT(i, i) FROM t string null += SELECT CONCAT(i, s) FROM t string null += SELECT CONCAT(j) FROM t string null += SELECT CONCAT(j, j) FROM t string null += SELECT CONCAT(n) FROM t string null += SELECT CONCAT(n, n) FROM t string null += SELECT CONCAT(s) FROM t string null += SELECT CONCAT(s, i) FROM t string null += SELECT CONCAT(s, s) FROM t string null += SELECT CONCAT(tm) FROM t string null += SELECT CONCAT(tm, tm) FROM t string null += SELECT CONCAT(u) FROM t string null += SELECT CONCAT(u, u) FROM t string null += SELECT CONCAT_WS(b, b) FROM t binary null += SELECT CONCAT_WS(b, b, b) FROM t binary null += SELECT CONCAT_WS(d, d) FROM t string null += SELECT CONCAT_WS(d, d, d) FROM t string null += SELECT CONCAT_WS(da, da) FROM t string null += SELECT CONCAT_WS(da, da, da) FROM t string null += SELECT CONCAT_WS(dt, dt) FROM t string null += SELECT CONCAT_WS(dt, dt, dt) FROM t string null += SELECT CONCAT_WS(dt, i) FROM t string null += SELECT CONCAT_WS(dt, i, i) FROM t string null += SELECT CONCAT_WS(f, f) FROM t string null += SELECT CONCAT_WS(f, f, f) FROM t string null += SELECT CONCAT_WS(i, i) FROM t string null += SELECT CONCAT_WS(i, i, i) FROM t string null += SELECT CONCAT_WS(i, s) FROM t string null += SELECT CONCAT_WS(i, s, s) FROM t string null += SELECT CONCAT_WS(j, j) FROM t string null += SELECT CONCAT_WS(j, j, j) FROM t string null += SELECT CONCAT_WS(n, n) FROM t string null += SELECT CONCAT_WS(n, n, n) FROM t string null += SELECT CONCAT_WS(s, i) FROM t string null += SELECT CONCAT_WS(s, i, i) FROM t string null += SELECT CONCAT_WS(s, s) FROM t string null += SELECT CONCAT_WS(s, s, NULL) FROM t string null += SELECT CONCAT_WS(s, s, s) FROM t string null += SELECT CONCAT_WS(tm, tm) FROM t string null += SELECT CONCAT_WS(tm, tm, tm) FROM t string null += SELECT CONCAT_WS(u, u) FROM t string null += SELECT CONCAT_WS(u, u, u) FROM t string null += SELECT CONNECTION_ID() FROM t bigint unsigned += SELECT CONV(b, b, b) FROM t string null += SELECT CONV(d, d, d) FROM t string null += SELECT CONV(da, da, da) FROM t string null += SELECT CONV(dt, dt, dt) FROM t string null += SELECT CONV(dt, i, i) FROM t string null += SELECT CONV(f, f, f) FROM t string null += SELECT CONV(i, i, i) FROM t string null += SELECT CONV(i, s, s) FROM t string null += SELECT CONV(j, j, j) FROM t string null += SELECT CONV(n, n, n) FROM t string null += SELECT CONV(s, i, i) FROM t string null += SELECT CONV(s, s, NULL) FROM t string null += SELECT CONV(s, s, s) FROM t string null += SELECT CONV(tm, tm, tm) FROM t string null += SELECT CONV(u, u, u) FROM t string null += SELECT CONVERT_TZ(b, b, b) FROM t datetime null += SELECT CONVERT_TZ(d, d, d) FROM t datetime null += SELECT CONVERT_TZ(da, da, da) FROM t datetime null += SELECT CONVERT_TZ(dt, dt, dt) FROM t datetime null += SELECT CONVERT_TZ(dt, i, i) FROM t datetime null += SELECT CONVERT_TZ(f, f, f) FROM t datetime null += SELECT CONVERT_TZ(i, i, i) FROM t datetime null += SELECT CONVERT_TZ(i, s, s) FROM t datetime null += SELECT CONVERT_TZ(j, j, j) FROM t datetime null += SELECT CONVERT_TZ(n, n, n) FROM t datetime null += SELECT CONVERT_TZ(s, i, i) FROM t datetime null += SELECT CONVERT_TZ(s, s, NULL) FROM t datetime null += SELECT CONVERT_TZ(s, s, s) FROM t datetime null += SELECT CONVERT_TZ(tm, tm, tm) FROM t datetime null += SELECT CONVERT_TZ(u, u, u) FROM t datetime null += SELECT COS(b) FROM t double null += SELECT COS(d) FROM t double null += SELECT COS(da) FROM t double null += SELECT COS(dt) FROM t double null += SELECT COS(f) FROM t double null += SELECT COS(i) FROM t double null += SELECT COS(j) FROM t double null += SELECT COS(n) FROM t double null += SELECT COS(s) FROM t double null += SELECT COS(tm) FROM t double null += SELECT COS(u) FROM t double null += SELECT COT(b) FROM t double null += SELECT COT(d) FROM t double null += SELECT COT(da) FROM t double null += SELECT COT(dt) FROM t double null += SELECT COT(f) FROM t double null += SELECT COT(i) FROM t double null += SELECT COT(j) FROM t double null += SELECT COT(n) FROM t double null += SELECT COT(s) FROM t double null += SELECT COT(tm) FROM t double null += SELECT COT(u) FROM t double null += SELECT CRC32(b) FROM t bigint unsigned += SELECT CRC32(d) FROM t bigint unsigned += SELECT CRC32(da) FROM t bigint unsigned += SELECT CRC32(dt) FROM t bigint unsigned += SELECT CRC32(f) FROM t bigint unsigned += SELECT CRC32(i) FROM t bigint unsigned += SELECT CRC32(j) FROM t bigint unsigned null += SELECT CRC32(n) FROM t bigint unsigned null += SELECT CRC32(s) FROM t bigint unsigned += SELECT CRC32(tm) FROM t bigint unsigned += SELECT CRC32(u) FROM t bigint unsigned += SELECT CURRENT_ROLE() FROM t string null += SELECT DATEDIFF(b, b) FROM t bigint null += SELECT DATEDIFF(d, d) FROM t bigint null += SELECT DATEDIFF(da, da) FROM t bigint null += SELECT DATEDIFF(dt, dt) FROM t bigint null += SELECT DATEDIFF(dt, i) FROM t bigint null += SELECT DATEDIFF(f, f) FROM t bigint null += SELECT DATEDIFF(i, i) FROM t bigint null += SELECT DATEDIFF(i, s) FROM t bigint null += SELECT DATEDIFF(j, j) FROM t bigint null += SELECT DATEDIFF(n, n) FROM t bigint null += SELECT DATEDIFF(s, i) FROM t bigint null += SELECT DATEDIFF(s, s) FROM t bigint null += SELECT DATEDIFF(tm, tm) FROM t bigint null += SELECT DATEDIFF(u, u) FROM t bigint null += SELECT DATE_FORMAT(b, b) FROM t string null += SELECT DATE_FORMAT(d, d) FROM t string null += SELECT DATE_FORMAT(da, da) FROM t string null += SELECT DATE_FORMAT(dt, dt) FROM t string null += SELECT DATE_FORMAT(dt, i) FROM t string null += SELECT DATE_FORMAT(f, f) FROM t string null += SELECT DATE_FORMAT(i, i) FROM t string null += SELECT DATE_FORMAT(i, s) FROM t string null += SELECT DATE_FORMAT(j, j) FROM t string null += SELECT DATE_FORMAT(n, n) FROM t string null += SELECT DATE_FORMAT(s, i) FROM t string null += SELECT DATE_FORMAT(s, s) FROM t string null += SELECT DATE_FORMAT(tm, tm) FROM t string null += SELECT DATE_FORMAT(u, u) FROM t string null += SELECT DAYNAME(b) FROM t string null += SELECT DAYNAME(d) FROM t string null += SELECT DAYNAME(da) FROM t string null += SELECT DAYNAME(dt) FROM t string null += SELECT DAYNAME(f) FROM t string null += SELECT DAYNAME(i) FROM t string null += SELECT DAYNAME(j) FROM t string null += SELECT DAYNAME(n) FROM t string null += SELECT DAYNAME(s) FROM t string null += SELECT DAYNAME(tm) FROM t string null += SELECT DAYNAME(u) FROM t string null += SELECT DAYOFMONTH(b) FROM t bigint null += SELECT DAYOFMONTH(d) FROM t bigint null += SELECT DAYOFMONTH(da) FROM t bigint null += SELECT DAYOFMONTH(dt) FROM t bigint null += SELECT DAYOFMONTH(f) FROM t bigint null += SELECT DAYOFMONTH(i) FROM t bigint null += SELECT DAYOFMONTH(j) FROM t bigint null += SELECT DAYOFMONTH(n) FROM t bigint null += SELECT DAYOFMONTH(s) FROM t bigint null += SELECT DAYOFMONTH(tm) FROM t bigint null += SELECT DAYOFMONTH(u) FROM t bigint null += SELECT DAYOFWEEK(b) FROM t bigint null += SELECT DAYOFWEEK(d) FROM t bigint null += SELECT DAYOFWEEK(da) FROM t bigint null += SELECT DAYOFWEEK(dt) FROM t bigint null += SELECT DAYOFWEEK(f) FROM t bigint null += SELECT DAYOFWEEK(i) FROM t bigint null += SELECT DAYOFWEEK(j) FROM t bigint null += SELECT DAYOFWEEK(n) FROM t bigint null += SELECT DAYOFWEEK(s) FROM t bigint null += SELECT DAYOFWEEK(tm) FROM t bigint null += SELECT DAYOFWEEK(u) FROM t bigint null += SELECT DAYOFYEAR(b) FROM t bigint null += SELECT DAYOFYEAR(d) FROM t bigint null += SELECT DAYOFYEAR(da) FROM t bigint null += SELECT DAYOFYEAR(dt) FROM t bigint null += SELECT DAYOFYEAR(f) FROM t bigint null += SELECT DAYOFYEAR(i) FROM t bigint null += SELECT DAYOFYEAR(j) FROM t bigint null += SELECT DAYOFYEAR(n) FROM t bigint null += SELECT DAYOFYEAR(s) FROM t bigint null += SELECT DAYOFYEAR(tm) FROM t bigint null += SELECT DAYOFYEAR(u) FROM t bigint null += SELECT DEGREES(b) FROM t double += SELECT DEGREES(d) FROM t double += SELECT DEGREES(da) FROM t double += SELECT DEGREES(dt) FROM t double += SELECT DEGREES(f) FROM t double += SELECT DEGREES(i) FROM t double += SELECT DEGREES(j) FROM t double null += SELECT DEGREES(n) FROM t double null += SELECT DEGREES(s) FROM t double += SELECT DEGREES(tm) FROM t double += SELECT DEGREES(u) FROM t double += SELECT ELT(b, b) FROM t binary null += SELECT ELT(b, b, b) FROM t binary null += SELECT ELT(d, d) FROM t string null += SELECT ELT(d, d, d) FROM t string null += SELECT ELT(da, da) FROM t string null += SELECT ELT(da, da, da) FROM t string null += SELECT ELT(dt, dt) FROM t string null += SELECT ELT(dt, dt, dt) FROM t string null += SELECT ELT(dt, i) FROM t string null += SELECT ELT(dt, i, i) FROM t string null += SELECT ELT(f, f) FROM t string null += SELECT ELT(f, f, f) FROM t string null += SELECT ELT(i, i) FROM t string null += SELECT ELT(i, i, i) FROM t string null += SELECT ELT(i, s) FROM t string null += SELECT ELT(i, s, s) FROM t string null += SELECT ELT(j, j) FROM t string null += SELECT ELT(j, j, j) FROM t string null += SELECT ELT(n, n) FROM t string null += SELECT ELT(n, n, n) FROM t string null += SELECT ELT(s, i) FROM t string null += SELECT ELT(s, i, i) FROM t string null += SELECT ELT(s, s) FROM t string null += SELECT ELT(s, s, NULL) FROM t string null += SELECT ELT(s, s, s) FROM t string null += SELECT ELT(tm, tm) FROM t string null += SELECT ELT(tm, tm, tm) FROM t string null += SELECT ELT(u, u) FROM t string null += SELECT ELT(u, u, u) FROM t string null += SELECT EXP(b) FROM t double null += SELECT EXP(d) FROM t double null += SELECT EXP(da) FROM t double null += SELECT EXP(dt) FROM t double null += SELECT EXP(f) FROM t double null += SELECT EXP(i) FROM t double null += SELECT EXP(j) FROM t double null += SELECT EXP(n) FROM t double null += SELECT EXP(s) FROM t double null += SELECT EXP(tm) FROM t double null += SELECT EXP(u) FROM t double null += SELECT EXPORT_SET(b, b, b) FROM t binary null += SELECT EXPORT_SET(b, b, b, b) FROM t binary null += SELECT EXPORT_SET(b, b, b, b, b) FROM t binary null += SELECT EXPORT_SET(d, d, d) FROM t string null += SELECT EXPORT_SET(d, d, d, d) FROM t string null += SELECT EXPORT_SET(d, d, d, d, d) FROM t string null += SELECT EXPORT_SET(da, da, da) FROM t string null += SELECT EXPORT_SET(da, da, da, da) FROM t string null += SELECT EXPORT_SET(da, da, da, da, da) FROM t string null += SELECT EXPORT_SET(dt, dt, dt) FROM t string null += SELECT EXPORT_SET(dt, dt, dt, dt) FROM t string null += SELECT EXPORT_SET(dt, dt, dt, dt, dt) FROM t string null += SELECT EXPORT_SET(dt, i, i) FROM t string null += SELECT EXPORT_SET(dt, i, i, i) FROM t string null += SELECT EXPORT_SET(dt, i, i, i, i) FROM t string null += SELECT EXPORT_SET(f, f, f) FROM t string null += SELECT EXPORT_SET(f, f, f, f) FROM t string null += SELECT EXPORT_SET(f, f, f, f, f) FROM t string null += SELECT EXPORT_SET(i, i, i) FROM t string null += SELECT EXPORT_SET(i, i, i, i) FROM t string null += SELECT EXPORT_SET(i, i, i, i, i) FROM t string null += SELECT EXPORT_SET(i, s, s) FROM t string null += SELECT EXPORT_SET(i, s, s, s) FROM t string null += SELECT EXPORT_SET(i, s, s, s, s) FROM t string null += SELECT EXPORT_SET(j, j, j) FROM t string null += SELECT EXPORT_SET(j, j, j, j) FROM t string null += SELECT EXPORT_SET(j, j, j, j, j) FROM t string null += SELECT EXPORT_SET(n, n, n) FROM t string null += SELECT EXPORT_SET(n, n, n, n) FROM t string null += SELECT EXPORT_SET(n, n, n, n, n) FROM t string null += SELECT EXPORT_SET(s, i, i) FROM t string null += SELECT EXPORT_SET(s, i, i, i) FROM t string null += SELECT EXPORT_SET(s, i, i, i, i) FROM t string null += SELECT EXPORT_SET(s, s, NULL) FROM t string null += SELECT EXPORT_SET(s, s, NULL, NULL) FROM t string null += SELECT EXPORT_SET(s, s, NULL, NULL, NULL) FROM t string null += SELECT EXPORT_SET(s, s, s) FROM t string null += SELECT EXPORT_SET(s, s, s, s) FROM t string null += SELECT EXPORT_SET(s, s, s, s, s) FROM t string null += SELECT EXPORT_SET(tm, tm, tm) FROM t string null += SELECT EXPORT_SET(tm, tm, tm, tm) FROM t string null += SELECT EXPORT_SET(tm, tm, tm, tm, tm) FROM t string null += SELECT EXPORT_SET(u, u, u) FROM t string null += SELECT EXPORT_SET(u, u, u, u) FROM t string null += SELECT EXPORT_SET(u, u, u, u, u) FROM t string null += SELECT FIELD(b, b) FROM t bigint += SELECT FIELD(b, b, b) FROM t bigint += SELECT FIELD(d, d) FROM t bigint += SELECT FIELD(d, d, d) FROM t bigint += SELECT FIELD(da, da) FROM t bigint += SELECT FIELD(da, da, da) FROM t bigint += SELECT FIELD(dt, dt) FROM t bigint += SELECT FIELD(dt, dt, dt) FROM t bigint += SELECT FIELD(dt, i) FROM t bigint += SELECT FIELD(dt, i, i) FROM t bigint += SELECT FIELD(f, f) FROM t bigint += SELECT FIELD(f, f, f) FROM t bigint += SELECT FIELD(i, i) FROM t bigint += SELECT FIELD(i, i, i) FROM t bigint += SELECT FIELD(i, s) FROM t bigint += SELECT FIELD(i, s, s) FROM t bigint += SELECT FIELD(j, j) FROM t bigint += SELECT FIELD(j, j, j) FROM t bigint += SELECT FIELD(n, n) FROM t bigint += SELECT FIELD(n, n, n) FROM t bigint += SELECT FIELD(s, i) FROM t bigint += SELECT FIELD(s, i, i) FROM t bigint += SELECT FIELD(s, s) FROM t bigint += SELECT FIELD(s, s, NULL) FROM t bigint += SELECT FIELD(s, s, s) FROM t bigint += SELECT FIELD(tm, tm) FROM t bigint += SELECT FIELD(tm, tm, tm) FROM t bigint += SELECT FIELD(u, u) FROM t bigint += SELECT FIELD(u, u, u) FROM t bigint += SELECT FIND_IN_SET(b, b) FROM t bigint += SELECT FIND_IN_SET(d, d) FROM t bigint += SELECT FIND_IN_SET(da, da) FROM t bigint += SELECT FIND_IN_SET(dt, dt) FROM t bigint += SELECT FIND_IN_SET(dt, i) FROM t bigint += SELECT FIND_IN_SET(f, f) FROM t bigint += SELECT FIND_IN_SET(i, i) FROM t bigint += SELECT FIND_IN_SET(i, s) FROM t bigint += SELECT FIND_IN_SET(j, j) FROM t bigint null += SELECT FIND_IN_SET(n, n) FROM t bigint null += SELECT FIND_IN_SET(s, i) FROM t bigint += SELECT FIND_IN_SET(s, s) FROM t bigint += SELECT FIND_IN_SET(tm, tm) FROM t bigint += SELECT FIND_IN_SET(u, u) FROM t bigint += SELECT FLOOR(b) FROM t double += SELECT FLOOR(d) FROM t bigint += SELECT FLOOR(da) FROM t double += SELECT FLOOR(dt) FROM t double += SELECT FLOOR(f) FROM t double += SELECT FLOOR(i) FROM t bigint += SELECT FLOOR(j) FROM t double null += SELECT FLOOR(n) FROM t bigint null += SELECT FLOOR(s) FROM t double += SELECT FLOOR(tm) FROM t double += SELECT FLOOR(u) FROM t bigint unsigned += SELECT FORMAT_BYTES(b) FROM t string null += SELECT FORMAT_BYTES(d) FROM t string null += SELECT FORMAT_BYTES(da) FROM t string null += SELECT FORMAT_BYTES(dt) FROM t string null += SELECT FORMAT_BYTES(f) FROM t string null += SELECT FORMAT_BYTES(i) FROM t string null += SELECT FORMAT_BYTES(j) FROM t string null += SELECT FORMAT_BYTES(n) FROM t string null += SELECT FORMAT_BYTES(s) FROM t string null += SELECT FORMAT_BYTES(tm) FROM t string null += SELECT FORMAT_BYTES(u) FROM t string null += SELECT FORMAT_PICO_TIME(b) FROM t string null += SELECT FORMAT_PICO_TIME(d) FROM t string null += SELECT FORMAT_PICO_TIME(da) FROM t string null += SELECT FORMAT_PICO_TIME(dt) FROM t string null += SELECT FORMAT_PICO_TIME(f) FROM t string null += SELECT FORMAT_PICO_TIME(i) FROM t string null += SELECT FORMAT_PICO_TIME(j) FROM t string null += SELECT FORMAT_PICO_TIME(n) FROM t string null += SELECT FORMAT_PICO_TIME(s) FROM t string null += SELECT FORMAT_PICO_TIME(tm) FROM t string null += SELECT FORMAT_PICO_TIME(u) FROM t string null += SELECT FOUND_ROWS() FROM t bigint += SELECT FROM_BASE64(b) FROM t binary null += SELECT FROM_BASE64(d) FROM t binary null += SELECT FROM_BASE64(da) FROM t binary null += SELECT FROM_BASE64(dt) FROM t binary null += SELECT FROM_BASE64(f) FROM t binary null += SELECT FROM_BASE64(i) FROM t binary null += SELECT FROM_BASE64(j) FROM t binary null += SELECT FROM_BASE64(n) FROM t binary null += SELECT FROM_BASE64(s) FROM t binary null += SELECT FROM_BASE64(tm) FROM t binary null += SELECT FROM_BASE64(u) FROM t binary null += SELECT FROM_DAYS(b) FROM t date += SELECT FROM_DAYS(d) FROM t date += SELECT FROM_DAYS(da) FROM t date += SELECT FROM_DAYS(dt) FROM t date += SELECT FROM_DAYS(f) FROM t date += SELECT FROM_DAYS(i) FROM t date += SELECT FROM_DAYS(j) FROM t date null += SELECT FROM_DAYS(n) FROM t date null += SELECT FROM_DAYS(s) FROM t date += SELECT FROM_DAYS(tm) FROM t date += SELECT FROM_DAYS(u) FROM t date += SELECT FROM_UNIXTIME(b) FROM t datetime null += SELECT FROM_UNIXTIME(b, b) FROM t string null += SELECT FROM_UNIXTIME(d) FROM t datetime null += SELECT FROM_UNIXTIME(d, d) FROM t string null += SELECT FROM_UNIXTIME(da) FROM t datetime null += SELECT FROM_UNIXTIME(da, da) FROM t string null += SELECT FROM_UNIXTIME(dt) FROM t datetime null += SELECT FROM_UNIXTIME(dt, dt) FROM t string null += SELECT FROM_UNIXTIME(dt, i) FROM t string null += SELECT FROM_UNIXTIME(f) FROM t datetime null += SELECT FROM_UNIXTIME(f, f) FROM t string null += SELECT FROM_UNIXTIME(i) FROM t datetime null += SELECT FROM_UNIXTIME(i, i) FROM t string null += SELECT FROM_UNIXTIME(i, s) FROM t string null += SELECT FROM_UNIXTIME(j) FROM t datetime null += SELECT FROM_UNIXTIME(j, j) FROM t string null += SELECT FROM_UNIXTIME(n) FROM t datetime null += SELECT FROM_UNIXTIME(n, n) FROM t string null += SELECT FROM_UNIXTIME(s) FROM t datetime null += SELECT FROM_UNIXTIME(s, i) FROM t string null += SELECT FROM_UNIXTIME(s, s) FROM t string null += SELECT FROM_UNIXTIME(tm) FROM t datetime null += SELECT FROM_UNIXTIME(tm, tm) FROM t string null += SELECT FROM_UNIXTIME(u) FROM t datetime null += SELECT FROM_UNIXTIME(u, u) FROM t string null += SELECT GET_LOCK(b, b) FROM t bigint null += SELECT GET_LOCK(d, d) FROM t bigint null += SELECT GET_LOCK(da, da) FROM t bigint null += SELECT GET_LOCK(dt, dt) FROM t bigint null += SELECT GET_LOCK(dt, i) FROM t bigint null += SELECT GET_LOCK(f, f) FROM t bigint null += SELECT GET_LOCK(i, i) FROM t bigint null += SELECT GET_LOCK(i, s) FROM t bigint null += SELECT GET_LOCK(j, j) FROM t bigint null += SELECT GET_LOCK(n, n) FROM t bigint null += SELECT GET_LOCK(s, i) FROM t bigint null += SELECT GET_LOCK(s, s) FROM t bigint null += SELECT GET_LOCK(tm, tm) FROM t bigint null += SELECT GET_LOCK(u, u) FROM t bigint null += SELECT GREATEST(b, b) FROM t binary += SELECT GREATEST(b, b, b) FROM t binary += SELECT GREATEST(d, d) FROM t decimal += SELECT GREATEST(d, d, d) FROM t decimal += SELECT GREATEST(da, da) FROM t date += SELECT GREATEST(da, da, da) FROM t date += SELECT GREATEST(dt, dt) FROM t datetime += SELECT GREATEST(dt, dt, dt) FROM t datetime += SELECT GREATEST(dt, i) FROM t string += SELECT GREATEST(dt, i, i) FROM t string += SELECT GREATEST(f, f) FROM t double += SELECT GREATEST(f, f, f) FROM t double += SELECT GREATEST(i, i) FROM t int += SELECT GREATEST(i, i, i) FROM t int += SELECT GREATEST(i, s) FROM t string += SELECT GREATEST(i, s, s) FROM t string += SELECT GREATEST(j, j) FROM t string null += SELECT GREATEST(j, j, j) FROM t string null += SELECT GREATEST(n, n) FROM t int null += SELECT GREATEST(n, n, n) FROM t int null += SELECT GREATEST(s, i) FROM t string += SELECT GREATEST(s, i, i) FROM t string += SELECT GREATEST(s, s) FROM t string += SELECT GREATEST(s, s, NULL) FROM t string null += SELECT GREATEST(s, s, s) FROM t string += SELECT GREATEST(tm, tm) FROM t time += SELECT GREATEST(tm, tm, tm) FROM t time += SELECT GREATEST(u, u) FROM t bigint unsigned += SELECT GREATEST(u, u, u) FROM t bigint unsigned += SELECT GTID_SUBSET(b, b) FROM t bigint += SELECT GTID_SUBSET(d, d) FROM t bigint += SELECT GTID_SUBSET(da, da) FROM t bigint += SELECT GTID_SUBSET(dt, dt) FROM t bigint += SELECT GTID_SUBSET(dt, i) FROM t bigint += SELECT GTID_SUBSET(f, f) FROM t bigint += SELECT GTID_SUBSET(i, i) FROM t bigint += SELECT GTID_SUBSET(i, s) FROM t bigint += SELECT GTID_SUBSET(j, j) FROM t bigint null += SELECT GTID_SUBSET(n, n) FROM t bigint null += SELECT GTID_SUBSET(s, i) FROM t bigint += SELECT GTID_SUBSET(s, s) FROM t bigint += SELECT GTID_SUBSET(tm, tm) FROM t bigint += SELECT GTID_SUBSET(u, u) FROM t bigint += SELECT GTID_SUBTRACT(b, b) FROM t string null += SELECT GTID_SUBTRACT(d, d) FROM t string null += SELECT GTID_SUBTRACT(da, da) FROM t string null += SELECT GTID_SUBTRACT(dt, dt) FROM t string null += SELECT GTID_SUBTRACT(dt, i) FROM t string null += SELECT GTID_SUBTRACT(f, f) FROM t string null += SELECT GTID_SUBTRACT(i, i) FROM t string null += SELECT GTID_SUBTRACT(i, s) FROM t string null += SELECT GTID_SUBTRACT(j, j) FROM t string null += SELECT GTID_SUBTRACT(n, n) FROM t string null += SELECT GTID_SUBTRACT(s, i) FROM t string null += SELECT GTID_SUBTRACT(s, s) FROM t string null += SELECT GTID_SUBTRACT(tm, tm) FROM t string null += SELECT GTID_SUBTRACT(u, u) FROM t string null += SELECT HEX(b) FROM t string null += SELECT HEX(d) FROM t string null += SELECT HEX(da) FROM t string null += SELECT HEX(dt) FROM t string null += SELECT HEX(f) FROM t string null += SELECT HEX(i) FROM t string null += SELECT HEX(j) FROM t string null += SELECT HEX(n) FROM t string null += SELECT HEX(s) FROM t string null += SELECT HEX(tm) FROM t string null += SELECT HEX(u) FROM t string null += SELECT ICU_VERSION() FROM t string += SELECT IFNULL(b, b) FROM t binary += SELECT IFNULL(d, d) FROM t decimal += SELECT IFNULL(da, da) FROM t date += SELECT IFNULL(dt, dt) FROM t datetime += SELECT IFNULL(dt, i) FROM t string += SELECT IFNULL(f, f) FROM t double += SELECT IFNULL(i, i) FROM t int += SELECT IFNULL(i, s) FROM t string += SELECT IFNULL(j, j) FROM t json null += SELECT IFNULL(n, n) FROM t int null += SELECT IFNULL(s, i) FROM t string += SELECT IFNULL(s, s) FROM t string += SELECT IFNULL(tm, tm) FROM t time += SELECT IFNULL(u, u) FROM t bigint unsigned += SELECT INET6_ATON(b) FROM t binary null += SELECT INET6_ATON(d) FROM t binary null += SELECT INET6_ATON(da) FROM t binary null += SELECT INET6_ATON(dt) FROM t binary null += SELECT INET6_ATON(f) FROM t binary null += SELECT INET6_ATON(i) FROM t binary null += SELECT INET6_ATON(j) FROM t binary null += SELECT INET6_ATON(n) FROM t binary null += SELECT INET6_ATON(s) FROM t binary null += SELECT INET6_ATON(tm) FROM t binary null += SELECT INET6_ATON(u) FROM t binary null += SELECT INET6_NTOA(b) FROM t string null += SELECT INET6_NTOA(d) FROM t string null += SELECT INET6_NTOA(da) FROM t string null += SELECT INET6_NTOA(dt) FROM t string null += SELECT INET6_NTOA(f) FROM t string null += SELECT INET6_NTOA(i) FROM t string null += SELECT INET6_NTOA(j) FROM t string null += SELECT INET6_NTOA(n) FROM t string null += SELECT INET6_NTOA(s) FROM t string null += SELECT INET6_NTOA(tm) FROM t string null += SELECT INET6_NTOA(u) FROM t string null += SELECT INET_ATON(b) FROM t bigint unsigned null += SELECT INET_ATON(d) FROM t bigint unsigned null += SELECT INET_ATON(da) FROM t bigint unsigned null += SELECT INET_ATON(dt) FROM t bigint unsigned null += SELECT INET_ATON(f) FROM t bigint unsigned null += SELECT INET_ATON(i) FROM t bigint unsigned null += SELECT INET_ATON(j) FROM t bigint unsigned null += SELECT INET_ATON(n) FROM t bigint unsigned null += SELECT INET_ATON(s) FROM t bigint unsigned null += SELECT INET_ATON(tm) FROM t bigint unsigned null += SELECT INET_ATON(u) FROM t bigint unsigned null += SELECT INET_NTOA(b) FROM t string null += SELECT INET_NTOA(d) FROM t string null += SELECT INET_NTOA(da) FROM t string null += SELECT INET_NTOA(dt) FROM t string null += SELECT INET_NTOA(f) FROM t string null += SELECT INET_NTOA(i) FROM t string null += SELECT INET_NTOA(j) FROM t string null += SELECT INET_NTOA(n) FROM t string null += SELECT INET_NTOA(s) FROM t string null += SELECT INET_NTOA(tm) FROM t string null += SELECT INET_NTOA(u) FROM t string null += SELECT INSTR(b, b) FROM t bigint += SELECT INSTR(d, d) FROM t bigint += SELECT INSTR(da, da) FROM t bigint += SELECT INSTR(dt, dt) FROM t bigint += SELECT INSTR(dt, i) FROM t bigint += SELECT INSTR(f, f) FROM t bigint += SELECT INSTR(i, i) FROM t bigint += SELECT INSTR(i, s) FROM t bigint += SELECT INSTR(j, j) FROM t bigint null += SELECT INSTR(n, n) FROM t bigint null += SELECT INSTR(s, i) FROM t bigint += SELECT INSTR(s, s) FROM t bigint += SELECT INSTR(tm, tm) FROM t bigint += SELECT INSTR(u, u) FROM t bigint += SELECT ISNULL(b) FROM t bigint += SELECT ISNULL(d) FROM t bigint += SELECT ISNULL(da) FROM t bigint += SELECT ISNULL(dt) FROM t bigint += SELECT ISNULL(f) FROM t bigint += SELECT ISNULL(i) FROM t bigint += SELECT ISNULL(j) FROM t bigint += SELECT ISNULL(n) FROM t bigint += SELECT ISNULL(s) FROM t bigint += SELECT ISNULL(tm) FROM t bigint += SELECT ISNULL(u) FROM t bigint += SELECT IS_FREE_LOCK(b) FROM t bigint null += SELECT IS_FREE_LOCK(d) FROM t bigint null += SELECT IS_FREE_LOCK(da) FROM t bigint null += SELECT IS_FREE_LOCK(dt) FROM t bigint null += SELECT IS_FREE_LOCK(f) FROM t bigint null += SELECT IS_FREE_LOCK(i) FROM t bigint null += SELECT IS_FREE_LOCK(j) FROM t bigint null += SELECT IS_FREE_LOCK(n) FROM t bigint null += SELECT IS_FREE_LOCK(s) FROM t bigint null += SELECT IS_FREE_LOCK(tm) FROM t bigint null += SELECT IS_FREE_LOCK(u) FROM t bigint null += SELECT IS_IPV4(b) FROM t bigint += SELECT IS_IPV4(d) FROM t bigint += SELECT IS_IPV4(da) FROM t bigint += SELECT IS_IPV4(dt) FROM t bigint += SELECT IS_IPV4(f) FROM t bigint += SELECT IS_IPV4(i) FROM t bigint += SELECT IS_IPV4(j) FROM t bigint null += SELECT IS_IPV4(n) FROM t bigint null += SELECT IS_IPV4(s) FROM t bigint += SELECT IS_IPV4(tm) FROM t bigint += SELECT IS_IPV4(u) FROM t bigint += SELECT IS_IPV4_COMPAT(b) FROM t bigint += SELECT IS_IPV4_COMPAT(d) FROM t bigint += SELECT IS_IPV4_COMPAT(da) FROM t bigint += SELECT IS_IPV4_COMPAT(dt) FROM t bigint += SELECT IS_IPV4_COMPAT(f) FROM t bigint += SELECT IS_IPV4_COMPAT(i) FROM t bigint += SELECT IS_IPV4_COMPAT(j) FROM t bigint null += SELECT IS_IPV4_COMPAT(n) FROM t bigint null += SELECT IS_IPV4_COMPAT(s) FROM t bigint += SELECT IS_IPV4_COMPAT(tm) FROM t bigint += SELECT IS_IPV4_COMPAT(u) FROM t bigint += SELECT IS_IPV4_MAPPED(b) FROM t bigint += SELECT IS_IPV4_MAPPED(d) FROM t bigint += SELECT IS_IPV4_MAPPED(da) FROM t bigint += SELECT IS_IPV4_MAPPED(dt) FROM t bigint += SELECT IS_IPV4_MAPPED(f) FROM t bigint += SELECT IS_IPV4_MAPPED(i) FROM t bigint += SELECT IS_IPV4_MAPPED(j) FROM t bigint null += SELECT IS_IPV4_MAPPED(n) FROM t bigint null += SELECT IS_IPV4_MAPPED(s) FROM t bigint += SELECT IS_IPV4_MAPPED(tm) FROM t bigint += SELECT IS_IPV4_MAPPED(u) FROM t bigint += SELECT IS_IPV6(b) FROM t bigint += SELECT IS_IPV6(d) FROM t bigint += SELECT IS_IPV6(da) FROM t bigint += SELECT IS_IPV6(dt) FROM t bigint += SELECT IS_IPV6(f) FROM t bigint += SELECT IS_IPV6(i) FROM t bigint += SELECT IS_IPV6(j) FROM t bigint null += SELECT IS_IPV6(n) FROM t bigint null += SELECT IS_IPV6(s) FROM t bigint += SELECT IS_IPV6(tm) FROM t bigint += SELECT IS_IPV6(u) FROM t bigint += SELECT IS_USED_LOCK(b) FROM t bigint unsigned null += SELECT IS_USED_LOCK(d) FROM t bigint unsigned null += SELECT IS_USED_LOCK(da) FROM t bigint unsigned null += SELECT IS_USED_LOCK(dt) FROM t bigint unsigned null += SELECT IS_USED_LOCK(f) FROM t bigint unsigned null += SELECT IS_USED_LOCK(i) FROM t bigint unsigned null += SELECT IS_USED_LOCK(j) FROM t bigint unsigned null += SELECT IS_USED_LOCK(n) FROM t bigint unsigned null += SELECT IS_USED_LOCK(s) FROM t bigint unsigned null += SELECT IS_USED_LOCK(tm) FROM t bigint unsigned null += SELECT IS_USED_LOCK(u) FROM t bigint unsigned null += SELECT IS_UUID(b) FROM t bigint null += SELECT IS_UUID(d) FROM t bigint null += SELECT IS_UUID(da) FROM t bigint null += SELECT IS_UUID(dt) FROM t bigint null += SELECT IS_UUID(f) FROM t bigint null += SELECT IS_UUID(i) FROM t bigint null += SELECT IS_UUID(j) FROM t bigint null += SELECT IS_UUID(n) FROM t bigint null += SELECT IS_UUID(s) FROM t bigint null += SELECT IS_UUID(tm) FROM t bigint null += SELECT IS_UUID(u) FROM t bigint null += SELECT JSON_ARRAY() FROM t json null += SELECT JSON_ARRAY(b) FROM t json null += SELECT JSON_ARRAY(b, b) FROM t json null += SELECT JSON_ARRAY(d) FROM t json null += SELECT JSON_ARRAY(d, d) FROM t json null += SELECT JSON_ARRAY(da) FROM t json null += SELECT JSON_ARRAY(da, da) FROM t json null += SELECT JSON_ARRAY(dt) FROM t json null += SELECT JSON_ARRAY(dt, dt) FROM t json null += SELECT JSON_ARRAY(dt, i) FROM t json null += SELECT JSON_ARRAY(f) FROM t json null += SELECT JSON_ARRAY(f, f) FROM t json null += SELECT JSON_ARRAY(i) FROM t json null += SELECT JSON_ARRAY(i, i) FROM t json null += SELECT JSON_ARRAY(i, s) FROM t json null += SELECT JSON_ARRAY(j) FROM t json null += SELECT JSON_ARRAY(j, j) FROM t json null += SELECT JSON_ARRAY(n) FROM t json null += SELECT JSON_ARRAY(n, n) FROM t json null += SELECT JSON_ARRAY(s) FROM t json null += SELECT JSON_ARRAY(s, i) FROM t json null += SELECT JSON_ARRAY(s, s) FROM t json null += SELECT JSON_ARRAY(tm) FROM t json null += SELECT JSON_ARRAY(tm, tm) FROM t json null += SELECT JSON_ARRAY(u) FROM t json null += SELECT JSON_ARRAY(u, u) FROM t json null += SELECT JSON_ARRAY_APPEND(b, b, b) FROM t json null += SELECT JSON_ARRAY_APPEND(d, d, d) FROM t json null += SELECT JSON_ARRAY_APPEND(da, da, da) FROM t json null += SELECT JSON_ARRAY_APPEND(dt, dt, dt) FROM t json null += SELECT JSON_ARRAY_APPEND(dt, i, i) FROM t json null += SELECT JSON_ARRAY_APPEND(f, f, f) FROM t json null += SELECT JSON_ARRAY_APPEND(i, i, i) FROM t json null += SELECT JSON_ARRAY_APPEND(i, s, s) FROM t json null += SELECT JSON_ARRAY_APPEND(j, j, j) FROM t json null += SELECT JSON_ARRAY_APPEND(n, n, n) FROM t json null += SELECT JSON_ARRAY_APPEND(s, i, i) FROM t json null += SELECT JSON_ARRAY_APPEND(s, s, NULL) FROM t json null += SELECT JSON_ARRAY_APPEND(s, s, s) FROM t json null += SELECT JSON_ARRAY_APPEND(tm, tm, tm) FROM t json null += SELECT JSON_ARRAY_APPEND(u, u, u) FROM t json null += SELECT JSON_ARRAY_INSERT(b, b, b) FROM t json null += SELECT JSON_ARRAY_INSERT(d, d, d) FROM t json null += SELECT JSON_ARRAY_INSERT(da, da, da) FROM t json null += SELECT JSON_ARRAY_INSERT(dt, dt, dt) FROM t json null += SELECT JSON_ARRAY_INSERT(dt, i, i) FROM t json null += SELECT JSON_ARRAY_INSERT(f, f, f) FROM t json null += SELECT JSON_ARRAY_INSERT(i, i, i) FROM t json null += SELECT JSON_ARRAY_INSERT(i, s, s) FROM t json null += SELECT JSON_ARRAY_INSERT(j, j, j) FROM t json null += SELECT JSON_ARRAY_INSERT(n, n, n) FROM t json null += SELECT JSON_ARRAY_INSERT(s, i, i) FROM t json null += SELECT JSON_ARRAY_INSERT(s, s, NULL) FROM t json null += SELECT JSON_ARRAY_INSERT(s, s, s) FROM t json null += SELECT JSON_ARRAY_INSERT(tm, tm, tm) FROM t json null += SELECT JSON_ARRAY_INSERT(u, u, u) FROM t json null += SELECT JSON_CONTAINS(b, b) FROM t bigint null += SELECT JSON_CONTAINS(b, b, b) FROM t bigint null += SELECT JSON_CONTAINS(d, d) FROM t bigint null += SELECT JSON_CONTAINS(d, d, d) FROM t bigint null += SELECT JSON_CONTAINS(da, da) FROM t bigint null += SELECT JSON_CONTAINS(da, da, da) FROM t bigint null += SELECT JSON_CONTAINS(dt, dt) FROM t bigint null += SELECT JSON_CONTAINS(dt, dt, dt) FROM t bigint null += SELECT JSON_CONTAINS(dt, i) FROM t bigint null += SELECT JSON_CONTAINS(dt, i, i) FROM t bigint null += SELECT JSON_CONTAINS(f, f) FROM t bigint null += SELECT JSON_CONTAINS(f, f, f) FROM t bigint null += SELECT JSON_CONTAINS(i, i) FROM t bigint null += SELECT JSON_CONTAINS(i, i, i) FROM t bigint null += SELECT JSON_CONTAINS(i, s) FROM t bigint null += SELECT JSON_CONTAINS(i, s, s) FROM t bigint null += SELECT JSON_CONTAINS(j, j) FROM t bigint null += SELECT JSON_CONTAINS(j, j, j) FROM t bigint null += SELECT JSON_CONTAINS(n, n) FROM t bigint null += SELECT JSON_CONTAINS(n, n, n) FROM t bigint null += SELECT JSON_CONTAINS(s, i) FROM t bigint null += SELECT JSON_CONTAINS(s, i, i) FROM t bigint null += SELECT JSON_CONTAINS(s, s) FROM t bigint null += SELECT JSON_CONTAINS(s, s, NULL) FROM t bigint null += SELECT JSON_CONTAINS(s, s, s) FROM t bigint null += SELECT JSON_CONTAINS(tm, tm) FROM t bigint null += SELECT JSON_CONTAINS(tm, tm, tm) FROM t bigint null += SELECT JSON_CONTAINS(u, u) FROM t bigint null += SELECT JSON_CONTAINS(u, u, u) FROM t bigint null += SELECT JSON_CONTAINS_PATH(b, b, b) FROM t bigint null += SELECT JSON_CONTAINS_PATH(b, b, b, b) FROM t bigint null += SELECT JSON_CONTAINS_PATH(d, d, d) FROM t bigint null += SELECT JSON_CONTAINS_PATH(d, d, d, d) FROM t bigint null += SELECT JSON_CONTAINS_PATH(da, da, da) FROM t bigint null += SELECT JSON_CONTAINS_PATH(da, da, da, da) FROM t bigint null += SELECT JSON_CONTAINS_PATH(dt, dt, dt) FROM t bigint null += SELECT JSON_CONTAINS_PATH(dt, dt, dt, dt) FROM t bigint null += SELECT JSON_CONTAINS_PATH(dt, i, i) FROM t bigint null += SELECT JSON_CONTAINS_PATH(dt, i, i, i) FROM t bigint null += SELECT JSON_CONTAINS_PATH(f, f, f) FROM t bigint null += SELECT JSON_CONTAINS_PATH(f, f, f, f) FROM t bigint null += SELECT JSON_CONTAINS_PATH(i, i, i) FROM t bigint null += SELECT JSON_CONTAINS_PATH(i, i, i, i) FROM t bigint null += SELECT JSON_CONTAINS_PATH(i, s, s) FROM t bigint null += SELECT JSON_CONTAINS_PATH(i, s, s, s) FROM t bigint null += SELECT JSON_CONTAINS_PATH(j, j, j) FROM t bigint null += SELECT JSON_CONTAINS_PATH(j, j, j, j) FROM t bigint null += SELECT JSON_CONTAINS_PATH(n, n, n) FROM t bigint null += SELECT JSON_CONTAINS_PATH(n, n, n, n) FROM t bigint null += SELECT JSON_CONTAINS_PATH(s, i, i) FROM t bigint null += SELECT JSON_CONTAINS_PATH(s, i, i, i) FROM t bigint null += SELECT JSON_CONTAINS_PATH(s, s, NULL) FROM t bigint null += SELECT JSON_CONTAINS_PATH(s, s, NULL, NULL) FROM t bigint null += SELECT JSON_CONTAINS_PATH(s, s, s) FROM t bigint null += SELECT JSON_CONTAINS_PATH(s, s, s, s) FROM t bigint null += SELECT JSON_CONTAINS_PATH(tm, tm, tm) FROM t bigint null += SELECT JSON_CONTAINS_PATH(tm, tm, tm, tm) FROM t bigint null += SELECT JSON_CONTAINS_PATH(u, u, u) FROM t bigint null += SELECT JSON_CONTAINS_PATH(u, u, u, u) FROM t bigint null += SELECT JSON_DEPTH(b) FROM t bigint null += SELECT JSON_DEPTH(d) FROM t bigint null += SELECT JSON_DEPTH(da) FROM t bigint null += SELECT JSON_DEPTH(dt) FROM t bigint null += SELECT JSON_DEPTH(f) FROM t bigint null += SELECT JSON_DEPTH(i) FROM t bigint null += SELECT JSON_DEPTH(j) FROM t bigint null += SELECT JSON_DEPTH(n) FROM t bigint null += SELECT JSON_DEPTH(s) FROM t bigint null += SELECT JSON_DEPTH(tm) FROM t bigint null += SELECT JSON_DEPTH(u) FROM t bigint null += SELECT JSON_EXTRACT(b, b) FROM t json null += SELECT JSON_EXTRACT(b, b, b) FROM t json null += SELECT JSON_EXTRACT(d, d) FROM t json null += SELECT JSON_EXTRACT(d, d, d) FROM t json null += SELECT JSON_EXTRACT(da, da) FROM t json null += SELECT JSON_EXTRACT(da, da, da) FROM t json null += SELECT JSON_EXTRACT(dt, dt) FROM t json null += SELECT JSON_EXTRACT(dt, dt, dt) FROM t json null += SELECT JSON_EXTRACT(dt, i) FROM t json null += SELECT JSON_EXTRACT(dt, i, i) FROM t json null += SELECT JSON_EXTRACT(f, f) FROM t json null += SELECT JSON_EXTRACT(f, f, f) FROM t json null += SELECT JSON_EXTRACT(i, i) FROM t json null += SELECT JSON_EXTRACT(i, i, i) FROM t json null += SELECT JSON_EXTRACT(i, s) FROM t json null += SELECT JSON_EXTRACT(i, s, s) FROM t json null += SELECT JSON_EXTRACT(j, j) FROM t json null += SELECT JSON_EXTRACT(j, j, j) FROM t json null += SELECT JSON_EXTRACT(n, n) FROM t json null += SELECT JSON_EXTRACT(n, n, n) FROM t json null += SELECT JSON_EXTRACT(s, i) FROM t json null += SELECT JSON_EXTRACT(s, i, i) FROM t json null += SELECT JSON_EXTRACT(s, s) FROM t json null += SELECT JSON_EXTRACT(s, s, NULL) FROM t json null += SELECT JSON_EXTRACT(s, s, s) FROM t json null += SELECT JSON_EXTRACT(tm, tm) FROM t json null += SELECT JSON_EXTRACT(tm, tm, tm) FROM t json null += SELECT JSON_EXTRACT(u, u) FROM t json null += SELECT JSON_EXTRACT(u, u, u) FROM t json null += SELECT JSON_INSERT(b, b, b) FROM t json null += SELECT JSON_INSERT(d, d, d) FROM t json null += SELECT JSON_INSERT(da, da, da) FROM t json null += SELECT JSON_INSERT(dt, dt, dt) FROM t json null += SELECT JSON_INSERT(dt, i, i) FROM t json null += SELECT JSON_INSERT(f, f, f) FROM t json null += SELECT JSON_INSERT(i, i, i) FROM t json null += SELECT JSON_INSERT(i, s, s) FROM t json null += SELECT JSON_INSERT(j, j, j) FROM t json null += SELECT JSON_INSERT(n, n, n) FROM t json null += SELECT JSON_INSERT(s, i, i) FROM t json null += SELECT JSON_INSERT(s, s, NULL) FROM t json null += SELECT JSON_INSERT(s, s, s) FROM t json null += SELECT JSON_INSERT(tm, tm, tm) FROM t json null += SELECT JSON_INSERT(u, u, u) FROM t json null += SELECT JSON_KEYS(b) FROM t json null += SELECT JSON_KEYS(b, b) FROM t json null += SELECT JSON_KEYS(d) FROM t json null += SELECT JSON_KEYS(d, d) FROM t json null += SELECT JSON_KEYS(da) FROM t json null += SELECT JSON_KEYS(da, da) FROM t json null += SELECT JSON_KEYS(dt) FROM t json null += SELECT JSON_KEYS(dt, dt) FROM t json null += SELECT JSON_KEYS(dt, i) FROM t json null += SELECT JSON_KEYS(f) FROM t json null += SELECT JSON_KEYS(f, f) FROM t json null += SELECT JSON_KEYS(i) FROM t json null += SELECT JSON_KEYS(i, i) FROM t json null += SELECT JSON_KEYS(i, s) FROM t json null += SELECT JSON_KEYS(j) FROM t json null += SELECT JSON_KEYS(j, j) FROM t json null += SELECT JSON_KEYS(n) FROM t json null += SELECT JSON_KEYS(n, n) FROM t json null += SELECT JSON_KEYS(s) FROM t json null += SELECT JSON_KEYS(s, i) FROM t json null += SELECT JSON_KEYS(s, s) FROM t json null += SELECT JSON_KEYS(tm) FROM t json null += SELECT JSON_KEYS(tm, tm) FROM t json null += SELECT JSON_KEYS(u) FROM t json null += SELECT JSON_KEYS(u, u) FROM t json null += SELECT JSON_LENGTH(b) FROM t bigint null += SELECT JSON_LENGTH(b, b) FROM t bigint null += SELECT JSON_LENGTH(d) FROM t bigint null += SELECT JSON_LENGTH(d, d) FROM t bigint null += SELECT JSON_LENGTH(da) FROM t bigint null += SELECT JSON_LENGTH(da, da) FROM t bigint null += SELECT JSON_LENGTH(dt) FROM t bigint null += SELECT JSON_LENGTH(dt, dt) FROM t bigint null += SELECT JSON_LENGTH(dt, i) FROM t bigint null += SELECT JSON_LENGTH(f) FROM t bigint null += SELECT JSON_LENGTH(f, f) FROM t bigint null += SELECT JSON_LENGTH(i) FROM t bigint null += SELECT JSON_LENGTH(i, i) FROM t bigint null += SELECT JSON_LENGTH(i, s) FROM t bigint null += SELECT JSON_LENGTH(j) FROM t bigint null += SELECT JSON_LENGTH(j, j) FROM t bigint null += SELECT JSON_LENGTH(n) FROM t bigint null += SELECT JSON_LENGTH(n, n) FROM t bigint null += SELECT JSON_LENGTH(s) FROM t bigint null += SELECT JSON_LENGTH(s, i) FROM t bigint null += SELECT JSON_LENGTH(s, s) FROM t bigint null += SELECT JSON_LENGTH(tm) FROM t bigint null += SELECT JSON_LENGTH(tm, tm) FROM t bigint null += SELECT JSON_LENGTH(u) FROM t bigint null += SELECT JSON_LENGTH(u, u) FROM t bigint null += SELECT JSON_MERGE(b, b) FROM t json null += SELECT JSON_MERGE(b, b, b) FROM t json null += SELECT JSON_MERGE(d, d) FROM t json null += SELECT JSON_MERGE(d, d, d) FROM t json null += SELECT JSON_MERGE(da, da) FROM t json null += SELECT JSON_MERGE(da, da, da) FROM t json null += SELECT JSON_MERGE(dt, dt) FROM t json null += SELECT JSON_MERGE(dt, dt, dt) FROM t json null += SELECT JSON_MERGE(dt, i) FROM t json null += SELECT JSON_MERGE(dt, i, i) FROM t json null += SELECT JSON_MERGE(f, f) FROM t json null += SELECT JSON_MERGE(f, f, f) FROM t json null += SELECT JSON_MERGE(i, i) FROM t json null += SELECT JSON_MERGE(i, i, i) FROM t json null += SELECT JSON_MERGE(i, s) FROM t json null += SELECT JSON_MERGE(i, s, s) FROM t json null += SELECT JSON_MERGE(j, j) FROM t json null += SELECT JSON_MERGE(j, j, j) FROM t json null += SELECT JSON_MERGE(n, n) FROM t json null += SELECT JSON_MERGE(n, n, n) FROM t json null += SELECT JSON_MERGE(s, i) FROM t json null += SELECT JSON_MERGE(s, i, i) FROM t json null += SELECT JSON_MERGE(s, s) FROM t json null += SELECT JSON_MERGE(s, s, NULL) FROM t json null += SELECT JSON_MERGE(s, s, s) FROM t json null += SELECT JSON_MERGE(tm, tm) FROM t json null += SELECT JSON_MERGE(tm, tm, tm) FROM t json null += SELECT JSON_MERGE(u, u) FROM t json null += SELECT JSON_MERGE(u, u, u) FROM t json null += SELECT JSON_MERGE_PATCH(b, b) FROM t json null += SELECT JSON_MERGE_PATCH(b, b, b) FROM t json null += SELECT JSON_MERGE_PATCH(d, d) FROM t json null += SELECT JSON_MERGE_PATCH(d, d, d) FROM t json null += SELECT JSON_MERGE_PATCH(da, da) FROM t json null += SELECT JSON_MERGE_PATCH(da, da, da) FROM t json null += SELECT JSON_MERGE_PATCH(dt, dt) FROM t json null += SELECT JSON_MERGE_PATCH(dt, dt, dt) FROM t json null += SELECT JSON_MERGE_PATCH(dt, i) FROM t json null += SELECT JSON_MERGE_PATCH(dt, i, i) FROM t json null += SELECT JSON_MERGE_PATCH(f, f) FROM t json null += SELECT JSON_MERGE_PATCH(f, f, f) FROM t json null += SELECT JSON_MERGE_PATCH(i, i) FROM t json null += SELECT JSON_MERGE_PATCH(i, i, i) FROM t json null += SELECT JSON_MERGE_PATCH(i, s) FROM t json null += SELECT JSON_MERGE_PATCH(i, s, s) FROM t json null += SELECT JSON_MERGE_PATCH(j, j) FROM t json null += SELECT JSON_MERGE_PATCH(j, j, j) FROM t json null += SELECT JSON_MERGE_PATCH(n, n) FROM t json null += SELECT JSON_MERGE_PATCH(n, n, n) FROM t json null += SELECT JSON_MERGE_PATCH(s, i) FROM t json null += SELECT JSON_MERGE_PATCH(s, i, i) FROM t json null += SELECT JSON_MERGE_PATCH(s, s) FROM t json null += SELECT JSON_MERGE_PATCH(s, s, NULL) FROM t json null += SELECT JSON_MERGE_PATCH(s, s, s) FROM t json null += SELECT JSON_MERGE_PATCH(tm, tm) FROM t json null += SELECT JSON_MERGE_PATCH(tm, tm, tm) FROM t json null += SELECT JSON_MERGE_PATCH(u, u) FROM t json null += SELECT JSON_MERGE_PATCH(u, u, u) FROM t json null += SELECT JSON_MERGE_PRESERVE(b, b) FROM t json null += SELECT JSON_MERGE_PRESERVE(b, b, b) FROM t json null += SELECT JSON_MERGE_PRESERVE(d, d) FROM t json null += SELECT JSON_MERGE_PRESERVE(d, d, d) FROM t json null += SELECT JSON_MERGE_PRESERVE(da, da) FROM t json null += SELECT JSON_MERGE_PRESERVE(da, da, da) FROM t json null += SELECT JSON_MERGE_PRESERVE(dt, dt) FROM t json null += SELECT JSON_MERGE_PRESERVE(dt, dt, dt) FROM t json null += SELECT JSON_MERGE_PRESERVE(dt, i) FROM t json null += SELECT JSON_MERGE_PRESERVE(dt, i, i) FROM t json null += SELECT JSON_MERGE_PRESERVE(f, f) FROM t json null += SELECT JSON_MERGE_PRESERVE(f, f, f) FROM t json null += SELECT JSON_MERGE_PRESERVE(i, i) FROM t json null += SELECT JSON_MERGE_PRESERVE(i, i, i) FROM t json null += SELECT JSON_MERGE_PRESERVE(i, s) FROM t json null += SELECT JSON_MERGE_PRESERVE(i, s, s) FROM t json null += SELECT JSON_MERGE_PRESERVE(j, j) FROM t json null += SELECT JSON_MERGE_PRESERVE(j, j, j) FROM t json null += SELECT JSON_MERGE_PRESERVE(n, n) FROM t json null += SELECT JSON_MERGE_PRESERVE(n, n, n) FROM t json null += SELECT JSON_MERGE_PRESERVE(s, i) FROM t json null += SELECT JSON_MERGE_PRESERVE(s, i, i) FROM t json null += SELECT JSON_MERGE_PRESERVE(s, s) FROM t json null += SELECT JSON_MERGE_PRESERVE(s, s, NULL) FROM t json null += SELECT JSON_MERGE_PRESERVE(s, s, s) FROM t json null += SELECT JSON_MERGE_PRESERVE(tm, tm) FROM t json null += SELECT JSON_MERGE_PRESERVE(tm, tm, tm) FROM t json null += SELECT JSON_MERGE_PRESERVE(u, u) FROM t json null += SELECT JSON_MERGE_PRESERVE(u, u, u) FROM t json null += SELECT JSON_OBJECT() FROM t json null += SELECT JSON_OBJECT(b, b) FROM t json null += SELECT JSON_OBJECT(d, d) FROM t json null += SELECT JSON_OBJECT(da, da) FROM t json null += SELECT JSON_OBJECT(dt, dt) FROM t json null += SELECT JSON_OBJECT(dt, i) FROM t json null += SELECT JSON_OBJECT(f, f) FROM t json null += SELECT JSON_OBJECT(i, i) FROM t json null += SELECT JSON_OBJECT(i, s) FROM t json null += SELECT JSON_OBJECT(j, j) FROM t json null += SELECT JSON_OBJECT(n, n) FROM t json null += SELECT JSON_OBJECT(s, i) FROM t json null += SELECT JSON_OBJECT(s, s) FROM t json null += SELECT JSON_OBJECT(tm, tm) FROM t json null += SELECT JSON_OBJECT(u, u) FROM t json null += SELECT JSON_OVERLAPS(b, b) FROM t bigint += SELECT JSON_OVERLAPS(d, d) FROM t bigint += SELECT JSON_OVERLAPS(da, da) FROM t bigint += SELECT JSON_OVERLAPS(dt, dt) FROM t bigint += SELECT JSON_OVERLAPS(dt, i) FROM t bigint += SELECT JSON_OVERLAPS(f, f) FROM t bigint += SELECT JSON_OVERLAPS(i, i) FROM t bigint += SELECT JSON_OVERLAPS(i, s) FROM t bigint += SELECT JSON_OVERLAPS(j, j) FROM t bigint null += SELECT JSON_OVERLAPS(n, n) FROM t bigint null += SELECT JSON_OVERLAPS(s, i) FROM t bigint += SELECT JSON_OVERLAPS(s, s) FROM t bigint += SELECT JSON_OVERLAPS(tm, tm) FROM t bigint += SELECT JSON_OVERLAPS(u, u) FROM t bigint += SELECT JSON_PRETTY(b) FROM t string null += SELECT JSON_PRETTY(d) FROM t string null += SELECT JSON_PRETTY(da) FROM t string null += SELECT JSON_PRETTY(dt) FROM t string null += SELECT JSON_PRETTY(f) FROM t string null += SELECT JSON_PRETTY(i) FROM t string null += SELECT JSON_PRETTY(j) FROM t string null += SELECT JSON_PRETTY(n) FROM t string null += SELECT JSON_PRETTY(s) FROM t string null += SELECT JSON_PRETTY(tm) FROM t string null += SELECT JSON_PRETTY(u) FROM t string null += SELECT JSON_QUOTE(b) FROM t string null += SELECT JSON_QUOTE(d) FROM t string null += SELECT JSON_QUOTE(da) FROM t string null += SELECT JSON_QUOTE(dt) FROM t string null += SELECT JSON_QUOTE(f) FROM t string null += SELECT JSON_QUOTE(i) FROM t string null += SELECT JSON_QUOTE(j) FROM t string null += SELECT JSON_QUOTE(n) FROM t string null += SELECT JSON_QUOTE(s) FROM t string null += SELECT JSON_QUOTE(tm) FROM t string null += SELECT JSON_QUOTE(u) FROM t string null += SELECT JSON_REMOVE(b, b) FROM t json null += SELECT JSON_REMOVE(b, b, b) FROM t json null += SELECT JSON_REMOVE(d, d) FROM t json null += SELECT JSON_REMOVE(d, d, d) FROM t json null += SELECT JSON_REMOVE(da, da) FROM t json null += SELECT JSON_REMOVE(da, da, da) FROM t json null += SELECT JSON_REMOVE(dt, dt) FROM t json null += SELECT JSON_REMOVE(dt, dt, dt) FROM t json null += SELECT JSON_REMOVE(dt, i) FROM t json null += SELECT JSON_REMOVE(dt, i, i) FROM t json null += SELECT JSON_REMOVE(f, f) FROM t json null += SELECT JSON_REMOVE(f, f, f) FROM t json null += SELECT JSON_REMOVE(i, i) FROM t json null += SELECT JSON_REMOVE(i, i, i) FROM t json null += SELECT JSON_REMOVE(i, s) FROM t json null += SELECT JSON_REMOVE(i, s, s) FROM t json null += SELECT JSON_REMOVE(j, j) FROM t json null += SELECT JSON_REMOVE(j, j, j) FROM t json null += SELECT JSON_REMOVE(n, n) FROM t json null += SELECT JSON_REMOVE(n, n, n) FROM t json null += SELECT JSON_REMOVE(s, i) FROM t json null += SELECT JSON_REMOVE(s, i, i) FROM t json null += SELECT JSON_REMOVE(s, s) FROM t json null += SELECT JSON_REMOVE(s, s, NULL) FROM t json null += SELECT JSON_REMOVE(s, s, s) FROM t json null += SELECT JSON_REMOVE(tm, tm) FROM t json null += SELECT JSON_REMOVE(tm, tm, tm) FROM t json null += SELECT JSON_REMOVE(u, u) FROM t json null += SELECT JSON_REMOVE(u, u, u) FROM t json null += SELECT JSON_REPLACE(b, b, b) FROM t json null += SELECT JSON_REPLACE(d, d, d) FROM t json null += SELECT JSON_REPLACE(da, da, da) FROM t json null += SELECT JSON_REPLACE(dt, dt, dt) FROM t json null += SELECT JSON_REPLACE(dt, i, i) FROM t json null += SELECT JSON_REPLACE(f, f, f) FROM t json null += SELECT JSON_REPLACE(i, i, i) FROM t json null += SELECT JSON_REPLACE(i, s, s) FROM t json null += SELECT JSON_REPLACE(j, j, j) FROM t json null += SELECT JSON_REPLACE(n, n, n) FROM t json null += SELECT JSON_REPLACE(s, i, i) FROM t json null += SELECT JSON_REPLACE(s, s, NULL) FROM t json null += SELECT JSON_REPLACE(s, s, s) FROM t json null += SELECT JSON_REPLACE(tm, tm, tm) FROM t json null += SELECT JSON_REPLACE(u, u, u) FROM t json null += SELECT JSON_SCHEMA_VALID(b, b) FROM t bigint += SELECT JSON_SCHEMA_VALID(j, j) FROM t bigint null += SELECT JSON_SCHEMA_VALID(s, s) FROM t bigint += SELECT JSON_SCHEMA_VALIDATION_REPORT(b, b) FROM t json null += SELECT JSON_SCHEMA_VALIDATION_REPORT(j, j) FROM t json null += SELECT JSON_SCHEMA_VALIDATION_REPORT(s, s) FROM t json null += SELECT JSON_SEARCH(b, b, b) FROM t json null += SELECT JSON_SEARCH(d, d, d) FROM t json null += SELECT JSON_SEARCH(da, da, da) FROM t json null += SELECT JSON_SEARCH(dt, dt, dt) FROM t json null += SELECT JSON_SEARCH(dt, i, i) FROM t json null += SELECT JSON_SEARCH(f, f, f) FROM t json null += SELECT JSON_SEARCH(i, i, i) FROM t json null += SELECT JSON_SEARCH(i, s, s) FROM t json null += SELECT JSON_SEARCH(j, j, j) FROM t json null += SELECT JSON_SEARCH(n, n, n) FROM t json null += SELECT JSON_SEARCH(s, i, i) FROM t json null += SELECT JSON_SEARCH(s, s, NULL) FROM t json null += SELECT JSON_SEARCH(s, s, NULL, NULL) FROM t json null += SELECT JSON_SEARCH(s, s, s) FROM t json null += SELECT JSON_SEARCH(tm, tm, tm) FROM t json null += SELECT JSON_SEARCH(u, u, u) FROM t json null += SELECT JSON_SET(b, b, b) FROM t json null += SELECT JSON_SET(d, d, d) FROM t json null += SELECT JSON_SET(da, da, da) FROM t json null += SELECT JSON_SET(dt, dt, dt) FROM t json null += SELECT JSON_SET(dt, i, i) FROM t json null += SELECT JSON_SET(f, f, f) FROM t json null += SELECT JSON_SET(i, i, i) FROM t json null += SELECT JSON_SET(i, s, s) FROM t json null += SELECT JSON_SET(j, j, j) FROM t json null += SELECT JSON_SET(n, n, n) FROM t json null += SELECT JSON_SET(s, i, i) FROM t json null += SELECT JSON_SET(s, s, NULL) FROM t json null += SELECT JSON_SET(s, s, s) FROM t json null += SELECT JSON_SET(tm, tm, tm) FROM t json null += SELECT JSON_SET(u, u, u) FROM t json null += SELECT JSON_STORAGE_FREE(b) FROM t bigint += SELECT JSON_STORAGE_FREE(d) FROM t bigint += SELECT JSON_STORAGE_FREE(da) FROM t bigint += SELECT JSON_STORAGE_FREE(dt) FROM t bigint += SELECT JSON_STORAGE_FREE(f) FROM t bigint += SELECT JSON_STORAGE_FREE(i) FROM t bigint += SELECT JSON_STORAGE_FREE(j) FROM t bigint null += SELECT JSON_STORAGE_FREE(n) FROM t bigint null += SELECT JSON_STORAGE_FREE(s) FROM t bigint += SELECT JSON_STORAGE_FREE(tm) FROM t bigint += SELECT JSON_STORAGE_FREE(u) FROM t bigint += SELECT JSON_STORAGE_SIZE(b) FROM t bigint null += SELECT JSON_STORAGE_SIZE(d) FROM t bigint null += SELECT JSON_STORAGE_SIZE(da) FROM t bigint null += SELECT JSON_STORAGE_SIZE(dt) FROM t bigint null += SELECT JSON_STORAGE_SIZE(f) FROM t bigint null += SELECT JSON_STORAGE_SIZE(i) FROM t bigint null += SELECT JSON_STORAGE_SIZE(j) FROM t bigint null += SELECT JSON_STORAGE_SIZE(n) FROM t bigint null += SELECT JSON_STORAGE_SIZE(s) FROM t bigint null += SELECT JSON_STORAGE_SIZE(tm) FROM t bigint null += SELECT JSON_STORAGE_SIZE(u) FROM t bigint null += SELECT JSON_TYPE(b) FROM t string null += SELECT JSON_TYPE(d) FROM t string null += SELECT JSON_TYPE(da) FROM t string null += SELECT JSON_TYPE(dt) FROM t string null += SELECT JSON_TYPE(f) FROM t string null += SELECT JSON_TYPE(i) FROM t string null += SELECT JSON_TYPE(j) FROM t string null += SELECT JSON_TYPE(n) FROM t string null += SELECT JSON_TYPE(s) FROM t string null += SELECT JSON_TYPE(tm) FROM t string null += SELECT JSON_TYPE(u) FROM t string null += SELECT JSON_UNQUOTE(b) FROM t string null += SELECT JSON_UNQUOTE(d) FROM t string null += SELECT JSON_UNQUOTE(da) FROM t string null += SELECT JSON_UNQUOTE(dt) FROM t string null += SELECT JSON_UNQUOTE(f) FROM t string null += SELECT JSON_UNQUOTE(i) FROM t string null += SELECT JSON_UNQUOTE(j) FROM t string null += SELECT JSON_UNQUOTE(n) FROM t string null += SELECT JSON_UNQUOTE(s) FROM t string null += SELECT JSON_UNQUOTE(tm) FROM t string null += SELECT JSON_UNQUOTE(u) FROM t string null += SELECT JSON_VALID(b) FROM t bigint null += SELECT JSON_VALID(d) FROM t bigint null += SELECT JSON_VALID(da) FROM t bigint null += SELECT JSON_VALID(dt) FROM t bigint null += SELECT JSON_VALID(f) FROM t bigint null += SELECT JSON_VALID(i) FROM t bigint null += SELECT JSON_VALID(j) FROM t bigint null += SELECT JSON_VALID(n) FROM t bigint null += SELECT JSON_VALID(s) FROM t bigint null += SELECT JSON_VALID(tm) FROM t bigint null += SELECT JSON_VALID(u) FROM t bigint null += SELECT LAST_DAY(b) FROM t date null += SELECT LAST_DAY(d) FROM t date null += SELECT LAST_DAY(da) FROM t date null += SELECT LAST_DAY(dt) FROM t date null += SELECT LAST_DAY(f) FROM t date null += SELECT LAST_DAY(i) FROM t date null += SELECT LAST_DAY(j) FROM t date null += SELECT LAST_DAY(n) FROM t date null += SELECT LAST_DAY(s) FROM t date null += SELECT LAST_DAY(tm) FROM t date null += SELECT LAST_DAY(u) FROM t date null += SELECT LAST_INSERT_ID() FROM t bigint unsigned += SELECT LAST_INSERT_ID(b) FROM t bigint unsigned += SELECT LAST_INSERT_ID(d) FROM t bigint unsigned += SELECT LAST_INSERT_ID(da) FROM t bigint unsigned += SELECT LAST_INSERT_ID(dt) FROM t bigint unsigned += SELECT LAST_INSERT_ID(f) FROM t bigint unsigned += SELECT LAST_INSERT_ID(i) FROM t bigint unsigned += SELECT LAST_INSERT_ID(j) FROM t bigint unsigned null += SELECT LAST_INSERT_ID(n) FROM t bigint unsigned null += SELECT LAST_INSERT_ID(s) FROM t bigint unsigned += SELECT LAST_INSERT_ID(tm) FROM t bigint unsigned += SELECT LAST_INSERT_ID(u) FROM t bigint unsigned += SELECT LCASE(b) FROM t binary null += SELECT LCASE(d) FROM t string null += SELECT LCASE(da) FROM t string null += SELECT LCASE(dt) FROM t string null += SELECT LCASE(f) FROM t string null += SELECT LCASE(i) FROM t string null += SELECT LCASE(j) FROM t string null += SELECT LCASE(n) FROM t string null += SELECT LCASE(s) FROM t string null += SELECT LCASE(tm) FROM t string null += SELECT LCASE(u) FROM t string null += SELECT LEAST(b, b) FROM t binary += SELECT LEAST(b, b, b) FROM t binary += SELECT LEAST(d, d) FROM t decimal += SELECT LEAST(d, d, d) FROM t decimal += SELECT LEAST(da, da) FROM t date += SELECT LEAST(da, da, da) FROM t date += SELECT LEAST(dt, dt) FROM t datetime += SELECT LEAST(dt, dt, dt) FROM t datetime += SELECT LEAST(dt, i) FROM t string += SELECT LEAST(dt, i, i) FROM t string += SELECT LEAST(f, f) FROM t double += SELECT LEAST(f, f, f) FROM t double += SELECT LEAST(i, i) FROM t int += SELECT LEAST(i, i, i) FROM t int += SELECT LEAST(i, s) FROM t string += SELECT LEAST(i, s, s) FROM t string += SELECT LEAST(j, j) FROM t string null += SELECT LEAST(j, j, j) FROM t string null += SELECT LEAST(n, n) FROM t int null += SELECT LEAST(n, n, n) FROM t int null += SELECT LEAST(s, i) FROM t string += SELECT LEAST(s, i, i) FROM t string += SELECT LEAST(s, s) FROM t string += SELECT LEAST(s, s, NULL) FROM t string null += SELECT LEAST(s, s, s) FROM t string += SELECT LEAST(tm, tm) FROM t time += SELECT LEAST(tm, tm, tm) FROM t time += SELECT LEAST(u, u) FROM t bigint unsigned += SELECT LEAST(u, u, u) FROM t bigint unsigned += SELECT LENGTH(b) FROM t bigint += SELECT LENGTH(d) FROM t bigint += SELECT LENGTH(da) FROM t bigint += SELECT LENGTH(dt) FROM t bigint += SELECT LENGTH(f) FROM t bigint += SELECT LENGTH(i) FROM t bigint += SELECT LENGTH(j) FROM t bigint null += SELECT LENGTH(n) FROM t bigint null += SELECT LENGTH(s) FROM t bigint += SELECT LENGTH(tm) FROM t bigint += SELECT LENGTH(u) FROM t bigint += SELECT LN(b) FROM t double null += SELECT LN(d) FROM t double null += SELECT LN(da) FROM t double null += SELECT LN(dt) FROM t double null += SELECT LN(f) FROM t double null += SELECT LN(i) FROM t double null += SELECT LN(j) FROM t double null += SELECT LN(n) FROM t double null += SELECT LN(s) FROM t double null += SELECT LN(tm) FROM t double null += SELECT LN(u) FROM t double null += SELECT LOAD_FILE(b) FROM t binary null += SELECT LOAD_FILE(d) FROM t binary null += SELECT LOAD_FILE(da) FROM t binary null += SELECT LOAD_FILE(dt) FROM t binary null += SELECT LOAD_FILE(f) FROM t binary null += SELECT LOAD_FILE(i) FROM t binary null += SELECT LOAD_FILE(j) FROM t binary null += SELECT LOAD_FILE(n) FROM t binary null += SELECT LOAD_FILE(s) FROM t binary null += SELECT LOAD_FILE(tm) FROM t binary null += SELECT LOAD_FILE(u) FROM t binary null += SELECT LOCATE(b, b) FROM t bigint += SELECT LOCATE(b, b, b) FROM t bigint += SELECT LOCATE(d, d) FROM t bigint += SELECT LOCATE(d, d, d) FROM t bigint += SELECT LOCATE(da, da) FROM t bigint += SELECT LOCATE(da, da, da) FROM t bigint += SELECT LOCATE(dt, dt) FROM t bigint += SELECT LOCATE(dt, dt, dt) FROM t bigint += SELECT LOCATE(dt, i) FROM t bigint += SELECT LOCATE(dt, i, i) FROM t bigint += SELECT LOCATE(f, f) FROM t bigint += SELECT LOCATE(f, f, f) FROM t bigint += SELECT LOCATE(i, i) FROM t bigint += SELECT LOCATE(i, i, i) FROM t bigint += SELECT LOCATE(i, s) FROM t bigint += SELECT LOCATE(i, s, s) FROM t bigint += SELECT LOCATE(j, j) FROM t bigint null += SELECT LOCATE(j, j, j) FROM t bigint null += SELECT LOCATE(n, n) FROM t bigint null += SELECT LOCATE(n, n, n) FROM t bigint null += SELECT LOCATE(s, i) FROM t bigint += SELECT LOCATE(s, i, i) FROM t bigint += SELECT LOCATE(s, s) FROM t bigint += SELECT LOCATE(s, s, NULL) FROM t bigint null += SELECT LOCATE(s, s, s) FROM t bigint += SELECT LOCATE(tm, tm) FROM t bigint += SELECT LOCATE(tm, tm, tm) FROM t bigint += SELECT LOCATE(u, u) FROM t bigint += SELECT LOCATE(u, u, u) FROM t bigint += SELECT LOG(b) FROM t double null += SELECT LOG(b, b) FROM t double null += SELECT LOG(d) FROM t double null += SELECT LOG(d, d) FROM t double null += SELECT LOG(da) FROM t double null += SELECT LOG(da, da) FROM t double null += SELECT LOG(dt) FROM t double null += SELECT LOG(dt, dt) FROM t double null += SELECT LOG(dt, i) FROM t double null += SELECT LOG(f) FROM t double null += SELECT LOG(f, f) FROM t double null += SELECT LOG(i) FROM t double null += SELECT LOG(i, i) FROM t double null += SELECT LOG(i, s) FROM t double null += SELECT LOG(j) FROM t double null += SELECT LOG(j, j) FROM t double null += SELECT LOG(n) FROM t double null += SELECT LOG(n, n) FROM t double null += SELECT LOG(s) FROM t double null += SELECT LOG(s, i) FROM t double null += SELECT LOG(s, s) FROM t double null += SELECT LOG(tm) FROM t double null += SELECT LOG(tm, tm) FROM t double null += SELECT LOG(u) FROM t double null += SELECT LOG(u, u) FROM t double null += SELECT LOG10(b) FROM t double null += SELECT LOG10(d) FROM t double null += SELECT LOG10(da) FROM t double null += SELECT LOG10(dt) FROM t double null += SELECT LOG10(f) FROM t double null += SELECT LOG10(i) FROM t double null += SELECT LOG10(j) FROM t double null += SELECT LOG10(n) FROM t double null += SELECT LOG10(s) FROM t double null += SELECT LOG10(tm) FROM t double null += SELECT LOG10(u) FROM t double null += SELECT LOG2(b) FROM t double null += SELECT LOG2(d) FROM t double null += SELECT LOG2(da) FROM t double null += SELECT LOG2(dt) FROM t double null += SELECT LOG2(f) FROM t double null += SELECT LOG2(i) FROM t double null += SELECT LOG2(j) FROM t double null += SELECT LOG2(n) FROM t double null += SELECT LOG2(s) FROM t double null += SELECT LOG2(tm) FROM t double null += SELECT LOG2(u) FROM t double null += SELECT LOWER(b) FROM t binary null += SELECT LOWER(d) FROM t string null += SELECT LOWER(da) FROM t string null += SELECT LOWER(dt) FROM t string null += SELECT LOWER(f) FROM t string null += SELECT LOWER(i) FROM t string null += SELECT LOWER(j) FROM t string null += SELECT LOWER(n) FROM t string null += SELECT LOWER(s) FROM t string null += SELECT LOWER(tm) FROM t string null += SELECT LOWER(u) FROM t string null += SELECT LPAD(b, b, b) FROM t binary null += SELECT LPAD(d, d, d) FROM t string null += SELECT LPAD(da, da, da) FROM t string null += SELECT LPAD(dt, dt, dt) FROM t string null += SELECT LPAD(dt, i, i) FROM t string null += SELECT LPAD(f, f, f) FROM t string null += SELECT LPAD(i, i, i) FROM t string null += SELECT LPAD(i, s, s) FROM t string null += SELECT LPAD(j, j, j) FROM t string null += SELECT LPAD(n, n, n) FROM t string null += SELECT LPAD(s, i, i) FROM t string null += SELECT LPAD(s, s, NULL) FROM t string null += SELECT LPAD(s, s, s) FROM t string null += SELECT LPAD(tm, tm, tm) FROM t string null += SELECT LPAD(u, u, u) FROM t string null += SELECT LTRIM(b) FROM t binary null += SELECT LTRIM(d) FROM t string null += SELECT LTRIM(da) FROM t string null += SELECT LTRIM(dt) FROM t string null += SELECT LTRIM(f) FROM t string null += SELECT LTRIM(i) FROM t string null += SELECT LTRIM(j) FROM t string null += SELECT LTRIM(n) FROM t string null += SELECT LTRIM(s) FROM t string null += SELECT LTRIM(tm) FROM t string null += SELECT LTRIM(u) FROM t string null += SELECT MAKEDATE(b, b) FROM t date null += SELECT MAKEDATE(d, d) FROM t date null += SELECT MAKEDATE(da, da) FROM t date null += SELECT MAKEDATE(dt, dt) FROM t date null += SELECT MAKEDATE(dt, i) FROM t date null += SELECT MAKEDATE(f, f) FROM t date null += SELECT MAKEDATE(i, i) FROM t date null += SELECT MAKEDATE(i, s) FROM t date null += SELECT MAKEDATE(j, j) FROM t date null += SELECT MAKEDATE(n, n) FROM t date null += SELECT MAKEDATE(s, i) FROM t date null += SELECT MAKEDATE(s, s) FROM t date null += SELECT MAKEDATE(tm, tm) FROM t date null += SELECT MAKEDATE(u, u) FROM t date null += SELECT MAKETIME(b, b, b) FROM t time null += SELECT MAKETIME(d, d, d) FROM t time null += SELECT MAKETIME(da, da, da) FROM t time null += SELECT MAKETIME(dt, dt, dt) FROM t time null += SELECT MAKETIME(dt, i, i) FROM t time null += SELECT MAKETIME(f, f, f) FROM t time null += SELECT MAKETIME(i, i, i) FROM t time null += SELECT MAKETIME(i, s, s) FROM t time null += SELECT MAKETIME(j, j, j) FROM t time null += SELECT MAKETIME(n, n, n) FROM t time null += SELECT MAKETIME(s, i, i) FROM t time null += SELECT MAKETIME(s, s, NULL) FROM t time null += SELECT MAKETIME(s, s, s) FROM t time null += SELECT MAKETIME(tm, tm, tm) FROM t time null += SELECT MAKETIME(u, u, u) FROM t time null += SELECT MAKE_SET(b, b) FROM t binary += SELECT MAKE_SET(b, b, b) FROM t binary += SELECT MAKE_SET(d, d) FROM t string += SELECT MAKE_SET(d, d, d) FROM t string += SELECT MAKE_SET(da, da) FROM t string += SELECT MAKE_SET(da, da, da) FROM t string += SELECT MAKE_SET(dt, dt) FROM t string += SELECT MAKE_SET(dt, dt, dt) FROM t string += SELECT MAKE_SET(dt, i) FROM t string += SELECT MAKE_SET(dt, i, i) FROM t string += SELECT MAKE_SET(f, f) FROM t string += SELECT MAKE_SET(f, f, f) FROM t string += SELECT MAKE_SET(i, i) FROM t string += SELECT MAKE_SET(i, i, i) FROM t string += SELECT MAKE_SET(i, s) FROM t string += SELECT MAKE_SET(i, s, s) FROM t string += SELECT MAKE_SET(j, j) FROM t string null += SELECT MAKE_SET(j, j, j) FROM t string null += SELECT MAKE_SET(n, n) FROM t string null += SELECT MAKE_SET(n, n, n) FROM t string null += SELECT MAKE_SET(s, i) FROM t string += SELECT MAKE_SET(s, i, i) FROM t string += SELECT MAKE_SET(s, s) FROM t string += SELECT MAKE_SET(s, s, NULL) FROM t string null += SELECT MAKE_SET(s, s, s) FROM t string += SELECT MAKE_SET(tm, tm) FROM t string += SELECT MAKE_SET(tm, tm, tm) FROM t string += SELECT MAKE_SET(u, u) FROM t string += SELECT MAKE_SET(u, u, u) FROM t string += SELECT MASTER_POS_WAIT(b, b) FROM t bigint null += SELECT MASTER_POS_WAIT(b, b, b) FROM t bigint null += SELECT MASTER_POS_WAIT(b, b, b, b) FROM t bigint null += SELECT MASTER_POS_WAIT(d, d) FROM t bigint null += SELECT MASTER_POS_WAIT(d, d, d) FROM t bigint null += SELECT MASTER_POS_WAIT(d, d, d, d) FROM t bigint null += SELECT MASTER_POS_WAIT(da, da) FROM t bigint null += SELECT MASTER_POS_WAIT(da, da, da) FROM t bigint null += SELECT MASTER_POS_WAIT(da, da, da, da) FROM t bigint null += SELECT MASTER_POS_WAIT(dt, dt) FROM t bigint null += SELECT MASTER_POS_WAIT(dt, dt, dt) FROM t bigint null += SELECT MASTER_POS_WAIT(dt, dt, dt, dt) FROM t bigint null += SELECT MASTER_POS_WAIT(dt, i) FROM t bigint null += SELECT MASTER_POS_WAIT(dt, i, i) FROM t bigint null += SELECT MASTER_POS_WAIT(dt, i, i, i) FROM t bigint null += SELECT MASTER_POS_WAIT(f, f) FROM t bigint null += SELECT MASTER_POS_WAIT(f, f, f) FROM t bigint null += SELECT MASTER_POS_WAIT(f, f, f, f) FROM t bigint null += SELECT MASTER_POS_WAIT(i, i) FROM t bigint null += SELECT MASTER_POS_WAIT(i, i, i) FROM t bigint null += SELECT MASTER_POS_WAIT(i, i, i, i) FROM t bigint null += SELECT MASTER_POS_WAIT(i, s) FROM t bigint null += SELECT MASTER_POS_WAIT(i, s, s) FROM t bigint null += SELECT MASTER_POS_WAIT(i, s, s, s) FROM t bigint null += SELECT MASTER_POS_WAIT(j, j) FROM t bigint null += SELECT MASTER_POS_WAIT(j, j, j) FROM t bigint null += SELECT MASTER_POS_WAIT(j, j, j, j) FROM t bigint null += SELECT MASTER_POS_WAIT(n, n) FROM t bigint null += SELECT MASTER_POS_WAIT(n, n, n) FROM t bigint null += SELECT MASTER_POS_WAIT(n, n, n, n) FROM t bigint null += SELECT MASTER_POS_WAIT(s, i) FROM t bigint null += SELECT MASTER_POS_WAIT(s, i, i) FROM t bigint null += SELECT MASTER_POS_WAIT(s, i, i, i) FROM t bigint null += SELECT MASTER_POS_WAIT(s, s) FROM t bigint null += SELECT MASTER_POS_WAIT(s, s, NULL) FROM t bigint null += SELECT MASTER_POS_WAIT(s, s, NULL, NULL) FROM t bigint null += SELECT MASTER_POS_WAIT(s, s, s) FROM t bigint null += SELECT MASTER_POS_WAIT(s, s, s, s) FROM t bigint null += SELECT MASTER_POS_WAIT(tm, tm) FROM t bigint null += SELECT MASTER_POS_WAIT(tm, tm, tm) FROM t bigint null += SELECT MASTER_POS_WAIT(tm, tm, tm, tm) FROM t bigint null += SELECT MASTER_POS_WAIT(u, u) FROM t bigint null += SELECT MASTER_POS_WAIT(u, u, u) FROM t bigint null += SELECT MASTER_POS_WAIT(u, u, u, u) FROM t bigint null += SELECT MBRCONTAINS(b, b) FROM t bigint null += SELECT MBRCONTAINS(d, d) FROM t bigint null += SELECT MBRCONTAINS(da, da) FROM t bigint null += SELECT MBRCONTAINS(dt, dt) FROM t bigint null += SELECT MBRCONTAINS(dt, i) FROM t bigint null += SELECT MBRCONTAINS(f, f) FROM t bigint null += SELECT MBRCONTAINS(i, i) FROM t bigint null += SELECT MBRCONTAINS(i, s) FROM t bigint null += SELECT MBRCONTAINS(j, j) FROM t bigint null += SELECT MBRCONTAINS(n, n) FROM t bigint null += SELECT MBRCONTAINS(s, i) FROM t bigint null += SELECT MBRCONTAINS(s, s) FROM t bigint null += SELECT MBRCONTAINS(tm, tm) FROM t bigint null += SELECT MBRCONTAINS(u, u) FROM t bigint null += SELECT MBRCOVEREDBY(b, b) FROM t bigint null += SELECT MBRCOVEREDBY(d, d) FROM t bigint null += SELECT MBRCOVEREDBY(da, da) FROM t bigint null += SELECT MBRCOVEREDBY(dt, dt) FROM t bigint null += SELECT MBRCOVEREDBY(dt, i) FROM t bigint null += SELECT MBRCOVEREDBY(f, f) FROM t bigint null += SELECT MBRCOVEREDBY(i, i) FROM t bigint null += SELECT MBRCOVEREDBY(i, s) FROM t bigint null += SELECT MBRCOVEREDBY(j, j) FROM t bigint null += SELECT MBRCOVEREDBY(n, n) FROM t bigint null += SELECT MBRCOVEREDBY(s, i) FROM t bigint null += SELECT MBRCOVEREDBY(s, s) FROM t bigint null += SELECT MBRCOVEREDBY(tm, tm) FROM t bigint null += SELECT MBRCOVEREDBY(u, u) FROM t bigint null += SELECT MBRCOVERS(b, b) FROM t bigint null += SELECT MBRCOVERS(d, d) FROM t bigint null += SELECT MBRCOVERS(da, da) FROM t bigint null += SELECT MBRCOVERS(dt, dt) FROM t bigint null += SELECT MBRCOVERS(dt, i) FROM t bigint null += SELECT MBRCOVERS(f, f) FROM t bigint null += SELECT MBRCOVERS(i, i) FROM t bigint null += SELECT MBRCOVERS(i, s) FROM t bigint null += SELECT MBRCOVERS(j, j) FROM t bigint null += SELECT MBRCOVERS(n, n) FROM t bigint null += SELECT MBRCOVERS(s, i) FROM t bigint null += SELECT MBRCOVERS(s, s) FROM t bigint null += SELECT MBRCOVERS(tm, tm) FROM t bigint null += SELECT MBRCOVERS(u, u) FROM t bigint null += SELECT MBRDISJOINT(b, b) FROM t bigint null += SELECT MBRDISJOINT(d, d) FROM t bigint null += SELECT MBRDISJOINT(da, da) FROM t bigint null += SELECT MBRDISJOINT(dt, dt) FROM t bigint null += SELECT MBRDISJOINT(dt, i) FROM t bigint null += SELECT MBRDISJOINT(f, f) FROM t bigint null += SELECT MBRDISJOINT(i, i) FROM t bigint null += SELECT MBRDISJOINT(i, s) FROM t bigint null += SELECT MBRDISJOINT(j, j) FROM t bigint null += SELECT MBRDISJOINT(n, n) FROM t bigint null += SELECT MBRDISJOINT(s, i) FROM t bigint null += SELECT MBRDISJOINT(s, s) FROM t bigint null += SELECT MBRDISJOINT(tm, tm) FROM t bigint null += SELECT MBRDISJOINT(u, u) FROM t bigint null += SELECT MBREQUALS(b, b) FROM t bigint null += SELECT MBREQUALS(d, d) FROM t bigint null += SELECT MBREQUALS(da, da) FROM t bigint null += SELECT MBREQUALS(dt, dt) FROM t bigint null += SELECT MBREQUALS(dt, i) FROM t bigint null += SELECT MBREQUALS(f, f) FROM t bigint null += SELECT MBREQUALS(i, i) FROM t bigint null += SELECT MBREQUALS(i, s) FROM t bigint null += SELECT MBREQUALS(j, j) FROM t bigint null += SELECT MBREQUALS(n, n) FROM t bigint null += SELECT MBREQUALS(s, i) FROM t bigint null += SELECT MBREQUALS(s, s) FROM t bigint null += SELECT MBREQUALS(tm, tm) FROM t bigint null += SELECT MBREQUALS(u, u) FROM t bigint null += SELECT MBRINTERSECTS(b, b) FROM t bigint null += SELECT MBRINTERSECTS(d, d) FROM t bigint null += SELECT MBRINTERSECTS(da, da) FROM t bigint null += SELECT MBRINTERSECTS(dt, dt) FROM t bigint null += SELECT MBRINTERSECTS(dt, i) FROM t bigint null += SELECT MBRINTERSECTS(f, f) FROM t bigint null += SELECT MBRINTERSECTS(i, i) FROM t bigint null += SELECT MBRINTERSECTS(i, s) FROM t bigint null += SELECT MBRINTERSECTS(j, j) FROM t bigint null += SELECT MBRINTERSECTS(n, n) FROM t bigint null += SELECT MBRINTERSECTS(s, i) FROM t bigint null += SELECT MBRINTERSECTS(s, s) FROM t bigint null += SELECT MBRINTERSECTS(tm, tm) FROM t bigint null += SELECT MBRINTERSECTS(u, u) FROM t bigint null += SELECT MBROVERLAPS(b, b) FROM t bigint null += SELECT MBROVERLAPS(d, d) FROM t bigint null += SELECT MBROVERLAPS(da, da) FROM t bigint null += SELECT MBROVERLAPS(dt, dt) FROM t bigint null += SELECT MBROVERLAPS(dt, i) FROM t bigint null += SELECT MBROVERLAPS(f, f) FROM t bigint null += SELECT MBROVERLAPS(i, i) FROM t bigint null += SELECT MBROVERLAPS(i, s) FROM t bigint null += SELECT MBROVERLAPS(j, j) FROM t bigint null += SELECT MBROVERLAPS(n, n) FROM t bigint null += SELECT MBROVERLAPS(s, i) FROM t bigint null += SELECT MBROVERLAPS(s, s) FROM t bigint null += SELECT MBROVERLAPS(tm, tm) FROM t bigint null += SELECT MBROVERLAPS(u, u) FROM t bigint null += SELECT MBRTOUCHES(b, b) FROM t bigint null += SELECT MBRTOUCHES(d, d) FROM t bigint null += SELECT MBRTOUCHES(da, da) FROM t bigint null += SELECT MBRTOUCHES(dt, dt) FROM t bigint null += SELECT MBRTOUCHES(dt, i) FROM t bigint null += SELECT MBRTOUCHES(f, f) FROM t bigint null += SELECT MBRTOUCHES(i, i) FROM t bigint null += SELECT MBRTOUCHES(i, s) FROM t bigint null += SELECT MBRTOUCHES(j, j) FROM t bigint null += SELECT MBRTOUCHES(n, n) FROM t bigint null += SELECT MBRTOUCHES(s, i) FROM t bigint null += SELECT MBRTOUCHES(s, s) FROM t bigint null += SELECT MBRTOUCHES(tm, tm) FROM t bigint null += SELECT MBRTOUCHES(u, u) FROM t bigint null += SELECT MBRWITHIN(b, b) FROM t bigint null += SELECT MBRWITHIN(d, d) FROM t bigint null += SELECT MBRWITHIN(da, da) FROM t bigint null += SELECT MBRWITHIN(dt, dt) FROM t bigint null += SELECT MBRWITHIN(dt, i) FROM t bigint null += SELECT MBRWITHIN(f, f) FROM t bigint null += SELECT MBRWITHIN(i, i) FROM t bigint null += SELECT MBRWITHIN(i, s) FROM t bigint null += SELECT MBRWITHIN(j, j) FROM t bigint null += SELECT MBRWITHIN(n, n) FROM t bigint null += SELECT MBRWITHIN(s, i) FROM t bigint null += SELECT MBRWITHIN(s, s) FROM t bigint null += SELECT MBRWITHIN(tm, tm) FROM t bigint null += SELECT MBRWITHIN(u, u) FROM t bigint null += SELECT MD5(b) FROM t string null += SELECT MD5(d) FROM t string null += SELECT MD5(da) FROM t string null += SELECT MD5(dt) FROM t string null += SELECT MD5(f) FROM t string null += SELECT MD5(i) FROM t string null += SELECT MD5(j) FROM t string null += SELECT MD5(n) FROM t string null += SELECT MD5(s) FROM t string null += SELECT MD5(tm) FROM t string null += SELECT MD5(u) FROM t string null += SELECT MONTHNAME(b) FROM t string null += SELECT MONTHNAME(d) FROM t string null += SELECT MONTHNAME(da) FROM t string null += SELECT MONTHNAME(dt) FROM t string null += SELECT MONTHNAME(f) FROM t string null += SELECT MONTHNAME(i) FROM t string null += SELECT MONTHNAME(j) FROM t string null += SELECT MONTHNAME(n) FROM t string null += SELECT MONTHNAME(s) FROM t string null += SELECT MONTHNAME(tm) FROM t string null += SELECT MONTHNAME(u) FROM t string null += SELECT NULLIF(b, b) FROM t binary null += SELECT NULLIF(d, d) FROM t decimal null += SELECT NULLIF(da, da) FROM t string null += SELECT NULLIF(dt, dt) FROM t string null += SELECT NULLIF(dt, i) FROM t string null += SELECT NULLIF(f, f) FROM t double null += SELECT NULLIF(i, i) FROM t int null += SELECT NULLIF(i, s) FROM t int null += SELECT NULLIF(j, j) FROM t json null += SELECT NULLIF(n, n) FROM t int null += SELECT NULLIF(s, i) FROM t string null += SELECT NULLIF(s, s) FROM t string null += SELECT NULLIF(tm, tm) FROM t string null += SELECT NULLIF(u, u) FROM t bigint unsigned null += SELECT OCT(b) FROM t string null += SELECT OCT(d) FROM t string null += SELECT OCT(da) FROM t string null += SELECT OCT(dt) FROM t string null += SELECT OCT(f) FROM t string null += SELECT OCT(i) FROM t string null += SELECT OCT(j) FROM t string null += SELECT OCT(n) FROM t string null += SELECT OCT(s) FROM t string null += SELECT OCT(tm) FROM t string null += SELECT OCT(u) FROM t string null += SELECT OCTET_LENGTH(b) FROM t bigint += SELECT OCTET_LENGTH(d) FROM t bigint += SELECT OCTET_LENGTH(da) FROM t bigint += SELECT OCTET_LENGTH(dt) FROM t bigint += SELECT OCTET_LENGTH(f) FROM t bigint += SELECT OCTET_LENGTH(i) FROM t bigint += SELECT OCTET_LENGTH(j) FROM t bigint null += SELECT OCTET_LENGTH(n) FROM t bigint null += SELECT OCTET_LENGTH(s) FROM t bigint += SELECT OCTET_LENGTH(tm) FROM t bigint += SELECT OCTET_LENGTH(u) FROM t bigint += SELECT ORD(b) FROM t bigint += SELECT ORD(d) FROM t bigint += SELECT ORD(da) FROM t bigint += SELECT ORD(dt) FROM t bigint += SELECT ORD(f) FROM t bigint += SELECT ORD(i) FROM t bigint += SELECT ORD(j) FROM t bigint null += SELECT ORD(n) FROM t bigint null += SELECT ORD(s) FROM t bigint += SELECT ORD(tm) FROM t bigint += SELECT ORD(u) FROM t bigint += SELECT PERIOD_ADD(b, b) FROM t bigint += SELECT PERIOD_ADD(d, d) FROM t bigint += SELECT PERIOD_ADD(da, da) FROM t bigint += SELECT PERIOD_ADD(dt, dt) FROM t bigint += SELECT PERIOD_ADD(dt, i) FROM t bigint += SELECT PERIOD_ADD(f, f) FROM t bigint += SELECT PERIOD_ADD(i, i) FROM t bigint += SELECT PERIOD_ADD(i, s) FROM t bigint += SELECT PERIOD_ADD(j, j) FROM t bigint null += SELECT PERIOD_ADD(n, n) FROM t bigint null += SELECT PERIOD_ADD(s, i) FROM t bigint += SELECT PERIOD_ADD(s, s) FROM t bigint += SELECT PERIOD_ADD(tm, tm) FROM t bigint += SELECT PERIOD_ADD(u, u) FROM t bigint += SELECT PERIOD_DIFF(b, b) FROM t bigint += SELECT PERIOD_DIFF(d, d) FROM t bigint += SELECT PERIOD_DIFF(da, da) FROM t bigint += SELECT PERIOD_DIFF(dt, dt) FROM t bigint += SELECT PERIOD_DIFF(dt, i) FROM t bigint += SELECT PERIOD_DIFF(f, f) FROM t bigint += SELECT PERIOD_DIFF(i, i) FROM t bigint += SELECT PERIOD_DIFF(i, s) FROM t bigint += SELECT PERIOD_DIFF(j, j) FROM t bigint null += SELECT PERIOD_DIFF(n, n) FROM t bigint null += SELECT PERIOD_DIFF(s, i) FROM t bigint += SELECT PERIOD_DIFF(s, s) FROM t bigint += SELECT PERIOD_DIFF(tm, tm) FROM t bigint += SELECT PERIOD_DIFF(u, u) FROM t bigint += SELECT PI() FROM t double += SELECT POW(b, b) FROM t double null += SELECT POW(d, d) FROM t double null += SELECT POW(da, da) FROM t double null += SELECT POW(dt, dt) FROM t double null += SELECT POW(dt, i) FROM t double null += SELECT POW(f, f) FROM t double null += SELECT POW(i, i) FROM t double null += SELECT POW(i, s) FROM t double null += SELECT POW(j, j) FROM t double null += SELECT POW(n, n) FROM t double null += SELECT POW(s, i) FROM t double null += SELECT POW(s, s) FROM t double null += SELECT POW(tm, tm) FROM t double null += SELECT POW(u, u) FROM t double null += SELECT POWER(b, b) FROM t double null += SELECT POWER(d, d) FROM t double null += SELECT POWER(da, da) FROM t double null += SELECT POWER(dt, dt) FROM t double null += SELECT POWER(dt, i) FROM t double null += SELECT POWER(f, f) FROM t double null += SELECT POWER(i, i) FROM t double null += SELECT POWER(i, s) FROM t double null += SELECT POWER(j, j) FROM t double null += SELECT POWER(n, n) FROM t double null += SELECT POWER(s, i) FROM t double null += SELECT POWER(s, s) FROM t double null += SELECT POWER(tm, tm) FROM t double null += SELECT POWER(u, u) FROM t double null += SELECT PS_CURRENT_THREAD_ID() FROM t bigint unsigned null += SELECT PS_THREAD_ID(b) FROM t bigint unsigned null += SELECT PS_THREAD_ID(d) FROM t bigint unsigned null += SELECT PS_THREAD_ID(da) FROM t bigint unsigned null += SELECT PS_THREAD_ID(dt) FROM t bigint unsigned null += SELECT PS_THREAD_ID(f) FROM t bigint unsigned null += SELECT PS_THREAD_ID(i) FROM t bigint unsigned null += SELECT PS_THREAD_ID(j) FROM t bigint unsigned null += SELECT PS_THREAD_ID(n) FROM t bigint unsigned null += SELECT PS_THREAD_ID(s) FROM t bigint unsigned null += SELECT PS_THREAD_ID(tm) FROM t bigint unsigned null += SELECT PS_THREAD_ID(u) FROM t bigint unsigned null += SELECT QUOTE(b) FROM t string null += SELECT QUOTE(d) FROM t string null += SELECT QUOTE(da) FROM t string null += SELECT QUOTE(dt) FROM t string null += SELECT QUOTE(f) FROM t string null += SELECT QUOTE(i) FROM t string null += SELECT QUOTE(j) FROM t string null += SELECT QUOTE(n) FROM t string null += SELECT QUOTE(s) FROM t string null += SELECT QUOTE(tm) FROM t string null += SELECT QUOTE(u) FROM t string null += SELECT RADIANS(b) FROM t double += SELECT RADIANS(d) FROM t double += SELECT RADIANS(da) FROM t double += SELECT RADIANS(dt) FROM t double += SELECT RADIANS(f) FROM t double += SELECT RADIANS(i) FROM t double += SELECT RADIANS(j) FROM t double null += SELECT RADIANS(n) FROM t double null += SELECT RADIANS(s) FROM t double += SELECT RADIANS(tm) FROM t double += SELECT RADIANS(u) FROM t double += SELECT RAND() FROM t double += SELECT RAND(b) FROM t double += SELECT RAND(d) FROM t double += SELECT RAND(da) FROM t double += SELECT RAND(dt) FROM t double += SELECT RAND(f) FROM t double += SELECT RAND(i) FROM t double += SELECT RAND(j) FROM t double null += SELECT RAND(n) FROM t double null += SELECT RAND(s) FROM t double += SELECT RAND(tm) FROM t double += SELECT RAND(u) FROM t double += SELECT RANDOM_BYTES(b) FROM t binary null += SELECT RANDOM_BYTES(d) FROM t binary null += SELECT RANDOM_BYTES(da) FROM t binary null += SELECT RANDOM_BYTES(dt) FROM t binary null += SELECT RANDOM_BYTES(f) FROM t binary null += SELECT RANDOM_BYTES(i) FROM t binary null += SELECT RANDOM_BYTES(j) FROM t binary null += SELECT RANDOM_BYTES(n) FROM t binary null += SELECT RANDOM_BYTES(s) FROM t binary null += SELECT RANDOM_BYTES(tm) FROM t binary null += SELECT RANDOM_BYTES(u) FROM t binary null += SELECT REGEXP_INSTR(b, b) FROM t bigint += SELECT REGEXP_INSTR(b, b, b) FROM t bigint += SELECT REGEXP_INSTR(b, b, b, b) FROM t bigint += SELECT REGEXP_INSTR(d, d) FROM t bigint += SELECT REGEXP_INSTR(d, d, d) FROM t bigint += SELECT REGEXP_INSTR(d, d, d, d) FROM t bigint += SELECT REGEXP_INSTR(da, da) FROM t bigint += SELECT REGEXP_INSTR(da, da, da) FROM t bigint += SELECT REGEXP_INSTR(da, da, da, da) FROM t bigint += SELECT REGEXP_INSTR(dt, dt) FROM t bigint += SELECT REGEXP_INSTR(dt, dt, dt) FROM t bigint += SELECT REGEXP_INSTR(dt, dt, dt, dt) FROM t bigint += SELECT REGEXP_INSTR(dt, i) FROM t bigint += SELECT REGEXP_INSTR(dt, i, i) FROM t bigint += SELECT REGEXP_INSTR(dt, i, i, i) FROM t bigint += SELECT REGEXP_INSTR(f, f) FROM t bigint += SELECT REGEXP_INSTR(f, f, f) FROM t bigint += SELECT REGEXP_INSTR(f, f, f, f) FROM t bigint += SELECT REGEXP_INSTR(i, i) FROM t bigint += SELECT REGEXP_INSTR(i, i, i) FROM t bigint += SELECT REGEXP_INSTR(i, i, i, i) FROM t bigint += SELECT REGEXP_INSTR(i, s) FROM t bigint += SELECT REGEXP_INSTR(i, s, s) FROM t bigint += SELECT REGEXP_INSTR(i, s, s, s) FROM t bigint += SELECT REGEXP_INSTR(j, j) FROM t bigint null += SELECT REGEXP_INSTR(j, j, j) FROM t bigint null += SELECT REGEXP_INSTR(j, j, j, j) FROM t bigint null += SELECT REGEXP_INSTR(n, n) FROM t bigint null += SELECT REGEXP_INSTR(n, n, n) FROM t bigint null += SELECT REGEXP_INSTR(n, n, n, n) FROM t bigint null += SELECT REGEXP_INSTR(s, i) FROM t bigint += SELECT REGEXP_INSTR(s, i, i) FROM t bigint += SELECT REGEXP_INSTR(s, i, i, i) FROM t bigint += SELECT REGEXP_INSTR(s, s) FROM t bigint += SELECT REGEXP_INSTR(s, s, NULL) FROM t bigint null += SELECT REGEXP_INSTR(s, s, NULL, NULL) FROM t bigint null += SELECT REGEXP_INSTR(s, s, s) FROM t bigint += SELECT REGEXP_INSTR(s, s, s, s) FROM t bigint += SELECT REGEXP_INSTR(tm, tm) FROM t bigint += SELECT REGEXP_INSTR(tm, tm, tm) FROM t bigint += SELECT REGEXP_INSTR(tm, tm, tm, tm) FROM t bigint += SELECT REGEXP_INSTR(u, u) FROM t bigint += SELECT REGEXP_INSTR(u, u, u) FROM t bigint += SELECT REGEXP_INSTR(u, u, u, u) FROM t bigint += SELECT REGEXP_LIKE(b, b) FROM t bigint += SELECT REGEXP_LIKE(b, b, b) FROM t bigint += SELECT REGEXP_LIKE(d, d) FROM t bigint += SELECT REGEXP_LIKE(d, d, d) FROM t bigint += SELECT REGEXP_LIKE(da, da) FROM t bigint += SELECT REGEXP_LIKE(da, da, da) FROM t bigint += SELECT REGEXP_LIKE(dt, dt) FROM t bigint += SELECT REGEXP_LIKE(dt, dt, dt) FROM t bigint += SELECT REGEXP_LIKE(dt, i) FROM t bigint += SELECT REGEXP_LIKE(dt, i, i) FROM t bigint += SELECT REGEXP_LIKE(f, f) FROM t bigint += SELECT REGEXP_LIKE(f, f, f) FROM t bigint += SELECT REGEXP_LIKE(i, i) FROM t bigint += SELECT REGEXP_LIKE(i, i, i) FROM t bigint += SELECT REGEXP_LIKE(i, s) FROM t bigint += SELECT REGEXP_LIKE(i, s, s) FROM t bigint += SELECT REGEXP_LIKE(j, j) FROM t bigint null += SELECT REGEXP_LIKE(j, j, j) FROM t bigint null += SELECT REGEXP_LIKE(n, n) FROM t bigint null += SELECT REGEXP_LIKE(n, n, n) FROM t bigint null += SELECT REGEXP_LIKE(s, i) FROM t bigint += SELECT REGEXP_LIKE(s, i, i) FROM t bigint += SELECT REGEXP_LIKE(s, s) FROM t bigint += SELECT REGEXP_LIKE(s, s, NULL) FROM t bigint null += SELECT REGEXP_LIKE(s, s, s) FROM t bigint += SELECT REGEXP_LIKE(tm, tm) FROM t bigint += SELECT REGEXP_LIKE(tm, tm, tm) FROM t bigint += SELECT REGEXP_LIKE(u, u) FROM t bigint += SELECT REGEXP_LIKE(u, u, u) FROM t bigint += SELECT REGEXP_REPLACE(b, b, b) FROM t binary += SELECT REGEXP_REPLACE(b, b, b, b) FROM t binary += SELECT REGEXP_REPLACE(b, b, b, b, b) FROM t binary += SELECT REGEXP_REPLACE(d, d, d) FROM t string += SELECT REGEXP_REPLACE(d, d, d, d) FROM t string += SELECT REGEXP_REPLACE(d, d, d, d, d) FROM t string += SELECT REGEXP_REPLACE(da, da, da) FROM t string += SELECT REGEXP_REPLACE(da, da, da, da) FROM t string += SELECT REGEXP_REPLACE(da, da, da, da, da) FROM t string += SELECT REGEXP_REPLACE(dt, dt, dt) FROM t string += SELECT REGEXP_REPLACE(dt, dt, dt, dt) FROM t string += SELECT REGEXP_REPLACE(dt, dt, dt, dt, dt) FROM t string += SELECT REGEXP_REPLACE(dt, i, i) FROM t string += SELECT REGEXP_REPLACE(dt, i, i, i) FROM t string += SELECT REGEXP_REPLACE(dt, i, i, i, i) FROM t string += SELECT REGEXP_REPLACE(f, f, f) FROM t string += SELECT REGEXP_REPLACE(f, f, f, f) FROM t string += SELECT REGEXP_REPLACE(f, f, f, f, f) FROM t string += SELECT REGEXP_REPLACE(i, i, i) FROM t string += SELECT REGEXP_REPLACE(i, i, i, i) FROM t string += SELECT REGEXP_REPLACE(i, i, i, i, i) FROM t string += SELECT REGEXP_REPLACE(i, s, s) FROM t string null += SELECT REGEXP_REPLACE(i, s, s, s) FROM t string null += SELECT REGEXP_REPLACE(i, s, s, s, s) FROM t string null += SELECT REGEXP_REPLACE(j, j, j) FROM t string null += SELECT REGEXP_REPLACE(j, j, j, j) FROM t string null += SELECT REGEXP_REPLACE(j, j, j, j, j) FROM t string null += SELECT REGEXP_REPLACE(n, n, n) FROM t string null += SELECT REGEXP_REPLACE(n, n, n, n) FROM t string null += SELECT REGEXP_REPLACE(n, n, n, n, n) FROM t string null += SELECT REGEXP_REPLACE(s, i, i) FROM t string null += SELECT REGEXP_REPLACE(s, i, i, i) FROM t string null += SELECT REGEXP_REPLACE(s, i, i, i, i) FROM t string null += SELECT REGEXP_REPLACE(s, s, NULL) FROM t string null += SELECT REGEXP_REPLACE(s, s, NULL, NULL) FROM t string null += SELECT REGEXP_REPLACE(s, s, NULL, NULL, NULL) FROM t string null += SELECT REGEXP_REPLACE(s, s, s) FROM t string null += SELECT REGEXP_REPLACE(s, s, s, s) FROM t string null += SELECT REGEXP_REPLACE(s, s, s, s, s) FROM t string null += SELECT REGEXP_REPLACE(tm, tm, tm) FROM t string += SELECT REGEXP_REPLACE(tm, tm, tm, tm) FROM t string += SELECT REGEXP_REPLACE(tm, tm, tm, tm, tm) FROM t string += SELECT REGEXP_REPLACE(u, u, u) FROM t string += SELECT REGEXP_REPLACE(u, u, u, u) FROM t string += SELECT REGEXP_REPLACE(u, u, u, u, u) FROM t string += SELECT REGEXP_SUBSTR(b, b) FROM t binary null += SELECT REGEXP_SUBSTR(b, b, b) FROM t binary null += SELECT REGEXP_SUBSTR(b, b, b, b) FROM t binary null += SELECT REGEXP_SUBSTR(d, d) FROM t string null += SELECT REGEXP_SUBSTR(d, d, d) FROM t string null += SELECT REGEXP_SUBSTR(d, d, d, d) FROM t string null += SELECT REGEXP_SUBSTR(da, da) FROM t string null += SELECT REGEXP_SUBSTR(da, da, da) FROM t string null += SELECT REGEXP_SUBSTR(da, da, da, da) FROM t string null += SELECT REGEXP_SUBSTR(dt, dt) FROM t string null += SELECT REGEXP_SUBSTR(dt, dt, dt) FROM t string null += SELECT REGEXP_SUBSTR(dt, dt, dt, dt) FROM t string null += SELECT REGEXP_SUBSTR(dt, i) FROM t string null += SELECT REGEXP_SUBSTR(dt, i, i) FROM t string null += SELECT REGEXP_SUBSTR(dt, i, i, i) FROM t string null += SELECT REGEXP_SUBSTR(f, f) FROM t string null += SELECT REGEXP_SUBSTR(f, f, f) FROM t string null += SELECT REGEXP_SUBSTR(f, f, f, f) FROM t string null += SELECT REGEXP_SUBSTR(i, i) FROM t string null += SELECT REGEXP_SUBSTR(i, i, i) FROM t string null += SELECT REGEXP_SUBSTR(i, i, i, i) FROM t string null += SELECT REGEXP_SUBSTR(i, s) FROM t string null += SELECT REGEXP_SUBSTR(i, s, s) FROM t string null += SELECT REGEXP_SUBSTR(i, s, s, s) FROM t string null += SELECT REGEXP_SUBSTR(j, j) FROM t string null += SELECT REGEXP_SUBSTR(j, j, j) FROM t string null += SELECT REGEXP_SUBSTR(j, j, j, j) FROM t string null += SELECT REGEXP_SUBSTR(n, n) FROM t string null += SELECT REGEXP_SUBSTR(n, n, n) FROM t string null += SELECT REGEXP_SUBSTR(n, n, n, n) FROM t string null += SELECT REGEXP_SUBSTR(s, i) FROM t string null += SELECT REGEXP_SUBSTR(s, i, i) FROM t string null += SELECT REGEXP_SUBSTR(s, i, i, i) FROM t string null += SELECT REGEXP_SUBSTR(s, s) FROM t string null += SELECT REGEXP_SUBSTR(s, s, NULL) FROM t string null += SELECT REGEXP_SUBSTR(s, s, NULL, NULL) FROM t string null += SELECT REGEXP_SUBSTR(s, s, s) FROM t string null += SELECT REGEXP_SUBSTR(s, s, s, s) FROM t string null += SELECT REGEXP_SUBSTR(tm, tm) FROM t string null += SELECT REGEXP_SUBSTR(tm, tm, tm) FROM t string null += SELECT REGEXP_SUBSTR(tm, tm, tm, tm) FROM t string null += SELECT REGEXP_SUBSTR(u, u) FROM t string null += SELECT REGEXP_SUBSTR(u, u, u) FROM t string null += SELECT REGEXP_SUBSTR(u, u, u, u) FROM t string null += SELECT RELEASE_ALL_LOCKS() FROM t bigint unsigned += SELECT RELEASE_LOCK(b) FROM t bigint null += SELECT RELEASE_LOCK(d) FROM t bigint null += SELECT RELEASE_LOCK(da) FROM t bigint null += SELECT RELEASE_LOCK(dt) FROM t bigint null += SELECT RELEASE_LOCK(f) FROM t bigint null += SELECT RELEASE_LOCK(i) FROM t bigint null += SELECT RELEASE_LOCK(j) FROM t bigint null += SELECT RELEASE_LOCK(n) FROM t bigint null += SELECT RELEASE_LOCK(s) FROM t bigint null += SELECT RELEASE_LOCK(tm) FROM t bigint null += SELECT RELEASE_LOCK(u) FROM t bigint null += SELECT REVERSE(b) FROM t binary null += SELECT REVERSE(d) FROM t string null += SELECT REVERSE(da) FROM t string null += SELECT REVERSE(dt) FROM t string null += SELECT REVERSE(f) FROM t string null += SELECT REVERSE(i) FROM t string null += SELECT REVERSE(j) FROM t string null += SELECT REVERSE(n) FROM t string null += SELECT REVERSE(s) FROM t string null += SELECT REVERSE(tm) FROM t string null += SELECT REVERSE(u) FROM t string null += SELECT ROLES_GRAPHML() FROM t string null += SELECT ROUND(b) FROM t double += SELECT ROUND(b, b) FROM t double += SELECT ROUND(d) FROM t decimal += SELECT ROUND(d, d) FROM t decimal += SELECT ROUND(da) FROM t double += SELECT ROUND(da, da) FROM t double += SELECT ROUND(dt) FROM t double += SELECT ROUND(dt, dt) FROM t double += SELECT ROUND(dt, i) FROM t double += SELECT ROUND(f) FROM t double += SELECT ROUND(f, f) FROM t double += SELECT ROUND(i) FROM t bigint += SELECT ROUND(i, i) FROM t bigint += SELECT ROUND(i, s) FROM t bigint += SELECT ROUND(j) FROM t double null += SELECT ROUND(j, j) FROM t double null += SELECT ROUND(n) FROM t bigint null += SELECT ROUND(n, n) FROM t bigint null += SELECT ROUND(s) FROM t double += SELECT ROUND(s, i) FROM t double += SELECT ROUND(s, s) FROM t double += SELECT ROUND(tm) FROM t double += SELECT ROUND(tm, tm) FROM t double += SELECT ROUND(u) FROM t bigint unsigned += SELECT ROUND(u, u) FROM t bigint unsigned += SELECT RPAD(b, b, b) FROM t binary null += SELECT RPAD(d, d, d) FROM t string null += SELECT RPAD(da, da, da) FROM t string null += SELECT RPAD(dt, dt, dt) FROM t string null += SELECT RPAD(dt, i, i) FROM t string null += SELECT RPAD(f, f, f) FROM t string null += SELECT RPAD(i, i, i) FROM t string null += SELECT RPAD(i, s, s) FROM t string null += SELECT RPAD(j, j, j) FROM t string null += SELECT RPAD(n, n, n) FROM t string null += SELECT RPAD(s, i, i) FROM t string null += SELECT RPAD(s, s, NULL) FROM t string null += SELECT RPAD(s, s, s) FROM t string null += SELECT RPAD(tm, tm, tm) FROM t string null += SELECT RPAD(u, u, u) FROM t string null += SELECT RTRIM(b) FROM t binary null += SELECT RTRIM(d) FROM t string null += SELECT RTRIM(da) FROM t string null += SELECT RTRIM(dt) FROM t string null += SELECT RTRIM(f) FROM t string null += SELECT RTRIM(i) FROM t string null += SELECT RTRIM(j) FROM t string null += SELECT RTRIM(n) FROM t string null += SELECT RTRIM(s) FROM t string null += SELECT RTRIM(tm) FROM t string null += SELECT RTRIM(u) FROM t string null += SELECT SEC_TO_TIME(b) FROM t time null += SELECT SEC_TO_TIME(d) FROM t time null += SELECT SEC_TO_TIME(da) FROM t time null += SELECT SEC_TO_TIME(dt) FROM t time null += SELECT SEC_TO_TIME(f) FROM t time null += SELECT SEC_TO_TIME(i) FROM t time null += SELECT SEC_TO_TIME(j) FROM t time null += SELECT SEC_TO_TIME(n) FROM t time null += SELECT SEC_TO_TIME(s) FROM t time null += SELECT SEC_TO_TIME(tm) FROM t time null += SELECT SEC_TO_TIME(u) FROM t time null += SELECT SHA(b) FROM t string null += SELECT SHA(d) FROM t string null += SELECT SHA(da) FROM t string null += SELECT SHA(dt) FROM t string null += SELECT SHA(f) FROM t string null += SELECT SHA(i) FROM t string null += SELECT SHA(j) FROM t string null += SELECT SHA(n) FROM t string null += SELECT SHA(s) FROM t string null += SELECT SHA(tm) FROM t string null += SELECT SHA(u) FROM t string null += SELECT SHA1(b) FROM t string null += SELECT SHA1(d) FROM t string null += SELECT SHA1(da) FROM t string null += SELECT SHA1(dt) FROM t string null += SELECT SHA1(f) FROM t string null += SELECT SHA1(i) FROM t string null += SELECT SHA1(j) FROM t string null += SELECT SHA1(n) FROM t string null += SELECT SHA1(s) FROM t string null += SELECT SHA1(tm) FROM t string null += SELECT SHA1(u) FROM t string null += SELECT SHA2(b, b) FROM t string null += SELECT SHA2(d, d) FROM t string null += SELECT SHA2(da, da) FROM t string null += SELECT SHA2(dt, dt) FROM t string null += SELECT SHA2(dt, i) FROM t string null += SELECT SHA2(f, f) FROM t string null += SELECT SHA2(i, i) FROM t string null += SELECT SHA2(i, s) FROM t string null += SELECT SHA2(j, j) FROM t string null += SELECT SHA2(n, n) FROM t string null += SELECT SHA2(s, i) FROM t string null += SELECT SHA2(s, s) FROM t string null += SELECT SHA2(tm, tm) FROM t string null += SELECT SHA2(u, u) FROM t string null += SELECT SIGN(b) FROM t bigint += SELECT SIGN(d) FROM t bigint += SELECT SIGN(da) FROM t bigint += SELECT SIGN(dt) FROM t bigint += SELECT SIGN(f) FROM t bigint += SELECT SIGN(i) FROM t bigint += SELECT SIGN(j) FROM t bigint null += SELECT SIGN(n) FROM t bigint null += SELECT SIGN(s) FROM t bigint += SELECT SIGN(tm) FROM t bigint += SELECT SIGN(u) FROM t bigint += SELECT SIN(b) FROM t double null += SELECT SIN(d) FROM t double null += SELECT SIN(da) FROM t double null += SELECT SIN(dt) FROM t double null += SELECT SIN(f) FROM t double null += SELECT SIN(i) FROM t double null += SELECT SIN(j) FROM t double null += SELECT SIN(n) FROM t double null += SELECT SIN(s) FROM t double null += SELECT SIN(tm) FROM t double null += SELECT SIN(u) FROM t double null += SELECT SLEEP(b) FROM t bigint += SELECT SLEEP(d) FROM t bigint += SELECT SLEEP(da) FROM t bigint += SELECT SLEEP(dt) FROM t bigint += SELECT SLEEP(f) FROM t bigint += SELECT SLEEP(i) FROM t bigint += SELECT SLEEP(j) FROM t bigint null += SELECT SLEEP(n) FROM t bigint null += SELECT SLEEP(s) FROM t bigint += SELECT SLEEP(tm) FROM t bigint += SELECT SLEEP(u) FROM t bigint += SELECT SOUNDEX(b) FROM t binary null += SELECT SOUNDEX(d) FROM t string null += SELECT SOUNDEX(da) FROM t string null += SELECT SOUNDEX(dt) FROM t string null += SELECT SOUNDEX(f) FROM t string null += SELECT SOUNDEX(i) FROM t string null += SELECT SOUNDEX(j) FROM t string null += SELECT SOUNDEX(n) FROM t string null += SELECT SOUNDEX(s) FROM t string null += SELECT SOUNDEX(tm) FROM t string null += SELECT SOUNDEX(u) FROM t string null += SELECT SOURCE_POS_WAIT(b, b) FROM t bigint null += SELECT SOURCE_POS_WAIT(b, b, b) FROM t bigint null += SELECT SOURCE_POS_WAIT(b, b, b, b) FROM t bigint null += SELECT SOURCE_POS_WAIT(d, d) FROM t bigint null += SELECT SOURCE_POS_WAIT(d, d, d) FROM t bigint null += SELECT SOURCE_POS_WAIT(d, d, d, d) FROM t bigint null += SELECT SOURCE_POS_WAIT(da, da) FROM t bigint null += SELECT SOURCE_POS_WAIT(da, da, da) FROM t bigint null += SELECT SOURCE_POS_WAIT(da, da, da, da) FROM t bigint null += SELECT SOURCE_POS_WAIT(dt, dt) FROM t bigint null += SELECT SOURCE_POS_WAIT(dt, dt, dt) FROM t bigint null += SELECT SOURCE_POS_WAIT(dt, dt, dt, dt) FROM t bigint null += SELECT SOURCE_POS_WAIT(dt, i) FROM t bigint null += SELECT SOURCE_POS_WAIT(dt, i, i) FROM t bigint null += SELECT SOURCE_POS_WAIT(dt, i, i, i) FROM t bigint null += SELECT SOURCE_POS_WAIT(f, f) FROM t bigint null += SELECT SOURCE_POS_WAIT(f, f, f) FROM t bigint null += SELECT SOURCE_POS_WAIT(f, f, f, f) FROM t bigint null += SELECT SOURCE_POS_WAIT(i, i) FROM t bigint null += SELECT SOURCE_POS_WAIT(i, i, i) FROM t bigint null += SELECT SOURCE_POS_WAIT(i, i, i, i) FROM t bigint null += SELECT SOURCE_POS_WAIT(i, s) FROM t bigint null += SELECT SOURCE_POS_WAIT(i, s, s) FROM t bigint null += SELECT SOURCE_POS_WAIT(i, s, s, s) FROM t bigint null += SELECT SOURCE_POS_WAIT(j, j) FROM t bigint null += SELECT SOURCE_POS_WAIT(j, j, j) FROM t bigint null += SELECT SOURCE_POS_WAIT(j, j, j, j) FROM t bigint null += SELECT SOURCE_POS_WAIT(n, n) FROM t bigint null += SELECT SOURCE_POS_WAIT(n, n, n) FROM t bigint null += SELECT SOURCE_POS_WAIT(n, n, n, n) FROM t bigint null += SELECT SOURCE_POS_WAIT(s, i) FROM t bigint null += SELECT SOURCE_POS_WAIT(s, i, i) FROM t bigint null += SELECT SOURCE_POS_WAIT(s, i, i, i) FROM t bigint null += SELECT SOURCE_POS_WAIT(s, s) FROM t bigint null += SELECT SOURCE_POS_WAIT(s, s, NULL) FROM t bigint null += SELECT SOURCE_POS_WAIT(s, s, NULL, NULL) FROM t bigint null += SELECT SOURCE_POS_WAIT(s, s, s) FROM t bigint null += SELECT SOURCE_POS_WAIT(s, s, s, s) FROM t bigint null += SELECT SOURCE_POS_WAIT(tm, tm) FROM t bigint null += SELECT SOURCE_POS_WAIT(tm, tm, tm) FROM t bigint null += SELECT SOURCE_POS_WAIT(tm, tm, tm, tm) FROM t bigint null += SELECT SOURCE_POS_WAIT(u, u) FROM t bigint null += SELECT SOURCE_POS_WAIT(u, u, u) FROM t bigint null += SELECT SOURCE_POS_WAIT(u, u, u, u) FROM t bigint null += SELECT SPACE(b) FROM t string null += SELECT SPACE(d) FROM t string null += SELECT SPACE(da) FROM t string null += SELECT SPACE(dt) FROM t string null += SELECT SPACE(f) FROM t string null += SELECT SPACE(i) FROM t string null += SELECT SPACE(j) FROM t string null += SELECT SPACE(n) FROM t string null += SELECT SPACE(s) FROM t string null += SELECT SPACE(tm) FROM t string null += SELECT SPACE(u) FROM t string null += SELECT SQRT(b) FROM t double null += SELECT SQRT(d) FROM t double null += SELECT SQRT(da) FROM t double null += SELECT SQRT(dt) FROM t double null += SELECT SQRT(f) FROM t double null += SELECT SQRT(i) FROM t double null += SELECT SQRT(j) FROM t double null += SELECT SQRT(n) FROM t double null += SELECT SQRT(s) FROM t double null += SELECT SQRT(tm) FROM t double null += SELECT SQRT(u) FROM t double null += SELECT STATEMENT_DIGEST(b) FROM t string null += SELECT STATEMENT_DIGEST(d) FROM t string null += SELECT STATEMENT_DIGEST(da) FROM t string null += SELECT STATEMENT_DIGEST(dt) FROM t string null += SELECT STATEMENT_DIGEST(f) FROM t string null += SELECT STATEMENT_DIGEST(i) FROM t string null += SELECT STATEMENT_DIGEST(j) FROM t string null += SELECT STATEMENT_DIGEST(n) FROM t string null += SELECT STATEMENT_DIGEST(s) FROM t string null += SELECT STATEMENT_DIGEST(tm) FROM t string null += SELECT STATEMENT_DIGEST(u) FROM t string null += SELECT STATEMENT_DIGEST_TEXT(b) FROM t binary null += SELECT STATEMENT_DIGEST_TEXT(d) FROM t string null += SELECT STATEMENT_DIGEST_TEXT(da) FROM t string null += SELECT STATEMENT_DIGEST_TEXT(dt) FROM t string null += SELECT STATEMENT_DIGEST_TEXT(f) FROM t string null += SELECT STATEMENT_DIGEST_TEXT(i) FROM t string null += SELECT STATEMENT_DIGEST_TEXT(j) FROM t string null += SELECT STATEMENT_DIGEST_TEXT(n) FROM t string null += SELECT STATEMENT_DIGEST_TEXT(s) FROM t string null += SELECT STATEMENT_DIGEST_TEXT(tm) FROM t string null += SELECT STATEMENT_DIGEST_TEXT(u) FROM t string null += SELECT STRCMP(b, b) FROM t bigint += SELECT STRCMP(d, d) FROM t bigint += SELECT STRCMP(da, da) FROM t bigint += SELECT STRCMP(dt, dt) FROM t bigint += SELECT STRCMP(dt, i) FROM t bigint += SELECT STRCMP(f, f) FROM t bigint += SELECT STRCMP(i, i) FROM t bigint += SELECT STRCMP(i, s) FROM t bigint += SELECT STRCMP(j, j) FROM t bigint null += SELECT STRCMP(n, n) FROM t bigint null += SELECT STRCMP(s, i) FROM t bigint += SELECT STRCMP(s, s) FROM t bigint += SELECT STRCMP(tm, tm) FROM t bigint += SELECT STRCMP(u, u) FROM t bigint += SELECT STR_TO_DATE(b, b) FROM t datetime null += SELECT STR_TO_DATE(d, d) FROM t datetime null += SELECT STR_TO_DATE(da, da) FROM t datetime null += SELECT STR_TO_DATE(dt, dt) FROM t datetime null += SELECT STR_TO_DATE(dt, i) FROM t datetime null += SELECT STR_TO_DATE(f, f) FROM t datetime null += SELECT STR_TO_DATE(i, i) FROM t datetime null += SELECT STR_TO_DATE(i, s) FROM t datetime null += SELECT STR_TO_DATE(j, j) FROM t datetime null += SELECT STR_TO_DATE(n, n) FROM t datetime null += SELECT STR_TO_DATE(s, i) FROM t datetime null += SELECT STR_TO_DATE(s, s) FROM t datetime null += SELECT STR_TO_DATE(tm, tm) FROM t datetime null += SELECT STR_TO_DATE(u, u) FROM t datetime null += SELECT ST_AREA(b) FROM t double null += SELECT ST_AREA(d) FROM t double null += SELECT ST_AREA(da) FROM t double null += SELECT ST_AREA(dt) FROM t double null += SELECT ST_AREA(f) FROM t double null += SELECT ST_AREA(i) FROM t double null += SELECT ST_AREA(j) FROM t double null += SELECT ST_AREA(n) FROM t double null += SELECT ST_AREA(s) FROM t double null += SELECT ST_AREA(tm) FROM t double null += SELECT ST_AREA(u) FROM t double null += SELECT ST_ASBINARY(b) FROM t binary null += SELECT ST_ASBINARY(b, b) FROM t binary null += SELECT ST_ASBINARY(d) FROM t binary null += SELECT ST_ASBINARY(d, d) FROM t binary null += SELECT ST_ASBINARY(da) FROM t binary null += SELECT ST_ASBINARY(da, da) FROM t binary null += SELECT ST_ASBINARY(dt) FROM t binary null += SELECT ST_ASBINARY(dt, dt) FROM t binary null += SELECT ST_ASBINARY(dt, i) FROM t binary null += SELECT ST_ASBINARY(f) FROM t binary null += SELECT ST_ASBINARY(f, f) FROM t binary null += SELECT ST_ASBINARY(i) FROM t binary null += SELECT ST_ASBINARY(i, i) FROM t binary null += SELECT ST_ASBINARY(i, s) FROM t binary null += SELECT ST_ASBINARY(j) FROM t binary null += SELECT ST_ASBINARY(j, j) FROM t binary null += SELECT ST_ASBINARY(n) FROM t binary null += SELECT ST_ASBINARY(n, n) FROM t binary null += SELECT ST_ASBINARY(s) FROM t binary null += SELECT ST_ASBINARY(s, i) FROM t binary null += SELECT ST_ASBINARY(s, s) FROM t binary null += SELECT ST_ASBINARY(tm) FROM t binary null += SELECT ST_ASBINARY(tm, tm) FROM t binary null += SELECT ST_ASBINARY(u) FROM t binary null += SELECT ST_ASBINARY(u, u) FROM t binary null += SELECT ST_ASGEOJSON(b) FROM t json null += SELECT ST_ASTEXT(b) FROM t string null += SELECT ST_ASTEXT(b, b) FROM t string null += SELECT ST_ASTEXT(d) FROM t string null += SELECT ST_ASTEXT(d, d) FROM t string null += SELECT ST_ASTEXT(da) FROM t string null += SELECT ST_ASTEXT(da, da) FROM t string null += SELECT ST_ASTEXT(dt) FROM t string null += SELECT ST_ASTEXT(dt, dt) FROM t string null += SELECT ST_ASTEXT(dt, i) FROM t string null += SELECT ST_ASTEXT(f) FROM t string null += SELECT ST_ASTEXT(f, f) FROM t string null += SELECT ST_ASTEXT(i) FROM t string null += SELECT ST_ASTEXT(i, i) FROM t string null += SELECT ST_ASTEXT(i, s) FROM t string null += SELECT ST_ASTEXT(j) FROM t string null += SELECT ST_ASTEXT(j, j) FROM t string null += SELECT ST_ASTEXT(n) FROM t string null += SELECT ST_ASTEXT(n, n) FROM t string null += SELECT ST_ASTEXT(s) FROM t string null += SELECT ST_ASTEXT(s, i) FROM t string null += SELECT ST_ASTEXT(s, s) FROM t string null += SELECT ST_ASTEXT(tm) FROM t string null += SELECT ST_ASTEXT(tm, tm) FROM t string null += SELECT ST_ASTEXT(u) FROM t string null += SELECT ST_ASTEXT(u, u) FROM t string null += SELECT ST_ASWKB(b) FROM t binary null += SELECT ST_ASWKB(b, b) FROM t binary null += SELECT ST_ASWKB(d) FROM t binary null += SELECT ST_ASWKB(d, d) FROM t binary null += SELECT ST_ASWKB(da) FROM t binary null += SELECT ST_ASWKB(da, da) FROM t binary null += SELECT ST_ASWKB(dt) FROM t binary null += SELECT ST_ASWKB(dt, dt) FROM t binary null += SELECT ST_ASWKB(dt, i) FROM t binary null += SELECT ST_ASWKB(f) FROM t binary null += SELECT ST_ASWKB(f, f) FROM t binary null += SELECT ST_ASWKB(i) FROM t binary null += SELECT ST_ASWKB(i, i) FROM t binary null += SELECT ST_ASWKB(i, s) FROM t binary null += SELECT ST_ASWKB(j) FROM t binary null += SELECT ST_ASWKB(j, j) FROM t binary null += SELECT ST_ASWKB(n) FROM t binary null += SELECT ST_ASWKB(n, n) FROM t binary null += SELECT ST_ASWKB(s) FROM t binary null += SELECT ST_ASWKB(s, i) FROM t binary null += SELECT ST_ASWKB(s, s) FROM t binary null += SELECT ST_ASWKB(tm) FROM t binary null += SELECT ST_ASWKB(tm, tm) FROM t binary null += SELECT ST_ASWKB(u) FROM t binary null += SELECT ST_ASWKB(u, u) FROM t binary null += SELECT ST_ASWKT(b) FROM t string null += SELECT ST_ASWKT(b, b) FROM t string null += SELECT ST_ASWKT(d) FROM t string null += SELECT ST_ASWKT(d, d) FROM t string null += SELECT ST_ASWKT(da) FROM t string null += SELECT ST_ASWKT(da, da) FROM t string null += SELECT ST_ASWKT(dt) FROM t string null += SELECT ST_ASWKT(dt, dt) FROM t string null += SELECT ST_ASWKT(dt, i) FROM t string null += SELECT ST_ASWKT(f) FROM t string null += SELECT ST_ASWKT(f, f) FROM t string null += SELECT ST_ASWKT(i) FROM t string null += SELECT ST_ASWKT(i, i) FROM t string null += SELECT ST_ASWKT(i, s) FROM t string null += SELECT ST_ASWKT(j) FROM t string null += SELECT ST_ASWKT(j, j) FROM t string null += SELECT ST_ASWKT(n) FROM t string null += SELECT ST_ASWKT(n, n) FROM t string null += SELECT ST_ASWKT(s) FROM t string null += SELECT ST_ASWKT(s, i) FROM t string null += SELECT ST_ASWKT(s, s) FROM t string null += SELECT ST_ASWKT(tm) FROM t string null += SELECT ST_ASWKT(tm, tm) FROM t string null += SELECT ST_ASWKT(u) FROM t string null += SELECT ST_ASWKT(u, u) FROM t string null += SELECT ST_BUFFER(b, b) FROM t geometry null += SELECT ST_BUFFER(b, b, b) FROM t geometry null += SELECT ST_BUFFER(b, b, b, b) FROM t geometry null += SELECT ST_BUFFER(d, d) FROM t geometry null += SELECT ST_BUFFER(d, d, d) FROM t geometry null += SELECT ST_BUFFER(d, d, d, d) FROM t geometry null += SELECT ST_BUFFER(da, da) FROM t geometry null += SELECT ST_BUFFER(da, da, da) FROM t geometry null += SELECT ST_BUFFER(da, da, da, da) FROM t geometry null += SELECT ST_BUFFER(dt, dt) FROM t geometry null += SELECT ST_BUFFER(dt, dt, dt) FROM t geometry null += SELECT ST_BUFFER(dt, dt, dt, dt) FROM t geometry null += SELECT ST_BUFFER(dt, i) FROM t geometry null += SELECT ST_BUFFER(dt, i, i) FROM t geometry null += SELECT ST_BUFFER(dt, i, i, i) FROM t geometry null += SELECT ST_BUFFER(f, f) FROM t geometry null += SELECT ST_BUFFER(f, f, f) FROM t geometry null += SELECT ST_BUFFER(f, f, f, f) FROM t geometry null += SELECT ST_BUFFER(i, i) FROM t geometry null += SELECT ST_BUFFER(i, i, i) FROM t geometry null += SELECT ST_BUFFER(i, i, i, i) FROM t geometry null += SELECT ST_BUFFER(i, s) FROM t geometry null += SELECT ST_BUFFER(i, s, s) FROM t geometry null += SELECT ST_BUFFER(i, s, s, s) FROM t geometry null += SELECT ST_BUFFER(j, j) FROM t geometry null += SELECT ST_BUFFER(j, j, j) FROM t geometry null += SELECT ST_BUFFER(j, j, j, j) FROM t geometry null += SELECT ST_BUFFER(n, n) FROM t geometry null += SELECT ST_BUFFER(n, n, n) FROM t geometry null += SELECT ST_BUFFER(n, n, n, n) FROM t geometry null += SELECT ST_BUFFER(s, i) FROM t geometry null += SELECT ST_BUFFER(s, i, i) FROM t geometry null += SELECT ST_BUFFER(s, i, i, i) FROM t geometry null += SELECT ST_BUFFER(s, s) FROM t geometry null += SELECT ST_BUFFER(s, s, NULL) FROM t geometry null += SELECT ST_BUFFER(s, s, NULL, NULL) FROM t geometry null += SELECT ST_BUFFER(s, s, s) FROM t geometry null += SELECT ST_BUFFER(s, s, s, s) FROM t geometry null += SELECT ST_BUFFER(tm, tm) FROM t geometry null += SELECT ST_BUFFER(tm, tm, tm) FROM t geometry null += SELECT ST_BUFFER(tm, tm, tm, tm) FROM t geometry null += SELECT ST_BUFFER(u, u) FROM t geometry null += SELECT ST_BUFFER(u, u, u) FROM t geometry null += SELECT ST_BUFFER(u, u, u, u) FROM t geometry null += SELECT ST_BUFFER_STRATEGY(b) FROM t binary null += SELECT ST_BUFFER_STRATEGY(b, b) FROM t binary null += SELECT ST_BUFFER_STRATEGY(d) FROM t binary null += SELECT ST_BUFFER_STRATEGY(d, d) FROM t binary null += SELECT ST_BUFFER_STRATEGY(da) FROM t binary null += SELECT ST_BUFFER_STRATEGY(da, da) FROM t binary null += SELECT ST_BUFFER_STRATEGY(dt) FROM t binary null += SELECT ST_BUFFER_STRATEGY(dt, dt) FROM t binary null += SELECT ST_BUFFER_STRATEGY(dt, i) FROM t binary null += SELECT ST_BUFFER_STRATEGY(f) FROM t binary null += SELECT ST_BUFFER_STRATEGY(f, f) FROM t binary null += SELECT ST_BUFFER_STRATEGY(i) FROM t binary null += SELECT ST_BUFFER_STRATEGY(i, i) FROM t binary null += SELECT ST_BUFFER_STRATEGY(i, s) FROM t binary null += SELECT ST_BUFFER_STRATEGY(j) FROM t binary null += SELECT ST_BUFFER_STRATEGY(j, j) FROM t binary null += SELECT ST_BUFFER_STRATEGY(n) FROM t binary null += SELECT ST_BUFFER_STRATEGY(n, n) FROM t binary null += SELECT ST_BUFFER_STRATEGY(s) FROM t binary null += SELECT ST_BUFFER_STRATEGY(s, i) FROM t binary null += SELECT ST_BUFFER_STRATEGY(s, s) FROM t binary null += SELECT ST_BUFFER_STRATEGY(tm) FROM t binary null += SELECT ST_BUFFER_STRATEGY(tm, tm) FROM t binary null += SELECT ST_BUFFER_STRATEGY(u) FROM t binary null += SELECT ST_BUFFER_STRATEGY(u, u) FROM t binary null += SELECT ST_CENTROID(b) FROM t geometry null += SELECT ST_CENTROID(d) FROM t geometry null += SELECT ST_CENTROID(da) FROM t geometry null += SELECT ST_CENTROID(dt) FROM t geometry null += SELECT ST_CENTROID(f) FROM t geometry null += SELECT ST_CENTROID(i) FROM t geometry null += SELECT ST_CENTROID(j) FROM t geometry null += SELECT ST_CENTROID(n) FROM t geometry null += SELECT ST_CENTROID(s) FROM t geometry null += SELECT ST_CENTROID(tm) FROM t geometry null += SELECT ST_CENTROID(u) FROM t geometry null += SELECT ST_CONTAINS(b, b) FROM t bigint null += SELECT ST_CONTAINS(d, d) FROM t bigint null += SELECT ST_CONTAINS(da, da) FROM t bigint null += SELECT ST_CONTAINS(dt, dt) FROM t bigint null += SELECT ST_CONTAINS(dt, i) FROM t bigint null += SELECT ST_CONTAINS(f, f) FROM t bigint null += SELECT ST_CONTAINS(i, i) FROM t bigint null += SELECT ST_CONTAINS(i, s) FROM t bigint null += SELECT ST_CONTAINS(j, j) FROM t bigint null += SELECT ST_CONTAINS(n, n) FROM t bigint null += SELECT ST_CONTAINS(s, i) FROM t bigint null += SELECT ST_CONTAINS(s, s) FROM t bigint null += SELECT ST_CONTAINS(tm, tm) FROM t bigint null += SELECT ST_CONTAINS(u, u) FROM t bigint null += SELECT ST_CONVEXHULL(b) FROM t geometry null += SELECT ST_CONVEXHULL(d) FROM t geometry null += SELECT ST_CONVEXHULL(da) FROM t geometry null += SELECT ST_CONVEXHULL(dt) FROM t geometry null += SELECT ST_CONVEXHULL(f) FROM t geometry null += SELECT ST_CONVEXHULL(i) FROM t geometry null += SELECT ST_CONVEXHULL(j) FROM t geometry null += SELECT ST_CONVEXHULL(n) FROM t geometry null += SELECT ST_CONVEXHULL(s) FROM t geometry null += SELECT ST_CONVEXHULL(tm) FROM t geometry null += SELECT ST_CONVEXHULL(u) FROM t geometry null += SELECT ST_CROSSES(b, b) FROM t bigint null += SELECT ST_CROSSES(d, d) FROM t bigint null += SELECT ST_CROSSES(da, da) FROM t bigint null += SELECT ST_CROSSES(dt, dt) FROM t bigint null += SELECT ST_CROSSES(dt, i) FROM t bigint null += SELECT ST_CROSSES(f, f) FROM t bigint null += SELECT ST_CROSSES(i, i) FROM t bigint null += SELECT ST_CROSSES(i, s) FROM t bigint null += SELECT ST_CROSSES(j, j) FROM t bigint null += SELECT ST_CROSSES(n, n) FROM t bigint null += SELECT ST_CROSSES(s, i) FROM t bigint null += SELECT ST_CROSSES(s, s) FROM t bigint null += SELECT ST_CROSSES(tm, tm) FROM t bigint null += SELECT ST_CROSSES(u, u) FROM t bigint null += SELECT ST_DIFFERENCE(b, b) FROM t geometry null += SELECT ST_DIFFERENCE(d, d) FROM t geometry null += SELECT ST_DIFFERENCE(da, da) FROM t geometry null += SELECT ST_DIFFERENCE(dt, dt) FROM t geometry null += SELECT ST_DIFFERENCE(dt, i) FROM t geometry null += SELECT ST_DIFFERENCE(f, f) FROM t geometry null += SELECT ST_DIFFERENCE(i, i) FROM t geometry null += SELECT ST_DIFFERENCE(i, s) FROM t geometry null += SELECT ST_DIFFERENCE(j, j) FROM t geometry null += SELECT ST_DIFFERENCE(n, n) FROM t geometry null += SELECT ST_DIFFERENCE(s, i) FROM t geometry null += SELECT ST_DIFFERENCE(s, s) FROM t geometry null += SELECT ST_DIFFERENCE(tm, tm) FROM t geometry null += SELECT ST_DIFFERENCE(u, u) FROM t geometry null += SELECT ST_DIMENSION(b) FROM t bigint null += SELECT ST_DIMENSION(d) FROM t bigint null += SELECT ST_DIMENSION(da) FROM t bigint null += SELECT ST_DIMENSION(dt) FROM t bigint null += SELECT ST_DIMENSION(f) FROM t bigint null += SELECT ST_DIMENSION(i) FROM t bigint null += SELECT ST_DIMENSION(j) FROM t bigint null += SELECT ST_DIMENSION(n) FROM t bigint null += SELECT ST_DIMENSION(s) FROM t bigint null += SELECT ST_DIMENSION(tm) FROM t bigint null += SELECT ST_DIMENSION(u) FROM t bigint null += SELECT ST_DISJOINT(b, b) FROM t bigint null += SELECT ST_DISJOINT(d, d) FROM t bigint null += SELECT ST_DISJOINT(da, da) FROM t bigint null += SELECT ST_DISJOINT(dt, dt) FROM t bigint null += SELECT ST_DISJOINT(dt, i) FROM t bigint null += SELECT ST_DISJOINT(f, f) FROM t bigint null += SELECT ST_DISJOINT(i, i) FROM t bigint null += SELECT ST_DISJOINT(i, s) FROM t bigint null += SELECT ST_DISJOINT(j, j) FROM t bigint null += SELECT ST_DISJOINT(n, n) FROM t bigint null += SELECT ST_DISJOINT(s, i) FROM t bigint null += SELECT ST_DISJOINT(s, s) FROM t bigint null += SELECT ST_DISJOINT(tm, tm) FROM t bigint null += SELECT ST_DISJOINT(u, u) FROM t bigint null += SELECT ST_DISTANCE(b, b) FROM t double null += SELECT ST_DISTANCE(b, b, b) FROM t double null += SELECT ST_DISTANCE(d, d) FROM t double null += SELECT ST_DISTANCE(d, d, d) FROM t double null += SELECT ST_DISTANCE(da, da) FROM t double null += SELECT ST_DISTANCE(da, da, da) FROM t double null += SELECT ST_DISTANCE(dt, dt) FROM t double null += SELECT ST_DISTANCE(dt, dt, dt) FROM t double null += SELECT ST_DISTANCE(dt, i) FROM t double null += SELECT ST_DISTANCE(dt, i, i) FROM t double null += SELECT ST_DISTANCE(f, f) FROM t double null += SELECT ST_DISTANCE(f, f, f) FROM t double null += SELECT ST_DISTANCE(i, i) FROM t double null += SELECT ST_DISTANCE(i, i, i) FROM t double null += SELECT ST_DISTANCE(i, s) FROM t double null += SELECT ST_DISTANCE(i, s, s) FROM t double null += SELECT ST_DISTANCE(j, j) FROM t double null += SELECT ST_DISTANCE(j, j, j) FROM t double null += SELECT ST_DISTANCE(n, n) FROM t double null += SELECT ST_DISTANCE(n, n, n) FROM t double null += SELECT ST_DISTANCE(s, i) FROM t double null += SELECT ST_DISTANCE(s, i, i) FROM t double null += SELECT ST_DISTANCE(s, s) FROM t double null += SELECT ST_DISTANCE(s, s, NULL) FROM t double null += SELECT ST_DISTANCE(s, s, s) FROM t double null += SELECT ST_DISTANCE(tm, tm) FROM t double null += SELECT ST_DISTANCE(tm, tm, tm) FROM t double null += SELECT ST_DISTANCE(u, u) FROM t double null += SELECT ST_DISTANCE(u, u, u) FROM t double null += SELECT ST_DISTANCE_SPHERE(b, b) FROM t double += SELECT ST_DISTANCE_SPHERE(b, b, b) FROM t double += SELECT ST_DISTANCE_SPHERE(d, d) FROM t double += SELECT ST_DISTANCE_SPHERE(d, d, d) FROM t double += SELECT ST_DISTANCE_SPHERE(da, da) FROM t double += SELECT ST_DISTANCE_SPHERE(da, da, da) FROM t double += SELECT ST_DISTANCE_SPHERE(dt, dt) FROM t double += SELECT ST_DISTANCE_SPHERE(dt, dt, dt) FROM t double += SELECT ST_DISTANCE_SPHERE(dt, i) FROM t double += SELECT ST_DISTANCE_SPHERE(dt, i, i) FROM t double += SELECT ST_DISTANCE_SPHERE(f, f) FROM t double += SELECT ST_DISTANCE_SPHERE(f, f, f) FROM t double += SELECT ST_DISTANCE_SPHERE(i, i) FROM t double += SELECT ST_DISTANCE_SPHERE(i, i, i) FROM t double += SELECT ST_DISTANCE_SPHERE(i, s) FROM t double += SELECT ST_DISTANCE_SPHERE(i, s, s) FROM t double += SELECT ST_DISTANCE_SPHERE(j, j) FROM t double null += SELECT ST_DISTANCE_SPHERE(j, j, j) FROM t double null += SELECT ST_DISTANCE_SPHERE(n, n) FROM t double null += SELECT ST_DISTANCE_SPHERE(n, n, n) FROM t double null += SELECT ST_DISTANCE_SPHERE(s, i) FROM t double += SELECT ST_DISTANCE_SPHERE(s, i, i) FROM t double += SELECT ST_DISTANCE_SPHERE(s, s) FROM t double += SELECT ST_DISTANCE_SPHERE(s, s, NULL) FROM t double null += SELECT ST_DISTANCE_SPHERE(s, s, s) FROM t double += SELECT ST_DISTANCE_SPHERE(tm, tm) FROM t double += SELECT ST_DISTANCE_SPHERE(tm, tm, tm) FROM t double += SELECT ST_DISTANCE_SPHERE(u, u) FROM t double += SELECT ST_DISTANCE_SPHERE(u, u, u) FROM t double += SELECT ST_ENDPOINT(b) FROM t geometry null += SELECT ST_ENDPOINT(d) FROM t geometry null += SELECT ST_ENDPOINT(da) FROM t geometry null += SELECT ST_ENDPOINT(dt) FROM t geometry null += SELECT ST_ENDPOINT(f) FROM t geometry null += SELECT ST_ENDPOINT(i) FROM t geometry null += SELECT ST_ENDPOINT(j) FROM t geometry null += SELECT ST_ENDPOINT(n) FROM t geometry null += SELECT ST_ENDPOINT(s) FROM t geometry null += SELECT ST_ENDPOINT(tm) FROM t geometry null += SELECT ST_ENDPOINT(u) FROM t geometry null += SELECT ST_ENVELOPE(b) FROM t geometry null += SELECT ST_ENVELOPE(d) FROM t geometry null += SELECT ST_ENVELOPE(da) FROM t geometry null += SELECT ST_ENVELOPE(dt) FROM t geometry null += SELECT ST_ENVELOPE(f) FROM t geometry null += SELECT ST_ENVELOPE(i) FROM t geometry null += SELECT ST_ENVELOPE(j) FROM t geometry null += SELECT ST_ENVELOPE(n) FROM t geometry null += SELECT ST_ENVELOPE(s) FROM t geometry null += SELECT ST_ENVELOPE(tm) FROM t geometry null += SELECT ST_ENVELOPE(u) FROM t geometry null += SELECT ST_EQUALS(b, b) FROM t bigint null += SELECT ST_EQUALS(d, d) FROM t bigint null += SELECT ST_EQUALS(da, da) FROM t bigint null += SELECT ST_EQUALS(dt, dt) FROM t bigint null += SELECT ST_EQUALS(dt, i) FROM t bigint null += SELECT ST_EQUALS(f, f) FROM t bigint null += SELECT ST_EQUALS(i, i) FROM t bigint null += SELECT ST_EQUALS(i, s) FROM t bigint null += SELECT ST_EQUALS(j, j) FROM t bigint null += SELECT ST_EQUALS(n, n) FROM t bigint null += SELECT ST_EQUALS(s, i) FROM t bigint null += SELECT ST_EQUALS(s, s) FROM t bigint null += SELECT ST_EQUALS(tm, tm) FROM t bigint null += SELECT ST_EQUALS(u, u) FROM t bigint null += SELECT ST_EXTERIORRING(b) FROM t geometry null += SELECT ST_EXTERIORRING(d) FROM t geometry null += SELECT ST_EXTERIORRING(da) FROM t geometry null += SELECT ST_EXTERIORRING(dt) FROM t geometry null += SELECT ST_EXTERIORRING(f) FROM t geometry null += SELECT ST_EXTERIORRING(i) FROM t geometry null += SELECT ST_EXTERIORRING(j) FROM t geometry null += SELECT ST_EXTERIORRING(n) FROM t geometry null += SELECT ST_EXTERIORRING(s) FROM t geometry null += SELECT ST_EXTERIORRING(tm) FROM t geometry null += SELECT ST_EXTERIORRING(u) FROM t geometry null += SELECT ST_FRECHETDISTANCE(b, b) FROM t double null += SELECT ST_FRECHETDISTANCE(b, b, b) FROM t double null += SELECT ST_FRECHETDISTANCE(d, d) FROM t double null += SELECT ST_FRECHETDISTANCE(d, d, d) FROM t double null += SELECT ST_FRECHETDISTANCE(da, da) FROM t double null += SELECT ST_FRECHETDISTANCE(da, da, da) FROM t double null += SELECT ST_FRECHETDISTANCE(dt, dt) FROM t double null += SELECT ST_FRECHETDISTANCE(dt, dt, dt) FROM t double null += SELECT ST_FRECHETDISTANCE(dt, i) FROM t double null += SELECT ST_FRECHETDISTANCE(dt, i, i) FROM t double null += SELECT ST_FRECHETDISTANCE(f, f) FROM t double null += SELECT ST_FRECHETDISTANCE(f, f, f) FROM t double null += SELECT ST_FRECHETDISTANCE(i, i) FROM t double null += SELECT ST_FRECHETDISTANCE(i, i, i) FROM t double null += SELECT ST_FRECHETDISTANCE(i, s) FROM t double null += SELECT ST_FRECHETDISTANCE(i, s, s) FROM t double null += SELECT ST_FRECHETDISTANCE(j, j) FROM t double null += SELECT ST_FRECHETDISTANCE(j, j, j) FROM t double null += SELECT ST_FRECHETDISTANCE(n, n) FROM t double null += SELECT ST_FRECHETDISTANCE(n, n, n) FROM t double null += SELECT ST_FRECHETDISTANCE(s, i) FROM t double null += SELECT ST_FRECHETDISTANCE(s, i, i) FROM t double null += SELECT ST_FRECHETDISTANCE(s, s) FROM t double null += SELECT ST_FRECHETDISTANCE(s, s, NULL) FROM t double null += SELECT ST_FRECHETDISTANCE(s, s, s) FROM t double null += SELECT ST_FRECHETDISTANCE(tm, tm) FROM t double null += SELECT ST_FRECHETDISTANCE(tm, tm, tm) FROM t double null += SELECT ST_FRECHETDISTANCE(u, u) FROM t double null += SELECT ST_FRECHETDISTANCE(u, u, u) FROM t double null += SELECT ST_GEOHASH(i, i, i) FROM t string += SELECT ST_GEOHASH(i, s, s) FROM t string += SELECT ST_GEOHASH(n, n, n) FROM t string null += SELECT ST_GEOHASH(s, i, i) FROM t string += SELECT ST_GEOHASH(s, s, NULL) FROM t string null += SELECT ST_GEOHASH(s, s, s) FROM t string += SELECT ST_GEOHASH(u, u, u) FROM t string += SELECT ST_GEOMCOLLFROMTEXT(b) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(b, b) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(b, b, b) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(d) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(d, d) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(d, d, d) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(da) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(da, da) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(da, da, da) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(dt) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(dt, dt) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(dt, dt, dt) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(dt, i) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(dt, i, i) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(f) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(f, f) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(f, f, f) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(i) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(i, i) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(i, i, i) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(i, s) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(i, s, s) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(j) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(j, j) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(j, j, j) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(n) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(n, n) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(n, n, n) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(s) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(s, i) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(s, i, i) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(s, s) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(s, s, NULL) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(s, s, s) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(tm) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(tm, tm) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(tm, tm, tm) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(u) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(u, u) FROM t geometry null += SELECT ST_GEOMCOLLFROMTEXT(u, u, u) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(b) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(b, b) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(b, b, b) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(d) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(d, d) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(d, d, d) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(da) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(da, da) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(da, da, da) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(dt) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(dt, dt) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(dt, dt, dt) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(dt, i) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(dt, i, i) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(f) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(f, f) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(f, f, f) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(i) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(i, i) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(i, i, i) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(i, s) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(i, s, s) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(j) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(j, j) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(j, j, j) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(n) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(n, n) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(n, n, n) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(s) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(s, i) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(s, i, i) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(s, s) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(s, s, NULL) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(s, s, s) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(tm) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(tm, tm) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(tm, tm, tm) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(u) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(u, u) FROM t geometry null += SELECT ST_GEOMCOLLFROMTXT(u, u, u) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(b) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(b, b) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(b, b, b) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(d) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(d, d) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(d, d, d) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(da) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(da, da) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(da, da, da) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(dt) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(dt, dt) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(dt, dt, dt) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(dt, i) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(dt, i, i) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(f) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(f, f) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(f, f, f) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(i) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(i, i) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(i, i, i) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(i, s) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(i, s, s) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(j) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(j, j) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(j, j, j) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(n) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(n, n) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(n, n, n) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(s) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(s, i) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(s, i, i) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(s, s) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(s, s, NULL) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(s, s, s) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(tm) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(tm, tm) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(tm, tm, tm) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(u) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(u, u) FROM t geometry null += SELECT ST_GEOMCOLLFROMWKB(u, u, u) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(b) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(b, b) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(b, b, b) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(d) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(d, d) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(d, d, d) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(da) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(da, da) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(da, da, da) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(dt) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(dt, dt) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(dt, dt, dt) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(dt, i) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(dt, i, i) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(f) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(f, f) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(f, f, f) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(i) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(i, i) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(i, i, i) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(i, s) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(i, s, s) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(j) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(j, j) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(j, j, j) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(n) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(n, n) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(n, n, n) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(s) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(s, i) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(s, i, i) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(s, s) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(s, s, NULL) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(s, s, s) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(tm) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(tm, tm) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(tm, tm, tm) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(u) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(u, u) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMTEXT(u, u, u) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(b) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(b, b) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(b, b, b) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(d) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(d, d) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(d, d, d) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(da) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(da, da) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(da, da, da) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(dt) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(dt, dt) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(dt, dt, dt) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(dt, i) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(dt, i, i) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(f) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(f, f) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(f, f, f) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(i) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(i, i) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(i, i, i) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(i, s) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(i, s, s) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(j) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(j, j) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(j, j, j) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(n) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(n, n) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(n, n, n) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(s) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(s, i) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(s, i, i) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(s, s) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(s, s, NULL) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(s, s, s) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(tm) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(tm, tm) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(tm, tm, tm) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(u) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(u, u) FROM t geometry null += SELECT ST_GEOMETRYCOLLECTIONFROMWKB(u, u, u) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(b) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(b, b) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(b, b, b) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(d) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(d, d) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(d, d, d) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(da) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(da, da) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(da, da, da) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(dt) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(dt, dt) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(dt, dt, dt) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(dt, i) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(dt, i, i) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(f) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(f, f) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(f, f, f) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(i) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(i, i) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(i, i, i) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(i, s) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(i, s, s) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(j) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(j, j) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(j, j, j) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(n) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(n, n) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(n, n, n) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(s) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(s, i) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(s, i, i) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(s, s) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(s, s, NULL) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(s, s, s) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(tm) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(tm, tm) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(tm, tm, tm) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(u) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(u, u) FROM t geometry null += SELECT ST_GEOMETRYFROMTEXT(u, u, u) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(b) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(b, b) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(b, b, b) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(d) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(d, d) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(d, d, d) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(da) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(da, da) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(da, da, da) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(dt) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(dt, dt) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(dt, dt, dt) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(dt, i) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(dt, i, i) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(f) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(f, f) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(f, f, f) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(i) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(i, i) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(i, i, i) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(i, s) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(i, s, s) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(j) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(j, j) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(j, j, j) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(n) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(n, n) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(n, n, n) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(s) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(s, i) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(s, i, i) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(s, s) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(s, s, NULL) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(s, s, s) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(tm) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(tm, tm) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(tm, tm, tm) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(u) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(u, u) FROM t geometry null += SELECT ST_GEOMETRYFROMWKB(u, u, u) FROM t geometry null += SELECT ST_GEOMETRYN(b, b) FROM t geometry null += SELECT ST_GEOMETRYN(d, d) FROM t geometry null += SELECT ST_GEOMETRYN(da, da) FROM t geometry null += SELECT ST_GEOMETRYN(dt, dt) FROM t geometry null += SELECT ST_GEOMETRYN(dt, i) FROM t geometry null += SELECT ST_GEOMETRYN(f, f) FROM t geometry null += SELECT ST_GEOMETRYN(i, i) FROM t geometry null += SELECT ST_GEOMETRYN(i, s) FROM t geometry null += SELECT ST_GEOMETRYN(j, j) FROM t geometry null += SELECT ST_GEOMETRYN(n, n) FROM t geometry null += SELECT ST_GEOMETRYN(s, i) FROM t geometry null += SELECT ST_GEOMETRYN(s, s) FROM t geometry null += SELECT ST_GEOMETRYN(tm, tm) FROM t geometry null += SELECT ST_GEOMETRYN(u, u) FROM t geometry null += SELECT ST_GEOMETRYTYPE(b) FROM t string null += SELECT ST_GEOMETRYTYPE(d) FROM t string null += SELECT ST_GEOMETRYTYPE(da) FROM t string null += SELECT ST_GEOMETRYTYPE(dt) FROM t string null += SELECT ST_GEOMETRYTYPE(f) FROM t string null += SELECT ST_GEOMETRYTYPE(i) FROM t string null += SELECT ST_GEOMETRYTYPE(j) FROM t string null += SELECT ST_GEOMETRYTYPE(n) FROM t string null += SELECT ST_GEOMETRYTYPE(s) FROM t string null += SELECT ST_GEOMETRYTYPE(tm) FROM t string null += SELECT ST_GEOMETRYTYPE(u) FROM t string null += SELECT ST_GEOMFROMGEOJSON(j) FROM t geometry null += SELECT ST_GEOMFROMGEOJSON(s) FROM t geometry null += SELECT ST_GEOMFROMGEOJSON(s, i) FROM t geometry null += SELECT ST_GEOMFROMGEOJSON(s, i, i) FROM t geometry null += SELECT ST_GEOMFROMGEOJSON(s, s) FROM t geometry null += SELECT ST_GEOMFROMGEOJSON(s, s, NULL) FROM t geometry null += SELECT ST_GEOMFROMGEOJSON(s, s, s) FROM t geometry null += SELECT ST_GEOMFROMTEXT(b) FROM t geometry null += SELECT ST_GEOMFROMTEXT(b, b) FROM t geometry null += SELECT ST_GEOMFROMTEXT(b, b, b) FROM t geometry null += SELECT ST_GEOMFROMTEXT(d) FROM t geometry null += SELECT ST_GEOMFROMTEXT(d, d) FROM t geometry null += SELECT ST_GEOMFROMTEXT(d, d, d) FROM t geometry null += SELECT ST_GEOMFROMTEXT(da) FROM t geometry null += SELECT ST_GEOMFROMTEXT(da, da) FROM t geometry null += SELECT ST_GEOMFROMTEXT(da, da, da) FROM t geometry null += SELECT ST_GEOMFROMTEXT(dt) FROM t geometry null += SELECT ST_GEOMFROMTEXT(dt, dt) FROM t geometry null += SELECT ST_GEOMFROMTEXT(dt, dt, dt) FROM t geometry null += SELECT ST_GEOMFROMTEXT(dt, i) FROM t geometry null += SELECT ST_GEOMFROMTEXT(dt, i, i) FROM t geometry null += SELECT ST_GEOMFROMTEXT(f) FROM t geometry null += SELECT ST_GEOMFROMTEXT(f, f) FROM t geometry null += SELECT ST_GEOMFROMTEXT(f, f, f) FROM t geometry null += SELECT ST_GEOMFROMTEXT(i) FROM t geometry null += SELECT ST_GEOMFROMTEXT(i, i) FROM t geometry null += SELECT ST_GEOMFROMTEXT(i, i, i) FROM t geometry null += SELECT ST_GEOMFROMTEXT(i, s) FROM t geometry null += SELECT ST_GEOMFROMTEXT(i, s, s) FROM t geometry null += SELECT ST_GEOMFROMTEXT(j) FROM t geometry null += SELECT ST_GEOMFROMTEXT(j, j) FROM t geometry null += SELECT ST_GEOMFROMTEXT(j, j, j) FROM t geometry null += SELECT ST_GEOMFROMTEXT(n) FROM t geometry null += SELECT ST_GEOMFROMTEXT(n, n) FROM t geometry null += SELECT ST_GEOMFROMTEXT(n, n, n) FROM t geometry null += SELECT ST_GEOMFROMTEXT(s) FROM t geometry null += SELECT ST_GEOMFROMTEXT(s, i) FROM t geometry null += SELECT ST_GEOMFROMTEXT(s, i, i) FROM t geometry null += SELECT ST_GEOMFROMTEXT(s, s) FROM t geometry null += SELECT ST_GEOMFROMTEXT(s, s, NULL) FROM t geometry null += SELECT ST_GEOMFROMTEXT(s, s, s) FROM t geometry null += SELECT ST_GEOMFROMTEXT(tm) FROM t geometry null += SELECT ST_GEOMFROMTEXT(tm, tm) FROM t geometry null += SELECT ST_GEOMFROMTEXT(tm, tm, tm) FROM t geometry null += SELECT ST_GEOMFROMTEXT(u) FROM t geometry null += SELECT ST_GEOMFROMTEXT(u, u) FROM t geometry null += SELECT ST_GEOMFROMTEXT(u, u, u) FROM t geometry null += SELECT ST_GEOMFROMWKB(b) FROM t geometry null += SELECT ST_GEOMFROMWKB(b, b) FROM t geometry null += SELECT ST_GEOMFROMWKB(b, b, b) FROM t geometry null += SELECT ST_GEOMFROMWKB(d) FROM t geometry null += SELECT ST_GEOMFROMWKB(d, d) FROM t geometry null += SELECT ST_GEOMFROMWKB(d, d, d) FROM t geometry null += SELECT ST_GEOMFROMWKB(da) FROM t geometry null += SELECT ST_GEOMFROMWKB(da, da) FROM t geometry null += SELECT ST_GEOMFROMWKB(da, da, da) FROM t geometry null += SELECT ST_GEOMFROMWKB(dt) FROM t geometry null += SELECT ST_GEOMFROMWKB(dt, dt) FROM t geometry null += SELECT ST_GEOMFROMWKB(dt, dt, dt) FROM t geometry null += SELECT ST_GEOMFROMWKB(dt, i) FROM t geometry null += SELECT ST_GEOMFROMWKB(dt, i, i) FROM t geometry null += SELECT ST_GEOMFROMWKB(f) FROM t geometry null += SELECT ST_GEOMFROMWKB(f, f) FROM t geometry null += SELECT ST_GEOMFROMWKB(f, f, f) FROM t geometry null += SELECT ST_GEOMFROMWKB(i) FROM t geometry null += SELECT ST_GEOMFROMWKB(i, i) FROM t geometry null += SELECT ST_GEOMFROMWKB(i, i, i) FROM t geometry null += SELECT ST_GEOMFROMWKB(i, s) FROM t geometry null += SELECT ST_GEOMFROMWKB(i, s, s) FROM t geometry null += SELECT ST_GEOMFROMWKB(j) FROM t geometry null += SELECT ST_GEOMFROMWKB(j, j) FROM t geometry null += SELECT ST_GEOMFROMWKB(j, j, j) FROM t geometry null += SELECT ST_GEOMFROMWKB(n) FROM t geometry null += SELECT ST_GEOMFROMWKB(n, n) FROM t geometry null += SELECT ST_GEOMFROMWKB(n, n, n) FROM t geometry null += SELECT ST_GEOMFROMWKB(s) FROM t geometry null += SELECT ST_GEOMFROMWKB(s, i) FROM t geometry null += SELECT ST_GEOMFROMWKB(s, i, i) FROM t geometry null += SELECT ST_GEOMFROMWKB(s, s) FROM t geometry null += SELECT ST_GEOMFROMWKB(s, s, NULL) FROM t geometry null += SELECT ST_GEOMFROMWKB(s, s, s) FROM t geometry null += SELECT ST_GEOMFROMWKB(tm) FROM t geometry null += SELECT ST_GEOMFROMWKB(tm, tm) FROM t geometry null += SELECT ST_GEOMFROMWKB(tm, tm, tm) FROM t geometry null += SELECT ST_GEOMFROMWKB(u) FROM t geometry null += SELECT ST_GEOMFROMWKB(u, u) FROM t geometry null += SELECT ST_GEOMFROMWKB(u, u, u) FROM t geometry null += SELECT ST_HAUSDORFFDISTANCE(b, b) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(b, b, b) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(d, d) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(d, d, d) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(da, da) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(da, da, da) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(dt, dt) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(dt, dt, dt) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(dt, i) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(dt, i, i) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(f, f) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(f, f, f) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(i, i) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(i, i, i) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(i, s) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(i, s, s) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(j, j) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(j, j, j) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(n, n) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(n, n, n) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(s, i) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(s, i, i) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(s, s) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(s, s, NULL) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(s, s, s) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(tm, tm) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(tm, tm, tm) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(u, u) FROM t double null += SELECT ST_HAUSDORFFDISTANCE(u, u, u) FROM t double null += SELECT ST_INTERIORRINGN(b, b) FROM t geometry null += SELECT ST_INTERIORRINGN(d, d) FROM t geometry null += SELECT ST_INTERIORRINGN(da, da) FROM t geometry null += SELECT ST_INTERIORRINGN(dt, dt) FROM t geometry null += SELECT ST_INTERIORRINGN(dt, i) FROM t geometry null += SELECT ST_INTERIORRINGN(f, f) FROM t geometry null += SELECT ST_INTERIORRINGN(i, i) FROM t geometry null += SELECT ST_INTERIORRINGN(i, s) FROM t geometry null += SELECT ST_INTERIORRINGN(j, j) FROM t geometry null += SELECT ST_INTERIORRINGN(n, n) FROM t geometry null += SELECT ST_INTERIORRINGN(s, i) FROM t geometry null += SELECT ST_INTERIORRINGN(s, s) FROM t geometry null += SELECT ST_INTERIORRINGN(tm, tm) FROM t geometry null += SELECT ST_INTERIORRINGN(u, u) FROM t geometry null += SELECT ST_INTERSECTION(b, b) FROM t geometry null += SELECT ST_INTERSECTION(d, d) FROM t geometry null += SELECT ST_INTERSECTION(da, da) FROM t geometry null += SELECT ST_INTERSECTION(dt, dt) FROM t geometry null += SELECT ST_INTERSECTION(dt, i) FROM t geometry null += SELECT ST_INTERSECTION(f, f) FROM t geometry null += SELECT ST_INTERSECTION(i, i) FROM t geometry null += SELECT ST_INTERSECTION(i, s) FROM t geometry null += SELECT ST_INTERSECTION(j, j) FROM t geometry null += SELECT ST_INTERSECTION(n, n) FROM t geometry null += SELECT ST_INTERSECTION(s, i) FROM t geometry null += SELECT ST_INTERSECTION(s, s) FROM t geometry null += SELECT ST_INTERSECTION(tm, tm) FROM t geometry null += SELECT ST_INTERSECTION(u, u) FROM t geometry null += SELECT ST_INTERSECTS(b, b) FROM t bigint null += SELECT ST_INTERSECTS(d, d) FROM t bigint null += SELECT ST_INTERSECTS(da, da) FROM t bigint null += SELECT ST_INTERSECTS(dt, dt) FROM t bigint null += SELECT ST_INTERSECTS(dt, i) FROM t bigint null += SELECT ST_INTERSECTS(f, f) FROM t bigint null += SELECT ST_INTERSECTS(i, i) FROM t bigint null += SELECT ST_INTERSECTS(i, s) FROM t bigint null += SELECT ST_INTERSECTS(j, j) FROM t bigint null += SELECT ST_INTERSECTS(n, n) FROM t bigint null += SELECT ST_INTERSECTS(s, i) FROM t bigint null += SELECT ST_INTERSECTS(s, s) FROM t bigint null += SELECT ST_INTERSECTS(tm, tm) FROM t bigint null += SELECT ST_INTERSECTS(u, u) FROM t bigint null += SELECT ST_ISCLOSED(b) FROM t bigint null += SELECT ST_ISCLOSED(d) FROM t bigint null += SELECT ST_ISCLOSED(da) FROM t bigint null += SELECT ST_ISCLOSED(dt) FROM t bigint null += SELECT ST_ISCLOSED(f) FROM t bigint null += SELECT ST_ISCLOSED(i) FROM t bigint null += SELECT ST_ISCLOSED(j) FROM t bigint null += SELECT ST_ISCLOSED(n) FROM t bigint null += SELECT ST_ISCLOSED(s) FROM t bigint null += SELECT ST_ISCLOSED(tm) FROM t bigint null += SELECT ST_ISCLOSED(u) FROM t bigint null += SELECT ST_ISEMPTY(b) FROM t bigint null += SELECT ST_ISEMPTY(d) FROM t bigint null += SELECT ST_ISEMPTY(da) FROM t bigint null += SELECT ST_ISEMPTY(dt) FROM t bigint null += SELECT ST_ISEMPTY(f) FROM t bigint null += SELECT ST_ISEMPTY(i) FROM t bigint null += SELECT ST_ISEMPTY(j) FROM t bigint null += SELECT ST_ISEMPTY(n) FROM t bigint null += SELECT ST_ISEMPTY(s) FROM t bigint null += SELECT ST_ISEMPTY(tm) FROM t bigint null += SELECT ST_ISEMPTY(u) FROM t bigint null += SELECT ST_ISSIMPLE(b) FROM t bigint += SELECT ST_ISSIMPLE(d) FROM t bigint += SELECT ST_ISSIMPLE(da) FROM t bigint += SELECT ST_ISSIMPLE(dt) FROM t bigint += SELECT ST_ISSIMPLE(f) FROM t bigint += SELECT ST_ISSIMPLE(i) FROM t bigint += SELECT ST_ISSIMPLE(j) FROM t bigint null += SELECT ST_ISSIMPLE(n) FROM t bigint null += SELECT ST_ISSIMPLE(s) FROM t bigint += SELECT ST_ISSIMPLE(tm) FROM t bigint += SELECT ST_ISSIMPLE(u) FROM t bigint += SELECT ST_ISVALID(b) FROM t bigint += SELECT ST_ISVALID(d) FROM t bigint += SELECT ST_ISVALID(da) FROM t bigint += SELECT ST_ISVALID(dt) FROM t bigint += SELECT ST_ISVALID(f) FROM t bigint += SELECT ST_ISVALID(i) FROM t bigint += SELECT ST_ISVALID(j) FROM t bigint null += SELECT ST_ISVALID(n) FROM t bigint null += SELECT ST_ISVALID(s) FROM t bigint += SELECT ST_ISVALID(tm) FROM t bigint += SELECT ST_ISVALID(u) FROM t bigint += SELECT ST_LATFROMGEOHASH(s) FROM t double += SELECT ST_LATITUDE(b) FROM t double += SELECT ST_LATITUDE(b, b) FROM t geometry null += SELECT ST_LATITUDE(d) FROM t double += SELECT ST_LATITUDE(d, d) FROM t geometry null += SELECT ST_LATITUDE(da) FROM t double += SELECT ST_LATITUDE(da, da) FROM t geometry null += SELECT ST_LATITUDE(dt) FROM t double += SELECT ST_LATITUDE(dt, dt) FROM t geometry null += SELECT ST_LATITUDE(dt, i) FROM t geometry null += SELECT ST_LATITUDE(f) FROM t double += SELECT ST_LATITUDE(f, f) FROM t geometry null += SELECT ST_LATITUDE(i) FROM t double += SELECT ST_LATITUDE(i, i) FROM t geometry null += SELECT ST_LATITUDE(i, s) FROM t geometry null += SELECT ST_LATITUDE(j) FROM t double null += SELECT ST_LATITUDE(j, j) FROM t geometry null += SELECT ST_LATITUDE(n) FROM t double null += SELECT ST_LATITUDE(n, n) FROM t geometry null += SELECT ST_LATITUDE(s) FROM t double += SELECT ST_LATITUDE(s, i) FROM t geometry null += SELECT ST_LATITUDE(s, s) FROM t geometry null += SELECT ST_LATITUDE(tm) FROM t double += SELECT ST_LATITUDE(tm, tm) FROM t geometry null += SELECT ST_LATITUDE(u) FROM t double += SELECT ST_LATITUDE(u, u) FROM t geometry null += SELECT ST_LENGTH(b) FROM t double null += SELECT ST_LENGTH(b, b) FROM t double null += SELECT ST_LENGTH(d) FROM t double null += SELECT ST_LENGTH(d, d) FROM t double null += SELECT ST_LENGTH(da) FROM t double null += SELECT ST_LENGTH(da, da) FROM t double null += SELECT ST_LENGTH(dt) FROM t double null += SELECT ST_LENGTH(dt, dt) FROM t double null += SELECT ST_LENGTH(dt, i) FROM t double null += SELECT ST_LENGTH(f) FROM t double null += SELECT ST_LENGTH(f, f) FROM t double null += SELECT ST_LENGTH(i) FROM t double null += SELECT ST_LENGTH(i, i) FROM t double null += SELECT ST_LENGTH(i, s) FROM t double null += SELECT ST_LENGTH(j) FROM t double null += SELECT ST_LENGTH(j, j) FROM t double null += SELECT ST_LENGTH(n) FROM t double null += SELECT ST_LENGTH(n, n) FROM t double null += SELECT ST_LENGTH(s) FROM t double null += SELECT ST_LENGTH(s, i) FROM t double null += SELECT ST_LENGTH(s, s) FROM t double null += SELECT ST_LENGTH(tm) FROM t double null += SELECT ST_LENGTH(tm, tm) FROM t double null += SELECT ST_LENGTH(u) FROM t double null += SELECT ST_LENGTH(u, u) FROM t double null += SELECT ST_LINEFROMTEXT(b) FROM t geometry null += SELECT ST_LINEFROMTEXT(b, b) FROM t geometry null += SELECT ST_LINEFROMTEXT(b, b, b) FROM t geometry null += SELECT ST_LINEFROMTEXT(d) FROM t geometry null += SELECT ST_LINEFROMTEXT(d, d) FROM t geometry null += SELECT ST_LINEFROMTEXT(d, d, d) FROM t geometry null += SELECT ST_LINEFROMTEXT(da) FROM t geometry null += SELECT ST_LINEFROMTEXT(da, da) FROM t geometry null += SELECT ST_LINEFROMTEXT(da, da, da) FROM t geometry null += SELECT ST_LINEFROMTEXT(dt) FROM t geometry null += SELECT ST_LINEFROMTEXT(dt, dt) FROM t geometry null += SELECT ST_LINEFROMTEXT(dt, dt, dt) FROM t geometry null += SELECT ST_LINEFROMTEXT(dt, i) FROM t geometry null += SELECT ST_LINEFROMTEXT(dt, i, i) FROM t geometry null += SELECT ST_LINEFROMTEXT(f) FROM t geometry null += SELECT ST_LINEFROMTEXT(f, f) FROM t geometry null += SELECT ST_LINEFROMTEXT(f, f, f) FROM t geometry null += SELECT ST_LINEFROMTEXT(i) FROM t geometry null += SELECT ST_LINEFROMTEXT(i, i) FROM t geometry null += SELECT ST_LINEFROMTEXT(i, i, i) FROM t geometry null += SELECT ST_LINEFROMTEXT(i, s) FROM t geometry null += SELECT ST_LINEFROMTEXT(i, s, s) FROM t geometry null += SELECT ST_LINEFROMTEXT(j) FROM t geometry null += SELECT ST_LINEFROMTEXT(j, j) FROM t geometry null += SELECT ST_LINEFROMTEXT(j, j, j) FROM t geometry null += SELECT ST_LINEFROMTEXT(n) FROM t geometry null += SELECT ST_LINEFROMTEXT(n, n) FROM t geometry null += SELECT ST_LINEFROMTEXT(n, n, n) FROM t geometry null += SELECT ST_LINEFROMTEXT(s) FROM t geometry null += SELECT ST_LINEFROMTEXT(s, i) FROM t geometry null += SELECT ST_LINEFROMTEXT(s, i, i) FROM t geometry null += SELECT ST_LINEFROMTEXT(s, s) FROM t geometry null += SELECT ST_LINEFROMTEXT(s, s, NULL) FROM t geometry null += SELECT ST_LINEFROMTEXT(s, s, s) FROM t geometry null += SELECT ST_LINEFROMTEXT(tm) FROM t geometry null += SELECT ST_LINEFROMTEXT(tm, tm) FROM t geometry null += SELECT ST_LINEFROMTEXT(tm, tm, tm) FROM t geometry null += SELECT ST_LINEFROMTEXT(u) FROM t geometry null += SELECT ST_LINEFROMTEXT(u, u) FROM t geometry null += SELECT ST_LINEFROMTEXT(u, u, u) FROM t geometry null += SELECT ST_LINEFROMWKB(b) FROM t geometry null += SELECT ST_LINEFROMWKB(b, b) FROM t geometry null += SELECT ST_LINEFROMWKB(b, b, b) FROM t geometry null += SELECT ST_LINEFROMWKB(d) FROM t geometry null += SELECT ST_LINEFROMWKB(d, d) FROM t geometry null += SELECT ST_LINEFROMWKB(d, d, d) FROM t geometry null += SELECT ST_LINEFROMWKB(da) FROM t geometry null += SELECT ST_LINEFROMWKB(da, da) FROM t geometry null += SELECT ST_LINEFROMWKB(da, da, da) FROM t geometry null += SELECT ST_LINEFROMWKB(dt) FROM t geometry null += SELECT ST_LINEFROMWKB(dt, dt) FROM t geometry null += SELECT ST_LINEFROMWKB(dt, dt, dt) FROM t geometry null += SELECT ST_LINEFROMWKB(dt, i) FROM t geometry null += SELECT ST_LINEFROMWKB(dt, i, i) FROM t geometry null += SELECT ST_LINEFROMWKB(f) FROM t geometry null += SELECT ST_LINEFROMWKB(f, f) FROM t geometry null += SELECT ST_LINEFROMWKB(f, f, f) FROM t geometry null += SELECT ST_LINEFROMWKB(i) FROM t geometry null += SELECT ST_LINEFROMWKB(i, i) FROM t geometry null += SELECT ST_LINEFROMWKB(i, i, i) FROM t geometry null += SELECT ST_LINEFROMWKB(i, s) FROM t geometry null += SELECT ST_LINEFROMWKB(i, s, s) FROM t geometry null += SELECT ST_LINEFROMWKB(j) FROM t geometry null += SELECT ST_LINEFROMWKB(j, j) FROM t geometry null += SELECT ST_LINEFROMWKB(j, j, j) FROM t geometry null += SELECT ST_LINEFROMWKB(n) FROM t geometry null += SELECT ST_LINEFROMWKB(n, n) FROM t geometry null += SELECT ST_LINEFROMWKB(n, n, n) FROM t geometry null += SELECT ST_LINEFROMWKB(s) FROM t geometry null += SELECT ST_LINEFROMWKB(s, i) FROM t geometry null += SELECT ST_LINEFROMWKB(s, i, i) FROM t geometry null += SELECT ST_LINEFROMWKB(s, s) FROM t geometry null += SELECT ST_LINEFROMWKB(s, s, NULL) FROM t geometry null += SELECT ST_LINEFROMWKB(s, s, s) FROM t geometry null += SELECT ST_LINEFROMWKB(tm) FROM t geometry null += SELECT ST_LINEFROMWKB(tm, tm) FROM t geometry null += SELECT ST_LINEFROMWKB(tm, tm, tm) FROM t geometry null += SELECT ST_LINEFROMWKB(u) FROM t geometry null += SELECT ST_LINEFROMWKB(u, u) FROM t geometry null += SELECT ST_LINEFROMWKB(u, u, u) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINT(b, b) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINT(d, d) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINT(da, da) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINT(dt, dt) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINT(dt, i) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINT(f, f) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINT(i, i) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINT(i, s) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINT(j, j) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINT(n, n) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINT(s, i) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINT(s, s) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINT(tm, tm) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINT(u, u) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINTS(b, b) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINTS(d, d) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINTS(da, da) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINTS(dt, dt) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINTS(dt, i) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINTS(f, f) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINTS(i, i) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINTS(i, s) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINTS(j, j) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINTS(n, n) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINTS(s, i) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINTS(s, s) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINTS(tm, tm) FROM t geometry null += SELECT ST_LINEINTERPOLATEPOINTS(u, u) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(b) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(b, b) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(b, b, b) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(d) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(d, d) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(d, d, d) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(da) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(da, da) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(da, da, da) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(dt) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(dt, dt) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(dt, dt, dt) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(dt, i) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(dt, i, i) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(f) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(f, f) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(f, f, f) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(i) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(i, i) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(i, i, i) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(i, s) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(i, s, s) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(j) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(j, j) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(j, j, j) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(n) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(n, n) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(n, n, n) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(s) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(s, i) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(s, i, i) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(s, s) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(s, s, NULL) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(s, s, s) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(tm) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(tm, tm) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(tm, tm, tm) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(u) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(u, u) FROM t geometry null += SELECT ST_LINESTRINGFROMTEXT(u, u, u) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(b) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(b, b) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(b, b, b) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(d) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(d, d) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(d, d, d) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(da) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(da, da) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(da, da, da) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(dt) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(dt, dt) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(dt, dt, dt) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(dt, i) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(dt, i, i) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(f) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(f, f) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(f, f, f) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(i) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(i, i) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(i, i, i) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(i, s) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(i, s, s) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(j) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(j, j) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(j, j, j) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(n) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(n, n) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(n, n, n) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(s) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(s, i) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(s, i, i) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(s, s) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(s, s, NULL) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(s, s, s) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(tm) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(tm, tm) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(tm, tm, tm) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(u) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(u, u) FROM t geometry null += SELECT ST_LINESTRINGFROMWKB(u, u, u) FROM t geometry null += SELECT ST_LONGFROMGEOHASH(s) FROM t double += SELECT ST_LONGITUDE(b) FROM t double += SELECT ST_LONGITUDE(b, b) FROM t geometry null += SELECT ST_LONGITUDE(d) FROM t double += SELECT ST_LONGITUDE(d, d) FROM t geometry null += SELECT ST_LONGITUDE(da) FROM t double += SELECT ST_LONGITUDE(da, da) FROM t geometry null += SELECT ST_LONGITUDE(dt) FROM t double += SELECT ST_LONGITUDE(dt, dt) FROM t geometry null += SELECT ST_LONGITUDE(dt, i) FROM t geometry null += SELECT ST_LONGITUDE(f) FROM t double += SELECT ST_LONGITUDE(f, f) FROM t geometry null += SELECT ST_LONGITUDE(i) FROM t double += SELECT ST_LONGITUDE(i, i) FROM t geometry null += SELECT ST_LONGITUDE(i, s) FROM t geometry null += SELECT ST_LONGITUDE(j) FROM t double null += SELECT ST_LONGITUDE(j, j) FROM t geometry null += SELECT ST_LONGITUDE(n) FROM t double null += SELECT ST_LONGITUDE(n, n) FROM t geometry null += SELECT ST_LONGITUDE(s) FROM t double += SELECT ST_LONGITUDE(s, i) FROM t geometry null += SELECT ST_LONGITUDE(s, s) FROM t geometry null += SELECT ST_LONGITUDE(tm) FROM t double += SELECT ST_LONGITUDE(tm, tm) FROM t geometry null += SELECT ST_LONGITUDE(u) FROM t double += SELECT ST_LONGITUDE(u, u) FROM t geometry null += SELECT ST_MAKEENVELOPE(b, b) FROM t geometry null += SELECT ST_MAKEENVELOPE(d, d) FROM t geometry null += SELECT ST_MAKEENVELOPE(da, da) FROM t geometry null += SELECT ST_MAKEENVELOPE(dt, dt) FROM t geometry null += SELECT ST_MAKEENVELOPE(dt, i) FROM t geometry null += SELECT ST_MAKEENVELOPE(f, f) FROM t geometry null += SELECT ST_MAKEENVELOPE(i, i) FROM t geometry null += SELECT ST_MAKEENVELOPE(i, s) FROM t geometry null += SELECT ST_MAKEENVELOPE(j, j) FROM t geometry null += SELECT ST_MAKEENVELOPE(n, n) FROM t geometry null += SELECT ST_MAKEENVELOPE(s, i) FROM t geometry null += SELECT ST_MAKEENVELOPE(s, s) FROM t geometry null += SELECT ST_MAKEENVELOPE(tm, tm) FROM t geometry null += SELECT ST_MAKEENVELOPE(u, u) FROM t geometry null += SELECT ST_MLINEFROMTEXT(b) FROM t geometry null += SELECT ST_MLINEFROMTEXT(b, b) FROM t geometry null += SELECT ST_MLINEFROMTEXT(b, b, b) FROM t geometry null += SELECT ST_MLINEFROMTEXT(d) FROM t geometry null += SELECT ST_MLINEFROMTEXT(d, d) FROM t geometry null += SELECT ST_MLINEFROMTEXT(d, d, d) FROM t geometry null += SELECT ST_MLINEFROMTEXT(da) FROM t geometry null += SELECT ST_MLINEFROMTEXT(da, da) FROM t geometry null += SELECT ST_MLINEFROMTEXT(da, da, da) FROM t geometry null += SELECT ST_MLINEFROMTEXT(dt) FROM t geometry null += SELECT ST_MLINEFROMTEXT(dt, dt) FROM t geometry null += SELECT ST_MLINEFROMTEXT(dt, dt, dt) FROM t geometry null += SELECT ST_MLINEFROMTEXT(dt, i) FROM t geometry null += SELECT ST_MLINEFROMTEXT(dt, i, i) FROM t geometry null += SELECT ST_MLINEFROMTEXT(f) FROM t geometry null += SELECT ST_MLINEFROMTEXT(f, f) FROM t geometry null += SELECT ST_MLINEFROMTEXT(f, f, f) FROM t geometry null += SELECT ST_MLINEFROMTEXT(i) FROM t geometry null += SELECT ST_MLINEFROMTEXT(i, i) FROM t geometry null += SELECT ST_MLINEFROMTEXT(i, i, i) FROM t geometry null += SELECT ST_MLINEFROMTEXT(i, s) FROM t geometry null += SELECT ST_MLINEFROMTEXT(i, s, s) FROM t geometry null += SELECT ST_MLINEFROMTEXT(j) FROM t geometry null += SELECT ST_MLINEFROMTEXT(j, j) FROM t geometry null += SELECT ST_MLINEFROMTEXT(j, j, j) FROM t geometry null += SELECT ST_MLINEFROMTEXT(n) FROM t geometry null += SELECT ST_MLINEFROMTEXT(n, n) FROM t geometry null += SELECT ST_MLINEFROMTEXT(n, n, n) FROM t geometry null += SELECT ST_MLINEFROMTEXT(s) FROM t geometry null += SELECT ST_MLINEFROMTEXT(s, i) FROM t geometry null += SELECT ST_MLINEFROMTEXT(s, i, i) FROM t geometry null += SELECT ST_MLINEFROMTEXT(s, s) FROM t geometry null += SELECT ST_MLINEFROMTEXT(s, s, NULL) FROM t geometry null += SELECT ST_MLINEFROMTEXT(s, s, s) FROM t geometry null += SELECT ST_MLINEFROMTEXT(tm) FROM t geometry null += SELECT ST_MLINEFROMTEXT(tm, tm) FROM t geometry null += SELECT ST_MLINEFROMTEXT(tm, tm, tm) FROM t geometry null += SELECT ST_MLINEFROMTEXT(u) FROM t geometry null += SELECT ST_MLINEFROMTEXT(u, u) FROM t geometry null += SELECT ST_MLINEFROMTEXT(u, u, u) FROM t geometry null += SELECT ST_MLINEFROMWKB(b) FROM t geometry null += SELECT ST_MLINEFROMWKB(b, b) FROM t geometry null += SELECT ST_MLINEFROMWKB(b, b, b) FROM t geometry null += SELECT ST_MLINEFROMWKB(d) FROM t geometry null += SELECT ST_MLINEFROMWKB(d, d) FROM t geometry null += SELECT ST_MLINEFROMWKB(d, d, d) FROM t geometry null += SELECT ST_MLINEFROMWKB(da) FROM t geometry null += SELECT ST_MLINEFROMWKB(da, da) FROM t geometry null += SELECT ST_MLINEFROMWKB(da, da, da) FROM t geometry null += SELECT ST_MLINEFROMWKB(dt) FROM t geometry null += SELECT ST_MLINEFROMWKB(dt, dt) FROM t geometry null += SELECT ST_MLINEFROMWKB(dt, dt, dt) FROM t geometry null += SELECT ST_MLINEFROMWKB(dt, i) FROM t geometry null += SELECT ST_MLINEFROMWKB(dt, i, i) FROM t geometry null += SELECT ST_MLINEFROMWKB(f) FROM t geometry null += SELECT ST_MLINEFROMWKB(f, f) FROM t geometry null += SELECT ST_MLINEFROMWKB(f, f, f) FROM t geometry null += SELECT ST_MLINEFROMWKB(i) FROM t geometry null += SELECT ST_MLINEFROMWKB(i, i) FROM t geometry null += SELECT ST_MLINEFROMWKB(i, i, i) FROM t geometry null += SELECT ST_MLINEFROMWKB(i, s) FROM t geometry null += SELECT ST_MLINEFROMWKB(i, s, s) FROM t geometry null += SELECT ST_MLINEFROMWKB(j) FROM t geometry null += SELECT ST_MLINEFROMWKB(j, j) FROM t geometry null += SELECT ST_MLINEFROMWKB(j, j, j) FROM t geometry null += SELECT ST_MLINEFROMWKB(n) FROM t geometry null += SELECT ST_MLINEFROMWKB(n, n) FROM t geometry null += SELECT ST_MLINEFROMWKB(n, n, n) FROM t geometry null += SELECT ST_MLINEFROMWKB(s) FROM t geometry null += SELECT ST_MLINEFROMWKB(s, i) FROM t geometry null += SELECT ST_MLINEFROMWKB(s, i, i) FROM t geometry null += SELECT ST_MLINEFROMWKB(s, s) FROM t geometry null += SELECT ST_MLINEFROMWKB(s, s, NULL) FROM t geometry null += SELECT ST_MLINEFROMWKB(s, s, s) FROM t geometry null += SELECT ST_MLINEFROMWKB(tm) FROM t geometry null += SELECT ST_MLINEFROMWKB(tm, tm) FROM t geometry null += SELECT ST_MLINEFROMWKB(tm, tm, tm) FROM t geometry null += SELECT ST_MLINEFROMWKB(u) FROM t geometry null += SELECT ST_MLINEFROMWKB(u, u) FROM t geometry null += SELECT ST_MLINEFROMWKB(u, u, u) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(b) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(b, b) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(b, b, b) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(d) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(d, d) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(d, d, d) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(da) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(da, da) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(da, da, da) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(dt) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(dt, dt) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(dt, dt, dt) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(dt, i) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(dt, i, i) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(f) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(f, f) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(f, f, f) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(i) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(i, i) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(i, i, i) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(i, s) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(i, s, s) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(j) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(j, j) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(j, j, j) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(n) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(n, n) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(n, n, n) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(s) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(s, i) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(s, i, i) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(s, s) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(s, s, NULL) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(s, s, s) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(tm) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(tm, tm) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(tm, tm, tm) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(u) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(u, u) FROM t geometry null += SELECT ST_MPOINTFROMTEXT(u, u, u) FROM t geometry null += SELECT ST_MPOINTFROMWKB(b) FROM t geometry null += SELECT ST_MPOINTFROMWKB(b, b) FROM t geometry null += SELECT ST_MPOINTFROMWKB(b, b, b) FROM t geometry null += SELECT ST_MPOINTFROMWKB(d) FROM t geometry null += SELECT ST_MPOINTFROMWKB(d, d) FROM t geometry null += SELECT ST_MPOINTFROMWKB(d, d, d) FROM t geometry null += SELECT ST_MPOINTFROMWKB(da) FROM t geometry null += SELECT ST_MPOINTFROMWKB(da, da) FROM t geometry null += SELECT ST_MPOINTFROMWKB(da, da, da) FROM t geometry null += SELECT ST_MPOINTFROMWKB(dt) FROM t geometry null += SELECT ST_MPOINTFROMWKB(dt, dt) FROM t geometry null += SELECT ST_MPOINTFROMWKB(dt, dt, dt) FROM t geometry null += SELECT ST_MPOINTFROMWKB(dt, i) FROM t geometry null += SELECT ST_MPOINTFROMWKB(dt, i, i) FROM t geometry null += SELECT ST_MPOINTFROMWKB(f) FROM t geometry null += SELECT ST_MPOINTFROMWKB(f, f) FROM t geometry null += SELECT ST_MPOINTFROMWKB(f, f, f) FROM t geometry null += SELECT ST_MPOINTFROMWKB(i) FROM t geometry null += SELECT ST_MPOINTFROMWKB(i, i) FROM t geometry null += SELECT ST_MPOINTFROMWKB(i, i, i) FROM t geometry null += SELECT ST_MPOINTFROMWKB(i, s) FROM t geometry null += SELECT ST_MPOINTFROMWKB(i, s, s) FROM t geometry null += SELECT ST_MPOINTFROMWKB(j) FROM t geometry null += SELECT ST_MPOINTFROMWKB(j, j) FROM t geometry null += SELECT ST_MPOINTFROMWKB(j, j, j) FROM t geometry null += SELECT ST_MPOINTFROMWKB(n) FROM t geometry null += SELECT ST_MPOINTFROMWKB(n, n) FROM t geometry null += SELECT ST_MPOINTFROMWKB(n, n, n) FROM t geometry null += SELECT ST_MPOINTFROMWKB(s) FROM t geometry null += SELECT ST_MPOINTFROMWKB(s, i) FROM t geometry null += SELECT ST_MPOINTFROMWKB(s, i, i) FROM t geometry null += SELECT ST_MPOINTFROMWKB(s, s) FROM t geometry null += SELECT ST_MPOINTFROMWKB(s, s, NULL) FROM t geometry null += SELECT ST_MPOINTFROMWKB(s, s, s) FROM t geometry null += SELECT ST_MPOINTFROMWKB(tm) FROM t geometry null += SELECT ST_MPOINTFROMWKB(tm, tm) FROM t geometry null += SELECT ST_MPOINTFROMWKB(tm, tm, tm) FROM t geometry null += SELECT ST_MPOINTFROMWKB(u) FROM t geometry null += SELECT ST_MPOINTFROMWKB(u, u) FROM t geometry null += SELECT ST_MPOINTFROMWKB(u, u, u) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(b) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(b, b) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(b, b, b) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(d) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(d, d) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(d, d, d) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(da) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(da, da) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(da, da, da) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(dt) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(dt, dt) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(dt, dt, dt) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(dt, i) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(dt, i, i) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(f) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(f, f) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(f, f, f) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(i) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(i, i) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(i, i, i) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(i, s) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(i, s, s) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(j) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(j, j) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(j, j, j) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(n) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(n, n) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(n, n, n) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(s) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(s, i) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(s, i, i) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(s, s) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(s, s, NULL) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(s, s, s) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(tm) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(tm, tm) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(tm, tm, tm) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(u) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(u, u) FROM t geometry null += SELECT ST_MPOLYFROMTEXT(u, u, u) FROM t geometry null += SELECT ST_MPOLYFROMWKB(b) FROM t geometry null += SELECT ST_MPOLYFROMWKB(b, b) FROM t geometry null += SELECT ST_MPOLYFROMWKB(b, b, b) FROM t geometry null += SELECT ST_MPOLYFROMWKB(d) FROM t geometry null += SELECT ST_MPOLYFROMWKB(d, d) FROM t geometry null += SELECT ST_MPOLYFROMWKB(d, d, d) FROM t geometry null += SELECT ST_MPOLYFROMWKB(da) FROM t geometry null += SELECT ST_MPOLYFROMWKB(da, da) FROM t geometry null += SELECT ST_MPOLYFROMWKB(da, da, da) FROM t geometry null += SELECT ST_MPOLYFROMWKB(dt) FROM t geometry null += SELECT ST_MPOLYFROMWKB(dt, dt) FROM t geometry null += SELECT ST_MPOLYFROMWKB(dt, dt, dt) FROM t geometry null += SELECT ST_MPOLYFROMWKB(dt, i) FROM t geometry null += SELECT ST_MPOLYFROMWKB(dt, i, i) FROM t geometry null += SELECT ST_MPOLYFROMWKB(f) FROM t geometry null += SELECT ST_MPOLYFROMWKB(f, f) FROM t geometry null += SELECT ST_MPOLYFROMWKB(f, f, f) FROM t geometry null += SELECT ST_MPOLYFROMWKB(i) FROM t geometry null += SELECT ST_MPOLYFROMWKB(i, i) FROM t geometry null += SELECT ST_MPOLYFROMWKB(i, i, i) FROM t geometry null += SELECT ST_MPOLYFROMWKB(i, s) FROM t geometry null += SELECT ST_MPOLYFROMWKB(i, s, s) FROM t geometry null += SELECT ST_MPOLYFROMWKB(j) FROM t geometry null += SELECT ST_MPOLYFROMWKB(j, j) FROM t geometry null += SELECT ST_MPOLYFROMWKB(j, j, j) FROM t geometry null += SELECT ST_MPOLYFROMWKB(n) FROM t geometry null += SELECT ST_MPOLYFROMWKB(n, n) FROM t geometry null += SELECT ST_MPOLYFROMWKB(n, n, n) FROM t geometry null += SELECT ST_MPOLYFROMWKB(s) FROM t geometry null += SELECT ST_MPOLYFROMWKB(s, i) FROM t geometry null += SELECT ST_MPOLYFROMWKB(s, i, i) FROM t geometry null += SELECT ST_MPOLYFROMWKB(s, s) FROM t geometry null += SELECT ST_MPOLYFROMWKB(s, s, NULL) FROM t geometry null += SELECT ST_MPOLYFROMWKB(s, s, s) FROM t geometry null += SELECT ST_MPOLYFROMWKB(tm) FROM t geometry null += SELECT ST_MPOLYFROMWKB(tm, tm) FROM t geometry null += SELECT ST_MPOLYFROMWKB(tm, tm, tm) FROM t geometry null += SELECT ST_MPOLYFROMWKB(u) FROM t geometry null += SELECT ST_MPOLYFROMWKB(u, u) FROM t geometry null += SELECT ST_MPOLYFROMWKB(u, u, u) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(b) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(b, b) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(b, b, b) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(d) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(d, d) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(d, d, d) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(da) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(da, da) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(da, da, da) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(dt) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(dt, dt) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(dt, dt, dt) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(dt, i) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(dt, i, i) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(f) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(f, f) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(f, f, f) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(i) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(i, i) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(i, i, i) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(i, s) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(i, s, s) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(j) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(j, j) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(j, j, j) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(n) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(n, n) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(n, n, n) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(s) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(s, i) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(s, i, i) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(s, s) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(s, s, NULL) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(s, s, s) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(tm) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(tm, tm) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(tm, tm, tm) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(u) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(u, u) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMTEXT(u, u, u) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(b) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(b, b) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(b, b, b) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(d) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(d, d) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(d, d, d) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(da) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(da, da) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(da, da, da) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(dt) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(dt, dt) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(dt, dt, dt) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(dt, i) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(dt, i, i) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(f) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(f, f) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(f, f, f) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(i) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(i, i) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(i, i, i) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(i, s) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(i, s, s) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(j) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(j, j) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(j, j, j) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(n) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(n, n) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(n, n, n) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(s) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(s, i) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(s, i, i) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(s, s) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(s, s, NULL) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(s, s, s) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(tm) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(tm, tm) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(tm, tm, tm) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(u) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(u, u) FROM t geometry null += SELECT ST_MULTILINESTRINGFROMWKB(u, u, u) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(b) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(b, b) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(b, b, b) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(d) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(d, d) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(d, d, d) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(da) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(da, da) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(da, da, da) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(dt) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(dt, dt) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(dt, dt, dt) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(dt, i) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(dt, i, i) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(f) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(f, f) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(f, f, f) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(i) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(i, i) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(i, i, i) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(i, s) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(i, s, s) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(j) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(j, j) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(j, j, j) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(n) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(n, n) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(n, n, n) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(s) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(s, i) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(s, i, i) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(s, s) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(s, s, NULL) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(s, s, s) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(tm) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(tm, tm) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(tm, tm, tm) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(u) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(u, u) FROM t geometry null += SELECT ST_MULTIPOINTFROMTEXT(u, u, u) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(b) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(b, b) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(b, b, b) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(d) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(d, d) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(d, d, d) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(da) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(da, da) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(da, da, da) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(dt) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(dt, dt) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(dt, dt, dt) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(dt, i) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(dt, i, i) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(f) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(f, f) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(f, f, f) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(i) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(i, i) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(i, i, i) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(i, s) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(i, s, s) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(j) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(j, j) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(j, j, j) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(n) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(n, n) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(n, n, n) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(s) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(s, i) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(s, i, i) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(s, s) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(s, s, NULL) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(s, s, s) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(tm) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(tm, tm) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(tm, tm, tm) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(u) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(u, u) FROM t geometry null += SELECT ST_MULTIPOINTFROMWKB(u, u, u) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(b) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(b, b) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(b, b, b) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(d) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(d, d) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(d, d, d) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(da) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(da, da) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(da, da, da) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(dt) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(dt, dt) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(dt, dt, dt) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(dt, i) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(dt, i, i) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(f) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(f, f) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(f, f, f) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(i) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(i, i) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(i, i, i) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(i, s) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(i, s, s) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(j) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(j, j) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(j, j, j) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(n) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(n, n) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(n, n, n) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(s) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(s, i) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(s, i, i) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(s, s) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(s, s, NULL) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(s, s, s) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(tm) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(tm, tm) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(tm, tm, tm) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(u) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(u, u) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMTEXT(u, u, u) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(b) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(b, b) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(b, b, b) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(d) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(d, d) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(d, d, d) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(da) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(da, da) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(da, da, da) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(dt) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(dt, dt) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(dt, dt, dt) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(dt, i) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(dt, i, i) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(f) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(f, f) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(f, f, f) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(i) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(i, i) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(i, i, i) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(i, s) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(i, s, s) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(j) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(j, j) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(j, j, j) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(n) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(n, n) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(n, n, n) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(s) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(s, i) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(s, i, i) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(s, s) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(s, s, NULL) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(s, s, s) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(tm) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(tm, tm) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(tm, tm, tm) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(u) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(u, u) FROM t geometry null += SELECT ST_MULTIPOLYGONFROMWKB(u, u, u) FROM t geometry null += SELECT ST_NUMGEOMETRIES(b) FROM t bigint null += SELECT ST_NUMGEOMETRIES(d) FROM t bigint null += SELECT ST_NUMGEOMETRIES(da) FROM t bigint null += SELECT ST_NUMGEOMETRIES(dt) FROM t bigint null += SELECT ST_NUMGEOMETRIES(f) FROM t bigint null += SELECT ST_NUMGEOMETRIES(i) FROM t bigint null += SELECT ST_NUMGEOMETRIES(j) FROM t bigint null += SELECT ST_NUMGEOMETRIES(n) FROM t bigint null += SELECT ST_NUMGEOMETRIES(s) FROM t bigint null += SELECT ST_NUMGEOMETRIES(tm) FROM t bigint null += SELECT ST_NUMGEOMETRIES(u) FROM t bigint null += SELECT ST_NUMINTERIORRING(b) FROM t bigint null += SELECT ST_NUMINTERIORRING(d) FROM t bigint null += SELECT ST_NUMINTERIORRING(da) FROM t bigint null += SELECT ST_NUMINTERIORRING(dt) FROM t bigint null += SELECT ST_NUMINTERIORRING(f) FROM t bigint null += SELECT ST_NUMINTERIORRING(i) FROM t bigint null += SELECT ST_NUMINTERIORRING(j) FROM t bigint null += SELECT ST_NUMINTERIORRING(n) FROM t bigint null += SELECT ST_NUMINTERIORRING(s) FROM t bigint null += SELECT ST_NUMINTERIORRING(tm) FROM t bigint null += SELECT ST_NUMINTERIORRING(u) FROM t bigint null += SELECT ST_NUMINTERIORRINGS(b) FROM t bigint null += SELECT ST_NUMINTERIORRINGS(d) FROM t bigint null += SELECT ST_NUMINTERIORRINGS(da) FROM t bigint null += SELECT ST_NUMINTERIORRINGS(dt) FROM t bigint null += SELECT ST_NUMINTERIORRINGS(f) FROM t bigint null += SELECT ST_NUMINTERIORRINGS(i) FROM t bigint null += SELECT ST_NUMINTERIORRINGS(j) FROM t bigint null += SELECT ST_NUMINTERIORRINGS(n) FROM t bigint null += SELECT ST_NUMINTERIORRINGS(s) FROM t bigint null += SELECT ST_NUMINTERIORRINGS(tm) FROM t bigint null += SELECT ST_NUMINTERIORRINGS(u) FROM t bigint null += SELECT ST_NUMPOINTS(b) FROM t bigint null += SELECT ST_NUMPOINTS(d) FROM t bigint null += SELECT ST_NUMPOINTS(da) FROM t bigint null += SELECT ST_NUMPOINTS(dt) FROM t bigint null += SELECT ST_NUMPOINTS(f) FROM t bigint null += SELECT ST_NUMPOINTS(i) FROM t bigint null += SELECT ST_NUMPOINTS(j) FROM t bigint null += SELECT ST_NUMPOINTS(n) FROM t bigint null += SELECT ST_NUMPOINTS(s) FROM t bigint null += SELECT ST_NUMPOINTS(tm) FROM t bigint null += SELECT ST_NUMPOINTS(u) FROM t bigint null += SELECT ST_OVERLAPS(b, b) FROM t bigint null += SELECT ST_OVERLAPS(d, d) FROM t bigint null += SELECT ST_OVERLAPS(da, da) FROM t bigint null += SELECT ST_OVERLAPS(dt, dt) FROM t bigint null += SELECT ST_OVERLAPS(dt, i) FROM t bigint null += SELECT ST_OVERLAPS(f, f) FROM t bigint null += SELECT ST_OVERLAPS(i, i) FROM t bigint null += SELECT ST_OVERLAPS(i, s) FROM t bigint null += SELECT ST_OVERLAPS(j, j) FROM t bigint null += SELECT ST_OVERLAPS(n, n) FROM t bigint null += SELECT ST_OVERLAPS(s, i) FROM t bigint null += SELECT ST_OVERLAPS(s, s) FROM t bigint null += SELECT ST_OVERLAPS(tm, tm) FROM t bigint null += SELECT ST_OVERLAPS(u, u) FROM t bigint null += SELECT ST_POINTATDISTANCE(b, b) FROM t geometry null += SELECT ST_POINTATDISTANCE(d, d) FROM t geometry null += SELECT ST_POINTATDISTANCE(da, da) FROM t geometry null += SELECT ST_POINTATDISTANCE(dt, dt) FROM t geometry null += SELECT ST_POINTATDISTANCE(dt, i) FROM t geometry null += SELECT ST_POINTATDISTANCE(f, f) FROM t geometry null += SELECT ST_POINTATDISTANCE(i, i) FROM t geometry null += SELECT ST_POINTATDISTANCE(i, s) FROM t geometry null += SELECT ST_POINTATDISTANCE(j, j) FROM t geometry null += SELECT ST_POINTATDISTANCE(n, n) FROM t geometry null += SELECT ST_POINTATDISTANCE(s, i) FROM t geometry null += SELECT ST_POINTATDISTANCE(s, s) FROM t geometry null += SELECT ST_POINTATDISTANCE(tm, tm) FROM t geometry null += SELECT ST_POINTATDISTANCE(u, u) FROM t geometry null += SELECT ST_POINTFROMGEOHASH(s, i) FROM t geometry += SELECT ST_POINTFROMGEOHASH(s, s) FROM t geometry += SELECT ST_POINTFROMTEXT(b) FROM t geometry null += SELECT ST_POINTFROMTEXT(b, b) FROM t geometry null += SELECT ST_POINTFROMTEXT(b, b, b) FROM t geometry null += SELECT ST_POINTFROMTEXT(d) FROM t geometry null += SELECT ST_POINTFROMTEXT(d, d) FROM t geometry null += SELECT ST_POINTFROMTEXT(d, d, d) FROM t geometry null += SELECT ST_POINTFROMTEXT(da) FROM t geometry null += SELECT ST_POINTFROMTEXT(da, da) FROM t geometry null += SELECT ST_POINTFROMTEXT(da, da, da) FROM t geometry null += SELECT ST_POINTFROMTEXT(dt) FROM t geometry null += SELECT ST_POINTFROMTEXT(dt, dt) FROM t geometry null += SELECT ST_POINTFROMTEXT(dt, dt, dt) FROM t geometry null += SELECT ST_POINTFROMTEXT(dt, i) FROM t geometry null += SELECT ST_POINTFROMTEXT(dt, i, i) FROM t geometry null += SELECT ST_POINTFROMTEXT(f) FROM t geometry null += SELECT ST_POINTFROMTEXT(f, f) FROM t geometry null += SELECT ST_POINTFROMTEXT(f, f, f) FROM t geometry null += SELECT ST_POINTFROMTEXT(i) FROM t geometry null += SELECT ST_POINTFROMTEXT(i, i) FROM t geometry null += SELECT ST_POINTFROMTEXT(i, i, i) FROM t geometry null += SELECT ST_POINTFROMTEXT(i, s) FROM t geometry null += SELECT ST_POINTFROMTEXT(i, s, s) FROM t geometry null += SELECT ST_POINTFROMTEXT(j) FROM t geometry null += SELECT ST_POINTFROMTEXT(j, j) FROM t geometry null += SELECT ST_POINTFROMTEXT(j, j, j) FROM t geometry null += SELECT ST_POINTFROMTEXT(n) FROM t geometry null += SELECT ST_POINTFROMTEXT(n, n) FROM t geometry null += SELECT ST_POINTFROMTEXT(n, n, n) FROM t geometry null += SELECT ST_POINTFROMTEXT(s) FROM t geometry null += SELECT ST_POINTFROMTEXT(s, i) FROM t geometry null += SELECT ST_POINTFROMTEXT(s, i, i) FROM t geometry null += SELECT ST_POINTFROMTEXT(s, s) FROM t geometry null += SELECT ST_POINTFROMTEXT(s, s, NULL) FROM t geometry null += SELECT ST_POINTFROMTEXT(s, s, s) FROM t geometry null += SELECT ST_POINTFROMTEXT(tm) FROM t geometry null += SELECT ST_POINTFROMTEXT(tm, tm) FROM t geometry null += SELECT ST_POINTFROMTEXT(tm, tm, tm) FROM t geometry null += SELECT ST_POINTFROMTEXT(u) FROM t geometry null += SELECT ST_POINTFROMTEXT(u, u) FROM t geometry null += SELECT ST_POINTFROMTEXT(u, u, u) FROM t geometry null += SELECT ST_POINTFROMWKB(b) FROM t geometry null += SELECT ST_POINTFROMWKB(b, b) FROM t geometry null += SELECT ST_POINTFROMWKB(b, b, b) FROM t geometry null += SELECT ST_POINTFROMWKB(d) FROM t geometry null += SELECT ST_POINTFROMWKB(d, d) FROM t geometry null += SELECT ST_POINTFROMWKB(d, d, d) FROM t geometry null += SELECT ST_POINTFROMWKB(da) FROM t geometry null += SELECT ST_POINTFROMWKB(da, da) FROM t geometry null += SELECT ST_POINTFROMWKB(da, da, da) FROM t geometry null += SELECT ST_POINTFROMWKB(dt) FROM t geometry null += SELECT ST_POINTFROMWKB(dt, dt) FROM t geometry null += SELECT ST_POINTFROMWKB(dt, dt, dt) FROM t geometry null += SELECT ST_POINTFROMWKB(dt, i) FROM t geometry null += SELECT ST_POINTFROMWKB(dt, i, i) FROM t geometry null += SELECT ST_POINTFROMWKB(f) FROM t geometry null += SELECT ST_POINTFROMWKB(f, f) FROM t geometry null += SELECT ST_POINTFROMWKB(f, f, f) FROM t geometry null += SELECT ST_POINTFROMWKB(i) FROM t geometry null += SELECT ST_POINTFROMWKB(i, i) FROM t geometry null += SELECT ST_POINTFROMWKB(i, i, i) FROM t geometry null += SELECT ST_POINTFROMWKB(i, s) FROM t geometry null += SELECT ST_POINTFROMWKB(i, s, s) FROM t geometry null += SELECT ST_POINTFROMWKB(j) FROM t geometry null += SELECT ST_POINTFROMWKB(j, j) FROM t geometry null += SELECT ST_POINTFROMWKB(j, j, j) FROM t geometry null += SELECT ST_POINTFROMWKB(n) FROM t geometry null += SELECT ST_POINTFROMWKB(n, n) FROM t geometry null += SELECT ST_POINTFROMWKB(n, n, n) FROM t geometry null += SELECT ST_POINTFROMWKB(s) FROM t geometry null += SELECT ST_POINTFROMWKB(s, i) FROM t geometry null += SELECT ST_POINTFROMWKB(s, i, i) FROM t geometry null += SELECT ST_POINTFROMWKB(s, s) FROM t geometry null += SELECT ST_POINTFROMWKB(s, s, NULL) FROM t geometry null += SELECT ST_POINTFROMWKB(s, s, s) FROM t geometry null += SELECT ST_POINTFROMWKB(tm) FROM t geometry null += SELECT ST_POINTFROMWKB(tm, tm) FROM t geometry null += SELECT ST_POINTFROMWKB(tm, tm, tm) FROM t geometry null += SELECT ST_POINTFROMWKB(u) FROM t geometry null += SELECT ST_POINTFROMWKB(u, u) FROM t geometry null += SELECT ST_POINTFROMWKB(u, u, u) FROM t geometry null += SELECT ST_POINTN(b, b) FROM t geometry null += SELECT ST_POINTN(d, d) FROM t geometry null += SELECT ST_POINTN(da, da) FROM t geometry null += SELECT ST_POINTN(dt, dt) FROM t geometry null += SELECT ST_POINTN(dt, i) FROM t geometry null += SELECT ST_POINTN(f, f) FROM t geometry null += SELECT ST_POINTN(i, i) FROM t geometry null += SELECT ST_POINTN(i, s) FROM t geometry null += SELECT ST_POINTN(j, j) FROM t geometry null += SELECT ST_POINTN(n, n) FROM t geometry null += SELECT ST_POINTN(s, i) FROM t geometry null += SELECT ST_POINTN(s, s) FROM t geometry null += SELECT ST_POINTN(tm, tm) FROM t geometry null += SELECT ST_POINTN(u, u) FROM t geometry null += SELECT ST_POLYFROMTEXT(b) FROM t geometry null += SELECT ST_POLYFROMTEXT(b, b) FROM t geometry null += SELECT ST_POLYFROMTEXT(b, b, b) FROM t geometry null += SELECT ST_POLYFROMTEXT(d) FROM t geometry null += SELECT ST_POLYFROMTEXT(d, d) FROM t geometry null += SELECT ST_POLYFROMTEXT(d, d, d) FROM t geometry null += SELECT ST_POLYFROMTEXT(da) FROM t geometry null += SELECT ST_POLYFROMTEXT(da, da) FROM t geometry null += SELECT ST_POLYFROMTEXT(da, da, da) FROM t geometry null += SELECT ST_POLYFROMTEXT(dt) FROM t geometry null += SELECT ST_POLYFROMTEXT(dt, dt) FROM t geometry null += SELECT ST_POLYFROMTEXT(dt, dt, dt) FROM t geometry null += SELECT ST_POLYFROMTEXT(dt, i) FROM t geometry null += SELECT ST_POLYFROMTEXT(dt, i, i) FROM t geometry null += SELECT ST_POLYFROMTEXT(f) FROM t geometry null += SELECT ST_POLYFROMTEXT(f, f) FROM t geometry null += SELECT ST_POLYFROMTEXT(f, f, f) FROM t geometry null += SELECT ST_POLYFROMTEXT(i) FROM t geometry null += SELECT ST_POLYFROMTEXT(i, i) FROM t geometry null += SELECT ST_POLYFROMTEXT(i, i, i) FROM t geometry null += SELECT ST_POLYFROMTEXT(i, s) FROM t geometry null += SELECT ST_POLYFROMTEXT(i, s, s) FROM t geometry null += SELECT ST_POLYFROMTEXT(j) FROM t geometry null += SELECT ST_POLYFROMTEXT(j, j) FROM t geometry null += SELECT ST_POLYFROMTEXT(j, j, j) FROM t geometry null += SELECT ST_POLYFROMTEXT(n) FROM t geometry null += SELECT ST_POLYFROMTEXT(n, n) FROM t geometry null += SELECT ST_POLYFROMTEXT(n, n, n) FROM t geometry null += SELECT ST_POLYFROMTEXT(s) FROM t geometry null += SELECT ST_POLYFROMTEXT(s, i) FROM t geometry null += SELECT ST_POLYFROMTEXT(s, i, i) FROM t geometry null += SELECT ST_POLYFROMTEXT(s, s) FROM t geometry null += SELECT ST_POLYFROMTEXT(s, s, NULL) FROM t geometry null += SELECT ST_POLYFROMTEXT(s, s, s) FROM t geometry null += SELECT ST_POLYFROMTEXT(tm) FROM t geometry null += SELECT ST_POLYFROMTEXT(tm, tm) FROM t geometry null += SELECT ST_POLYFROMTEXT(tm, tm, tm) FROM t geometry null += SELECT ST_POLYFROMTEXT(u) FROM t geometry null += SELECT ST_POLYFROMTEXT(u, u) FROM t geometry null += SELECT ST_POLYFROMTEXT(u, u, u) FROM t geometry null += SELECT ST_POLYFROMWKB(b) FROM t geometry null += SELECT ST_POLYFROMWKB(b, b) FROM t geometry null += SELECT ST_POLYFROMWKB(b, b, b) FROM t geometry null += SELECT ST_POLYFROMWKB(d) FROM t geometry null += SELECT ST_POLYFROMWKB(d, d) FROM t geometry null += SELECT ST_POLYFROMWKB(d, d, d) FROM t geometry null += SELECT ST_POLYFROMWKB(da) FROM t geometry null += SELECT ST_POLYFROMWKB(da, da) FROM t geometry null += SELECT ST_POLYFROMWKB(da, da, da) FROM t geometry null += SELECT ST_POLYFROMWKB(dt) FROM t geometry null += SELECT ST_POLYFROMWKB(dt, dt) FROM t geometry null += SELECT ST_POLYFROMWKB(dt, dt, dt) FROM t geometry null += SELECT ST_POLYFROMWKB(dt, i) FROM t geometry null += SELECT ST_POLYFROMWKB(dt, i, i) FROM t geometry null += SELECT ST_POLYFROMWKB(f) FROM t geometry null += SELECT ST_POLYFROMWKB(f, f) FROM t geometry null += SELECT ST_POLYFROMWKB(f, f, f) FROM t geometry null += SELECT ST_POLYFROMWKB(i) FROM t geometry null += SELECT ST_POLYFROMWKB(i, i) FROM t geometry null += SELECT ST_POLYFROMWKB(i, i, i) FROM t geometry null += SELECT ST_POLYFROMWKB(i, s) FROM t geometry null += SELECT ST_POLYFROMWKB(i, s, s) FROM t geometry null += SELECT ST_POLYFROMWKB(j) FROM t geometry null += SELECT ST_POLYFROMWKB(j, j) FROM t geometry null += SELECT ST_POLYFROMWKB(j, j, j) FROM t geometry null += SELECT ST_POLYFROMWKB(n) FROM t geometry null += SELECT ST_POLYFROMWKB(n, n) FROM t geometry null += SELECT ST_POLYFROMWKB(n, n, n) FROM t geometry null += SELECT ST_POLYFROMWKB(s) FROM t geometry null += SELECT ST_POLYFROMWKB(s, i) FROM t geometry null += SELECT ST_POLYFROMWKB(s, i, i) FROM t geometry null += SELECT ST_POLYFROMWKB(s, s) FROM t geometry null += SELECT ST_POLYFROMWKB(s, s, NULL) FROM t geometry null += SELECT ST_POLYFROMWKB(s, s, s) FROM t geometry null += SELECT ST_POLYFROMWKB(tm) FROM t geometry null += SELECT ST_POLYFROMWKB(tm, tm) FROM t geometry null += SELECT ST_POLYFROMWKB(tm, tm, tm) FROM t geometry null += SELECT ST_POLYFROMWKB(u) FROM t geometry null += SELECT ST_POLYFROMWKB(u, u) FROM t geometry null += SELECT ST_POLYFROMWKB(u, u, u) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(b) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(b, b) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(b, b, b) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(d) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(d, d) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(d, d, d) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(da) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(da, da) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(da, da, da) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(dt) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(dt, dt) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(dt, dt, dt) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(dt, i) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(dt, i, i) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(f) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(f, f) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(f, f, f) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(i) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(i, i) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(i, i, i) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(i, s) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(i, s, s) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(j) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(j, j) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(j, j, j) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(n) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(n, n) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(n, n, n) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(s) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(s, i) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(s, i, i) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(s, s) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(s, s, NULL) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(s, s, s) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(tm) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(tm, tm) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(tm, tm, tm) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(u) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(u, u) FROM t geometry null += SELECT ST_POLYGONFROMTEXT(u, u, u) FROM t geometry null += SELECT ST_POLYGONFROMWKB(b) FROM t geometry null += SELECT ST_POLYGONFROMWKB(b, b) FROM t geometry null += SELECT ST_POLYGONFROMWKB(b, b, b) FROM t geometry null += SELECT ST_POLYGONFROMWKB(d) FROM t geometry null += SELECT ST_POLYGONFROMWKB(d, d) FROM t geometry null += SELECT ST_POLYGONFROMWKB(d, d, d) FROM t geometry null += SELECT ST_POLYGONFROMWKB(da) FROM t geometry null += SELECT ST_POLYGONFROMWKB(da, da) FROM t geometry null += SELECT ST_POLYGONFROMWKB(da, da, da) FROM t geometry null += SELECT ST_POLYGONFROMWKB(dt) FROM t geometry null += SELECT ST_POLYGONFROMWKB(dt, dt) FROM t geometry null += SELECT ST_POLYGONFROMWKB(dt, dt, dt) FROM t geometry null += SELECT ST_POLYGONFROMWKB(dt, i) FROM t geometry null += SELECT ST_POLYGONFROMWKB(dt, i, i) FROM t geometry null += SELECT ST_POLYGONFROMWKB(f) FROM t geometry null += SELECT ST_POLYGONFROMWKB(f, f) FROM t geometry null += SELECT ST_POLYGONFROMWKB(f, f, f) FROM t geometry null += SELECT ST_POLYGONFROMWKB(i) FROM t geometry null += SELECT ST_POLYGONFROMWKB(i, i) FROM t geometry null += SELECT ST_POLYGONFROMWKB(i, i, i) FROM t geometry null += SELECT ST_POLYGONFROMWKB(i, s) FROM t geometry null += SELECT ST_POLYGONFROMWKB(i, s, s) FROM t geometry null += SELECT ST_POLYGONFROMWKB(j) FROM t geometry null += SELECT ST_POLYGONFROMWKB(j, j) FROM t geometry null += SELECT ST_POLYGONFROMWKB(j, j, j) FROM t geometry null += SELECT ST_POLYGONFROMWKB(n) FROM t geometry null += SELECT ST_POLYGONFROMWKB(n, n) FROM t geometry null += SELECT ST_POLYGONFROMWKB(n, n, n) FROM t geometry null += SELECT ST_POLYGONFROMWKB(s) FROM t geometry null += SELECT ST_POLYGONFROMWKB(s, i) FROM t geometry null += SELECT ST_POLYGONFROMWKB(s, i, i) FROM t geometry null += SELECT ST_POLYGONFROMWKB(s, s) FROM t geometry null += SELECT ST_POLYGONFROMWKB(s, s, NULL) FROM t geometry null += SELECT ST_POLYGONFROMWKB(s, s, s) FROM t geometry null += SELECT ST_POLYGONFROMWKB(tm) FROM t geometry null += SELECT ST_POLYGONFROMWKB(tm, tm) FROM t geometry null += SELECT ST_POLYGONFROMWKB(tm, tm, tm) FROM t geometry null += SELECT ST_POLYGONFROMWKB(u) FROM t geometry null += SELECT ST_POLYGONFROMWKB(u, u) FROM t geometry null += SELECT ST_POLYGONFROMWKB(u, u, u) FROM t geometry null += SELECT ST_SIMPLIFY(b, b) FROM t geometry null += SELECT ST_SIMPLIFY(d, d) FROM t geometry null += SELECT ST_SIMPLIFY(da, da) FROM t geometry null += SELECT ST_SIMPLIFY(dt, dt) FROM t geometry null += SELECT ST_SIMPLIFY(dt, i) FROM t geometry null += SELECT ST_SIMPLIFY(f, f) FROM t geometry null += SELECT ST_SIMPLIFY(i, i) FROM t geometry null += SELECT ST_SIMPLIFY(i, s) FROM t geometry null += SELECT ST_SIMPLIFY(j, j) FROM t geometry null += SELECT ST_SIMPLIFY(n, n) FROM t geometry null += SELECT ST_SIMPLIFY(s, i) FROM t geometry null += SELECT ST_SIMPLIFY(s, s) FROM t geometry null += SELECT ST_SIMPLIFY(tm, tm) FROM t geometry null += SELECT ST_SIMPLIFY(u, u) FROM t geometry null += SELECT ST_SRID(b) FROM t bigint += SELECT ST_SRID(b, b) FROM t geometry null += SELECT ST_SRID(d) FROM t bigint += SELECT ST_SRID(d, d) FROM t geometry null += SELECT ST_SRID(da) FROM t bigint += SELECT ST_SRID(da, da) FROM t geometry null += SELECT ST_SRID(dt) FROM t bigint += SELECT ST_SRID(dt, dt) FROM t geometry null += SELECT ST_SRID(dt, i) FROM t geometry null += SELECT ST_SRID(f) FROM t bigint += SELECT ST_SRID(f, f) FROM t geometry null += SELECT ST_SRID(i) FROM t bigint += SELECT ST_SRID(i, i) FROM t geometry null += SELECT ST_SRID(i, s) FROM t geometry null += SELECT ST_SRID(j) FROM t bigint null += SELECT ST_SRID(j, j) FROM t geometry null += SELECT ST_SRID(n) FROM t bigint null += SELECT ST_SRID(n, n) FROM t geometry null += SELECT ST_SRID(s) FROM t bigint += SELECT ST_SRID(s, i) FROM t geometry null += SELECT ST_SRID(s, s) FROM t geometry null += SELECT ST_SRID(tm) FROM t bigint += SELECT ST_SRID(tm, tm) FROM t geometry null += SELECT ST_SRID(u) FROM t bigint += SELECT ST_SRID(u, u) FROM t geometry null += SELECT ST_STARTPOINT(b) FROM t geometry null += SELECT ST_STARTPOINT(d) FROM t geometry null += SELECT ST_STARTPOINT(da) FROM t geometry null += SELECT ST_STARTPOINT(dt) FROM t geometry null += SELECT ST_STARTPOINT(f) FROM t geometry null += SELECT ST_STARTPOINT(i) FROM t geometry null += SELECT ST_STARTPOINT(j) FROM t geometry null += SELECT ST_STARTPOINT(n) FROM t geometry null += SELECT ST_STARTPOINT(s) FROM t geometry null += SELECT ST_STARTPOINT(tm) FROM t geometry null += SELECT ST_STARTPOINT(u) FROM t geometry null += SELECT ST_SWAPXY(b) FROM t geometry null += SELECT ST_SWAPXY(d) FROM t geometry null += SELECT ST_SWAPXY(da) FROM t geometry null += SELECT ST_SWAPXY(dt) FROM t geometry null += SELECT ST_SWAPXY(f) FROM t geometry null += SELECT ST_SWAPXY(i) FROM t geometry null += SELECT ST_SWAPXY(j) FROM t geometry null += SELECT ST_SWAPXY(n) FROM t geometry null += SELECT ST_SWAPXY(s) FROM t geometry null += SELECT ST_SWAPXY(tm) FROM t geometry null += SELECT ST_SWAPXY(u) FROM t geometry null += SELECT ST_SYMDIFFERENCE(b, b) FROM t geometry null += SELECT ST_SYMDIFFERENCE(d, d) FROM t geometry null += SELECT ST_SYMDIFFERENCE(da, da) FROM t geometry null += SELECT ST_SYMDIFFERENCE(dt, dt) FROM t geometry null += SELECT ST_SYMDIFFERENCE(dt, i) FROM t geometry null += SELECT ST_SYMDIFFERENCE(f, f) FROM t geometry null += SELECT ST_SYMDIFFERENCE(i, i) FROM t geometry null += SELECT ST_SYMDIFFERENCE(i, s) FROM t geometry null += SELECT ST_SYMDIFFERENCE(j, j) FROM t geometry null += SELECT ST_SYMDIFFERENCE(n, n) FROM t geometry null += SELECT ST_SYMDIFFERENCE(s, i) FROM t geometry null += SELECT ST_SYMDIFFERENCE(s, s) FROM t geometry null += SELECT ST_SYMDIFFERENCE(tm, tm) FROM t geometry null += SELECT ST_SYMDIFFERENCE(u, u) FROM t geometry null += SELECT ST_TOUCHES(b, b) FROM t bigint null += SELECT ST_TOUCHES(d, d) FROM t bigint null += SELECT ST_TOUCHES(da, da) FROM t bigint null += SELECT ST_TOUCHES(dt, dt) FROM t bigint null += SELECT ST_TOUCHES(dt, i) FROM t bigint null += SELECT ST_TOUCHES(f, f) FROM t bigint null += SELECT ST_TOUCHES(i, i) FROM t bigint null += SELECT ST_TOUCHES(i, s) FROM t bigint null += SELECT ST_TOUCHES(j, j) FROM t bigint null += SELECT ST_TOUCHES(n, n) FROM t bigint null += SELECT ST_TOUCHES(s, i) FROM t bigint null += SELECT ST_TOUCHES(s, s) FROM t bigint null += SELECT ST_TOUCHES(tm, tm) FROM t bigint null += SELECT ST_TOUCHES(u, u) FROM t bigint null += SELECT ST_TRANSFORM(b, b) FROM t geometry null += SELECT ST_TRANSFORM(d, d) FROM t geometry null += SELECT ST_TRANSFORM(da, da) FROM t geometry null += SELECT ST_TRANSFORM(dt, dt) FROM t geometry null += SELECT ST_TRANSFORM(dt, i) FROM t geometry null += SELECT ST_TRANSFORM(f, f) FROM t geometry null += SELECT ST_TRANSFORM(i, i) FROM t geometry null += SELECT ST_TRANSFORM(i, s) FROM t geometry null += SELECT ST_TRANSFORM(j, j) FROM t geometry null += SELECT ST_TRANSFORM(n, n) FROM t geometry null += SELECT ST_TRANSFORM(s, i) FROM t geometry null += SELECT ST_TRANSFORM(s, s) FROM t geometry null += SELECT ST_TRANSFORM(tm, tm) FROM t geometry null += SELECT ST_TRANSFORM(u, u) FROM t geometry null += SELECT ST_UNION(b, b) FROM t geometry null += SELECT ST_UNION(d, d) FROM t geometry null += SELECT ST_UNION(da, da) FROM t geometry null += SELECT ST_UNION(dt, dt) FROM t geometry null += SELECT ST_UNION(dt, i) FROM t geometry null += SELECT ST_UNION(f, f) FROM t geometry null += SELECT ST_UNION(i, i) FROM t geometry null += SELECT ST_UNION(i, s) FROM t geometry null += SELECT ST_UNION(j, j) FROM t geometry null += SELECT ST_UNION(n, n) FROM t geometry null += SELECT ST_UNION(s, i) FROM t geometry null += SELECT ST_UNION(s, s) FROM t geometry null += SELECT ST_UNION(tm, tm) FROM t geometry null += SELECT ST_UNION(u, u) FROM t geometry null += SELECT ST_VALIDATE(b) FROM t geometry null += SELECT ST_VALIDATE(d) FROM t geometry null += SELECT ST_VALIDATE(da) FROM t geometry null += SELECT ST_VALIDATE(dt) FROM t geometry null += SELECT ST_VALIDATE(f) FROM t geometry null += SELECT ST_VALIDATE(i) FROM t geometry null += SELECT ST_VALIDATE(j) FROM t geometry null += SELECT ST_VALIDATE(n) FROM t geometry null += SELECT ST_VALIDATE(s) FROM t geometry null += SELECT ST_VALIDATE(tm) FROM t geometry null += SELECT ST_VALIDATE(u) FROM t geometry null += SELECT ST_WITHIN(b, b) FROM t bigint null += SELECT ST_WITHIN(d, d) FROM t bigint null += SELECT ST_WITHIN(da, da) FROM t bigint null += SELECT ST_WITHIN(dt, dt) FROM t bigint null += SELECT ST_WITHIN(dt, i) FROM t bigint null += SELECT ST_WITHIN(f, f) FROM t bigint null += SELECT ST_WITHIN(i, i) FROM t bigint null += SELECT ST_WITHIN(i, s) FROM t bigint null += SELECT ST_WITHIN(j, j) FROM t bigint null += SELECT ST_WITHIN(n, n) FROM t bigint null += SELECT ST_WITHIN(s, i) FROM t bigint null += SELECT ST_WITHIN(s, s) FROM t bigint null += SELECT ST_WITHIN(tm, tm) FROM t bigint null += SELECT ST_WITHIN(u, u) FROM t bigint null += SELECT ST_X(b) FROM t double += SELECT ST_X(b, b) FROM t geometry null += SELECT ST_X(d) FROM t double += SELECT ST_X(d, d) FROM t geometry null += SELECT ST_X(da) FROM t double += SELECT ST_X(da, da) FROM t geometry null += SELECT ST_X(dt) FROM t double += SELECT ST_X(dt, dt) FROM t geometry null += SELECT ST_X(dt, i) FROM t geometry null += SELECT ST_X(f) FROM t double += SELECT ST_X(f, f) FROM t geometry null += SELECT ST_X(i) FROM t double += SELECT ST_X(i, i) FROM t geometry null += SELECT ST_X(i, s) FROM t geometry null += SELECT ST_X(j) FROM t double null += SELECT ST_X(j, j) FROM t geometry null += SELECT ST_X(n) FROM t double null += SELECT ST_X(n, n) FROM t geometry null += SELECT ST_X(s) FROM t double += SELECT ST_X(s, i) FROM t geometry null += SELECT ST_X(s, s) FROM t geometry null += SELECT ST_X(tm) FROM t double += SELECT ST_X(tm, tm) FROM t geometry null += SELECT ST_X(u) FROM t double += SELECT ST_X(u, u) FROM t geometry null += SELECT ST_Y(b) FROM t double += SELECT ST_Y(b, b) FROM t geometry null += SELECT ST_Y(d) FROM t double += SELECT ST_Y(d, d) FROM t geometry null += SELECT ST_Y(da) FROM t double += SELECT ST_Y(da, da) FROM t geometry null += SELECT ST_Y(dt) FROM t double += SELECT ST_Y(dt, dt) FROM t geometry null += SELECT ST_Y(dt, i) FROM t geometry null += SELECT ST_Y(f) FROM t double += SELECT ST_Y(f, f) FROM t geometry null += SELECT ST_Y(i) FROM t double += SELECT ST_Y(i, i) FROM t geometry null += SELECT ST_Y(i, s) FROM t geometry null += SELECT ST_Y(j) FROM t double null += SELECT ST_Y(j, j) FROM t geometry null += SELECT ST_Y(n) FROM t double null += SELECT ST_Y(n, n) FROM t geometry null += SELECT ST_Y(s) FROM t double += SELECT ST_Y(s, i) FROM t geometry null += SELECT ST_Y(s, s) FROM t geometry null += SELECT ST_Y(tm) FROM t double += SELECT ST_Y(tm, tm) FROM t geometry null += SELECT ST_Y(u) FROM t double += SELECT ST_Y(u, u) FROM t geometry null += SELECT SUBSTRING_INDEX(b, b, b) FROM t binary null += SELECT SUBSTRING_INDEX(d, d, d) FROM t string null += SELECT SUBSTRING_INDEX(da, da, da) FROM t string null += SELECT SUBSTRING_INDEX(dt, dt, dt) FROM t string null += SELECT SUBSTRING_INDEX(dt, i, i) FROM t string null += SELECT SUBSTRING_INDEX(f, f, f) FROM t string null += SELECT SUBSTRING_INDEX(i, i, i) FROM t string null += SELECT SUBSTRING_INDEX(i, s, s) FROM t string null += SELECT SUBSTRING_INDEX(j, j, j) FROM t string null += SELECT SUBSTRING_INDEX(n, n, n) FROM t string null += SELECT SUBSTRING_INDEX(s, i, i) FROM t string null += SELECT SUBSTRING_INDEX(s, s, NULL) FROM t string null += SELECT SUBSTRING_INDEX(s, s, s) FROM t string null += SELECT SUBSTRING_INDEX(tm, tm, tm) FROM t string null += SELECT SUBSTRING_INDEX(u, u, u) FROM t string null += SELECT SUBTIME(b, b) FROM t string null += SELECT SUBTIME(d, d) FROM t string null += SELECT SUBTIME(da, da) FROM t datetime null += SELECT SUBTIME(dt, dt) FROM t datetime null += SELECT SUBTIME(dt, i) FROM t datetime null += SELECT SUBTIME(f, f) FROM t string null += SELECT SUBTIME(i, i) FROM t string null += SELECT SUBTIME(i, s) FROM t string null += SELECT SUBTIME(j, j) FROM t string null += SELECT SUBTIME(n, n) FROM t string null += SELECT SUBTIME(s, i) FROM t string null += SELECT SUBTIME(s, s) FROM t string null += SELECT SUBTIME(tm, tm) FROM t time null += SELECT SUBTIME(u, u) FROM t string null += SELECT TAN(b) FROM t double null += SELECT TAN(d) FROM t double null += SELECT TAN(da) FROM t double null += SELECT TAN(dt) FROM t double null += SELECT TAN(f) FROM t double null += SELECT TAN(i) FROM t double null += SELECT TAN(j) FROM t double null += SELECT TAN(n) FROM t double null += SELECT TAN(s) FROM t double null += SELECT TAN(tm) FROM t double null += SELECT TAN(u) FROM t double null += SELECT TIMEDIFF(b, b) FROM t time null += SELECT TIMEDIFF(d, d) FROM t time null += SELECT TIMEDIFF(da, da) FROM t time null += SELECT TIMEDIFF(dt, dt) FROM t time null += SELECT TIMEDIFF(dt, i) FROM t time null += SELECT TIMEDIFF(f, f) FROM t time null += SELECT TIMEDIFF(i, i) FROM t time null += SELECT TIMEDIFF(i, s) FROM t time null += SELECT TIMEDIFF(j, j) FROM t time null += SELECT TIMEDIFF(n, n) FROM t time null += SELECT TIMEDIFF(s, i) FROM t time null += SELECT TIMEDIFF(s, s) FROM t time null += SELECT TIMEDIFF(tm, tm) FROM t time null += SELECT TIMEDIFF(u, u) FROM t time null += SELECT TIME_FORMAT(b, b) FROM t string null += SELECT TIME_FORMAT(d, d) FROM t string null += SELECT TIME_FORMAT(da, da) FROM t string null += SELECT TIME_FORMAT(dt, dt) FROM t string null += SELECT TIME_FORMAT(dt, i) FROM t string null += SELECT TIME_FORMAT(f, f) FROM t string null += SELECT TIME_FORMAT(i, i) FROM t string null += SELECT TIME_FORMAT(i, s) FROM t string null += SELECT TIME_FORMAT(j, j) FROM t string null += SELECT TIME_FORMAT(n, n) FROM t string null += SELECT TIME_FORMAT(s, i) FROM t string null += SELECT TIME_FORMAT(s, s) FROM t string null += SELECT TIME_FORMAT(tm, tm) FROM t string null += SELECT TIME_FORMAT(u, u) FROM t string null += SELECT TIME_TO_SEC(b) FROM t bigint null += SELECT TIME_TO_SEC(d) FROM t bigint null += SELECT TIME_TO_SEC(da) FROM t bigint null += SELECT TIME_TO_SEC(dt) FROM t bigint null += SELECT TIME_TO_SEC(f) FROM t bigint null += SELECT TIME_TO_SEC(i) FROM t bigint null += SELECT TIME_TO_SEC(j) FROM t bigint null += SELECT TIME_TO_SEC(n) FROM t bigint null += SELECT TIME_TO_SEC(s) FROM t bigint null += SELECT TIME_TO_SEC(tm) FROM t bigint null += SELECT TIME_TO_SEC(u) FROM t bigint null += SELECT TO_BASE64(b) FROM t string null += SELECT TO_BASE64(d) FROM t string null += SELECT TO_BASE64(da) FROM t string null += SELECT TO_BASE64(dt) FROM t string null += SELECT TO_BASE64(f) FROM t string null += SELECT TO_BASE64(i) FROM t string null += SELECT TO_BASE64(j) FROM t string null += SELECT TO_BASE64(n) FROM t string null += SELECT TO_BASE64(s) FROM t string null += SELECT TO_BASE64(tm) FROM t string null += SELECT TO_BASE64(u) FROM t string null += SELECT TO_DAYS(b) FROM t bigint null += SELECT TO_DAYS(d) FROM t bigint null += SELECT TO_DAYS(da) FROM t bigint null += SELECT TO_DAYS(dt) FROM t bigint null += SELECT TO_DAYS(f) FROM t bigint null += SELECT TO_DAYS(i) FROM t bigint null += SELECT TO_DAYS(j) FROM t bigint null += SELECT TO_DAYS(n) FROM t bigint null += SELECT TO_DAYS(s) FROM t bigint null += SELECT TO_DAYS(tm) FROM t bigint null += SELECT TO_DAYS(u) FROM t bigint null += SELECT TO_SECONDS(b) FROM t bigint null += SELECT TO_SECONDS(d) FROM t bigint null += SELECT TO_SECONDS(da) FROM t bigint null += SELECT TO_SECONDS(dt) FROM t bigint null += SELECT TO_SECONDS(f) FROM t bigint null += SELECT TO_SECONDS(i) FROM t bigint null += SELECT TO_SECONDS(j) FROM t bigint null += SELECT TO_SECONDS(n) FROM t bigint null += SELECT TO_SECONDS(s) FROM t bigint null += SELECT TO_SECONDS(tm) FROM t bigint null += SELECT TO_SECONDS(u) FROM t bigint null += SELECT UCASE(b) FROM t binary null += SELECT UCASE(d) FROM t string null += SELECT UCASE(da) FROM t string null += SELECT UCASE(dt) FROM t string null += SELECT UCASE(f) FROM t string null += SELECT UCASE(i) FROM t string null += SELECT UCASE(j) FROM t string null += SELECT UCASE(n) FROM t string null += SELECT UCASE(s) FROM t string null += SELECT UCASE(tm) FROM t string null += SELECT UCASE(u) FROM t string null += SELECT UNCOMPRESS(b) FROM t binary null += SELECT UNCOMPRESS(d) FROM t binary null += SELECT UNCOMPRESS(da) FROM t binary null += SELECT UNCOMPRESS(dt) FROM t binary null += SELECT UNCOMPRESS(f) FROM t binary null += SELECT UNCOMPRESS(i) FROM t binary null += SELECT UNCOMPRESS(j) FROM t binary null += SELECT UNCOMPRESS(n) FROM t binary null += SELECT UNCOMPRESS(s) FROM t binary null += SELECT UNCOMPRESS(tm) FROM t binary null += SELECT UNCOMPRESS(u) FROM t binary null += SELECT UNCOMPRESSED_LENGTH(b) FROM t bigint += SELECT UNCOMPRESSED_LENGTH(d) FROM t bigint += SELECT UNCOMPRESSED_LENGTH(da) FROM t bigint += SELECT UNCOMPRESSED_LENGTH(dt) FROM t bigint += SELECT UNCOMPRESSED_LENGTH(f) FROM t bigint += SELECT UNCOMPRESSED_LENGTH(i) FROM t bigint += SELECT UNCOMPRESSED_LENGTH(j) FROM t bigint null += SELECT UNCOMPRESSED_LENGTH(n) FROM t bigint null += SELECT UNCOMPRESSED_LENGTH(s) FROM t bigint += SELECT UNCOMPRESSED_LENGTH(tm) FROM t bigint += SELECT UNCOMPRESSED_LENGTH(u) FROM t bigint += SELECT UNHEX(b) FROM t binary null += SELECT UNHEX(d) FROM t binary null += SELECT UNHEX(da) FROM t binary null += SELECT UNHEX(dt) FROM t binary null += SELECT UNHEX(f) FROM t binary null += SELECT UNHEX(i) FROM t binary null += SELECT UNHEX(j) FROM t binary null += SELECT UNHEX(n) FROM t binary null += SELECT UNHEX(s) FROM t binary null += SELECT UNHEX(tm) FROM t binary null += SELECT UNHEX(u) FROM t binary null += SELECT UNIX_TIMESTAMP() FROM t bigint += SELECT UNIX_TIMESTAMP(b) FROM t decimal += SELECT UNIX_TIMESTAMP(d) FROM t decimal += SELECT UNIX_TIMESTAMP(da) FROM t bigint += SELECT UNIX_TIMESTAMP(dt) FROM t decimal += SELECT UNIX_TIMESTAMP(f) FROM t decimal += SELECT UNIX_TIMESTAMP(i) FROM t bigint += SELECT UNIX_TIMESTAMP(j) FROM t decimal null += SELECT UNIX_TIMESTAMP(n) FROM t bigint null += SELECT UNIX_TIMESTAMP(s) FROM t decimal += SELECT UNIX_TIMESTAMP(tm) FROM t bigint += SELECT UNIX_TIMESTAMP(u) FROM t bigint += SELECT UPPER(b) FROM t binary null += SELECT UPPER(d) FROM t string null += SELECT UPPER(da) FROM t string null += SELECT UPPER(dt) FROM t string null += SELECT UPPER(f) FROM t string null += SELECT UPPER(i) FROM t string null += SELECT UPPER(j) FROM t string null += SELECT UPPER(n) FROM t string null += SELECT UPPER(s) FROM t string null += SELECT UPPER(tm) FROM t string null += SELECT UPPER(u) FROM t string null += SELECT UUID() FROM t string null += SELECT UUID_SHORT() FROM t bigint unsigned += SELECT UUID_TO_BIN(b) FROM t binary null += SELECT UUID_TO_BIN(b, b) FROM t binary null += SELECT UUID_TO_BIN(d) FROM t binary null += SELECT UUID_TO_BIN(d, d) FROM t binary null += SELECT UUID_TO_BIN(da) FROM t binary null += SELECT UUID_TO_BIN(da, da) FROM t binary null += SELECT UUID_TO_BIN(dt) FROM t binary null += SELECT UUID_TO_BIN(dt, dt) FROM t binary null += SELECT UUID_TO_BIN(dt, i) FROM t binary null += SELECT UUID_TO_BIN(f) FROM t binary null += SELECT UUID_TO_BIN(f, f) FROM t binary null += SELECT UUID_TO_BIN(i) FROM t binary null += SELECT UUID_TO_BIN(i, i) FROM t binary null += SELECT UUID_TO_BIN(i, s) FROM t binary null += SELECT UUID_TO_BIN(j) FROM t binary null += SELECT UUID_TO_BIN(j, j) FROM t binary null += SELECT UUID_TO_BIN(n) FROM t binary null += SELECT UUID_TO_BIN(n, n) FROM t binary null += SELECT UUID_TO_BIN(s) FROM t binary null += SELECT UUID_TO_BIN(s, i) FROM t binary null += SELECT UUID_TO_BIN(s, s) FROM t binary null += SELECT UUID_TO_BIN(tm) FROM t binary null += SELECT UUID_TO_BIN(tm, tm) FROM t binary null += SELECT UUID_TO_BIN(u) FROM t binary null += SELECT UUID_TO_BIN(u, u) FROM t binary null += SELECT VALIDATE_PASSWORD_STRENGTH(b) FROM t bigint null += SELECT VALIDATE_PASSWORD_STRENGTH(d) FROM t bigint null += SELECT VALIDATE_PASSWORD_STRENGTH(da) FROM t bigint null += SELECT VALIDATE_PASSWORD_STRENGTH(dt) FROM t bigint null += SELECT VALIDATE_PASSWORD_STRENGTH(f) FROM t bigint null += SELECT VALIDATE_PASSWORD_STRENGTH(i) FROM t bigint null += SELECT VALIDATE_PASSWORD_STRENGTH(j) FROM t bigint null += SELECT VALIDATE_PASSWORD_STRENGTH(n) FROM t bigint null += SELECT VALIDATE_PASSWORD_STRENGTH(s) FROM t bigint null += SELECT VALIDATE_PASSWORD_STRENGTH(tm) FROM t bigint null += SELECT VALIDATE_PASSWORD_STRENGTH(u) FROM t bigint null += SELECT VERSION() FROM t string += SELECT WAIT_FOR_EXECUTED_GTID_SET(b) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(b, b) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(d) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(d, d) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(da) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(da, da) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(dt) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(dt, dt) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(dt, i) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(f) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(f, f) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(i) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(i, i) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(i, s) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(j) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(j, j) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(n) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(n, n) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(s) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(s, i) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(s, s) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(tm) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(tm, tm) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(u) FROM t bigint null += SELECT WAIT_FOR_EXECUTED_GTID_SET(u, u) FROM t bigint null += SELECT WEEKDAY(b) FROM t bigint null += SELECT WEEKDAY(d) FROM t bigint null += SELECT WEEKDAY(da) FROM t bigint null += SELECT WEEKDAY(dt) FROM t bigint null += SELECT WEEKDAY(f) FROM t bigint null += SELECT WEEKDAY(i) FROM t bigint null += SELECT WEEKDAY(j) FROM t bigint null += SELECT WEEKDAY(n) FROM t bigint null += SELECT WEEKDAY(s) FROM t bigint null += SELECT WEEKDAY(tm) FROM t bigint null += SELECT WEEKDAY(u) FROM t bigint null += SELECT WEEKOFYEAR(b) FROM t bigint null += SELECT WEEKOFYEAR(d) FROM t bigint null += SELECT WEEKOFYEAR(da) FROM t bigint null += SELECT WEEKOFYEAR(dt) FROM t bigint null += SELECT WEEKOFYEAR(f) FROM t bigint null += SELECT WEEKOFYEAR(i) FROM t bigint null += SELECT WEEKOFYEAR(j) FROM t bigint null += SELECT WEEKOFYEAR(n) FROM t bigint null += SELECT WEEKOFYEAR(s) FROM t bigint null += SELECT WEEKOFYEAR(tm) FROM t bigint null += SELECT WEEKOFYEAR(u) FROM t bigint null += SELECT YEARWEEK(b) FROM t bigint null += SELECT YEARWEEK(b, b) FROM t bigint null += SELECT YEARWEEK(d) FROM t bigint null += SELECT YEARWEEK(d, d) FROM t bigint null += SELECT YEARWEEK(da) FROM t bigint null += SELECT YEARWEEK(da, da) FROM t bigint null += SELECT YEARWEEK(dt) FROM t bigint null += SELECT YEARWEEK(dt, dt) FROM t bigint null += SELECT YEARWEEK(dt, i) FROM t bigint null += SELECT YEARWEEK(f) FROM t bigint null += SELECT YEARWEEK(f, f) FROM t bigint null += SELECT YEARWEEK(i) FROM t bigint null += SELECT YEARWEEK(i, i) FROM t bigint null += SELECT YEARWEEK(i, s) FROM t bigint null += SELECT YEARWEEK(j) FROM t bigint null += SELECT YEARWEEK(j, j) FROM t bigint null += SELECT YEARWEEK(n) FROM t bigint null += SELECT YEARWEEK(n, n) FROM t bigint null += SELECT YEARWEEK(s) FROM t bigint null += SELECT YEARWEEK(s, i) FROM t bigint null += SELECT YEARWEEK(s, s) FROM t bigint null += SELECT YEARWEEK(tm) FROM t bigint null += SELECT YEARWEEK(tm, tm) FROM t bigint null += SELECT YEARWEEK(u) FROM t bigint null += SELECT YEARWEEK(u, u) FROM t bigint null +x SELECT EXTRACTVALUE(b, b) FROM t binary null 1105 Only constant XPATH queries are supported +x SELECT EXTRACTVALUE(d, d) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT EXTRACTVALUE(da, da) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT EXTRACTVALUE(dt, dt) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT EXTRACTVALUE(dt, i) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT EXTRACTVALUE(f, f) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT EXTRACTVALUE(i, i) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT EXTRACTVALUE(i, s) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT EXTRACTVALUE(j, j) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT EXTRACTVALUE(n, n) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT EXTRACTVALUE(s, i) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT EXTRACTVALUE(s, s) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT EXTRACTVALUE(tm, tm) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT EXTRACTVALUE(u, u) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT JSON_LENGTH() FROM t bigint null 1582 Incorrect parameter count in the call to native function 'JSON_LENGTH' +x SELECT JSON_SCHEMA_VALID(d, d) FROM t bigint 3146 Invalid data type for JSON data in argument 1 to function json_schema_valid; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALID(da, da) FROM t bigint 3146 Invalid data type for JSON data in argument 1 to function json_schema_valid; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALID(dt, dt) FROM t bigint 3146 Invalid data type for JSON data in argument 1 to function json_schema_valid; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALID(dt, i) FROM t bigint 3146 Invalid data type for JSON data in argument 1 to function json_schema_valid; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALID(f, f) FROM t bigint 3146 Invalid data type for JSON data in argument 1 to function json_schema_valid; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALID(i, i) FROM t bigint 3146 Invalid data type for JSON data in argument 1 to function json_schema_valid; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALID(i, s) FROM t bigint 3146 Invalid data type for JSON data in argument 1 to function json_schema_valid; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALID(n, n) FROM t bigint null 3146 Invalid data type for JSON data in argument 1 to function json_schema_valid; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALID(s, i) FROM t bigint 3146 Invalid data type for JSON data in argument 2 to function json_schema_valid; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALID(tm, tm) FROM t bigint 3146 Invalid data type for JSON data in argument 1 to function json_schema_valid; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALID(u, u) FROM t bigint 3146 Invalid data type for JSON data in argument 1 to function json_schema_valid; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALIDATION_REPORT(d, d) FROM t json null 3146 Invalid data type for JSON data in argument 1 to function json_schema_validation_report; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALIDATION_REPORT(da, da) FROM t json null 3146 Invalid data type for JSON data in argument 1 to function json_schema_validation_report; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALIDATION_REPORT(dt, dt) FROM t json null 3146 Invalid data type for JSON data in argument 1 to function json_schema_validation_report; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALIDATION_REPORT(dt, i) FROM t json null 3146 Invalid data type for JSON data in argument 1 to function json_schema_validation_report; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALIDATION_REPORT(f, f) FROM t json null 3146 Invalid data type for JSON data in argument 1 to function json_schema_validation_report; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALIDATION_REPORT(i, i) FROM t json null 3146 Invalid data type for JSON data in argument 1 to function json_schema_validation_report; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALIDATION_REPORT(i, s) FROM t json null 3146 Invalid data type for JSON data in argument 1 to function json_schema_validation_report; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALIDATION_REPORT(n, n) FROM t json null 3146 Invalid data type for JSON data in argument 1 to function json_schema_validation_report; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALIDATION_REPORT(s, i) FROM t json null 3146 Invalid data type for JSON data in argument 2 to function json_schema_validation_report; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALIDATION_REPORT(tm, tm) FROM t json null 3146 Invalid data type for JSON data in argument 1 to function json_schema_validation_report; a JSON string or JSON type is required. +x SELECT JSON_SCHEMA_VALIDATION_REPORT(u, u) FROM t json null 3146 Invalid data type for JSON data in argument 1 to function json_schema_validation_report; a JSON string or JSON type is required. +x SELECT JSON_SEARCH(b, b, b, b) FROM t json null 1210 Incorrect arguments to ESCAPE +x SELECT JSON_SEARCH(d, d, d, d) FROM t json null 1210 Incorrect arguments to ESCAPE +x SELECT JSON_SEARCH(da, da, da, da) FROM t json null 1210 Incorrect arguments to ESCAPE +x SELECT JSON_SEARCH(dt, dt, dt, dt) FROM t json null 1210 Incorrect arguments to ESCAPE +x SELECT JSON_SEARCH(dt, i, i, i) FROM t json null 1210 Incorrect arguments to ESCAPE +x SELECT JSON_SEARCH(f, f, f, f) FROM t json null 1210 Incorrect arguments to ESCAPE +x SELECT JSON_SEARCH(i, i, i, i) FROM t json null 1210 Incorrect arguments to ESCAPE +x SELECT JSON_SEARCH(i, s, s, s) FROM t json null 1210 Incorrect arguments to ESCAPE +x SELECT JSON_SEARCH(j, j, j, j) FROM t json null 1210 Incorrect arguments to ESCAPE +x SELECT JSON_SEARCH(n, n, n, n) FROM t json null 1210 Incorrect arguments to ESCAPE +x SELECT JSON_SEARCH(s, i, i, i) FROM t json null 1210 Incorrect arguments to ESCAPE +x SELECT JSON_SEARCH(s, s, s, s) FROM t json null 1210 Incorrect arguments to ESCAPE +x SELECT JSON_SEARCH(tm, tm, tm, tm) FROM t json null 1210 Incorrect arguments to ESCAPE +x SELECT JSON_SEARCH(u, u, u, u) FROM t json null 1210 Incorrect arguments to ESCAPE +x SELECT LIKE_RANGE_MAX(b, b) FROM t binary null 1305 FUNCTION sqlshape.LIKE_RANGE_MAX does not exist +x SELECT LIKE_RANGE_MAX(d, d) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MAX does not exist +x SELECT LIKE_RANGE_MAX(da, da) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MAX does not exist +x SELECT LIKE_RANGE_MAX(dt, dt) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MAX does not exist +x SELECT LIKE_RANGE_MAX(dt, i) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MAX does not exist +x SELECT LIKE_RANGE_MAX(f, f) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MAX does not exist +x SELECT LIKE_RANGE_MAX(i, i) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MAX does not exist +x SELECT LIKE_RANGE_MAX(i, s) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MAX does not exist +x SELECT LIKE_RANGE_MAX(j, j) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MAX does not exist +x SELECT LIKE_RANGE_MAX(n, n) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MAX does not exist +x SELECT LIKE_RANGE_MAX(s, i) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MAX does not exist +x SELECT LIKE_RANGE_MAX(s, s) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MAX does not exist +x SELECT LIKE_RANGE_MAX(tm, tm) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MAX does not exist +x SELECT LIKE_RANGE_MAX(u, u) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MAX does not exist +x SELECT LIKE_RANGE_MIN(b, b) FROM t binary null 1305 FUNCTION sqlshape.LIKE_RANGE_MIN does not exist +x SELECT LIKE_RANGE_MIN(d, d) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MIN does not exist +x SELECT LIKE_RANGE_MIN(da, da) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MIN does not exist +x SELECT LIKE_RANGE_MIN(dt, dt) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MIN does not exist +x SELECT LIKE_RANGE_MIN(dt, i) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MIN does not exist +x SELECT LIKE_RANGE_MIN(f, f) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MIN does not exist +x SELECT LIKE_RANGE_MIN(i, i) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MIN does not exist +x SELECT LIKE_RANGE_MIN(i, s) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MIN does not exist +x SELECT LIKE_RANGE_MIN(j, j) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MIN does not exist +x SELECT LIKE_RANGE_MIN(n, n) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MIN does not exist +x SELECT LIKE_RANGE_MIN(s, i) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MIN does not exist +x SELECT LIKE_RANGE_MIN(s, s) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MIN does not exist +x SELECT LIKE_RANGE_MIN(tm, tm) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MIN does not exist +x SELECT LIKE_RANGE_MIN(u, u) FROM t string null 1305 FUNCTION sqlshape.LIKE_RANGE_MIN does not exist +x SELECT NAME_CONST(b, b) FROM t Incorrect arguments to NAME_CONST (MySQL error 1210) 1210 Incorrect arguments to NAME_CONST +x SELECT NAME_CONST(d, d) FROM t Incorrect arguments to NAME_CONST (MySQL error 1210) 1210 Incorrect arguments to NAME_CONST +x SELECT NAME_CONST(da, da) FROM t Incorrect arguments to NAME_CONST (MySQL error 1210) 1210 Incorrect arguments to NAME_CONST +x SELECT NAME_CONST(dt, dt) FROM t Incorrect arguments to NAME_CONST (MySQL error 1210) 1210 Incorrect arguments to NAME_CONST +x SELECT NAME_CONST(dt, i) FROM t Incorrect arguments to NAME_CONST (MySQL error 1210) 1210 Incorrect arguments to NAME_CONST +x SELECT NAME_CONST(f, f) FROM t Incorrect arguments to NAME_CONST (MySQL error 1210) 1210 Incorrect arguments to NAME_CONST +x SELECT NAME_CONST(i, i) FROM t Incorrect arguments to NAME_CONST (MySQL error 1210) 1210 Incorrect arguments to NAME_CONST +x SELECT NAME_CONST(i, s) FROM t Incorrect arguments to NAME_CONST (MySQL error 1210) 1210 Incorrect arguments to NAME_CONST +x SELECT NAME_CONST(j, j) FROM t Incorrect arguments to NAME_CONST (MySQL error 1210) 1210 Incorrect arguments to NAME_CONST +x SELECT NAME_CONST(n, n) FROM t Incorrect arguments to NAME_CONST (MySQL error 1210) 1210 Incorrect arguments to NAME_CONST +x SELECT NAME_CONST(s, i) FROM t Incorrect arguments to NAME_CONST (MySQL error 1210) 1210 Incorrect arguments to NAME_CONST +x SELECT NAME_CONST(s, s) FROM t Incorrect arguments to NAME_CONST (MySQL error 1210) 1210 Incorrect arguments to NAME_CONST +x SELECT NAME_CONST(tm, tm) FROM t Incorrect arguments to NAME_CONST (MySQL error 1210) 1210 Incorrect arguments to NAME_CONST +x SELECT NAME_CONST(u, u) FROM t Incorrect arguments to NAME_CONST (MySQL error 1210) 1210 Incorrect arguments to NAME_CONST +x SELECT ST_ASGEOJSON(b, b) FROM t json null 3064 Incorrect type for argument max decimal digits in function st_asgeojson. +x SELECT ST_ASGEOJSON(b, b, b) FROM t json null 3064 Incorrect type for argument max decimal digits in function st_asgeojson. +x SELECT ST_ASGEOJSON(d) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(d, d) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(d, d, d) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(da) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(da, da) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(da, da, da) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(dt) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(dt, dt) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(dt, dt, dt) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(dt, i) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(dt, i, i) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(f) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(f, f) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(f, f, f) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(i) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(i, i) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(i, i, i) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(i, s) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(i, s, s) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(j) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(j, j) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(j, j, j) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(n) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(n, n) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(n, n, n) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(s) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(s, i) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(s, i, i) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(s, s) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(s, s, NULL) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(s, s, s) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(tm) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(tm, tm) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(tm, tm, tm) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(u) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(u, u) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_ASGEOJSON(u, u, u) FROM t json null 3064 Incorrect type for argument geometry in function st_asgeojson. +x SELECT ST_GEOHASH(b, b) FROM t string 3064 Incorrect type for argument geohash max length in function st_geohash. +x SELECT ST_GEOHASH(b, b, b) FROM t string 3064 Incorrect type for argument longitude in function st_geohash. +x SELECT ST_GEOHASH(d, d) FROM t string 3064 Incorrect type for argument point in function st_geohash. +x SELECT ST_GEOHASH(d, d, d) FROM t string 3064 Incorrect type for argument geohash max length in function st_geohash. +x SELECT ST_GEOHASH(da, da) FROM t string 3064 Incorrect type for argument point in function st_geohash. +x SELECT ST_GEOHASH(da, da, da) FROM t string 3064 Incorrect type for argument longitude in function st_geohash. +x SELECT ST_GEOHASH(dt, dt) FROM t string 3064 Incorrect type for argument point in function st_geohash. +x SELECT ST_GEOHASH(dt, dt, dt) FROM t string 3064 Incorrect type for argument longitude in function st_geohash. +x SELECT ST_GEOHASH(dt, i) FROM t string 3064 Incorrect type for argument point in function st_geohash. +x SELECT ST_GEOHASH(dt, i, i) FROM t string 3064 Incorrect type for argument longitude in function st_geohash. +x SELECT ST_GEOHASH(f, f) FROM t string 3064 Incorrect type for argument point in function st_geohash. +x SELECT ST_GEOHASH(f, f, f) FROM t string 3064 Incorrect type for argument geohash max length in function st_geohash. +x SELECT ST_GEOHASH(i, i) FROM t string 3064 Incorrect type for argument point in function st_geohash. +x SELECT ST_GEOHASH(i, s) FROM t string 3064 Incorrect type for argument point in function st_geohash. +x SELECT ST_GEOHASH(j, j) FROM t string null 3064 Incorrect type for argument point in function st_geohash. +x SELECT ST_GEOHASH(j, j, j) FROM t string null 3064 Incorrect type for argument longitude in function st_geohash. +x SELECT ST_GEOHASH(n, n) FROM t string null 3064 Incorrect type for argument point in function st_geohash. +x SELECT ST_GEOHASH(s, i) FROM t string 3064 Incorrect type for argument point in function st_geohash. +x SELECT ST_GEOHASH(s, s) FROM t string 3064 Incorrect type for argument point in function st_geohash. +x SELECT ST_GEOHASH(tm, tm) FROM t string 3064 Incorrect type for argument point in function st_geohash. +x SELECT ST_GEOHASH(tm, tm, tm) FROM t string 3064 Incorrect type for argument longitude in function st_geohash. +x SELECT ST_GEOHASH(u, u) FROM t string 3064 Incorrect type for argument point in function st_geohash. +x SELECT ST_GEOMFROMGEOJSON(b) FROM t geometry null 3064 Incorrect type for argument geojson in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(b, b) FROM t geometry null 3064 Incorrect type for argument options in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(b, b, b) FROM t geometry null 3064 Incorrect type for argument SRID in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(d) FROM t geometry null 3064 Incorrect type for argument geojson in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(d, d) FROM t geometry null 3064 Incorrect type for argument options in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(d, d, d) FROM t geometry null 3064 Incorrect type for argument SRID in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(da) FROM t geometry null 3064 Incorrect type for argument geojson in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(da, da) FROM t geometry null 3064 Incorrect type for argument options in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(da, da, da) FROM t geometry null 3064 Incorrect type for argument SRID in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(dt) FROM t geometry null 3064 Incorrect type for argument geojson in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(dt, dt) FROM t geometry null 3064 Incorrect type for argument options in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(dt, dt, dt) FROM t geometry null 3064 Incorrect type for argument SRID in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(dt, i) FROM t geometry null 3064 Incorrect type for argument geojson in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(dt, i, i) FROM t geometry null 3064 Incorrect type for argument geojson in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(f) FROM t geometry null 3064 Incorrect type for argument geojson in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(f, f) FROM t geometry null 3064 Incorrect type for argument options in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(f, f, f) FROM t geometry null 3064 Incorrect type for argument SRID in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(i) FROM t geometry null 3064 Incorrect type for argument geojson in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(i, i) FROM t geometry null 3064 Incorrect type for argument geojson in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(i, i, i) FROM t geometry null 3064 Incorrect type for argument geojson in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(i, s) FROM t geometry null 3064 Incorrect type for argument geojson in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(i, s, s) FROM t geometry null 3064 Incorrect type for argument geojson in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(j, j) FROM t geometry null 3064 Incorrect type for argument options in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(j, j, j) FROM t geometry null 3064 Incorrect type for argument SRID in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(n) FROM t geometry null 3064 Incorrect type for argument geojson in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(n, n) FROM t geometry null 3064 Incorrect type for argument geojson in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(n, n, n) FROM t geometry null 3064 Incorrect type for argument geojson in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(tm) FROM t geometry null 3064 Incorrect type for argument geojson in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(tm, tm) FROM t geometry null 3064 Incorrect type for argument options in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(tm, tm, tm) FROM t geometry null 3064 Incorrect type for argument SRID in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(u) FROM t geometry null 3064 Incorrect type for argument geojson in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(u, u) FROM t geometry null 3064 Incorrect type for argument geojson in function st_geomfromgeojson. +x SELECT ST_GEOMFROMGEOJSON(u, u, u) FROM t geometry null 3064 Incorrect type for argument geojson in function st_geomfromgeojson. +x SELECT ST_LATFROMGEOHASH(b) FROM t double 3064 Incorrect type for argument geohash in function ST_LATFROMGEOHASH. +x SELECT ST_LATFROMGEOHASH(d) FROM t double 3064 Incorrect type for argument geohash in function ST_LATFROMGEOHASH. +x SELECT ST_LATFROMGEOHASH(da) FROM t double 3064 Incorrect type for argument geohash in function ST_LATFROMGEOHASH. +x SELECT ST_LATFROMGEOHASH(dt) FROM t double 3064 Incorrect type for argument geohash in function ST_LATFROMGEOHASH. +x SELECT ST_LATFROMGEOHASH(f) FROM t double 3064 Incorrect type for argument geohash in function ST_LATFROMGEOHASH. +x SELECT ST_LATFROMGEOHASH(i) FROM t double 3064 Incorrect type for argument geohash in function ST_LATFROMGEOHASH. +x SELECT ST_LATFROMGEOHASH(j) FROM t double null 3064 Incorrect type for argument geohash in function ST_LATFROMGEOHASH. +x SELECT ST_LATFROMGEOHASH(n) FROM t double null 3064 Incorrect type for argument geohash in function ST_LATFROMGEOHASH. +x SELECT ST_LATFROMGEOHASH(tm) FROM t double 3064 Incorrect type for argument geohash in function ST_LATFROMGEOHASH. +x SELECT ST_LATFROMGEOHASH(u) FROM t double 3064 Incorrect type for argument geohash in function ST_LATFROMGEOHASH. +x SELECT ST_LONGFROMGEOHASH(b) FROM t double 3064 Incorrect type for argument geohash in function ST_LONGFROMGEOHASH. +x SELECT ST_LONGFROMGEOHASH(d) FROM t double 3064 Incorrect type for argument geohash in function ST_LONGFROMGEOHASH. +x SELECT ST_LONGFROMGEOHASH(da) FROM t double 3064 Incorrect type for argument geohash in function ST_LONGFROMGEOHASH. +x SELECT ST_LONGFROMGEOHASH(dt) FROM t double 3064 Incorrect type for argument geohash in function ST_LONGFROMGEOHASH. +x SELECT ST_LONGFROMGEOHASH(f) FROM t double 3064 Incorrect type for argument geohash in function ST_LONGFROMGEOHASH. +x SELECT ST_LONGFROMGEOHASH(i) FROM t double 3064 Incorrect type for argument geohash in function ST_LONGFROMGEOHASH. +x SELECT ST_LONGFROMGEOHASH(j) FROM t double null 3064 Incorrect type for argument geohash in function ST_LONGFROMGEOHASH. +x SELECT ST_LONGFROMGEOHASH(n) FROM t double null 3064 Incorrect type for argument geohash in function ST_LONGFROMGEOHASH. +x SELECT ST_LONGFROMGEOHASH(tm) FROM t double 3064 Incorrect type for argument geohash in function ST_LONGFROMGEOHASH. +x SELECT ST_LONGFROMGEOHASH(u) FROM t double 3064 Incorrect type for argument geohash in function ST_LONGFROMGEOHASH. +x SELECT ST_POINTFROMGEOHASH(b, b) FROM t geometry 3064 Incorrect type for argument geohash in function st_pointfromgeohash. +x SELECT ST_POINTFROMGEOHASH(d, d) FROM t geometry 3064 Incorrect type for argument geohash in function st_pointfromgeohash. +x SELECT ST_POINTFROMGEOHASH(da, da) FROM t geometry 3064 Incorrect type for argument geohash in function st_pointfromgeohash. +x SELECT ST_POINTFROMGEOHASH(dt, dt) FROM t geometry 3064 Incorrect type for argument geohash in function st_pointfromgeohash. +x SELECT ST_POINTFROMGEOHASH(dt, i) FROM t geometry 3064 Incorrect type for argument geohash in function st_pointfromgeohash. +x SELECT ST_POINTFROMGEOHASH(f, f) FROM t geometry 3064 Incorrect type for argument geohash in function st_pointfromgeohash. +x SELECT ST_POINTFROMGEOHASH(i, i) FROM t geometry 3064 Incorrect type for argument geohash in function st_pointfromgeohash. +x SELECT ST_POINTFROMGEOHASH(i, s) FROM t geometry 3064 Incorrect type for argument geohash in function st_pointfromgeohash. +x SELECT ST_POINTFROMGEOHASH(j, j) FROM t geometry null 3064 Incorrect type for argument geohash in function st_pointfromgeohash. +x SELECT ST_POINTFROMGEOHASH(n, n) FROM t geometry null 3064 Incorrect type for argument geohash in function st_pointfromgeohash. +x SELECT ST_POINTFROMGEOHASH(tm, tm) FROM t geometry 3064 Incorrect type for argument geohash in function st_pointfromgeohash. +x SELECT ST_POINTFROMGEOHASH(u, u) FROM t geometry 3064 Incorrect type for argument geohash in function st_pointfromgeohash. +x SELECT UPDATEXML(b, b, b) FROM t binary null 1105 Only constant XPATH queries are supported +x SELECT UPDATEXML(d, d, d) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT UPDATEXML(da, da, da) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT UPDATEXML(dt, dt, dt) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT UPDATEXML(dt, i, i) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT UPDATEXML(f, f, f) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT UPDATEXML(i, i, i) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT UPDATEXML(i, s, s) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT UPDATEXML(j, j, j) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT UPDATEXML(n, n, n) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT UPDATEXML(s, i, i) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT UPDATEXML(s, s, NULL) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT UPDATEXML(s, s, s) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT UPDATEXML(tm, tm, tm) FROM t string null 1105 Only constant XPATH queries are supported +x SELECT UPDATEXML(u, u, u) FROM t string null 1105 Only constant XPATH queries are supported diff --git a/check/mysql/internal/analyze/trigger_violations_server_test.go b/check/mysql/internal/analyze/trigger_violations_server_test.go new file mode 100644 index 0000000..d22265f --- /dev/null +++ b/check/mysql/internal/analyze/trigger_violations_server_test.go @@ -0,0 +1,198 @@ +package analyze + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// triggerServerSchema is triggerViolationSchema (see trigger_violations_test.go) minus the +// self-writing trigger (which the server refuses at CREATE time, so a schema meant to load +// and run cannot declare it) plus REPLACE / ON DUPLICATE KEY UPDATE and IGNORE cases. +const triggerServerSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE orders ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + customer_id BIGINT UNSIGNED NOT NULL, + total DECIMAL(10,2) NOT NULL, + note VARCHAR(100) +); +CREATE TABLE audit_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + msg VARCHAR(100) NOT NULL +); + +-- sqlshape: error 30001 = OrderTooLarge +CREATE TRIGGER orders_bi BEFORE INSERT ON orders FOR EACH ROW +BEGIN + IF NEW.total > 1000000 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'too large', MYSQL_ERRNO = 30001; + END IF; + INSERT INTO audit_log (msg) VALUES ('insert'); +END; + +CREATE TRIGGER orders_bu BEFORE UPDATE ON orders FOR EACH ROW +BEGIN + DECLARE too_large CONDITION FOR SQLSTATE '45000'; + IF NEW.total > 1000000 THEN + SIGNAL too_large SET MESSAGE_TEXT = 'too large'; + END IF; +END; + +CREATE TABLE widgets ( + id INT NOT NULL PRIMARY KEY, + v INT NOT NULL +); +-- sqlshape: error 40001 = WidgetDeleted +CREATE TRIGGER widgets_bd BEFORE DELETE ON widgets FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'deleted', MYSQL_ERRNO = 40001; +END; +-- sqlshape: error 40002 = WidgetUpdated +CREATE TRIGGER widgets_bu BEFORE UPDATE ON widgets FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'updated', MYSQL_ERRNO = 40002; +END; +` + +// TestTriggerViolationsServer runs statements that fire orders_bi / orders_bu against a real +// server, and checks that the checker's predicted failure modes (Analyze's Violations) are +// exactly what the server actually raises: the SIGNAL's own number/SQLSTATE (with and +// without MYSQL_ERRNO, plain and through a named CONDITION), a schema constraint through the +// trigger's own embedded write, and that INSERT IGNORE does not absorb a trigger's SIGNAL +// (measured in the failure-modes milestone: it still fails the whole statement). +func TestTriggerViolationsServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, triggerServerSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + s, err := schema.Load(triggerServerSchema) + if err != nil { + t.Fatal(err) + } + conn := db.Conn() + if _, err := conn.ExecContext(ctx, "INSERT INTO orders (id, customer_id, total) VALUES (1, 1, 5)"); err != nil { + t.Fatal(err) + } + cases := []struct { + sql string + args []any + wantNum int + wantState string + }{ + // the BEFORE INSERT trigger's SIGNAL, with its own MYSQL_ERRNO + {"INSERT INTO orders (id, customer_id, total) VALUES ($1, $2, $3)", []any{2, 1, 2000000}, 30001, "45000"}, + // the BEFORE UPDATE trigger's SIGNAL through a named CONDITION, no MYSQL_ERRNO: the + // server reports the generic unhandled number, 1644 (measured) + {"UPDATE orders SET total = $1 WHERE id = $2", []any{2000000, 1}, 1644, "45000"}, + // INSERT IGNORE does not absorb the trigger's SIGNAL (measured) + {"INSERT IGNORE INTO orders (id, customer_id, total) VALUES ($1, $2, $3)", []any{3, 1, 2000000}, 30001, "45000"}, + // a normal insert: no failure mode predicted or raised + {"INSERT INTO orders (id, customer_id, total) VALUES ($1, $2, $3)", []any{4, 1, 5}, 0, ""}, + } + for _, c := range cases { + r, err := Analyze(s, c.sql) + if err != nil { + t.Errorf("%s: %v", c.sql, err) + continue + } + predicted := map[string]bool{} + for _, v := range r.Violations { + predicted[fmt.Sprintf("%d %s", v.Code, v.Key())] = true + } + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + t.Fatal(err) + } + q := strings.NewReplacer("$1", "?", "$2", "?", "$3", "?").Replace(c.sql) + _, execErr := tx.ExecContext(ctx, q, c.args...) + tx.Rollback() + if c.wantNum == 0 { + if execErr != nil { + t.Errorf("%s: predicted no failure, the server says %v", c.sql, execErr) + } + continue + } + var me *driver.MySQLError + if !errors.As(execErr, &me) { + t.Errorf("%s: want %d (%s), the server says %v", c.sql, c.wantNum, c.wantState, execErr) + continue + } + if int(me.Number) != c.wantNum || string(me.SQLState[:]) != c.wantState { + t.Errorf("%s: want %d (%s), the server says %d (%s): %s", c.sql, c.wantNum, c.wantState, me.Number, me.SQLState, me.Message) + } + key := fmt.Sprintf("%d %s", c.wantNum, c.wantState) + if c.wantNum != 1644 && c.wantNum != 1643 { + key = fmt.Sprintf("%d %d", c.wantNum, c.wantNum) + } + if !predicted[key] { + t.Errorf("%s: the server raises %s, the checker predicted %v", c.sql, key, predicted) + } + } +} + +// TestReplaceODKUTriggersServer checks the event pairs REPLACE / ON DUPLICATE KEY UPDATE +// predict (violations.go's triggerFailureModes) against what mysqld actually fires on a +// collision: REPLACE additionally fires the DELETE event's triggers (BD/AD, the displaced +// row), ON DUPLICATE KEY UPDATE additionally fires the UPDATE event's (BU/AU) -- both +// measured in the failure-modes milestone. +func TestReplaceODKUTriggersServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, triggerServerSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + s, err := schema.Load(triggerServerSchema) + if err != nil { + t.Fatal(err) + } + conn := db.Conn() + if _, err := conn.ExecContext(ctx, "INSERT INTO widgets VALUES (1, 1)"); err != nil { + t.Fatal(err) + } + + replaceSQL := "REPLACE INTO widgets VALUES ($1, $2)" + r, err := Analyze(s, replaceSQL) + if err != nil { + t.Fatal(err) + } + if got := violationKeys(r.Violations); got != "1048 widgets.id, 1048 widgets.v, 40001 40001=WidgetDeleted" { + t.Errorf("REPLACE predicted %s, want the DELETE event's trigger (40001)", got) + } + _, execErr := conn.ExecContext(ctx, "REPLACE INTO widgets VALUES (1, 2)") + var me *driver.MySQLError + if !errors.As(execErr, &me) || me.Number != 40001 { + t.Errorf("REPLACE colliding on widgets: got %v, want 40001 (BEFORE DELETE fires on the displaced row)", execErr) + } + + odkuSQL := "INSERT INTO widgets VALUES ($1, $2) ON DUPLICATE KEY UPDATE v = $3" + r, err = Analyze(s, odkuSQL) + if err != nil { + t.Fatal(err) + } + if got := violationKeys(r.Violations); got != "1048 widgets.id, 1048 widgets.v, 40002 40002=WidgetUpdated" { + t.Errorf("ODKU predicted %s, want the UPDATE event's trigger (40002)", got) + } + _, execErr = conn.ExecContext(ctx, "INSERT INTO widgets VALUES (1, 9) ON DUPLICATE KEY UPDATE v = 3") + if !errors.As(execErr, &me) || me.Number != 40002 { + t.Errorf("ODKU colliding on widgets: got %v, want 40002 (BEFORE UPDATE fires on the collision)", execErr) + } +} diff --git a/check/mysql/internal/analyze/trigger_violations_test.go b/check/mysql/internal/analyze/trigger_violations_test.go new file mode 100644 index 0000000..16c3f4e --- /dev/null +++ b/check/mysql/internal/analyze/trigger_violations_test.go @@ -0,0 +1,217 @@ +package analyze + +import ( + "fmt" + "sort" + "strings" + "testing" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" +) + +// triggerViolationSchema exercises the failure modes a trigger's body contributes to the +// statement that fires it: a plain SIGNAL, a named CONDITION, a HANDLER absorbing what it +// catches, a RESIGNAL, a trigger writing its own table (1442), a SELECT INTO that cannot be +// proved single (1172), and REPLACE / ON DUPLICATE KEY UPDATE's own event pairs. +const triggerViolationSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE orders ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + customer_id BIGINT UNSIGNED NOT NULL, + total DECIMAL(10,2) NOT NULL, + note VARCHAR(100) +); +CREATE TABLE audit_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + msg VARCHAR(100) NOT NULL +); + +-- sqlshape: error 30001 = OrderTooLarge +CREATE TRIGGER orders_bi BEFORE INSERT ON orders FOR EACH ROW +BEGIN + IF NEW.total > 1000000 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'too large', MYSQL_ERRNO = 30001; + END IF; + INSERT INTO audit_log (msg) VALUES ('insert'); +END; + +CREATE TRIGGER orders_bu BEFORE UPDATE ON orders FOR EACH ROW +BEGIN + DECLARE too_large CONDITION FOR SQLSTATE '45000'; + IF NEW.total > 1000000 THEN + SIGNAL too_large SET MESSAGE_TEXT = 'too large'; + END IF; +END; + +CREATE TRIGGER orders_own_write AFTER DELETE ON orders FOR EACH ROW +BEGIN + DELETE FROM orders WHERE id = OLD.id; +END; + +CREATE PROCEDURE find_total(IN cid BIGINT UNSIGNED) +BEGIN + DECLARE t DECIMAL(10,2); + SELECT total INTO t FROM orders WHERE customer_id = cid; + SELECT t; +END; + +CREATE PROCEDURE find_total_by_note(IN n VARCHAR(100)) +BEGIN + DECLARE t DECIMAL(10,2); + SELECT total INTO t FROM orders WHERE note = n LIMIT 1; + SELECT t; +END; + +CREATE PROCEDURE sum_total(IN cid BIGINT UNSIGNED) +BEGIN + DECLARE t DECIMAL(10,2); + SELECT COALESCE(SUM(total), 0) INTO t FROM orders WHERE customer_id = cid; + SELECT t; +END; + +CREATE PROCEDURE insert_absorbed(IN v DECIMAL(10,2)) +BEGIN + DECLARE CONTINUE HANDLER FOR 1062 + BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'dup-mapped', MYSQL_ERRNO = 30003; + END; + INSERT INTO orders (id, total) VALUES (1, v); + INSERT INTO orders (id, total) VALUES (1, v); +END; + +CREATE PROCEDURE reraises() +BEGIN + DECLARE EXIT HANDLER FOR SQLEXCEPTION + BEGIN + RESIGNAL; + END; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'orig', MYSQL_ERRNO = 30002; +END; +` + +func loadTriggerViolations(t *testing.T) *schema.Schema { + s, err := schema.Load(triggerViolationSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + return s +} + +func violationKeys(vs []Violation) string { + var keys []string + for _, v := range vs { + k := fmt.Sprintf("%d %s", v.Code, v.Key()) + if v.Name != "" { + k += "=" + v.Name + } + keys = append(keys, k) + } + sort.Strings(keys) + return strings.Join(keys, ", ") +} + +// TestTriggerSignalPropagates: a BEFORE INSERT trigger's SIGNAL (with its own +// `-- sqlshape: error 30001 = OrderTooLarge` annotation) becomes a failure mode of the +// INSERT that fires it, alongside the schema's own constraint (the primary key). +func TestTriggerSignalPropagates(t *testing.T) { + s := loadTriggerViolations(t) + r, err := Analyze(s, "INSERT INTO orders (id, total) VALUES ($1, $2)") + if err != nil { + t.Fatal(err) + } + want := "1048 orders.id, 1048 orders.total, 1062 PRIMARY, 1364 orders.customer_id, 30001 30001=OrderTooLarge" + if got := violationKeys(r.Violations); got != want { + t.Errorf("got %s\nwant %s", got, want) + } +} + +// TestTriggerNamedConditionSignal: a BEFORE UPDATE trigger's SIGNAL through a named +// DECLARE ... CONDITION FOR SQLSTATE, with no MYSQL_ERRNO, keys by the SQLSTATE and reports +// as the unhandled generic number 1644 (measured). +func TestTriggerNamedConditionSignal(t *testing.T) { + s := loadTriggerViolations(t) + r, err := Analyze(s, "UPDATE orders SET total = $1 WHERE id = $2") + if err != nil { + t.Fatal(err) + } + want := "1048 orders.total, 1644 45000" + if got := violationKeys(r.Violations); got != want { + t.Errorf("got %s\nwant %s", got, want) + } +} + +// TestTriggerOwnTableWriteIs1442: a trigger whose own body writes its own table cannot be +// analyzed as a Definition (the server always refuses it, measured), so AnalyzeTrigger +// returns the 1442 Error rather than a BodyResult. +func TestTriggerOwnTableWriteIs1442(t *testing.T) { + s := loadTriggerViolations(t) + tg := s.Trigger("orders_own_write") + if tg == nil { + t.Fatal("no such trigger") + } + _, err := AnalyzeTrigger(s, tg) + ae, ok := err.(*Error) + if !ok || ae.Code != 1442 { + t.Fatalf("got %v, want a 1442 Error", err) + } +} + +// TestSelectIntoCardinality: a SELECT ... INTO not provably single-row is 1172; a LIMIT 1 +// (or another x/cardinality proof) rules it out. +func TestSelectIntoCardinality(t *testing.T) { + s := loadTriggerViolations(t) + br, err := AnalyzeRoutine(s, s.RoutineOf(schema.Procedure, "find_total")) + if err != nil { + t.Fatal(err) + } + if got := violationKeys(br.Violations); got != "1172 1172" { + t.Errorf("find_total (no proof of at most one row): got %s, want 1172 1172", got) + } + br, err = AnalyzeRoutine(s, s.RoutineOf(schema.Procedure, "find_total_by_note")) + if err != nil { + t.Fatal(err) + } + if got := violationKeys(br.Violations); got != "" { + t.Errorf("find_total_by_note (LIMIT 1): got %s, want no violations", got) + } + // an aggregate without GROUP BY is one row even wrapped in a function: SUM over no + // rows is one NULL row, so COALESCE has something to read and INTO is never NOT FOUND + br, err = AnalyzeRoutine(s, s.RoutineOf(schema.Procedure, "sum_total")) + if err != nil { + t.Fatal(err) + } + if got := violationKeys(br.Violations); got != "" { + t.Errorf("sum_total (COALESCE(SUM(...)) without GROUP BY): got %s, want no violations", got) + } +} + +// TestHandlerAbsorbsByNumber: a CONTINUE HANDLER FOR 1062 in a routine absorbs the +// duplicate-key violation its own embedded INSERT may raise, but its own SIGNAL (which +// runs when the handler fires) is not absorbed by anything and stays. +func TestHandlerAbsorbsByNumber(t *testing.T) { + s := loadTriggerViolations(t) + br, err := AnalyzeRoutine(s, s.RoutineOf(schema.Procedure, "insert_absorbed")) + if err != nil { + t.Fatal(err) + } + want := "1048 orders.total, 1364 orders.customer_id, 30001 30001=OrderTooLarge, 30003 30003" + if got := violationKeys(br.Violations); got != want { + t.Errorf("got %s\nwant %s", got, want) + } +} + +// TestResignalReraises: a bare RESIGNAL inside a HANDLER FOR SQLEXCEPTION re-raises the +// exact SIGNAL it caught (measured: the server reports the same number). +func TestResignalReraises(t *testing.T) { + s := loadTriggerViolations(t) + br, err := AnalyzeRoutine(s, s.RoutineOf(schema.Procedure, "reraises")) + if err != nil { + t.Fatal(err) + } + want := "30002 30002" + if got := violationKeys(br.Violations); got != want { + t.Errorf("got %s\nwant %s", got, want) + } +} diff --git a/check/mysql/internal/analyze/types.go b/check/mysql/internal/analyze/types.go new file mode 100644 index 0000000..4f54d58 --- /dev/null +++ b/check/mysql/internal/analyze/types.go @@ -0,0 +1,680 @@ +package analyze + +import ( + "slices" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/catalog" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" +) + +// The type rules, as the server states them. A value has a data type (enum_field_types, +// which schema.Type names the SQL way) and a result kind (Item_result: INT, DECIMAL, REAL +// or STRING), and the rules compose on those: Item_num_op::set_numeric_type for +// arithmetic, Item_func_num1 for one-argument numeric functions, Item::aggregate_type over +// Field::field_type_merge for CASE / IF / COALESCE / GREATEST, and the Item class +// families of the catalog for everything the registry names. + +// typed is what the analyzer knows of an expression's value. +type typed struct { + typ schema.Type + known bool + nullable bool +} + +var unknown = typed{nullable: true} + +// known makes a typed of a plain type name. +func known(name string, nullable bool) typed { + return typed{typ: schema.Type{Name: name, Length: -1, Dec: -1}, known: true, nullable: nullable} +} + +// boolean is what a comparison or a logical operator returns: MySQL spells it as a +// bigint of display width 1. +func boolean(nullable bool) typed { + return typed{typ: schema.Type{Name: "bigint", Length: 1, Dec: -1}, known: true, nullable: nullable} +} + +// fieldType is the enum_field_types name (without MYSQL_TYPE_) of a schema type. +func fieldType(t schema.Type) string { + switch t.Name { + case "tinyint": + return "TINY" + case "smallint": + return "SHORT" + case "mediumint": + return "INT24" + case "int": + return "LONG" + case "bigint": + return "LONGLONG" + case "decimal": + return "NEWDECIMAL" + case "float": + return "FLOAT" + case "double": + return "DOUBLE" + case "bit": + return "BIT" + case "year": + return "YEAR" + case "date": + return "DATE" + case "time": + return "TIME" + case "datetime": + return "DATETIME" + case "timestamp": + return "TIMESTAMP" + case "char", "binary": + return "STRING" + case "varchar", "varbinary": + return "VARCHAR" + case "tinytext", "tinyblob": + return "TINY_BLOB" + case "text", "blob": + return "BLOB" + case "mediumtext", "mediumblob": + return "MEDIUM_BLOB" + case "longtext", "longblob": + return "LONG_BLOB" + case "enum": + return "ENUM" + case "set": + return "SET" + case "json": + return "JSON" + case "null": + return "NULL" + case "geometry", "point", "linestring", "polygon", "multipoint", "multilinestring", "multipolygon", "geometrycollection", "geomcollection": + return "GEOMETRY" + } + return "" +} + +// fromFieldType is the schema type of an enum_field_types name. binary says whether a +// string result is a binary string (BLOB rather than TEXT, VARBINARY rather than VARCHAR). +func fromFieldType(ft string, binary bool) (schema.Type, bool) { + t := schema.Type{Length: -1, Dec: -1} + switch ft { + case "TINY": + t.Name = "tinyint" + case "SHORT": + t.Name = "smallint" + case "INT24": + t.Name = "mediumint" + case "LONG": + t.Name = "int" + case "LONGLONG": + t.Name = "bigint" + case "DECIMAL", "NEWDECIMAL": + t.Name = "decimal" + case "FLOAT": + t.Name = "float" + case "DOUBLE": + t.Name = "double" + case "BIT": + t.Name = "bit" + case "YEAR": + t.Name = "year" + case "DATE", "NEWDATE": + t.Name = "date" + case "TIME", "TIME2": + t.Name = "time" + case "DATETIME", "DATETIME2": + t.Name = "datetime" + case "TIMESTAMP", "TIMESTAMP2": + t.Name = "timestamp" + case "STRING": + t.Name = "char" + if binary { + t.Name = "binary" + } + case "VARCHAR", "VAR_STRING": + t.Name = "varchar" + if binary { + t.Name = "varbinary" + } + case "TINY_BLOB": + t.Name = "tinytext" + if binary { + t.Name = "tinyblob" + } + case "BLOB": + t.Name = "text" + if binary { + t.Name = "blob" + } + case "MEDIUM_BLOB": + t.Name = "mediumtext" + if binary { + t.Name = "mediumblob" + } + case "LONG_BLOB": + t.Name = "longtext" + if binary { + t.Name = "longblob" + } + case "ENUM": + t.Name = "enum" + case "SET": + t.Name = "set" + case "JSON": + t.Name = "json" + case "NULL": + t.Name = "null" + case "GEOMETRY": + t.Name = "geometry" + default: + return t, false + } + return t, true +} + +// isBinary reports a binary string type. +func isBinary(t schema.Type) bool { + switch t.Name { + case "binary", "varbinary", "tinyblob", "blob", "mediumblob", "longblob": + return true + } + return t.Charset == "binary" || t.Binary +} + +// kindOf is the Item_result of a type: "INT_RESULT", "DECIMAL_RESULT", "REAL_RESULT" or +// "STRING_RESULT" (Field::result_merge_type). +func kindOf(t schema.Type) string { + return catalog.ResultKind(fieldType(t)) +} + +// tmpTableInt is what an integer becomes when a window function's value goes through the +// window's temporary table (sql_tmp_table.cc's create_tmp_field_from_item, which an +// Item::SUM_FUNC_ITEM with m_is_window_function reaches, unlike a plain aggregate whose +// Item_sum_hybrid::create_tmp_field keeps the column's own type): an INT_RESULT of display +// width MY_INT32_NUM_DECIMAL_DIGITS - 1 (10) or more is a Field_longlong, a narrower one a +// Field_long -- so INT and BIGINT come back as BIGINT, TINYINT / SMALLINT / MEDIUMINT as +// INT, the signedness kept, and a YEAR (Field_year is unsigned) as INT UNSIGNED (measured +// on mysqld 8.4: TestOracle, the corpus probe's window_min_max file). The other result +// kinds keep their type (a DECIMAL its precision, a string its length; a FLOAT stays a +// FLOAT). A ROLLUP group column's Item_rollup_group_item wrapper takes the same path only +// when the plan materializes the grouping (a join without an index, DISTINCT): the plan's +// decision, not the statement's, so it is not predicted (measured: grouping on a key keeps +// the column's type). +func tmpTableInt(t schema.Type) schema.Type { + width := t.Length + switch t.Name { + case "tinyint", "smallint", "mediumint", "int", "bigint", "year": + if width <= 0 { + switch t.Name { + case "tinyint", "year": + width = 4 + case "smallint": + width = 6 + case "mediumint": + width = 9 + case "int": + width = 11 + case "bigint": + width = 20 + } + if t.Unsigned { + width-- + } + } + default: + return t + } + name := "int" + if width >= 10 { + name = "bigint" + } + return schema.Type{Name: name, Length: -1, Dec: -1, Unsigned: t.Unsigned || t.Name == "year"} +} + +// isTemporal reports the temporal types (is_temporal_type). +func isTemporal(t schema.Type) bool { + switch t.Name { + case "date", "time", "datetime", "timestamp": + return true + } + return false +} + +// numericContext is Item::numeric_context_result_type: in arithmetic a temporal value +// counts as an integer (a decimal when it has fractional seconds) and a string as a real. +func numericContext(t typed) string { + if !t.known { + return "REAL_RESULT" + } + if isTemporal(t.typ) { + if t.typ.Dec > 0 { + return "DECIMAL_RESULT" + } + return "INT_RESULT" + } + k := kindOf(t.typ) + if k == "STRING_RESULT" || k == "" { + return "REAL_RESULT" + } + return k +} + +// numOp is Item_num_op::set_numeric_type: a real operand makes a double, else a decimal +// operand makes a decimal, else the result is a bigint. Integer results keep +// unsigned_flag when either operand is unsigned (result_precision); decimals keep it when +// both are. mod is Item_func_mod: unsigned follows the first operand. +func numOp(a, b typed, mod bool) typed { + ra, rb := numericContext(a), numericContext(b) + var out typed + switch { + case ra == "REAL_RESULT" || rb == "REAL_RESULT": + out = known("double", false) + case ra == "DECIMAL_RESULT" || rb == "DECIMAL_RESULT": + out = known("decimal", false) + out.typ.Unsigned = a.typ.Unsigned && b.typ.Unsigned + default: + out = known("bigint", false) + out.typ.Unsigned = unsignedOperand(a.typ) || unsignedOperand(b.typ) + } + if mod { + out.typ.Unsigned = unsignedOperand(a.typ) + } + out.known = a.known && b.known + out.nullable = a.nullable || b.nullable + return out +} + +// num1 is Item_func_num1::set_numeric_type: the numeric type of a one-argument numeric +// function follows its argument's result kind. intVal is Item_func_int_val (FLOOR, +// CEILING), which turns a decimal into a bigint when its integer digits fit one, else a +// decimal of scale 0. +func num1(a typed, intVal bool) typed { + if !a.known { + return unknown + } + var out typed + switch kindOf(a.typ) { + case "INT_RESULT": + out = known("bigint", a.nullable) + out.typ.Unsigned = unsignedOperand(a.typ) + case "DECIMAL_RESULT": + out = known("decimal", a.nullable) + out.typ.Unsigned = a.typ.Unsigned + if intVal { + precision := a.typ.Length - max(a.typ.Dec, 0) + if a.typ.Dec != 0 { + precision++ + } + if a.typ.Length < 0 || precision+1 < 20 { // max_length (with the sign) < DECIMAL_LONGLONG_DIGITS - 2 + out = known("bigint", a.nullable) + } else { + out.typ.Dec = 0 + out.typ.Length = precision + } + } + default: // STRING_RESULT, REAL_RESULT + out = known("double", a.nullable) + } + return out +} + +// aggregate is Item::aggregate_type: the type a CASE / IF / COALESCE / GREATEST returns +// over its branches. NULL-typed items do not count; the rest merge pairwise through +// field_type_merge, and integers of mixed signedness widen one step. The result is +// nullable when any item is; callers with their own rule (COALESCE, CASE) override it. +func aggregate(items []typed) typed { + var first *typed + for i := range items { + if !items[i].known { + return unknown + } + if items[i].typ.Name != "null" { + first = &items[i] + break + } + } + if first == nil { + if len(items) == 0 { + return unknown + } + return known("null", true) + } + ft := fieldType(first.typ) + if ft == "" { + return unknown + } + unsigned := first.typ.Unsigned + mixed := false + binary := isBinary(first.typ) + nullable := false + for _, it := range items { + nullable = nullable || it.nullable + if it.typ.Name == "null" { + continue + } + other := fieldType(it.typ) + if other == "" { + return unknown + } + ft = catalog.Merge(ft, other) + if ft == "" || ft == "INVALID" { + return unknown + } + mixed = mixed || unsigned != it.typ.Unsigned + binary = binary && isBinary(it.typ) + } + if mixed && isIntegerFieldType(ft) { + bump := false + for _, it := range items { + bump = bump || it.typ.Unsigned && (fieldType(it.typ) == ft || fieldType(it.typ) == "BIT") + } + unsigned = false + if bump { + switch ft { + case "TINY": + ft = "SHORT" + case "SHORT": + ft = "INT24" + case "INT24": + ft = "LONG" + case "LONG": + ft = "LONGLONG" + case "LONGLONG": + ft = "NEWDECIMAL" + } + } + } + t, ok := fromFieldType(ft, binary) + if !ok { + return unknown + } + t.Unsigned = unsigned && isIntegerFieldType(ft) + // an ENUM / SET aggregates to itself only against NULL; MySQL keeps the typelib then + if (ft == "ENUM" || ft == "SET") && len(first.typ.Values) > 0 { + t.Values = first.typ.Values + } + return typed{typ: t, known: true, nullable: nullable} +} + +func isIntegerFieldType(ft string) bool { + switch ft { + case "TINY", "SHORT", "INT24", "LONG", "LONGLONG": + return true + } + return false +} + +// facts gathers the resolve_type facts that apply to class: the class's own, after those +// of the base whose resolve_type it calls first (InheritsResolve); a class without a +// resolve_type of its own is judged by the nearest base that has one. +func classFacts(class string) []string { + seen := map[string]bool{} + cur := class + for cur != "" && !seen[cur] { + seen[cur] = true + it, ok := catalog.Items[cur] + if !ok { + return nil + } + if len(it.Facts) > 0 || it.InheritsResolve != "" { + var out []string + if it.InheritsResolve != "" { + out = append(out, classFacts(it.InheritsResolve)...) + } + return append(out, it.Facts...) + } + cur = it.Base + } + return nil +} + +// strictNullable is whether Item_str_func::fix_fields runs for class: in strict mode (the +// default sql_mode) it makes every string function nullable. It runs unless a class on the +// way up overrides fix_fields without calling it (MAKE_SET, ST_GEOHASH). +func strictNullable(class string) bool { + seen := map[string]bool{} + for cur := class; cur != "" && !seen[cur]; { + if cur == "Item_str_func" { + return true + } + seen[cur] = true + it, ok := catalog.Items[cur] + if !ok { + return false + } + switch it.FixFields { + case "": + cur = it.Base + case "own": + return false + default: + cur = it.FixFields // the override calls this base's fix_fields: follow it + } + } + return false +} + +// factBinary reads a binary result collation from the facts. +func factBinary(fs []string) bool { + for _, f := range fs { + if strings.Contains(f, "my_charset_bin") { + return true + } + } + return false +} + +// isA reports whether class is, or derives from, base. +func isA(class, base string) bool { + seen := map[string]bool{} + for cur := class; cur != "" && !seen[cur]; { + if cur == base { + return true + } + seen[cur] = true + it, ok := catalog.Items[cur] + if !ok { + return false + } + cur = it.Base + } + return false +} + +// factType is the type the `set_data_type_*` facts fix: the last one, since a class's +// resolve_type runs after the base's it calls; "" when they leave it to the arguments. +func factType(fs []string) string { + found := "" + for _, f := range fs { + name := "" + switch { + case strings.HasPrefix(f, "set_data_type_string("), strings.HasPrefix(f, "set_data_type_char("): + name = "VARCHAR" + case strings.HasPrefix(f, "set_data_type_blob("): + name = "BLOB" + case strings.HasPrefix(f, "set_data_type_longlong("): + name = "LONGLONG" + case strings.HasPrefix(f, "set_data_type_double("): + name = "DOUBLE" + case strings.HasPrefix(f, "set_data_type_float("): + name = "FLOAT" + case strings.HasPrefix(f, "set_data_type_decimal("): + name = "NEWDECIMAL" + case strings.HasPrefix(f, "set_data_type_datetime("): + name = "DATETIME" + case strings.HasPrefix(f, "set_data_type_date("): + name = "DATE" + case strings.HasPrefix(f, "set_data_type_time("): + name = "TIME" + case strings.HasPrefix(f, "set_data_type_timestamp("): + name = "TIMESTAMP" + case strings.HasPrefix(f, "set_data_type_year("): + name = "YEAR" + case strings.HasPrefix(f, "set_data_type_json("): + name = "JSON" + case strings.HasPrefix(f, "set_data_type_geometry("): + name = "GEOMETRY" + case strings.HasPrefix(f, "set_data_type_bit("): + name = "BIT" + case strings.HasPrefix(f, "set_data_type(MYSQL_TYPE_"): + name = strings.TrimSuffix(strings.TrimPrefix(f, "set_data_type(MYSQL_TYPE_"), ")") + case strings.HasPrefix(f, "set_data_type_from_item("), strings.HasPrefix(f, "set_data_type("): + found = "" // follows an argument or a computed type + continue + default: + continue + } + found = name + } + return found +} + +// explicitCharset reports a fact that gives a string result a character set of its own +// (default_charset(), the connection's, a named one): without one, Item_str_func's result +// is a binary string unless agg_arg_charsets_for_string_result takes the arguments'. +func explicitCharset(fs []string) bool { + for _, f := range fs { + if strings.Contains(f, "my_charset_bin") { + continue + } + if strings.Contains(f, "charset") || strings.Contains(f, "collation") || strings.Contains(f, "agg_arg_charsets") { + return true + } + } + return false +} + +// aggregatesCharset reports agg_arg_charsets_for_string_result / _for_comparison: the +// result's character set is the arguments'. +func aggregatesCharset(fs []string) bool { + for _, f := range fs { + if strings.HasPrefix(f, "agg_arg_charsets") { + return true + } + } + return false +} + +// fixNullable reports whether the nearest fix_fields override on the way up sets the +// nullability itself, in which case it follows the arguments whatever resolve_type said. +// The family roots (Item_str_func, Item_geometry_func, Item_sum ...) do not count: what +// their fix_fields does is modelled as the family's rule. +func fixNullable(class string) bool { + seen := map[string]bool{} + for cur := class; cur != "" && !seen[cur]; { + seen[cur] = true + it, ok := catalog.Items[cur] + if !ok || familyRoots()[cur] { + return false + } + if it.FixFields != "" { + return it.FixNullable + } + cur = it.Base + } + return false +} + +// factNullable reads set_nullable(true) / set_nullable(false); ok is false when the facts +// say nothing unconditional (the default is then: nullable when any argument is). +func factNullable(fs []string) (nullable, ok bool) { + for _, f := range fs { + switch f { + case "set_nullable(true)": + nullable, ok = true, true + case "set_nullable(false)": + nullable, ok = false, true + } + } + return +} + +// factUnsigned reads unsigned_flag=true. +func factUnsigned(fs []string) bool { + return slices.Contains(fs, "unsigned_flag=true") +} + +// paramDefault is one param_type_is_default(thd, from, to[, MYSQL_TYPE_X]) fact: the +// placeholders among args[from:to) take type X (VARCHAR when none is given); to = -1 is +// the end. ok is false for a fact that is not of that form. +func paramDefault(f string) (from, to int, ft string, ok bool) { + if !strings.HasPrefix(f, "param_type_is_default(") { + return + } + inner := strings.TrimSuffix(strings.TrimPrefix(f, "param_type_is_default("), ")") + parts := strings.Split(inner, ",") + if len(parts) < 3 { + return + } + from, ok1 := atoi(strings.TrimSpace(parts[1])) + to, ok2 := atoi(strings.TrimSpace(parts[2])) + if !ok1 || !ok2 { + return + } + ft = "VARCHAR" + if len(parts) > 3 { + ft = strings.TrimPrefix(strings.TrimSpace(parts[3]), "MYSQL_TYPE_") + if _, known := fromFieldType(ft, false); !known { + return 0, 0, "", false // a computed type (assumed_type, m_datetime ? ... : ...) + } + } + return from, to, ft, true +} + +// paramNonParam reads param_type_uses_non_param(thd[, MYSQL_TYPE_X]): placeholders take +// the type of the other arguments (or X). ok is false when the fact is absent. +func paramNonParam(fs []string) (ft string, ok bool) { + for _, f := range fs { + if !strings.HasPrefix(f, "param_type_uses_non_param(") { + continue + } + inner := strings.TrimSuffix(strings.TrimPrefix(f, "param_type_uses_non_param("), ")") + parts := strings.Split(inner, ",") + if len(parts) > 1 { + return strings.TrimPrefix(strings.TrimSpace(parts[1]), "MYSQL_TYPE_"), true + } + return "", true + } + return "", false +} + +func atoi(s string) (int, bool) { + n, neg := 0, false + if strings.HasPrefix(s, "-") { + neg, s = true, s[1:] + } + if s == "" { + return 0, false + } + for _, c := range s { + if c < '0' || c > '9' { + return 0, false + } + n = n*10 + int(c-'0') + } + if neg { + n = -n + } + return n, true +} + +var roots map[string]bool + +// familyRoots are the classes that name a family: what their own resolve_type and +// fix_fields do is the family's rule, not a class's override. +func familyRoots() map[string]bool { + if roots == nil { + roots = map[string]bool{"Item_func": true} + for _, it := range catalog.Items { + if it.Family != "" { + roots[it.Family] = true + } + } + } + return roots +} + +// unsignedOperand: the operand counts as unsigned in arithmetic -- declared so, or a BIT +// (Field_bit::unsigned_flag is set). +func unsignedOperand(t schema.Type) bool { + return t.Unsigned || t.Name == "bit" +} diff --git a/check/mysql/internal/analyze/viewwrite_server_test.go b/check/mysql/internal/analyze/viewwrite_server_test.go new file mode 100644 index 0000000..1a63f85 --- /dev/null +++ b/check/mysql/internal/analyze/viewwrite_server_test.go @@ -0,0 +1,262 @@ +package analyze + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +const viewWriteSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE t (a INT PRIMARY KEY, b INT NOT NULL DEFAULT 5, c INT NOT NULL, d INT); +CREATE TABLE u (x INT PRIMARY KEY, y INT); +CREATE VIEW v_all AS SELECT a, b, c, d FROM t; +CREATE VIEW v_missing AS SELECT a, b, d FROM t; +CREATE VIEW v_expr AS SELECT a, a+1 AS a1, c, d FROM t; +CREATE VIEW v_ren AS SELECT a AS aa, c AS cc, d AS dd FROM t; +CREATE VIEW v_where AS SELECT a, b, c, d FROM t WHERE a > 10 WITH CHECK OPTION; +CREATE VIEW v_join AS SELECT t.a ta, t.c tc, t.d td, u.x ux, u.y uy FROM t JOIN u ON u.x = t.a; +CREATE VIEW v_agg AS SELECT a, COUNT(*) n FROM t GROUP BY a; +CREATE ALGORITHM=TEMPTABLE VIEW v_temp AS SELECT a, b, c, d FROM t; +CREATE VIEW v_nest AS SELECT a, b, c, d FROM v_all; +CREATE VIEW v_dist AS SELECT DISTINCT a, b, c, d FROM t; +CREATE VIEW v_lim AS SELECT a, b, c, d FROM t LIMIT 10; +CREATE VIEW v_dual AS SELECT 1 AS k; +CREATE VIEW v_joind AS SELECT t.a ta, t.c tc, u.x ux, t.a+1 e FROM t JOIN u ON u.x = t.a; +CREATE TABLE s (b INT, c INT NOT NULL, name VARCHAR(10) NOT NULL DEFAULT 'x'); +CREATE VIEW v_collate AS SELECT b, c, name COLLATE utf8mb4_bin AS name FROM s; +CREATE VIEW v_dup AS SELECT a, a AS a2, c FROM t; +CREATE VIEW v_expronly AS SELECT a+1 AS a1 FROM t; +CREATE VIEW v_overtemp AS SELECT a, b, c, d FROM v_temp; +CREATE ALGORITHM=TEMPTABLE VIEW v_temp2 AS SELECT a AS a2, c AS b2 FROM t; +CREATE VIEW v_matjoin AS SELECT t.a ta, t.c tc, dt.a2, dt.b2 FROM t JOIN v_temp2 AS dt ON t.a = dt.a2; +CREATE VIEW v_outer AS SELECT t.a ta, t.c tc, u.y uy FROM t LEFT JOIN u ON u.x = t.a; +CREATE VIEW v_chain AS SELECT a, b, c, d FROM v_where; +CREATE VIEW v_selfread AS SELECT a, c FROM t WHERE a < (SELECT MAX(x) FROM u); +CREATE VIEW v_rereads AS SELECT a, c FROM t WHERE a < (SELECT MAX(d) FROM t); +CREATE VIEW v_overjoin AS SELECT ta, tc FROM v_join; +` + +// TestViewWriteServer pins how a write through a view is judged, against mysqld: the +// statement's own errors (1471 not insertable, 1393 / 1394 / 1395 join view, 1348 a +// derived column, 1288 not updatable, 1054 / 1136 resolved against the view), the +// violations a running one may hit (the base table's own 1062, the view's 1369 and 1423), +// and the shapes that must go through clean. +func TestViewWriteServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, viewWriteSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + s, err := schema.Load(viewWriteSchema) + if err != nil { + t.Fatal(err) + } + conn := db.Conn() + for _, seed := range []string{ + "INSERT INTO t (a, c) VALUES (100, 1), (11, 1)", + "INSERT INTO u (x, y) VALUES (100, 2)", + } { + if _, err := conn.ExecContext(ctx, seed); err != nil { + t.Fatalf("%s: %v", seed, err) + } + } + cases := []struct { + sql string + // stmt: the statement's own error number, raised by the checker and the server + // alike; 0 when the statement analyzes + stmt int + // want: " " the server raises and the checker must have predicted; + // "" when the server must accept the write + want string + }{ + // a single-table view: the write lands on the base table + {"INSERT INTO v_all (a, c) VALUES (200, 1)", 0, ""}, + {"INSERT INTO v_all VALUES (201, 2, 3, 4)", 0, ""}, + {"INSERT INTO v_all (a, c) VALUES (100, 9)", 0, "1062 PRIMARY"}, + {"INSERT INTO v_ren (aa, cc) VALUES (204, 1)", 0, ""}, + {"INSERT INTO v_nest (a, c) VALUES (205, 1)", 0, ""}, + {"INSERT INTO v_all (a, c) VALUES (100, 1) ON DUPLICATE KEY UPDATE d = 7", 0, ""}, + {"REPLACE INTO v_all (a, c) VALUES (100, 8)", 0, ""}, + // a base column with no default the write leaves out: the view's 1423, exposed or not + {"INSERT INTO v_missing (a) VALUES (202)", 0, "1423 v_missing"}, + {"INSERT INTO v_missing (a) SELECT 203", 0, "1423 v_missing"}, + {"INSERT INTO v_missing (d) VALUES (1)", 0, "1423 v_missing"}, + {"INSERT INTO v_where (c) VALUES (1)", 0, "1423 v_where"}, + {"INSERT IGNORE INTO v_missing (a) VALUES (221)", 0, ""}, + // WITH CHECK OPTION: the view's 1369, INSERT and REPLACE alike + {"INSERT INTO v_where (a, c) VALUES (1, 1)", 0, "1369 v_where"}, + {"INSERT INTO v_where (a, c) VALUES (215, 1)", 0, ""}, + {"REPLACE INTO v_where (a, c) VALUES (2, 1)", 0, "1369 v_where"}, + // a derived column anywhere makes the view non-insertable; a listed one is its own 1348 + {"INSERT INTO v_expr (a, c) VALUES (205, 1)", 1471, ""}, + {"INSERT INTO v_expr (a, c) SELECT 206, 1", 1471, ""}, + {"INSERT INTO v_expr (a1) VALUES (1)", 1348, ""}, + {"INSERT INTO v_joind (ta, tc) VALUES (260, 1)", 1471, ""}, + {"REPLACE INTO v_expr (a, c) VALUES (240, 1)", 1471, ""}, + // not merged: never insertable + {"INSERT INTO v_agg (a) VALUES (210)", 1471, ""}, + {"INSERT INTO v_temp (a, c) VALUES (211, 1)", 1471, ""}, + {"INSERT INTO v_dist (a, c) VALUES (213, 1)", 1471, ""}, + {"INSERT INTO v_lim (a, c) VALUES (214, 1)", 1471, ""}, + {"INSERT INTO v_dual (k) VALUES (1)", 1471, ""}, + // a join view: one base table's columns, under an explicit list + {"INSERT INTO v_join (ta, tc) VALUES (207, 1)", 0, ""}, + {"INSERT INTO v_join (ta, ux) VALUES (208, 1)", 1393, ""}, + {"INSERT INTO v_join VALUES (209, 1, 2, 3, 4)", 1394, ""}, + {"REPLACE INTO v_join (ta, tc) VALUES (220, 1)", 1395, ""}, + {"INSERT INTO v_join (ta, tc) VALUES (241, 1) ON DUPLICATE KEY UPDATE td = 1", 0, ""}, + {"INSERT INTO v_join (ta, tc) VALUES (242, 1) ON DUPLICATE KEY UPDATE uy = 1", 1393, ""}, + // the list resolves against the view + {"INSERT INTO v_all (nope) VALUES (1)", 1054, ""}, + {"INSERT INTO v_missing (a, b) VALUES (231, 2, 3)", 1136, ""}, + // DELETE: a merged single-table view deletes from its base (derived columns do not + // block it); a join view is 1395, anything not merged 1288 + {"DELETE FROM v_all WHERE a = 11", 0, ""}, + {"DELETE FROM v_where WHERE a = 215", 0, ""}, + {"DELETE FROM v_expr WHERE a = 0", 0, ""}, + {"DELETE FROM v_nest WHERE a = 201", 0, ""}, + {"DELETE FROM v_join WHERE ta = 1", 1395, ""}, + {"DELETE v_join FROM v_join JOIN u ON u.x = v_join.ta", 1395, ""}, + {"DELETE FROM v_agg WHERE a = 1", 1288, ""}, + {"DELETE FROM v_temp WHERE a = 1", 1288, ""}, + {"DELETE FROM v_dist WHERE a = 1", 1288, ""}, + {"DELETE FROM v_lim WHERE a = 1", 1288, ""}, + {"DELETE v_all FROM v_all JOIN u ON u.x = v_all.a", 0, ""}, + {"DELETE FROM v_all, u USING v_all JOIN u ON u.x = v_all.a", 0, ""}, + {"WITH cx AS (SELECT a FROM t) DELETE FROM cx", 1288, ""}, + {"WITH cx AS (SELECT a FROM t) UPDATE cx SET a = 1", 1288, ""}, + // UPDATE: a derived column is its own 1348, two base tables through a join view 1393 + {"UPDATE v_expr SET a1 = 1", 1348, ""}, + {"UPDATE v_expr SET a = 300 WHERE a = 0", 0, ""}, + {"UPDATE v_join SET td = 1, uy = 2 WHERE ta = 1", 1393, ""}, + {"UPDATE v_join SET td = 1 WHERE ta = 1", 0, ""}, + {"UPDATE v_agg SET a = 1", 1288, ""}, + // a COLLATE wrapper is transparent for view updating (field_for_view_update) + {"INSERT INTO v_collate (c, name) VALUES (1, 'a')", 0, ""}, + {"UPDATE v_collate SET name = 'b' WHERE c = 0", 0, ""}, + // the same base column behind two view columns: not insertable, still updatable + {"INSERT INTO v_dup (a, c) VALUES (300, 1)", 1471, ""}, + {"UPDATE v_dup SET a = 301 WHERE a = 0", 0, ""}, + // a view of only expressions still deletes and updates through (its leaf is + // updatable), and refuses INSERT + {"DELETE FROM v_expronly WHERE a1 = 0", 0, ""}, + {"INSERT INTO v_expronly (a1) VALUES (1)", 1348, ""}, + {"INSERT INTO v_expronly VALUES (1)", 1348, ""}, + // a view over a TEMPTABLE view: nothing under it is updatable + {"INSERT INTO v_overtemp (a, c) VALUES (310, 1)", 1471, ""}, + {"UPDATE v_overtemp SET a = 1", 1288, ""}, + {"DELETE FROM v_overtemp WHERE a = 1", 1288, ""}, + // a join with a materialized side: never insertable; assigning the materialized + // side's column is the view's 1288, the base side's fine + {"INSERT INTO v_matjoin (ta, tc) VALUES (320, 1)", 1471, ""}, + {"UPDATE v_matjoin SET a2 = 1", 1288, ""}, + {"UPDATE v_matjoin SET td_no = 1", 1054, ""}, + {"UPDATE v_matjoin SET tc = 9 WHERE ta = 0", 0, ""}, + {"DELETE FROM v_matjoin WHERE ta = 1", 1395, ""}, + // an outer join: neither updatable nor insertable + {"INSERT INTO v_outer (ta, tc) VALUES (330, 1)", 1471, ""}, + {"UPDATE v_outer SET tc = 1", 1288, ""}, + {"DELETE FROM v_outer WHERE ta = 1", 1288, ""}, + // a plain view over a WITH CHECK OPTION one: the chain still checks, naming the + // view written through + {"INSERT INTO v_chain (a, c) VALUES (3, 1)", 0, "1369 v_chain"}, + {"UPDATE v_chain SET a = 4 WHERE a = 215", 0, ""}, + // a view whose own body reads the target base table again in a subquery is not + // insertable-into; reading another table that way is fine + {"INSERT INTO v_selfread (a, c) VALUES (400, 1)", 0, ""}, + {"INSERT INTO v_rereads (a, c) VALUES (401, 1)", 1471, ""}, + // a single-leaf view over a join view flattens into its leaves + {"DELETE FROM v_overjoin WHERE ta = 1", 1395, ""}, + {"INSERT INTO v_overjoin (ta, tc) VALUES (410, 1)", 0, ""}, + // a subquery reading the very view the statement writes: the target itself (1093) + {"DELETE FROM v_all WHERE a = (SELECT MAX(a) FROM v_all)", 1093, ""}, + {"UPDATE v_all SET d = 1 WHERE a = (SELECT MAX(a) FROM v_all)", 1093, ""}, + } + for _, c := range cases { + r, aerr := Analyze(s, c.sql) + if c.stmt != 0 { + var e *Error + if !errors.As(aerr, &e) || e.Code != c.stmt { + t.Errorf("%s:\n checker %v\n want its own error %d", c.sql, aerr, c.stmt) + } + } else if aerr != nil { + t.Errorf("%s: checker: %v", c.sql, aerr) + } + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + t.Fatal(err) + } + _, serr := tx.ExecContext(ctx, c.sql) + tx.Rollback() + var me *driver.MySQLError + switch { + case c.stmt != 0: + if !errors.As(serr, &me) || int(me.Number) != c.stmt { + t.Errorf("%s:\n server %v\n want %d", c.sql, serr, c.stmt) + } + case c.want != "": + if !errors.As(serr, &me) { + t.Errorf("%s: server accepted, want %s", c.sql, c.want) + continue + } + got := fmt.Sprintf("%d %s", me.Number, keyOfMessage(int(me.Number), me.Message)) + if got != c.want { + t.Errorf("%s:\n server %s\n want %s", c.sql, got, c.want) + } + if r == nil { + continue + } + predicted := false + for _, v := range r.Violations { + predicted = predicted || fmt.Sprintf("%d %s", v.Code, v.Key()) == c.want + } + if !predicted { + var keys []string + for _, v := range r.Violations { + keys = append(keys, fmt.Sprintf("%d %s", v.Code, v.Key())) + } + t.Errorf("%s:\n predicted %v\n want %s", c.sql, keys, c.want) + } + default: + if serr != nil { + t.Errorf("%s: server: %v", c.sql, serr) + } + } + } +} + +// keyOfMessage reads the constraint key out of the server's message the way +// mysql.ConstraintError does, for the codes this test meets. +func keyOfMessage(number int, msg string) string { + switch number { + case 1062: + if m := reDuplicate.FindStringSubmatch(msg); m != nil { + return m[1] + } + case 1369, 1423: + if i := strings.Index(msg, "'"); i >= 0 { + name := strings.Trim(msg[i:], "'") + if j := strings.Index(name, "'"); j >= 0 { + name = name[:j] + } + if k := strings.Index(name, "."); k >= 0 { + name = name[k+1:] + } + return name + } + } + return "" +} diff --git a/check/mysql/internal/analyze/violations.go b/check/mysql/internal/analyze/violations.go new file mode 100644 index 0000000..b8d938b --- /dev/null +++ b/check/mysql/internal/analyze/violations.go @@ -0,0 +1,696 @@ +package analyze + +import ( + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlast" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/facts" +) + +// Failure modes: which constraints a statement can violate. +// +// The schema says how a write can fail: unique keys, foreign keys, CHECKs, NOT NULL. +// sqlshape lists them per statement so the caller's error handling can be checked against +// the schema instead of being guessed: the checker diffs this list with the +// `-- sqlshape: expect ...` line of the template, and the runtime maps the server's error +// number back to the constraint (mysql.ConstraintError). +// +// The list is "may violate", not "will": an INSERT can violate every unique key of the +// table (but not a key made only of an AUTO_INCREMENT column it leaves to the server), an +// UPDATE only those that include a SET column, a DELETE only the foreign keys that +// reference the table with RESTRICT / NO ACTION. A NOT NULL violation is listed only when +// the stored expression may be NULL; when that expression is a bare parameter the +// violation carries its number so the checker can drop it if the Go type cannot be NULL. +// INSERT / UPDATE / DELETE IGNORE turn every constraint error into a warning, so such a +// statement violates nothing; INSERT ... ON DUPLICATE KEY UPDATE absorbs the unique +// violations of the insert and may violate what its update assigns. + +// Violation is one constraint a statement may violate. +type Violation struct { + Code int // MySQL error number: 1062 duplicate key, 1452 foreign key (child), 1451 foreign key (parent row), 1048 NOT NULL, 3819 CHECK, 1442 a trigger writing its own table, 1172 SELECT INTO more than one row, 1644/1643/a custom MYSQL_ERRNO for a SIGNAL + Constraint string // the key's, FOREIGN KEY's or CHECK's name; "" for NOT NULL; for a SIGNAL, the key body.go computes (the MYSQL_ERRNO as text, or the SQLSTATE) + Table string // the table the constraint belongs to (the referencing table for a foreign key, or a trigger's own table for a SIGNAL) + Columns []string // constrained columns + RefTable string // foreign key: the referenced table + Param int // NOT NULL: the bare parameter whose NULL would violate (0 otherwise) + // Trigger / Name: a SIGNAL raised by a trigger's body (Trigger is the trigger's name); + // Name is the `-- sqlshape: error = ` annotation's name, "" when none was + // declared for this key. Neither is read by Key (see check/postgres/analyze/violation.go's + // own Key, which does not prefer Name over Constraint either -- Name only decorates the + // description body.go / dialect.go build). + Trigger string + Name string + // Function is the stored FUNCTION whose call site (through a SELECT/WHERE/... in the + // analyzed statement, or the routine a CALL's own PROCEDURE runs) is where this failure + // mode was found: the function's/procedure's own body raised it (a SIGNAL, or one of its + // embedded writes), and it propagates to whatever statement called it (m6). + Function string + // SQLState is the failure's SQLSTATE class, body.go's own bookkeeping for a DECLARE ... + // HANDLER's absorption (SQLWARNING is class "01", NOT FOUND is class "02", SQLEXCEPTION + // is anything else); "" outside a trigger's/routine's body walk, where nothing absorbs. + SQLState string +} + +const ( + codeDuplicateKey = 1062 + codeForeignKeyRow = 1452 // "Cannot add or update a child row" + codeForeignKeyRef = 1451 // "Cannot delete or update a parent row" + codeNotNull = 1048 + codeNullToNotNull = 1263 // "NULL supplied to NOT NULL column": a LOAD DATA field (SQLSTATE 22004) + codeCheckViolation = 3819 + code1442 = 1442 // a trigger writing its own table: always fails (measured) + code1172 = 1172 // SELECT ... INTO with more than one row + codeNoDefault = 1364 // "Field '...' doesn't have a default value" + codeViewCheck = 1369 // ER_VIEW_CHECK_FAILED: a write through a WITH CHECK OPTION view + codeViewNoDefault = 1423 // ER_NO_DEFAULT_FOR_VIEW_FIELD: 1364 through a view, naming the view + code1690 = 1690 // ER_DATA_OUT_OF_RANGE: a constant the server cannot compute, run per row (fold.go) + code1292 = 1292 // ER_TRUNCATED_WRONG_VALUE: a constant cast or conversion the value does not survive, in a strict write (fold.go) +) + +// Key identifies a violation the way the expect line and mysql.Violates spell it: the +// constraint's name, or table.column for NOT NULL. +func (v Violation) Key() string { + if v.Constraint != "" { + return v.Constraint + } + if len(v.Columns) == 1 { + return v.Table + "." + v.Columns[0] + } + return v.Table +} + +// violations enumerates the constraints the analyzed statement may violate: the schema's +// own (unique keys, foreign keys, CHECKs, NOT NULL -- absorbed by IGNORE) plus what the +// write's table's triggers may raise for this event (never absorbed by IGNORE: measured on +// mysqld, an INSERT IGNORE whose BEFORE INSERT trigger SIGNALs, or whose trigger's own +// embedded write collides on a constraint, still fails the whole statement). +func (a *analyzer) violations() []Violation { + var out []Violation + if w := a.write; w != nil && w.table != nil { + if !w.ignore { + switch w.kind { + case facts.Insert: + out = a.insertViolations(w) + out = append(out, a.checkOptionViolations(w.table)...) + case facts.Update: + out = a.updateViolations(w.table, w.values, nil, a.strictFor(w.table)) + out = append(out, a.checkOptionViolations(w.table)...) + for _, m := range w.more { + out = append(out, a.updateViolations(m.table, m.values, nil, a.strictFor(m.table))...) + out = append(out, a.checkOptionViolations(m.table)...) + } + case facts.Delete: + out = a.referencingViolations(w.table, nil, true, map[*schema.Table]bool{}) + for _, m := range w.more { + out = append(out, a.referencingViolations(m.table, nil, true, map[*schema.Table]bool{})...) + } + } + } + out = append(out, a.triggerFailureModes(w)...) + } + out = append(out, a.calledRoutineViolations()...) + out = append(out, a.storeRaised...) + return dedupe(out) +} + +// calledRoutineViolations lists what the FUNCTIONs a call site in this statement resolved +// (expr.go's call), and the PROCEDURE a CALL statement itself runs, may raise: each one's +// own body, analyzed once and cached (AnalyzeRoutine), already resolved to its own failure +// modes the same way a trigger's are (body.go). Tagged with Function so the description can +// say which one (dialect.go's describeViolation). +func (a *analyzer) calledRoutineViolations() []Violation { + var out []Violation + for _, cr := range a.calledRoutines { + br, err := AnalyzeRoutine(a.s, cr.r) + if br == nil { + // the callee's own body is certain to fail CREATE (a malformed SQLSTATE, a + // FUNCTION's own direct recursion, ...): that certain failure is this call + // site's own "may" (measured: TestAdv3ChainCallCascadeThroughAnotherTriggerSwallowed + // and TestAdv3ChainCallRecursionAnd1442BothSwallowed reach the same swallow here + // as triggerViolations' own, below), not nothing at all. + if v, ok := errorAsViolation(err); ok { + v.Function = cr.r.Name + out = append(out, v) + } + continue + } + for _, v := range br.Violations { + v.Function = cr.r.Name + out = append(out, v) + } + } + return out +} + +// errorAsViolation folds a chained trigger's or called routine's own certain CREATE-time +// failure (an *Error, from cachedAnalyzeTrigger/AnalyzeRoutine) into a Violation the firing +// statement itself may raise, instead of discarding it outright (measured: +// TestAdv3ChainCallCascadeThroughAnotherTriggerSwallowed -- AnalyzeTrigger(ca_a_bi), asked +// directly, already computes the correct 1442 that firing `INSERT INTO ca_a` always +// reaches, but triggerViolations/calledRoutineViolations threw it away). Not every analysis +// failure is an *Error (a construct the analyzer does not understand yet is a plain error, +// never seen here in practice): ok is false for those, unchanged from before. +func errorAsViolation(err error) (Violation, bool) { + ae, ok := err.(*Error) + if !ok { + return Violation{}, false + } + return Violation{Code: ae.Code, Constraint: itoa(ae.Code), SQLState: constraintSQLState(ae.Code)}, true +} + +// triggerFailureModes lists what the write's table's triggers may raise for this +// statement's event(s), through triggerViolations (which each trigger's own body, +// analyzed once and cached, has already resolved with its HANDLERs absorbed -- see +// body.go). REPLACE fires the INSERT event's triggers always and the DELETE event's when +// it displaces a row (measured: BI, then on a collision BD/AD before AI); ON DUPLICATE KEY +// UPDATE fires the INSERT event's triggers always and the UPDATE event's on a collision +// (measured: BI always, then BU/AU instead of AI on a collision) -- both "may", the way +// every other failure mode here is. +func (a *analyzer) triggerFailureModes(w *write) []Violation { + // inUse seeds the table-reuse chain (triggerViolations) with the tables this very + // statement already writes: a trigger reached further down the chain writing back into + // one of them is 1442 too, the same certain way a trigger writing its own table + // directly is (measured: a chain through a different table, back into one already in + // use, always fails -- see triggerViolations). + inUse := writeTableSet(w) + var out []Violation + switch w.kind { + case facts.Insert: + out = append(out, triggerViolations(a.s, w.table, "INSERT", inUse)...) + if w.replace { + out = append(out, triggerViolations(a.s, w.table, "DELETE", inUse)...) + } + if w.onDuplicate != nil { + out = append(out, triggerViolations(a.s, w.table, "UPDATE", inUse)...) + } + case facts.Update: + out = append(out, triggerViolations(a.s, w.table, "UPDATE", inUse)...) + for _, m := range w.more { + out = append(out, triggerViolations(a.s, m.table, "UPDATE", inUse)...) + } + case facts.Delete: + out = append(out, triggerViolations(a.s, w.table, "DELETE", inUse)...) + for _, m := range w.more { + out = append(out, triggerViolations(a.s, m.table, "DELETE", inUse)...) + } + } + return out +} + +// writeTableSet is w's own tables (lower-cased, w.table plus every w.more target): what +// triggerFailureModes seeds triggerViolations' own "already in use" set with. +func writeTableSet(w *write) map[string]bool { + set := map[string]bool{} + if w.table != nil { + set[strings.ToLower(w.table.Name)] = true + } + for _, m := range w.more { + if m.table != nil { + set[strings.ToLower(m.table.Name)] = true + } + } + return set +} + +// triggerViolations lists what t's triggers for event (INSERT/UPDATE/DELETE) may raise: each +// matching trigger's own body, analyzed once and cached (cachedAnalyzeTrigger), already +// resolved to its own failure modes (its SIGNALs, and what its own embedded writes may +// violate -- including their own triggers, recursively, a cycle broken by the cache's +// in-progress marker: see body.go). +// +// inUse is the set of tables (lower-cased) already in use by the statement that reaches +// this point: the firing statement's own write table(s) to begin with, grown by each +// trigger's own table and writes as the chain is walked further down. A trigger's own body +// writing a table already in this set is 1442 the same way a trigger writing its OWN table +// is (walkDML's ownTableWrite, body.go) -- not only when it is the very same table, but +// anywhere up the chain that reached it (measured: `INSERT INTO x` fires x_bi, which +// INSERTs into y, which fires y_bi, which INSERTs into x -- x is already in use by the very +// first INSERT, and mysqld always raises 1442 there, though neither trigger's own table is +// the one its own body writes). Each of a trigger's own BodyStatements already carries its +// write's table and DML kind (facts.Write), which is what this walks to find the next +// table/event to chain into, and what it compares against inUse. +func triggerViolations(s *schema.Schema, t *schema.Table, event string, inUse map[string]bool) []Violation { + if t == nil { + // defensive: every caller passes w.table or a moreTarget's table, both resolved + // (non-nil) *schema.Table values by the time a write is recorded. + return nil + } + var out []Violation + for _, tg := range s.Triggers { + if !strings.EqualFold(tg.Table, t.Name) || !strings.EqualFold(tg.Event, event) { + continue + } + br, err := cachedAnalyzeTrigger(s, tg) + if br == nil { + // tg's own body is certain to fail once fired here (a CALL cascading through + // another trigger back into a table already in use, a CALL that both recurses + // and would otherwise collide, ...): fold that failure into this firing + // statement's own Violations instead of discarding it (measured: + // TestAdv3ChainCallCascadeThroughAnotherTriggerSwallowed, + // TestAdv3ChainCallRecursionAnd1442BothSwallowed -- AnalyzeTrigger(tg), asked + // directly, already computes the right answer; this walk threw it away instead + // of reaching for the firing statement's own prediction). + if v, ok := errorAsViolation(err); ok { + v.Table, v.Trigger = tg.Table, tg.Name + out = append(out, v) + } + continue + } + out = append(out, br.Violations...) + chained := addTable(inUse, tg.Table) + for _, st := range br.Statements { + for _, w := range st.Facts.Writes { + lw := strings.ToLower(w.Table) + if chained[lw] { + out = append(out, Violation{ + Code: code1442, Constraint: itoa(code1442), + Table: tg.Table, Trigger: tg.Name, SQLState: constraintSQLState(code1442), + }) + continue + } + out = append(out, triggerViolations(s, s.Table(w.Table), w.Kind.String(), addTable(chained, w.Table))...) + } + // a locking read (SELECT ... FOR UPDATE / FOR SHARE / LOCK IN SHARE MODE) of a + // table already in the chain is 1442 too, the same certain way a further write + // back into it is (measured: TestAdv3ChainSelectForUpdateInsideTriggerIs1442 -- + // a plain SELECT or a subquery read does not collide, only a locking one; see + // walkSelect's own markLastLockedReads, body.go, for how st.LockedReads is + // populated). + for _, lr := range st.LockedReads { + if chained[strings.ToLower(lr)] { + out = append(out, Violation{ + Code: code1442, Constraint: itoa(code1442), + Table: tg.Table, Trigger: tg.Name, SQLState: constraintSQLState(code1442), + }) + } + } + } + } + return dedupe(out) +} + +// addTable copies set with name added (case-insensitively), leaving set itself untouched: a +// sibling branch of the trigger chain must not see another branch's own growth. +func addTable(set map[string]bool, name string) map[string]bool { + out := make(map[string]bool, len(set)+1) + for k := range set { + out[k] = true + } + out[strings.ToLower(name)] = true + return out +} + +// constraintSQLState is the SQLSTATE class a schema-constraint violation carries, for a +// DECLARE ... HANDLER FOR SQLEXCEPTION inside a trigger/routine body to absorb it the same +// way it absorbs a SIGNAL (see body.go's block): every one of these is a real error MySQL +// raises, never a warning or a NOT FOUND condition, so the exact digits do not matter to +// absorption, only that they are not "01" or "02". +func constraintSQLState(code int) string { + switch code { + case codeDuplicateKey, codeForeignKeyRow, codeForeignKeyRef, codeNotNull, codeCheckViolation: + return "23000" + case code1442, codeNoDefault: + return "HY000" + case code1172: + return "42000" + case codeNullToNotNull: + return "22004" + case code1690: + return "22003" + case code1292: + return "22007" + } + return "HY000" +} + +// strictFor reports whether a write into t is judged under strict mode: STRICT_ALL_TABLES +// is unconditional, but STRICT_TRANS_TABLES (without STRICT_ALL_TABLES) is strict only for +// a transactional storage engine -- on a nontransactional one (MyISAM et al.) a later row +// of a multi-row statement instead gets the column's implicit default with a warning, the +// same as with no strict mode at all (measured: TestAdv2StrictTransTablesIgnoresEngine). +func (a *analyzer) strictFor(t *schema.Table) bool { + mode := a.s.Settings.SQLMode + if mode.StrictAll() { + return true + } + if mode.StrictTransOnly() { + return transactionalEngine(t.Engine) + } + return false +} + +// transactionalEngine reports whether a storage engine is transactional. An unspecified +// ENGINE (schema.Table.Engine == "") defaults to InnoDB, the server's own default, which is +// transactional. +func transactionalEngine(engine string) bool { + switch strings.ToUpper(engine) { + case "", "INNODB", "NDB", "NDBCLUSTER": + return true + default: + return false + } +} + +func (a *analyzer) insertViolations(w *write) []Violation { + t := w.table + var out []Violation + for _, k := range t.Keys { + if k.Kind != schema.Primary && k.Kind != schema.Unique || w.onDuplicate != nil || w.replace { + continue // ON DUPLICATE KEY UPDATE and REPLACE absorb a colliding key + } + cols, ok := violableKeyColumns(k) + if !ok || systemGenerated(t, cols, w.inserted) || leftNull(t, cols, w.inserted) { + continue + } + out = append(out, Violation{Code: codeDuplicateKey, Constraint: k.Name, Table: t.Name, Columns: cols}) + } + for _, fk := range t.ForeignKeys { + if anyIn(fk.Columns, w.inserted) { + out = append(out, Violation{Code: codeForeignKeyRow, Constraint: t.ForeignKeyName(fk), Table: t.Name, Columns: fk.Columns, RefTable: fk.RefTable}) + } + } + for _, c := range t.Checks { + cols := exprColumns(c.Expr) + if c.Enforced && anyIn(expandGeneratedColumns(t, cols), w.inserted) { + out = append(out, Violation{Code: codeCheckViolation, Constraint: t.CheckName(c), Table: t.Name, Columns: cols}) + } + } + // a NULL for a NOT NULL column: an error in strict mode, and for a single-row INSERT + // (its ON DUPLICATE KEY UPDATE included) in any mode + notNull := a.strictFor(t) || w.rows == 1 + if notNull { + out = append(out, notNullViolations(t, w.values)...) + if !w.load { + omitted := omittedNotNullViolations(t, w.inserted) + if w.view != "" { + // through a view the omitted-column failure is the view's own 1423 ("Field + // of view ... underlying table doesn't have a default value", measured -- + // whether or not the view exposes the column), not the table's 1364 + for i := range omitted { + omitted[i].Code = codeViewNoDefault + omitted[i].Constraint = w.view + } + } + out = append(out, omitted...) + } + } + if w.onDuplicate != nil { + out = append(out, a.updateViolations(t, w.onDuplicate, nil, notNull)...) + } + if w.replace { + // the row a REPLACE displaces is deleted: the rows referring to it object + out = append(out, a.referencingViolations(t, nil, true, map[*schema.Table]bool{})...) + } + return dedupe(out) +} + +// systemGenerated: the key is made only of AUTO_INCREMENT columns the INSERT leaves to the +// server, which never collide. +func systemGenerated(t *schema.Table, cols []string, inserted map[string]bool) bool { + for _, name := range cols { + c := t.Column(name) + if c == nil || !c.AutoIncrement || inserted[name] { + return false + } + } + return true +} + +// leftNull: at least one column of the key is left to default to NULL (not inserted, +// nullable, no DEFAULT, not AUTO_INCREMENT). MySQL's multi-column UNIQUE key semantics take +// the whole tuple out of duplicate checking when ANY of its columns holds NULL, not only +// when every column does (measured: TestAdv2CompositeUniqueKeyLeftPartiallyNull). +func leftNull(t *schema.Table, cols []string, inserted map[string]bool) bool { + for _, name := range cols { + c := t.Column(name) + if c == nil { + continue + } + if !inserted[name] && !c.NotNull && c.Default == nil && !c.AutoIncrement { + return true + } + } + return false +} + +// updateViolations: the constraints an UPDATE (or the update of ON DUPLICATE KEY UPDATE) +// storing values into t may violate. +func (a *analyzer) updateViolations(t *schema.Table, values []assignment, skip map[string]bool, notNull bool) []Violation { + set := map[string]bool{} + for _, as := range values { + set[as.col.Name] = true + } + var out []Violation + for _, k := range t.Keys { + if k.Kind != schema.Primary && k.Kind != schema.Unique || skip[k.Name] { + continue + } + if cols, ok := violableKeyColumns(k); ok && anyIn(cols, set) { + out = append(out, Violation{Code: codeDuplicateKey, Constraint: k.Name, Table: t.Name, Columns: cols}) + } + } + for _, fk := range t.ForeignKeys { + if anyIn(fk.Columns, set) { + out = append(out, Violation{Code: codeForeignKeyRow, Constraint: t.ForeignKeyName(fk), Table: t.Name, Columns: fk.Columns, RefTable: fk.RefTable}) + } + } + for _, c := range t.Checks { + cols := exprColumns(c.Expr) + if c.Enforced && anyIn(expandGeneratedColumns(t, cols), set) { + out = append(out, Violation{Code: codeCheckViolation, Constraint: t.CheckName(c), Table: t.Name, Columns: cols}) + } + } + if notNull { + out = append(out, notNullViolations(t, values)...) + } + out = append(out, a.referencingViolations(t, set, false, map[*schema.Table]bool{})...) + return dedupe(out) +} + +// checkOptionViolations is the 1369 (ER_VIEW_CHECK_FAILED) an UPDATE reaching t through a +// WITH CHECK OPTION view may hit -- one entry per view the statement writes through +// directly (facts.go's checkOptionFacts records it there, alongside the pinned lift +// through the same view chain). MySQL's own message always names the view written +// through, whichever level's WHERE actually failed (measured: a CASCADED chain through an +// underlying view with no CHECK OPTION of its own still names the outer one), so unlike +// PostgreSQL's checkedViews there is nothing to walk here -- the view named in the +// statement is the only name that can appear. +func (a *analyzer) checkOptionViolations(t *schema.Table) []Violation { + var out []Violation + for _, name := range a.checkOptionViews[t] { + out = append(out, Violation{Code: codeViewCheck, Constraint: name, Table: name, Name: name}) + } + return out +} + +// notNullViolations: a NOT NULL column stored a value that may be NULL. +func notNullViolations(t *schema.Table, values []assignment) []Violation { + var out []Violation + seen := map[*schema.Column]bool{} + for _, as := range values { + // an AUTO_INCREMENT column given NULL takes the next number instead + if seen[as.col] || !as.col.NotNull || as.col.Generated != nil || as.col.AutoIncrement || !as.nullable { + continue + } + seen[as.col] = true + code := codeNotNull + if as.fromFile { + code = codeNullToNotNull + } + out = append(out, Violation{Code: code, Table: t.Name, Columns: []string{as.col.Name}, Param: as.param}) + } + return out +} + +// omittedNotNullViolations: a NOT NULL column with no DEFAULT, not AUTO_INCREMENT and not +// GENERATED, left entirely out of the INSERT's column list. mysqld raises 1364 "Field +// '...' doesn't have a default value" for this in strict mode (measured: +// TestAdv2OmittedNotNullColumnNoDefault) -- notNullViolations never sees it since it only +// walks the columns the statement actually assigns. +func omittedNotNullViolations(t *schema.Table, inserted map[string]bool) []Violation { + var out []Violation + for _, c := range t.Columns { + if inserted[c.Name] || !c.NotNull || c.Default != nil || c.AutoIncrement || c.Generated != nil { + continue + } + out = append(out, Violation{Code: codeNoDefault, Table: t.Name, Columns: []string{c.Name}}) + } + return out +} + +// referencingViolations: the foreign keys of other tables that reference t reject (or +// cascade) a DELETE of its rows, or an UPDATE of the columns they reference. +func (a *analyzer) referencingViolations(t *schema.Table, changed map[string]bool, del bool, visited map[*schema.Table]bool) []Violation { + if visited[t] { + return nil + } + visited[t] = true + var out []Violation + for _, other := range a.s.Tables { + for _, fk := range other.ForeignKeys { + if !strings.EqualFold(fk.RefTable, t.Name) { + continue + } + action := fk.OnUpdate + if del { + action = fk.OnDelete + } + if !del { + // RefColumns is never empty: the loader refuses a foreign key whose + // reference list is missing or mismatched, as the server does (1239) + if !anyIn(fk.RefColumns, changed) { + continue + } + } + switch strings.ToUpper(action) { + case "", "RESTRICT", "NO ACTION": // the change itself is rejected + out = append(out, Violation{Code: codeForeignKeyRef, Constraint: other.ForeignKeyName(fk), Table: other.Name, Columns: fk.Columns, RefTable: t.Name}) + case "CASCADE": // the referencing rows are deleted / updated the same way, cascading further + out = append(out, a.referencingViolations(other, colSet(fk.Columns), del, visited)...) + } + // SET NULL / SET DEFAULT: InnoDB refuses the declaration when the columns are NOT + // NULL, so the action itself cannot fail + } + } + return out +} + +// violableKeyColumns lists the columns whose values a key constrains, prefix parts +// (`UNIQUE (c(10))`, judged on the first characters) and expression parts (`UNIQUE +// ((col + 1))`, judged on the columns the expression reads) included: a write that assigns +// any of them may collide on the key (1062), unlike keyColumns' reading, where such a +// part is not something an equality on the column fixes. Found by the corpus probe +// (ctype_utf8's prefix keys), pinned by TestViolationsServer. +func violableKeyColumns(k *schema.Key) ([]string, bool) { + cols := make([]string, 0, len(k.Parts)) + seen := map[string]bool{} + add := func(c string) { + if c != "" && !seen[strings.ToLower(c)] { + seen[strings.ToLower(c)] = true + cols = append(cols, c) + } + } + for _, p := range k.Parts { + if p.Expr != nil { + for _, c := range exprColumns(p.Expr) { + add(c) + } + continue + } + add(p.Column) + } + return cols, len(cols) > 0 +} + +// exprColumns lists the column names an expression references (a CHECK's), in order of +// first appearance. +func exprColumns(v mysqlast.Value) []string { + var out []string + seen := map[string]bool{} + var walk func(v mysqlast.Value) + walk = func(v mysqlast.Value) { + switch x := v.(type) { + case *mysqlast.Node: + switch x.Class { + case "PTI_simple_ident_ident", "PTI_simple_ident_nospvar_ident": + if name := str(x.Arg("ident")); name != "" && !seen[strings.ToLower(name)] { + seen[strings.ToLower(name)] = true + out = append(out, name) + } + return + case "PTI_simple_ident_q_2d", "PTI_simple_ident_q_3d": + if name := str(x.Arg("field")); name != "" && !seen[strings.ToLower(name)] { + seen[strings.ToLower(name)] = true + out = append(out, name) + } + return + } + for _, arg := range x.Args { + walk(arg) + } + case mysqlast.List: + for _, e := range x { + walk(e) + } + case *mysqlast.Struct: + for _, k := range x.Order { + walk(x.Fields[k]) + } + } + } + walk(v) + return out +} + +// expandGeneratedColumns: cols is a CHECK's own column set (exprColumns of its expression); +// for each one that is itself a generated column, the source columns it is computed from +// are added too, since a generated column is never itself assigned -- a write to the +// source column recomputes it and re-checks the CHECK on every write that can change it +// (measured: TestAdv2GeneratedColumnCheckViaSourceColumn). Recurses through chains of +// generated columns; a cycle cannot occur (the schema loader rejects a generated column +// whose expression refers to itself or a later column). +func expandGeneratedColumns(t *schema.Table, cols []string) []string { + seen := map[string]bool{} + var out []string + var add func(name string) + add = func(name string) { + key := strings.ToLower(name) + if seen[key] { + return + } + seen[key] = true + out = append(out, name) + if c := t.Column(name); c != nil && c.Generated != nil { + for _, src := range exprColumns(c.Generated) { + add(src) + } + } + } + for _, name := range cols { + add(name) + } + return out +} + +func colSet(cols []string) map[string]bool { + m := map[string]bool{} + for _, c := range cols { + m[c] = true + } + return m +} + +func anyIn(cols []string, set map[string]bool) bool { + for _, c := range cols { + if set[c] { + return true + } + } + return false +} + +func dedupe(vs []Violation) []Violation { + seen := map[string]bool{} + var out []Violation + for _, v := range vs { + k := v.Key() + "\x00" + itoa(v.Code) + if seen[k] { + continue + } + seen[k] = true + out = append(out, v) + } + return out +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b []byte + for ; n > 0; n /= 10 { + b = append([]byte{byte('0' + n%10)}, b...) + } + return string(b) +} diff --git a/check/mysql/internal/analyze/violations_server_test.go b/check/mysql/internal/analyze/violations_server_test.go new file mode 100644 index 0000000..73860a1 --- /dev/null +++ b/check/mysql/internal/analyze/violations_server_test.go @@ -0,0 +1,165 @@ +package analyze + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// The runtime's reading of the server's messages (mysql/errors.go), repeated here so that +// the checker's keys are compared with what mysql.ConstraintError.Key will carry. +var ( + reDuplicate = regexp.MustCompile(`for key '(?:[^'.]+\.)?([^']+)'$`) + reForeign = regexp.MustCompile("CONSTRAINT `([^`]+)`") + reNotNull = regexp.MustCompile(`^Column '([^']+)' cannot be null$`) + reCheck = regexp.MustCompile(`^Check constraint '([^']+)' is violated\.$`) +) + +// TestViolationsServer runs statements built to violate each predicted constraint against a +// real server and checks that the error number and the constraint's name are the ones the +// checker predicted; a statement predicted to violate nothing must go through. +func TestViolationsServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + db, err := mysqltest.Start(ctx, violationSchema) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + s, err := schema.Load(violationSchema) + if err != nil { + t.Fatal(err) + } + conn := db.Conn() + for _, seed := range []string{ + "INSERT INTO accounts (id, email, nick, balance) VALUES (1, 'a@x', 'a', 10), (2, 'b@x', NULL, 0), (3, 'c@x', NULL, 0)", + "INSERT INTO payments (id, account_id, amount) VALUES (10, 1, 5)", + "INSERT INTO receipts (id, payment_id) VALUES (100, 10)", + "INSERT INTO audit (id, receipt_id) VALUES (1000, 100)", + "INSERT INTO tags (id, code, body, n) VALUES (1, 'abc', 'wxyz', 7)", + } { + if _, err := conn.ExecContext(ctx, seed); err != nil { + t.Fatalf("%s: %v", seed, err) + } + } + cases := []struct { + sql string // with $n placeholders, as the checker reads it + args []any + want string // " " the server raises, "" when it must succeed + }{ + {"INSERT INTO accounts (id, email) VALUES ($1, $2)", []any{1, "z@x"}, "1062 PRIMARY"}, + {"INSERT INTO accounts (email) VALUES ($1)", []any{"a@x"}, "1062 accounts_email"}, + {"INSERT INTO accounts (email, nick) VALUES ($1, $2)", []any{"z@x", "a"}, "1062 nick"}, + {"INSERT INTO accounts (email) VALUES ($1)", []any{nil}, "1048 email"}, + {"INSERT INTO accounts (email, balance) VALUES ($1, $2)", []any{"z@x", -1}, "3819 accounts_chk_1"}, + {"INSERT INTO accounts (email, nick) VALUES ($1, $2)", []any{"z@x", ""}, "3819 positive_nick"}, + {"INSERT INTO payments (id, account_id, amount) VALUES ($1, $2, $3)", []any{11, 999, 1}, "1452 fk_payments_account"}, + {"UPDATE accounts SET id = $1 WHERE id = $2", []any{50, 1}, "1451 fk_payments_account"}, + {"UPDATE accounts SET email = $1 WHERE id = $2", []any{"b@x", 1}, "1062 accounts_email"}, + {"UPDATE accounts SET balance = $1 WHERE id = $2", []any{-5, 1}, "3819 accounts_chk_1"}, + {"UPDATE payments SET account_id = $1 WHERE id = $2", []any{999, 10}, "1452 fk_payments_account"}, + {"DELETE FROM accounts WHERE id = $1", []any{1}, "1451 fk_payments_account"}, + {"DELETE FROM payments WHERE id = $1", []any{10}, "1451 audit_ibfk_1"}, + // a prefix key collides on the first characters, an expression key on the expression + {"INSERT INTO tags (id, code, body, n) VALUES ($1, $2, $3, $4)", []any{2, "abz", "qqqq", 8}, "1062 code_prefix"}, + {"INSERT INTO tags (id, code, body, n) VALUES ($1, $2, $3, $4)", []any{2, "zzz", "wxyq", 8}, "1062 body_prefix"}, + {"INSERT INTO tags (id, code, body, n) VALUES ($1, $2, $3, $4)", []any{2, "zzz", "qqqq", 7}, "1062 n_expr"}, + {"UPDATE tags SET code = $1 WHERE id = $2", []any{"abq", 1}, ""}, + // two rows with a NULL nick do not collide on the unique key: nothing to expect + {"INSERT INTO accounts (email) VALUES ($1)", []any{"d@x"}, ""}, + {"INSERT IGNORE INTO accounts (id, email) VALUES ($1, $2)", []any{1, "z@x"}, ""}, + {"UPDATE IGNORE accounts SET email = $1 WHERE id = $2", []any{"b@x", 1}, ""}, + {"DELETE IGNORE FROM accounts WHERE id = $1", []any{1}, ""}, + {"DELETE FROM audit WHERE id = $1", []any{1000}, ""}, + } + for _, c := range cases { + r, err := Analyze(s, c.sql) + if err != nil { + t.Errorf("%s: %v", c.sql, err) + continue + } + predicted := map[string]bool{} + for _, v := range r.Violations { + predicted[fmt.Sprintf("%d %s", v.Code, v.Key())] = true + } + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + t.Fatal(err) + } + _, execErr := tx.ExecContext(ctx, strings.NewReplacer("$1", "?", "$2", "?", "$3", "?", "$4", "?").Replace(c.sql), c.args...) + tx.Rollback() + if c.want == "" { + if execErr != nil { + t.Errorf("%s: predicted no violation, the server says %v", c.sql, execErr) + } + continue + } + var me *driver.MySQLError + if !errors.As(execErr, &me) { + t.Errorf("%s: want %s, the server says %v", c.sql, c.want, execErr) + continue + } + got := fmt.Sprintf("%d %s", me.Number, serverKey(me)) + if got != c.want { + t.Errorf("%s: want %s, the server says %s (%s)", c.sql, c.want, got, me.Message) + } + // the checker spells NOT NULL as table.column; the server names the column alone + key := c.want + if me.Number == 1048 { + key = fmt.Sprintf("1048 %s.%s", tableOf(c.sql), serverKey(me)) + } + if !predicted[key] { + t.Errorf("%s: the server raises %s, the checker predicted %v", c.sql, key, keys(predicted)) + } + } +} + +func serverKey(me *driver.MySQLError) string { + var re *regexp.Regexp + switch me.Number { + case 1062: + re = reDuplicate + case 1451, 1452: + re = reForeign + case 1048: + re = reNotNull + case 3819: + re = reCheck + default: + return "" + } + if m := re.FindStringSubmatch(me.Message); m != nil { + return m[1] + } + return "" +} + +func tableOf(sql string) string { + f := strings.Fields(sql) + for i, w := range f { + if (strings.EqualFold(w, "INTO") || strings.EqualFold(w, "UPDATE") || strings.EqualFold(w, "FROM")) && i+1 < len(f) { + return f[i+1] + } + } + return "" +} + +func keys(m map[string]bool) []string { + var out []string + for k := range m { + out = append(out, k) + } + return out +} diff --git a/check/mysql/internal/analyze/violations_test.go b/check/mysql/internal/analyze/violations_test.go new file mode 100644 index 0000000..752d2af --- /dev/null +++ b/check/mysql/internal/analyze/violations_test.go @@ -0,0 +1,333 @@ +package analyze + +import ( + "fmt" + "sort" + "strings" + "testing" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/facts" +) + +const violationSchema = `-- sqlshape: mysql 8.4 +CREATE TABLE accounts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL, + nick VARCHAR(20), + balance INT NOT NULL DEFAULT 0, + UNIQUE KEY accounts_email (email), + UNIQUE KEY (nick), + CHECK (balance >= 0), + CONSTRAINT positive_nick CHECK (nick <> '') +); +CREATE TABLE payments ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + account_id BIGINT UNSIGNED NOT NULL, + amount INT NOT NULL, + CONSTRAINT fk_payments_account FOREIGN KEY (account_id) REFERENCES accounts (id) +); +CREATE TABLE receipts ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + payment_id BIGINT UNSIGNED NOT NULL, + FOREIGN KEY (payment_id) REFERENCES payments (id) ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE TABLE audit ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + receipt_id BIGINT UNSIGNED NOT NULL, + FOREIGN KEY (receipt_id) REFERENCES receipts (id) +); +CREATE TABLE tags ( + id INT NOT NULL PRIMARY KEY, + code VARCHAR(30) NOT NULL, + body TEXT NOT NULL, + n INT NOT NULL, + UNIQUE KEY code_prefix (code(2)), + UNIQUE KEY body_prefix (body(3)), + UNIQUE KEY n_expr ((n * 2)) +); +` + +// TestViolations covers the failure modes: what each write may violate, spelled as the +// expect line spells it (code key[:param]). +func TestViolations(t *testing.T) { + s, err := schema.Load(violationSchema) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("schema problems: %v", s.Problems) + } + cases := []struct { + sql string + want string + }{ + // an INSERT may hit every unique key but one the server numbers itself, the foreign + // keys and CHECKs over the inserted columns, and NOT NULL where the value may be NULL + {"INSERT INTO accounts (email, nick) VALUES ($1, $2)", "1048 accounts.email:1, 1062 accounts_email, 1062 nick, 3819 positive_nick"}, + {"INSERT INTO accounts (id, email) VALUES ($1, $2)", "1048 accounts.email:2, 1062 PRIMARY, 1062 accounts_email"}, + {"INSERT INTO accounts (email, balance) VALUES ($1, $2)", "1048 accounts.balance:2, 1048 accounts.email:1, 1062 accounts_email, 3819 accounts_chk_1"}, + {"INSERT INTO accounts (email) VALUES ('a@x')", "1062 accounts_email"}, + {"INSERT INTO accounts (email, balance) VALUES ('a@x', -1)", "1062 accounts_email, 3819 accounts_chk_1"}, + {"INSERT INTO payments (id, account_id, amount) VALUES ($1, $2, $3)", "1048 payments.account_id:2, 1048 payments.amount:3, 1048 payments.id:1, 1062 PRIMARY, 1452 fk_payments_account"}, + {"INSERT INTO payments (id, account_id, amount) SELECT id, id, 1 FROM accounts WHERE id = $1", "1062 PRIMARY, 1452 fk_payments_account"}, + {"INSERT INTO payments (id, account_id, amount) SELECT id, id, nick FROM accounts", "1048 payments.amount, 1062 PRIMARY, 1452 fk_payments_account"}, + // IGNORE turns every constraint error into a warning + {"INSERT IGNORE INTO accounts (email) VALUES ($1)", ""}, + {"UPDATE IGNORE accounts SET email = $1 WHERE id = $2", ""}, + {"DELETE IGNORE FROM accounts WHERE id = $1", ""}, + // ON DUPLICATE KEY UPDATE absorbs the insert's unique violations; its update has its own + {"INSERT INTO accounts (email) VALUES ($1) ON DUPLICATE KEY UPDATE balance = $2", "1048 accounts.balance:2, 1048 accounts.email:1, 3819 accounts_chk_1"}, + {"INSERT INTO accounts (email) VALUES ($1) ON DUPLICATE KEY UPDATE nick = $2", "1048 accounts.email:1, 1062 nick, 3819 positive_nick"}, + // an UPDATE may hit the keys, foreign keys and CHECKs over its SET columns, and the + // foreign keys of other tables that reference the columns it changes + {"UPDATE accounts SET balance = balance + 1 WHERE id = $1", "3819 accounts_chk_1"}, + {"UPDATE accounts SET nick = $1 WHERE id = $2", "1062 nick, 3819 positive_nick"}, + {"UPDATE accounts SET email = $1 WHERE id = $2", "1048 accounts.email:1, 1062 accounts_email"}, + {"UPDATE accounts SET id = $1 WHERE id = $2", "1062 PRIMARY, 1451 fk_payments_account"}, + {"UPDATE payments SET account_id = $1 WHERE id = $2", "1048 payments.account_id:1, 1452 fk_payments_account"}, + // a cascade carries the question to the next table: receipts follow payments, audit + // does not follow receipts + {"UPDATE payments SET id = $1 WHERE id = $2", "1048 payments.id:1, 1062 PRIMARY"}, // receipts follow by cascade; audit refers to receipts.id, which does not change + {"DELETE FROM accounts WHERE id = $1", "1451 fk_payments_account"}, + {"DELETE FROM payments WHERE id = $1", "1451 audit_ibfk_1"}, + {"DELETE FROM audit WHERE id = $1", ""}, + {"SELECT id FROM accounts WHERE id = $1", ""}, + } + for _, c := range cases { + r, err := Analyze(s, c.sql) + if err != nil { + t.Errorf("%s: %v", c.sql, err) + continue + } + var keys []string + for _, v := range r.Violations { + k := fmt.Sprintf("%d %s", v.Code, v.Key()) + if v.Param > 0 { + k += fmt.Sprintf(":%d", v.Param) + } + keys = append(keys, k) + } + sort.Strings(keys) + if got := strings.Join(keys, ", "); got != c.want { + t.Errorf("%s:\n got %s\n want %s", c.sql, got, c.want) + } + } +} + +// TestUses covers the column references a statement records: every table's (or view's) +// column once, by position, an INSERT / SET target that is never read marked Assigned. +func TestUses(t *testing.T) { + s := load(t) + cases := []struct { + sql string + want string + }{ + {"SELECT u.id, o.total FROM users u JOIN orders o ON o.user_id = u.id WHERE o.id = $1", "users.id@7 orders.total@13 orders.user_id@51 orders.id@74"}, + {"SELECT id, name FROM v_users WHERE id = $1", "v_users.id@7 v_users.name@11"}, + {"INSERT INTO users (name, email) VALUES ($1, $2)", "users.name@19= users.email@25="}, + {"UPDATE users SET name = $1, email = NULL WHERE id = $2", "users.name@17= users.email@28= users.id@47"}, + {"UPDATE users SET id = $1 WHERE id = $2", "users.id@17"}, + {"SELECT d.id FROM (SELECT id FROM users WHERE name = $1) d", "users.id@25 users.name@45"}, + } + for _, c := range cases { + r, err := Analyze(s, c.sql) + if err != nil { + t.Errorf("%s: %v", c.sql, err) + continue + } + var parts []string + for _, u := range r.Uses { + p := fmt.Sprintf("%s.%s@%d", u.Table, u.Column, u.Position) + if u.Assigned { + p += "=" + } + parts = append(parts, p) + } + if got := strings.Join(parts, " "); got != c.want { + t.Errorf("%s:\n got %s\n want %s", c.sql, got, c.want) + } + } +} + +// TestChildren covers the nested blocks a statement's facts hang under its own: derived +// tables, CTE bodies, the subqueries of its conditions, the arms of a set operation. +func TestChildren(t *testing.T) { + s := load(t) + cases := []struct { + sql string + want string // the top level's shape and its children's leaves + }{ + {"SELECT d.id FROM (SELECT id FROM users WHERE id = 1) d", "derived d; [users]"}, + {"WITH c AS (SELECT id, user_id FROM orders) SELECT id FROM c WHERE user_id = $1", "cte c; [orders]"}, + {"SELECT id FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)", "table users; "}, // the body hangs off the Exists predicate + {"SELECT id FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)", "table users; [orders]"}, + {"SELECT id FROM users GROUP BY id WITH ROLLUP", "table users, grouping sets; "}, + {"SELECT name, count(*) FROM users GROUP BY 1", "table users, group name; "}, + {"SELECT count(*) FROM users", "table users, single; "}, + {"SELECT COALESCE(SUM(total), 0) FROM orders", "table orders, single; "}, // the aggregate wrapped in a function is still the block's, one row + {"SELECT COUNT(*) + 1 AS n, MAX(id) FROM users", "table users, single; "}, // an expression over aggregates, and a bare one + {"SELECT COUNT(*) FROM users HAVING MAX(id) > 1", "table users, single; "}, // HAVING's aggregate too (zero or one row) + {"SELECT (SELECT COUNT(*) FROM orders) AS n FROM users", "table users; [orders]"}, // the subquery owns its aggregate: one row per user + {"SELECT (SELECT COUNT(o.id) FROM orders o WHERE o.user_id = u.id) AS n FROM users u", "table users; [orders]"}, // still the subquery's: its own column + {"SELECT id FROM users WHERE id = (SELECT MAX(user_id) FROM orders)", "table users; [orders]"}, + {"SELECT id FROM users UNION SELECT user_id FROM orders WHERE id = 1", "UNION may combine rows; [users] [orders]"}, + {"UPDATE users SET name = $1 WHERE id IN (SELECT user_id FROM orders)", "table users; "}, + {"UPDATE users SET name = $1 WHERE id NOT IN (SELECT user_id FROM orders)", "table users; [orders]"}, + {"UPDATE users SET name = $1 WHERE id IN (SELECT user_id + 1 FROM orders)", "table users; [orders]"}, + {"INSERT INTO orders (id, user_id, total) SELECT id, id, 0 FROM users WHERE id = $1", "table orders; [users]"}, + } + for _, c := range cases { + r, err := Analyze(s, c.sql) + if err != nil { + t.Errorf("%s: %v", c.sql, err) + continue + } + top := r.Facts.Top + var head []string + for _, l := range top.Leaves { + name := l.Table + if name == "" { + name = l.Alias + } + head = append(head, l.Kind.String()+" "+name) + } + if top.Many != "" { + head = append(head, top.Many) + } + if top.Single { + head = append(head, "single") + } + if top.GroupingSets { + head = append(head, "grouping sets") + } + for _, g := range top.Groups { + if !top.GroupingSets { + head = append(head, "group "+g.Col.Column+g.Text) + } + } + var kids []string + for _, ch := range top.Children { + var names []string + for _, l := range ch.Leaves { + names = append(names, l.Table) + } + kids = append(kids, "["+strings.Join(names, " ")+"]") + } + if got := strings.Join(head, ", ") + "; " + strings.Join(kids, " "); got != c.want { + t.Errorf("%s:\n got %s\n want %s", c.sql, got, c.want) + } + if r.Facts.Kind == facts.Insert && strings.Contains(c.sql, "SELECT") && r.Facts.Source == nil { + t.Errorf("%s: INSERT ... SELECT records no Source", c.sql) + } + } +} + +// TestLower covers the reading of a declared predicate in the facts language. +func TestLower(t *testing.T) { + s := load(t) + orders := s.Table("orders") + cases := []struct { + expr string + want string + err string + }{ + {"user_id = $1", "0.user_id = $1", ""}, + {"note IS NULL AND total > 0", "0.note IS NULL | opaque \"total > 0\" cols 0.total", ""}, + {"user_id IN (1, 2)", "0.user_id IN (const i1, const i2)", ""}, // constText's tagged spelling (facts.go) + {"nope = 1", "", "Unknown column 'nope' in 'where clause' (MySQL error 1054)"}, + {"user_id = ", "", "1064"}, + } + for _, c := range cases { + preds, err := Lower(s, c.expr, orders) + if c.err != "" { + if err == nil || !strings.Contains(err.Error(), c.err) { + t.Errorf("%s: error %v, want %q", c.expr, err, c.err) + } + continue + } + if err != nil { + t.Errorf("%s: %v", c.expr, err) + continue + } + var parts []string + for _, p := range preds { + parts = append(parts, p.String()) + } + if got := strings.Join(parts, " | "); got != c.want { + t.Errorf("%s:\n got %s\n want %s", c.expr, got, c.want) + } + } + if _, err := Lower(s, "nope = 1", orders); err != nil { + if e, ok := err.(*Error); !ok || e.Position != 0 { + t.Errorf("position of the declaration's error: %v", err) + } + } +} + +// TestAnalyzeView covers a view analyzed on its own: its columns, the base column each +// passes through, its facts. +func TestAnalyzeView(t *testing.T) { + s := load(t) + vr, err := AnalyzeView(s, s.View("v_stats")) + if err != nil { + t.Fatal(err) + } + if got := fmt.Sprintf("%v %v", vr.Sources, vr.Facts.Top.Leaves[0].Table); got != "[{orders user_id} { }] orders" { + t.Errorf("v_stats: %s", got) + } + if len(vr.Facts.Top.Groups) != 1 || len(vr.Facts.Uses) != 1 { + t.Errorf("v_stats: groups %v, uses %v", vr.Facts.Top.Groups, vr.Facts.Uses) + } +} + +// TestExistsPred covers the EXISTS / IN subquery conjuncts: the body is the predicate's +// own scope, its correlation to the outer row an Outer term, and `x IN (SELECT y ...)` is +// the body with y = x added. +func TestExistsPred(t *testing.T) { + s := load(t) + cases := []struct { + sql string + want string // the predicate's shape: sub leaves, then the body's Eq predicates + }{ + {"SELECT id FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)", "exists[orders] user_id=outer(0.id)"}, + {"SELECT id FROM users u WHERE u.id IN (SELECT o.user_id FROM orders o WHERE o.total > 1)", "exists[orders] user_id=outer(0.id)"}, + {"SELECT id FROM users WHERE $1 IN (SELECT user_id FROM orders)", "exists[orders] user_id=param(1)"}, + {"SELECT id FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.total = $1)", "exists[orders] user_id=outer(0.id) total=param(1)"}, + } + for _, c := range cases { + r, err := Analyze(s, c.sql) + if err != nil { + t.Errorf("%s: %v", c.sql, err) + continue + } + got := "" + for _, p := range r.Facts.Top.Preds { + if p.Op != facts.Exists || p.Sub == nil { + continue + } + got += "exists[" + for _, l := range p.Sub.Leaves { + got += l.Table + } + got += "]" + for _, q := range p.Sub.Preds { + if q.Op != facts.Eq { + continue + } + got += " " + q.Col.Column + "=" + switch q.Term.Kind { + case facts.Outer: + got += fmt.Sprintf("outer(%d.%s)", q.Term.Col.Leaf, q.Term.Col.Column) + case facts.Param: + got += fmt.Sprintf("param(%d)", q.Term.Param) + default: + got += "?" + } + } + } + if got != c.want { + t.Errorf("%s:\n got %s\n want %s", c.sql, got, c.want) + } + } +} diff --git a/check/mysql/internal/catalog/catalog.go b/check/mysql/internal/catalog/catalog.go new file mode 100644 index 0000000..29fb599 --- /dev/null +++ b/check/mysql/internal/catalog/catalog.go @@ -0,0 +1,102 @@ +// Package catalog is what MySQL knows about its built-in functions, read out of the server +// source by parsegen: the native function registry (name, argument count, evaluating +// class), and for every Item class its base, the family that fixes its result kind, and +// the declarative statements of its resolve_type(). The analyzer interprets the facts; this +// package only holds and indexes them. +package catalog + +import "strings" + +// Function is one entry of the native function registry. +type Function struct { + Name string // upper case + Class string // the Item class that evaluates it + Min, Max int // argument count; Max -1 for unbounded + Odd bool + Even bool + Internal bool // only from system views + Factory string // the special instantiator, when any +} + +// Item is what the server declares about an Item class. +type Item struct { + Base string + Family string + InheritsResolve string + // FixFields is the base fix_fields an override calls ("own" when none); "" when the + // class does not override fix_fields. FixNullable says that override sets the + // nullability itself, last. + FixFields string + FixNullable bool + Facts []string +} + +// Lookup returns the registry entry for name (any case), or nil. +func Lookup(name string) *Function { + u := strings.ToUpper(name) + for i := range Functions { + if Functions[i].Name == u { + return &Functions[i] + } + } + return nil +} + +// Accepts reports whether the function takes n arguments. +func (f *Function) Accepts(n int) bool { + if n < f.Min || (f.Max >= 0 && n > f.Max) { + return false + } + if f.Odd && n%2 == 0 || f.Even && n%2 == 1 { + return false + } + return true +} + +// FamilyOf walks the class hierarchy to the family of class, or "". +func FamilyOf(class string) string { + seen := map[string]bool{} + for cur := class; cur != "" && !seen[cur]; { + seen[cur] = true + it, ok := Items[cur] + if !ok { + return "" + } + if it.Family != "" { + return it.Family + } + cur = it.Base + } + return "" +} + +// Merge is the type two values aggregate to, by MySQL's field_types_merge_rules: the +// type of a CASE / IF / COALESCE / GREATEST over values of types a and b (enum_field_types +// names without the MYSQL_TYPE_ prefix: "LONGLONG", "NEWDECIMAL", "VARCHAR"). "" when +// either is not a known type. +func Merge(a, b string) string { + i, j := fieldIndex(a), fieldIndex(b) + if i < 0 || j < 0 { + return "" + } + return MergeRules[i][j] +} + +// ResultKind is the Item_result of a type: "INT_RESULT", "DECIMAL_RESULT", "REAL_RESULT" +// or "STRING_RESULT"; "" when unknown. +func ResultKind(fieldType string) string { + i := fieldIndex(fieldType) + if i < 0 { + return "" + } + return ResultKinds[i] +} + +func fieldIndex(t string) int { + for i, ft := range FieldTypes { + if ft == t { + return i + } + } + return -1 +} diff --git a/check/mysql/internal/catalog/catalog_test.go b/check/mysql/internal/catalog/catalog_test.go new file mode 100644 index 0000000..7558f63 --- /dev/null +++ b/check/mysql/internal/catalog/catalog_test.go @@ -0,0 +1,36 @@ +package catalog + +import "testing" + +func TestRegistry(t *testing.T) { + if len(Functions) < 340 { + t.Fatalf("only %d functions", len(Functions)) + } + abs := Lookup("abs") + if abs == nil || abs.Class != "Item_func_abs" || !abs.Accepts(1) || abs.Accepts(2) { + t.Errorf("ABS: %+v", abs) + } + concat := Lookup("CONCAT") + if concat == nil || concat.Max != -1 || !concat.Accepts(9) || concat.Accepts(0) { + t.Errorf("CONCAT: %+v", concat) + } + round := Lookup("ROUND") + if round == nil || round.Class != "Item_func_round" || round.Min != 1 || round.Max != 2 || round.Factory != "Round_instantiator" { + t.Errorf("ROUND: %+v", round) + } + if st := Lookup("ST_STARTPOINT"); st == nil || st.Class != "Item_func_spatial_decomp" || st.Min != 1 { + t.Errorf("ST_STARTPOINT: %+v", st) + } + if f := FamilyOf("Item_func_char_length"); f != "Item_int_func" { + t.Errorf("CHAR_LENGTH family %q", f) + } + if f := FamilyOf("Item_func_concat"); f != "Item_str_func" { + t.Errorf("CONCAT family %q", f) + } + if facts := Items["Item_func_round"].Facts; len(facts) == 0 { + t.Error("ROUND has no resolve_type facts") + } + if Lookup("no_such_function") != nil { + t.Error("unknown function found") + } +} diff --git a/check/mysql/internal/catalog/functions.go b/check/mysql/internal/catalog/functions.go new file mode 100644 index 0000000..9c9cabc --- /dev/null +++ b/check/mysql/internal/catalog/functions.go @@ -0,0 +1,866 @@ +// Code generated by parsegen from MySQL 8.4.6 sql/item_create.cc and sql/item*. DO NOT EDIT. + +package catalog + +// Functions is the native function registry (sql/item_create.cc func_array), in the server's order. +var Functions = [...]Function{ + {Name: "ABS", Class: "Item_func_abs", Min: 1, Max: 1}, + {Name: "ACOS", Class: "Item_func_acos", Min: 1, Max: 1}, + {Name: "ADDTIME", Class: "Item_func_add_time", Min: 2, Max: 2}, + {Name: "AES_DECRYPT", Class: "Item_func_aes_decrypt", Min: 2, Max: 6}, + {Name: "AES_ENCRYPT", Class: "Item_func_aes_encrypt", Min: 2, Max: 6}, + {Name: "ANY_VALUE", Class: "Item_func_any_value", Min: 1, Max: 1}, + {Name: "ASIN", Class: "Item_func_asin", Min: 1, Max: 1}, + {Name: "ATAN", Class: "Item_func_atan", Min: 1, Max: 2}, + {Name: "ATAN2", Class: "Item_func_atan", Min: 1, Max: 2}, + {Name: "BENCHMARK", Class: "Item_func_benchmark", Min: 2, Max: 2}, + {Name: "BIN", Class: "Item_func_conv", Min: 1, Max: 1, Factory: "Bin_instantiator"}, + {Name: "BIN_TO_UUID", Class: "Item_func_bin_to_uuid", Min: 1, Max: 2}, + {Name: "BIT_COUNT", Class: "Item_func_bit_count", Min: 1, Max: 1}, + {Name: "BIT_LENGTH", Class: "Item_func_bit_length", Min: 1, Max: 1}, + {Name: "CEIL", Class: "Item_func_ceiling", Min: 1, Max: 1}, + {Name: "CEILING", Class: "Item_func_ceiling", Min: 1, Max: 1}, + {Name: "CHARACTER_LENGTH", Class: "Item_func_char_length", Min: 1, Max: 1}, + {Name: "CHAR_LENGTH", Class: "Item_func_char_length", Min: 1, Max: 1}, + {Name: "COERCIBILITY", Class: "Item_func_coercibility", Min: 1, Max: 1}, + {Name: "COMPRESS", Class: "Item_func_compress", Min: 1, Max: 1}, + {Name: "CONCAT", Class: "Item_func_concat", Min: 1, Max: -1}, + {Name: "CONCAT_WS", Class: "Item_func_concat_ws", Min: 2, Max: -1}, + {Name: "CONNECTION_ID", Class: "Item_func_connection_id", Min: 0, Max: 0}, + {Name: "CONV", Class: "Item_func_conv", Min: 3, Max: 3}, + {Name: "CONVERT_TZ", Class: "Item_func_convert_tz", Min: 3, Max: 3}, + {Name: "COS", Class: "Item_func_cos", Min: 1, Max: 1}, + {Name: "COT", Class: "Item_func_cot", Min: 1, Max: 1}, + {Name: "CRC32", Class: "Item_func_crc32", Min: 1, Max: 1}, + {Name: "CURRENT_ROLE", Class: "Item_func_current_role", Min: 0, Max: 0}, + {Name: "DATEDIFF", Class: "Item_func_minus", Min: 2, Max: 2, Factory: "Datediff_instantiator"}, + {Name: "DATE_FORMAT", Class: "Item_func_date_format", Min: 2, Max: 2}, + {Name: "DAYNAME", Class: "Item_func_dayname", Min: 1, Max: 1}, + {Name: "DAYOFMONTH", Class: "Item_func_dayofmonth", Min: 1, Max: 1}, + {Name: "DAYOFWEEK", Class: "Item_func_weekday", Min: 1, Max: 1, Factory: "Dayofweek_instantiator"}, + {Name: "DAYOFYEAR", Class: "Item_func_dayofyear", Min: 1, Max: 1}, + {Name: "DEGREES", Class: "Item_func_degrees", Min: 1, Max: 1}, + {Name: "ELT", Class: "Item_func_elt", Min: 2, Max: -1}, + {Name: "EXP", Class: "Item_func_exp", Min: 1, Max: 1}, + {Name: "EXPORT_SET", Class: "Item_func_export_set", Min: 3, Max: 5}, + {Name: "EXTRACTVALUE", Class: "Item_func_xml_extractvalue", Min: 2, Max: 2}, + {Name: "FIELD", Class: "Item_func_field", Min: 2, Max: -1}, + {Name: "FIND_IN_SET", Class: "Item_func_find_in_set", Min: 2, Max: 2}, + {Name: "FLOOR", Class: "Item_func_floor", Min: 1, Max: 1}, + {Name: "FORMAT_BYTES", Class: "Item_func_pfs_format_bytes", Min: 1, Max: 1}, + {Name: "FORMAT_PICO_TIME", Class: "Item_func_pfs_format_pico_time", Min: 1, Max: 1}, + {Name: "FOUND_ROWS", Class: "Item_func_found_rows", Min: 0, Max: 0}, + {Name: "FROM_BASE64", Class: "Item_func_from_base64", Min: 1, Max: 1}, + {Name: "FROM_DAYS", Class: "Item_func_from_days", Min: 1, Max: 1}, + {Name: "FROM_UNIXTIME", Class: "Item_func_date_format", Min: 1, Max: 2, Factory: "From_unixtime_instantiator"}, + {Name: "GET_LOCK", Class: "Item_func_get_lock", Min: 2, Max: 2}, + {Name: "GREATEST", Class: "Item_func_max", Min: 2, Max: -1}, + {Name: "GTID_SUBTRACT", Class: "Item_func_gtid_subtract", Min: 2, Max: 2}, + {Name: "GTID_SUBSET", Class: "Item_func_gtid_subset", Min: 2, Max: 2}, + {Name: "HEX", Class: "Item_func_hex", Min: 1, Max: 1}, + {Name: "IFNULL", Class: "Item_func_ifnull", Min: 2, Max: 2}, + {Name: "INET_ATON", Class: "Item_func_inet_aton", Min: 1, Max: 1}, + {Name: "INET_NTOA", Class: "Item_func_inet_ntoa", Min: 1, Max: 1}, + {Name: "INET6_ATON", Class: "Item_func_inet6_aton", Min: 1, Max: 1}, + {Name: "INET6_NTOA", Class: "Item_func_inet6_ntoa", Min: 1, Max: 1}, + {Name: "IS_IPV4", Class: "Item_func_is_ipv4", Min: 1, Max: 1}, + {Name: "IS_IPV6", Class: "Item_func_is_ipv6", Min: 1, Max: 1}, + {Name: "IS_IPV4_COMPAT", Class: "Item_func_is_ipv4_compat", Min: 1, Max: 1}, + {Name: "IS_IPV4_MAPPED", Class: "Item_func_is_ipv4_mapped", Min: 1, Max: 1}, + {Name: "IS_UUID", Class: "Item_func_is_uuid", Min: 1, Max: 1}, + {Name: "INSTR", Class: "Item_func_instr", Min: 2, Max: 2}, + {Name: "ISNULL", Class: "Item_func_isnull", Min: 1, Max: 1}, + {Name: "JSON_VALID", Class: "Item_func_json_valid", Min: 1, Max: 1}, + {Name: "JSON_CONTAINS", Class: "Item_func_json_contains", Min: 2, Max: 3}, + {Name: "JSON_CONTAINS_PATH", Class: "Item_func_json_contains_path", Min: 3, Max: -1}, + {Name: "JSON_LENGTH", Class: "Item_func_json_length", Min: -1, Max: -1, Factory: "Json_length_instantiator"}, + {Name: "JSON_DEPTH", Class: "Item_func_json_depth", Min: 1, Max: 1}, + {Name: "JSON_PRETTY", Class: "Item_func_json_pretty", Min: 1, Max: 1}, + {Name: "JSON_TYPE", Class: "Item_func_json_type", Min: 1, Max: 1}, + {Name: "JSON_KEYS", Class: "Item_func_json_keys", Min: 1, Max: 2}, + {Name: "JSON_EXTRACT", Class: "Item_func_json_extract", Min: 2, Max: -1}, + {Name: "JSON_ARRAY_APPEND", Class: "Item_func_json_array_append", Min: 3, Max: -1, Odd: true}, + {Name: "JSON_INSERT", Class: "Item_func_json_insert", Min: 3, Max: -1, Odd: true}, + {Name: "JSON_ARRAY_INSERT", Class: "Item_func_json_array_insert", Min: 3, Max: -1, Odd: true}, + {Name: "JSON_OBJECT", Class: "Item_func_json_row_object", Min: 0, Max: -1, Even: true}, + {Name: "JSON_OVERLAPS", Class: "Item_func_json_overlaps", Min: 2, Max: 2}, + {Name: "JSON_SEARCH", Class: "Item_func_json_search", Min: 3, Max: -1}, + {Name: "JSON_SET", Class: "Item_func_json_set", Min: 3, Max: -1, Odd: true}, + {Name: "JSON_REPLACE", Class: "Item_func_json_replace", Min: 3, Max: -1, Odd: true}, + {Name: "JSON_ARRAY", Class: "Item_func_json_array", Min: 0, Max: -1}, + {Name: "JSON_REMOVE", Class: "Item_func_json_remove", Min: 2, Max: -1}, + {Name: "JSON_MERGE", Class: "Item_func_json_merge", Min: 2, Max: -1}, + {Name: "JSON_MERGE_PATCH", Class: "Item_func_json_merge_patch", Min: 2, Max: -1}, + {Name: "JSON_MERGE_PRESERVE", Class: "Item_func_json_merge_preserve", Min: 2, Max: -1}, + {Name: "JSON_QUOTE", Class: "Item_func_json_quote", Min: 1, Max: 1}, + {Name: "JSON_SCHEMA_VALID", Class: "Item_func_json_schema_valid", Min: 2, Max: 2}, + {Name: "JSON_SCHEMA_VALIDATION_REPORT", Class: "Item_func_json_schema_validation_report", Min: 2, Max: 2}, + {Name: "JSON_STORAGE_FREE", Class: "Item_func_json_storage_free", Min: 1, Max: 1}, + {Name: "JSON_STORAGE_SIZE", Class: "Item_func_json_storage_size", Min: 1, Max: 1}, + {Name: "JSON_UNQUOTE", Class: "Item_func_json_unquote", Min: 1, Max: 1}, + {Name: "IS_FREE_LOCK", Class: "Item_func_is_free_lock", Min: 1, Max: 1}, + {Name: "IS_USED_LOCK", Class: "Item_func_is_used_lock", Min: 1, Max: 1}, + {Name: "LAST_DAY", Class: "Item_func_last_day", Min: 1, Max: 1}, + {Name: "LAST_INSERT_ID", Class: "Item_func_last_insert_id", Min: 0, Max: 1}, + {Name: "LCASE", Class: "Item_func_lower", Min: 1, Max: 1}, + {Name: "LEAST", Class: "Item_func_min", Min: 2, Max: -1}, + {Name: "LENGTH", Class: "Item_func_length", Min: 1, Max: 1}, + {Name: "LIKE_RANGE_MIN", Class: "Item_func_like_range_min", Min: 2, Max: 2}, + {Name: "LIKE_RANGE_MAX", Class: "Item_func_like_range_max", Min: 2, Max: 2}, + {Name: "LN", Class: "Item_func_ln", Min: 1, Max: 1}, + {Name: "LOAD_FILE", Class: "Item_load_file", Min: 1, Max: 1}, + {Name: "LOCATE", Class: "Item_func_locate", Min: 2, Max: 3, Factory: "Locate_instantiator"}, + {Name: "LOG", Class: "Item_func_log", Min: 1, Max: 2}, + {Name: "LOG10", Class: "Item_func_log10", Min: 1, Max: 1}, + {Name: "LOG2", Class: "Item_func_log2", Min: 1, Max: 1}, + {Name: "LOWER", Class: "Item_func_lower", Min: 1, Max: 1}, + {Name: "LPAD", Class: "Item_func_lpad", Min: 3, Max: 3}, + {Name: "LTRIM", Class: "Item_func_ltrim", Min: 1, Max: 1}, + {Name: "MAKEDATE", Class: "Item_func_makedate", Min: 2, Max: 2}, + {Name: "MAKETIME", Class: "Item_func_maketime", Min: 3, Max: 3}, + {Name: "MAKE_SET", Class: "Item_func_make_set", Min: 2, Max: -1, Factory: "Make_set_instantiator"}, + {Name: "MASTER_POS_WAIT", Class: "Item_master_pos_wait", Min: 2, Max: 4}, + {Name: "MBRCONTAINS", Class: "Item_func_mbrcontains", Min: 2, Max: 2}, + {Name: "MBRCOVEREDBY", Class: "Item_func_mbrcoveredby", Min: 2, Max: 2}, + {Name: "MBRCOVERS", Class: "Item_func_mbrcovers", Min: 2, Max: 2}, + {Name: "MBRDISJOINT", Class: "Item_func_mbrdisjoint", Min: 2, Max: 2}, + {Name: "MBREQUALS", Class: "Item_func_mbrequals", Min: 2, Max: 2}, + {Name: "MBRINTERSECTS", Class: "Item_func_mbrintersects", Min: 2, Max: 2}, + {Name: "MBROVERLAPS", Class: "Item_func_mbroverlaps", Min: 2, Max: 2}, + {Name: "MBRTOUCHES", Class: "Item_func_mbrtouches", Min: 2, Max: 2}, + {Name: "MBRWITHIN", Class: "Item_func_mbrwithin", Min: 2, Max: 2}, + {Name: "MD5", Class: "Item_func_md5", Min: 1, Max: 1}, + {Name: "MONTHNAME", Class: "Item_func_monthname", Min: 1, Max: 1}, + {Name: "NAME_CONST", Class: "Item_name_const", Min: 2, Max: 2}, + {Name: "NULLIF", Class: "Item_func_nullif", Min: 2, Max: 2}, + {Name: "OCT", Class: "Item_func_conv", Min: 1, Max: 1, Factory: "Oct_instantiator"}, + {Name: "OCTET_LENGTH", Class: "Item_func_length", Min: 1, Max: 1}, + {Name: "ORD", Class: "Item_func_ord", Min: 1, Max: 1}, + {Name: "PERIOD_ADD", Class: "Item_func_period_add", Min: 2, Max: 2}, + {Name: "PERIOD_DIFF", Class: "Item_func_period_diff", Min: 2, Max: 2}, + {Name: "PI", Class: "Item_func_pi", Min: 0, Max: 0}, + {Name: "POW", Class: "Item_func_pow", Min: 2, Max: 2}, + {Name: "POWER", Class: "Item_func_pow", Min: 2, Max: 2}, + {Name: "PS_CURRENT_THREAD_ID", Class: "Item_func_pfs_current_thread_id", Min: 0, Max: 0}, + {Name: "PS_THREAD_ID", Class: "Item_func_pfs_thread_id", Min: 1, Max: 1}, + {Name: "QUOTE", Class: "Item_func_quote", Min: 1, Max: 1}, + {Name: "RADIANS", Class: "Item_func_radians", Min: 1, Max: 1}, + {Name: "RAND", Class: "Item_func_rand", Min: 0, Max: 1}, + {Name: "RANDOM_BYTES", Class: "Item_func_random_bytes", Min: 1, Max: 1}, + {Name: "REGEXP_INSTR", Class: "Item_func_regexp_instr", Min: 2, Max: 6}, + {Name: "REGEXP_LIKE", Class: "Item_func_regexp_like", Min: 2, Max: 3}, + {Name: "REGEXP_REPLACE", Class: "Item_func_regexp_replace", Min: 3, Max: 6}, + {Name: "REGEXP_SUBSTR", Class: "Item_func_regexp_substr", Min: 2, Max: 5}, + {Name: "RELEASE_ALL_LOCKS", Class: "Item_func_release_all_locks", Min: 0, Max: 0}, + {Name: "RELEASE_LOCK", Class: "Item_func_release_lock", Min: 1, Max: 1}, + {Name: "REVERSE", Class: "Item_func_reverse", Min: 1, Max: 1}, + {Name: "ROLES_GRAPHML", Class: "Item_func_roles_graphml", Min: 0, Max: 0}, + {Name: "ROUND", Class: "Item_func_round", Min: 1, Max: 2, Factory: "Round_instantiator"}, + {Name: "RPAD", Class: "Item_func_rpad", Min: 3, Max: 3}, + {Name: "RTRIM", Class: "Item_func_rtrim", Min: 1, Max: 1}, + {Name: "SEC_TO_TIME", Class: "Item_func_sec_to_time", Min: 1, Max: 1}, + {Name: "SHA", Class: "Item_func_sha", Min: 1, Max: 1}, + {Name: "SHA1", Class: "Item_func_sha", Min: 1, Max: 1}, + {Name: "SHA2", Class: "Item_func_sha2", Min: 2, Max: 2}, + {Name: "SIGN", Class: "Item_func_sign", Min: 1, Max: 1}, + {Name: "SIN", Class: "Item_func_sin", Min: 1, Max: 1}, + {Name: "SLEEP", Class: "Item_func_sleep", Min: 1, Max: 1}, + {Name: "SOUNDEX", Class: "Item_func_soundex", Min: 1, Max: 1}, + {Name: "SOURCE_POS_WAIT", Class: "Item_source_pos_wait", Min: 2, Max: 4}, + {Name: "SPACE", Class: "Item_func_space", Min: 1, Max: 1}, + {Name: "STATEMENT_DIGEST", Class: "Item_func_statement_digest", Min: 1, Max: 1}, + {Name: "STATEMENT_DIGEST_TEXT", Class: "Item_func_statement_digest_text", Min: 1, Max: 1}, + {Name: "WAIT_FOR_EXECUTED_GTID_SET", Class: "Item_wait_for_executed_gtid_set", Min: 1, Max: 2}, + {Name: "SQRT", Class: "Item_func_sqrt", Min: 1, Max: 1}, + {Name: "STRCMP", Class: "Item_func_strcmp", Min: 2, Max: 2}, + {Name: "STR_TO_DATE", Class: "Item_func_str_to_date", Min: 2, Max: 2}, + {Name: "ST_AREA", Class: "Item_func_st_area", Min: 1, Max: 1}, + {Name: "ST_ASBINARY", Class: "Item_func_as_wkb", Min: 1, Max: 2}, + {Name: "ST_ASGEOJSON", Class: "Item_func_as_geojson", Min: 1, Max: 3}, + {Name: "ST_ASTEXT", Class: "Item_func_as_wkt", Min: 1, Max: 2}, + {Name: "ST_ASWKB", Class: "Item_func_as_wkb", Min: 1, Max: 2}, + {Name: "ST_ASWKT", Class: "Item_func_as_wkt", Min: 1, Max: 2}, + {Name: "ST_BUFFER", Class: "Item_func_st_buffer", Min: 2, Max: 5}, + {Name: "ST_BUFFER_STRATEGY", Class: "Item_func_buffer_strategy", Min: 1, Max: 2}, + {Name: "ST_CENTROID", Class: "Item_func_centroid", Min: 1, Max: 1}, + {Name: "ST_CONTAINS", Class: "Item_func_st_contains", Min: 2, Max: 2}, + {Name: "ST_CONVEXHULL", Class: "Item_func_convex_hull", Min: 1, Max: 1}, + {Name: "ST_CROSSES", Class: "Item_func_st_crosses", Min: 2, Max: 2}, + {Name: "ST_DIFFERENCE", Class: "Item_func_st_difference", Min: 2, Max: 2}, + {Name: "ST_DIMENSION", Class: "Item_func_dimension", Min: 1, Max: 1}, + {Name: "ST_DISJOINT", Class: "Item_func_st_disjoint", Min: 2, Max: 2}, + {Name: "ST_DISTANCE", Class: "Item_func_distance", Min: 2, Max: 3}, + {Name: "ST_DISTANCE_SPHERE", Class: "Item_func_st_distance_sphere", Min: 2, Max: 3}, + {Name: "ST_ENDPOINT", Class: "Item_func_spatial_decomp", Min: 1, Max: 1, Factory: "Endpoint_instantiator"}, + {Name: "ST_ENVELOPE", Class: "Item_func_envelope", Min: 1, Max: 1}, + {Name: "ST_EQUALS", Class: "Item_func_st_equals", Min: 2, Max: 2}, + {Name: "ST_EXTERIORRING", Class: "Item_func_spatial_decomp", Min: 1, Max: 1, Factory: "Exteriorring_instantiator"}, + {Name: "ST_FRECHETDISTANCE", Class: "Item_func_st_frechet_distance", Min: 2, Max: 3}, + {Name: "ST_GEOHASH", Class: "Item_func_geohash", Min: 2, Max: 3}, + {Name: "ST_GEOMCOLLFROMTEXT", Class: "Item_func_geometry_from_text", Min: 1, Max: 3, Factory: "Geomcollfromtext_instantiator"}, + {Name: "ST_GEOMCOLLFROMTXT", Class: "Item_func_geometry_from_text", Min: 1, Max: 3, Factory: "Geomcollfromtxt_instantiator"}, + {Name: "ST_GEOMCOLLFROMWKB", Class: "Item_func_geometry_from_wkb", Min: 1, Max: 3, Factory: "Geomcollfromwkb_instantiator"}, + {Name: "ST_GEOMETRYCOLLECTIONFROMTEXT", Class: "Item_func_geometry_from_text", Min: 1, Max: 3, Factory: "Geometrycollectionfromtext_instantiator"}, + {Name: "ST_GEOMETRYCOLLECTIONFROMWKB", Class: "Item_func_geometry_from_wkb", Min: 1, Max: 3, Factory: "Geometrycollectionfromwkb_instantiator"}, + {Name: "ST_GEOMETRYFROMTEXT", Class: "Item_func_geometry_from_text", Min: 1, Max: 3, Factory: "Geometryfromtext_instantiator"}, + {Name: "ST_GEOMETRYFROMWKB", Class: "Item_func_geometry_from_wkb", Min: 1, Max: 3, Factory: "Geometryfromwkb_instantiator"}, + {Name: "ST_GEOMETRYN", Class: "Item_func_spatial_decomp_n", Min: 2, Max: 2, Factory: "Sp_geometryn_instantiator"}, + {Name: "ST_GEOMETRYTYPE", Class: "Item_func_geometry_type", Min: 1, Max: 1}, + {Name: "ST_GEOMFROMGEOJSON", Class: "Item_func_geomfromgeojson", Min: 1, Max: 3}, + {Name: "ST_GEOMFROMTEXT", Class: "Item_func_geometry_from_text", Min: 1, Max: 3, Factory: "Geomfromtext_instantiator"}, + {Name: "ST_GEOMFROMWKB", Class: "Item_func_geometry_from_wkb", Min: 1, Max: 3, Factory: "Geomfromwkb_instantiator"}, + {Name: "ST_HAUSDORFFDISTANCE", Class: "Item_func_st_hausdorff_distance", Min: 2, Max: 3}, + {Name: "ST_INTERIORRINGN", Class: "Item_func_spatial_decomp_n", Min: 2, Max: 2, Factory: "Sp_interiorringn_instantiator"}, + {Name: "ST_INTERSECTS", Class: "Item_func_st_intersects", Min: 2, Max: 2}, + {Name: "ST_INTERSECTION", Class: "Item_func_st_intersection", Min: 2, Max: 2}, + {Name: "ST_ISCLOSED", Class: "Item_func_isclosed", Min: 1, Max: 1}, + {Name: "ST_ISEMPTY", Class: "Item_func_isempty", Min: 1, Max: 1}, + {Name: "ST_ISSIMPLE", Class: "Item_func_st_issimple", Min: 1, Max: 1}, + {Name: "ST_ISVALID", Class: "Item_func_isvalid", Min: 1, Max: 1}, + {Name: "ST_LATFROMGEOHASH", Class: "Item_func_latfromgeohash", Min: 1, Max: 1}, + {Name: "ST_LATITUDE", Class: "Item_func_st_latitude_mutator", Min: 1, Max: 2, Factory: "Latitude_instantiator"}, + {Name: "ST_LENGTH", Class: "Item_func_st_length", Min: 1, Max: 2}, + {Name: "ST_LINEFROMTEXT", Class: "Item_func_geometry_from_text", Min: 1, Max: 3, Factory: "Linefromtext_instantiator"}, + {Name: "ST_LINEFROMWKB", Class: "Item_func_geometry_from_wkb", Min: 1, Max: 3, Factory: "Linefromwkb_instantiator"}, + {Name: "ST_LINEINTERPOLATEPOINT", Class: "Item_func_lineinterpolatepoint", Min: 2, Max: 2}, + {Name: "ST_LINEINTERPOLATEPOINTS", Class: "Item_func_lineinterpolatepoints", Min: 2, Max: 2}, + {Name: "ST_LINESTRINGFROMTEXT", Class: "Item_func_geometry_from_text", Min: 1, Max: 3, Factory: "Linestringfromtext_instantiator"}, + {Name: "ST_LINESTRINGFROMWKB", Class: "Item_func_geometry_from_wkb", Min: 1, Max: 3, Factory: "Linestringfromwkb_instantiator"}, + {Name: "ST_LONGFROMGEOHASH", Class: "Item_func_longfromgeohash", Min: 1, Max: 1}, + {Name: "ST_LONGITUDE", Class: "Item_func_st_longitude_mutator", Min: 1, Max: 2, Factory: "Longitude_instantiator"}, + {Name: "ST_MAKEENVELOPE", Class: "Item_func_make_envelope", Min: 2, Max: 2}, + {Name: "ST_MLINEFROMTEXT", Class: "Item_func_geometry_from_text", Min: 1, Max: 3, Factory: "Mlinefromtext_instantiator"}, + {Name: "ST_MLINEFROMWKB", Class: "Item_func_geometry_from_wkb", Min: 1, Max: 3, Factory: "Mlinefromwkb_instantiator"}, + {Name: "ST_MPOINTFROMTEXT", Class: "Item_func_geometry_from_text", Min: 1, Max: 3, Factory: "Mpointfromtext_instantiator"}, + {Name: "ST_MPOINTFROMWKB", Class: "Item_func_geometry_from_wkb", Min: 1, Max: 3, Factory: "Mpointfromwkb_instantiator"}, + {Name: "ST_MPOLYFROMTEXT", Class: "Item_func_geometry_from_text", Min: 1, Max: 3, Factory: "Mpolyfromtext_instantiator"}, + {Name: "ST_MPOLYFROMWKB", Class: "Item_func_geometry_from_wkb", Min: 1, Max: 3, Factory: "Mpolyfromwkb_instantiator"}, + {Name: "ST_MULTILINESTRINGFROMTEXT", Class: "Item_func_geometry_from_text", Min: 1, Max: 3, Factory: "Multilinestringfromtext_instantiator"}, + {Name: "ST_MULTILINESTRINGFROMWKB", Class: "Item_func_geometry_from_wkb", Min: 1, Max: 3, Factory: "Multilinestringfromwkb_instantiator"}, + {Name: "ST_MULTIPOINTFROMTEXT", Class: "Item_func_geometry_from_text", Min: 1, Max: 3, Factory: "Multipointfromtext_instantiator"}, + {Name: "ST_MULTIPOINTFROMWKB", Class: "Item_func_geometry_from_wkb", Min: 1, Max: 3, Factory: "Multipointfromwkb_instantiator"}, + {Name: "ST_MULTIPOLYGONFROMTEXT", Class: "Item_func_geometry_from_text", Min: 1, Max: 3, Factory: "Multipolygonfromtext_instantiator"}, + {Name: "ST_MULTIPOLYGONFROMWKB", Class: "Item_func_geometry_from_wkb", Min: 1, Max: 3, Factory: "Multipolygonfromwkb_instantiator"}, + {Name: "ST_NUMGEOMETRIES", Class: "Item_func_numgeometries", Min: 1, Max: 1}, + {Name: "ST_NUMINTERIORRING", Class: "Item_func_numinteriorring", Min: 1, Max: 1}, + {Name: "ST_NUMINTERIORRINGS", Class: "Item_func_numinteriorring", Min: 1, Max: 1}, + {Name: "ST_NUMPOINTS", Class: "Item_func_numpoints", Min: 1, Max: 1}, + {Name: "ST_OVERLAPS", Class: "Item_func_st_overlaps", Min: 2, Max: 2}, + {Name: "ST_POINTATDISTANCE", Class: "Item_func_st_pointatdistance", Min: 2, Max: 2}, + {Name: "ST_POINTFROMGEOHASH", Class: "Item_func_pointfromgeohash", Min: 2, Max: 2}, + {Name: "ST_POINTFROMTEXT", Class: "Item_func_geometry_from_text", Min: 1, Max: 3, Factory: "Pointfromtext_instantiator"}, + {Name: "ST_POINTFROMWKB", Class: "Item_func_geometry_from_wkb", Min: 1, Max: 3, Factory: "Pointfromwkb_instantiator"}, + {Name: "ST_POINTN", Class: "Item_func_spatial_decomp_n", Min: 2, Max: 2, Factory: "Sp_pointn_instantiator"}, + {Name: "ST_POLYFROMTEXT", Class: "Item_func_geometry_from_text", Min: 1, Max: 3, Factory: "Polyfromtext_instantiator"}, + {Name: "ST_POLYFROMWKB", Class: "Item_func_geometry_from_wkb", Min: 1, Max: 3, Factory: "Polyfromwkb_instantiator"}, + {Name: "ST_POLYGONFROMTEXT", Class: "Item_func_geometry_from_text", Min: 1, Max: 3, Factory: "Polygonfromtext_instantiator"}, + {Name: "ST_POLYGONFROMWKB", Class: "Item_func_geometry_from_wkb", Min: 1, Max: 3, Factory: "Polygonfromwkb_instantiator"}, + {Name: "ST_SIMPLIFY", Class: "Item_func_st_simplify", Min: 2, Max: 2}, + {Name: "ST_SRID", Class: "Item_func_st_srid_mutator", Min: 1, Max: 2, Factory: "Srid_instantiator"}, + {Name: "ST_STARTPOINT", Class: "Item_func_spatial_decomp", Min: 1, Max: 1, Factory: "Startpoint_instantiator"}, + {Name: "ST_SYMDIFFERENCE", Class: "Item_func_st_symdifference", Min: 2, Max: 2}, + {Name: "ST_SWAPXY", Class: "Item_func_swap_xy", Min: 1, Max: 1}, + {Name: "ST_TOUCHES", Class: "Item_func_st_touches", Min: 2, Max: 2}, + {Name: "ST_TRANSFORM", Class: "Item_func_st_transform", Min: 2, Max: 2}, + {Name: "ST_UNION", Class: "Item_func_st_union", Min: 2, Max: 2}, + {Name: "ST_VALIDATE", Class: "Item_func_validate", Min: 1, Max: 1}, + {Name: "ST_WITHIN", Class: "Item_func_st_within", Min: 2, Max: 2}, + {Name: "ST_X", Class: "Item_func_st_x_mutator", Min: 1, Max: 2, Factory: "X_instantiator"}, + {Name: "ST_Y", Class: "Item_func_st_y_mutator", Min: 1, Max: 2, Factory: "Y_instantiator"}, + {Name: "SUBSTRING_INDEX", Class: "Item_func_substr_index", Min: 3, Max: 3}, + {Name: "SUBTIME", Class: "Item_func_add_time", Min: 2, Max: 2, Factory: "Subtime_instantiator"}, + {Name: "TAN", Class: "Item_func_tan", Min: 1, Max: 1}, + {Name: "TIMEDIFF", Class: "Item_func_timediff", Min: 2, Max: 2}, + {Name: "TIME_FORMAT", Class: "Item_func_date_format", Min: 2, Max: 2, Factory: "Time_format_instantiator"}, + {Name: "TIME_TO_SEC", Class: "Item_func_time_to_sec", Min: 1, Max: 1}, + {Name: "TO_BASE64", Class: "Item_func_to_base64", Min: 1, Max: 1}, + {Name: "TO_DAYS", Class: "Item_func_to_days", Min: 1, Max: 1}, + {Name: "TO_SECONDS", Class: "Item_func_to_seconds", Min: 1, Max: 1}, + {Name: "UCASE", Class: "Item_func_upper", Min: 1, Max: 1}, + {Name: "UNCOMPRESS", Class: "Item_func_uncompress", Min: 1, Max: 1}, + {Name: "UNCOMPRESSED_LENGTH", Class: "Item_func_uncompressed_length", Min: 1, Max: 1}, + {Name: "UNHEX", Class: "Item_func_unhex", Min: 1, Max: 1}, + {Name: "UNIX_TIMESTAMP", Class: "Item_func_unix_timestamp", Min: 0, Max: 1}, + {Name: "UPDATEXML", Class: "Item_func_xml_update", Min: 3, Max: 3}, + {Name: "UPPER", Class: "Item_func_upper", Min: 1, Max: 1}, + {Name: "UUID", Class: "Item_func_uuid", Min: 0, Max: 0}, + {Name: "UUID_SHORT", Class: "Item_func_uuid_short", Min: 0, Max: 0}, + {Name: "UUID_TO_BIN", Class: "Item_func_uuid_to_bin", Min: 1, Max: 2}, + {Name: "VALIDATE_PASSWORD_STRENGTH", Class: "Item_func_validate_password_strength", Min: 1, Max: 1}, + {Name: "VERSION", Class: "Item_func_version", Min: 0, Max: 0}, + {Name: "WEEKDAY", Class: "Item_func_weekday", Min: 1, Max: 1, Factory: "Weekday_instantiator"}, + {Name: "WEEKOFYEAR", Class: "Item_func_week", Min: 1, Max: 1, Factory: "Weekofyear_instantiator"}, + {Name: "YEARWEEK", Class: "Item_func_yearweek", Min: 1, Max: 2, Factory: "Yearweek_instantiator"}, + {Name: "GET_DD_COLUMN_PRIVILEGES", Class: "Item_func_get_dd_column_privileges", Min: 3, Max: 3, Internal: true}, + {Name: "GET_DD_INDEX_SUB_PART_LENGTH", Class: "Item_func_get_dd_index_sub_part_length", Min: 5, Max: 5, Internal: true}, + {Name: "GET_DD_CREATE_OPTIONS", Class: "Item_func_get_dd_create_options", Min: 3, Max: 3, Internal: true}, + {Name: "GET_DD_SCHEMA_OPTIONS", Class: "Item_func_get_dd_schema_options", Min: 1, Max: 1, Internal: true}, + {Name: "GET_DD_TABLESPACE_PRIVATE_DATA", Class: "Item_func_get_dd_tablespace_private_data", Min: 2, Max: 2, Internal: true}, + {Name: "GET_DD_INDEX_PRIVATE_DATA", Class: "Item_func_get_dd_index_private_data", Min: 2, Max: 2, Internal: true}, + {Name: "INTERNAL_DD_CHAR_LENGTH", Class: "Item_func_internal_dd_char_length", Min: 4, Max: 4, Internal: true}, + {Name: "CAN_ACCESS_DATABASE", Class: "Item_func_can_access_database", Min: 1, Max: 1, Internal: true}, + {Name: "CAN_ACCESS_TABLE", Class: "Item_func_can_access_table", Min: 2, Max: 2, Internal: true}, + {Name: "CAN_ACCESS_COLUMN", Class: "Item_func_can_access_column", Min: 3, Max: 3, Internal: true}, + {Name: "CAN_ACCESS_VIEW", Class: "Item_func_can_access_view", Min: 4, Max: 4, Internal: true}, + {Name: "CAN_ACCESS_TRIGGER", Class: "Item_func_can_access_trigger", Min: 2, Max: 2, Internal: true}, + {Name: "CAN_ACCESS_ROUTINE", Class: "Item_func_can_access_routine", Min: 5, Max: 5, Internal: true}, + {Name: "CAN_ACCESS_EVENT", Class: "Item_func_can_access_event", Min: 1, Max: 1, Internal: true}, + {Name: "CAN_ACCESS_USER", Class: "Item_func_can_access_user", Min: 2, Max: 2, Internal: true}, + {Name: "ICU_VERSION", Class: "Item_func_icu_version", Min: 0, Max: 0}, + {Name: "CAN_ACCESS_RESOURCE_GROUP", Class: "Item_func_can_access_resource_group", Min: 1, Max: 1, Internal: true}, + {Name: "CONVERT_CPU_ID_MASK", Class: "Item_func_convert_cpu_id_mask", Min: 1, Max: 1, Internal: true}, + {Name: "IS_VISIBLE_DD_OBJECT", Class: "Item_func_is_visible_dd_object", Min: 1, Max: 3, Internal: true}, + {Name: "INTERNAL_TABLE_ROWS", Class: "Item_func_internal_table_rows", Min: 8, Max: 9, Internal: true}, + {Name: "INTERNAL_AVG_ROW_LENGTH", Class: "Item_func_internal_avg_row_length", Min: 8, Max: 9, Internal: true}, + {Name: "INTERNAL_DATA_LENGTH", Class: "Item_func_internal_data_length", Min: 8, Max: 9, Internal: true}, + {Name: "INTERNAL_MAX_DATA_LENGTH", Class: "Item_func_internal_max_data_length", Min: 8, Max: 9, Internal: true}, + {Name: "INTERNAL_INDEX_LENGTH", Class: "Item_func_internal_index_length", Min: 8, Max: 9, Internal: true}, + {Name: "INTERNAL_DATA_FREE", Class: "Item_func_internal_data_free", Min: 8, Max: 9, Internal: true}, + {Name: "INTERNAL_AUTO_INCREMENT", Class: "Item_func_internal_auto_increment", Min: 9, Max: 10, Internal: true}, + {Name: "INTERNAL_CHECKSUM", Class: "Item_func_internal_checksum", Min: 8, Max: 9, Internal: true}, + {Name: "INTERNAL_UPDATE_TIME", Class: "Item_func_internal_update_time", Min: 8, Max: 9, Internal: true}, + {Name: "INTERNAL_CHECK_TIME", Class: "Item_func_internal_check_time", Min: 8, Max: 9, Internal: true}, + {Name: "INTERNAL_KEYS_DISABLED", Class: "Item_func_internal_keys_disabled", Min: 1, Max: 1, Internal: true}, + {Name: "INTERNAL_INDEX_COLUMN_CARDINALITY", Class: "Item_func_internal_index_column_cardinality", Min: 11, Max: 11, Internal: true}, + {Name: "INTERNAL_GET_COMMENT_OR_ERROR", Class: "Item_func_internal_get_comment_or_error", Min: 5, Max: 5, Internal: true}, + {Name: "INTERNAL_GET_VIEW_WARNING_OR_ERROR", Class: "Item_func_internal_get_view_warning_or_error", Min: 4, Max: 4, Internal: true}, + {Name: "INTERNAL_GET_PARTITION_NODEGROUP", Class: "Item_func_get_partition_nodegroup", Min: 1, Max: 1, Internal: true}, + {Name: "INTERNAL_TABLESPACE_ID", Class: "Item_func_internal_tablespace_id", Min: 4, Max: 4, Internal: true}, + {Name: "INTERNAL_TABLESPACE_TYPE", Class: "Item_func_internal_tablespace_type", Min: 4, Max: 4, Internal: true}, + {Name: "INTERNAL_TABLESPACE_LOGFILE_GROUP_NAME", Class: "Item_func_internal_tablespace_logfile_group_name", Min: 4, Max: 4, Internal: true}, + {Name: "INTERNAL_TABLESPACE_LOGFILE_GROUP_NUMBER", Class: "Item_func_internal_tablespace_logfile_group_number", Min: 4, Max: 4, Internal: true}, + {Name: "INTERNAL_TABLESPACE_FREE_EXTENTS", Class: "Item_func_internal_tablespace_free_extents", Min: 4, Max: 4, Internal: true}, + {Name: "INTERNAL_TABLESPACE_TOTAL_EXTENTS", Class: "Item_func_internal_tablespace_total_extents", Min: 4, Max: 4, Internal: true}, + {Name: "INTERNAL_TABLESPACE_EXTENT_SIZE", Class: "Item_func_internal_tablespace_extent_size", Min: 4, Max: 4, Internal: true}, + {Name: "INTERNAL_TABLESPACE_INITIAL_SIZE", Class: "Item_func_internal_tablespace_initial_size", Min: 4, Max: 4, Internal: true}, + {Name: "INTERNAL_TABLESPACE_MAXIMUM_SIZE", Class: "Item_func_internal_tablespace_maximum_size", Min: 4, Max: 4, Internal: true}, + {Name: "INTERNAL_TABLESPACE_AUTOEXTEND_SIZE", Class: "Item_func_internal_tablespace_autoextend_size", Min: 4, Max: 4, Internal: true}, + {Name: "INTERNAL_TABLESPACE_VERSION", Class: "Item_func_internal_tablespace_version", Min: 4, Max: 4, Internal: true}, + {Name: "INTERNAL_TABLESPACE_ROW_FORMAT", Class: "Item_func_internal_tablespace_row_format", Min: 4, Max: 4, Internal: true}, + {Name: "INTERNAL_TABLESPACE_DATA_FREE", Class: "Item_func_internal_tablespace_data_free", Min: 4, Max: 4, Internal: true}, + {Name: "INTERNAL_TABLESPACE_STATUS", Class: "Item_func_internal_tablespace_status", Min: 4, Max: 4, Internal: true}, + {Name: "INTERNAL_TABLESPACE_EXTRA", Class: "Item_func_internal_tablespace_extra", Min: 4, Max: 4, Internal: true}, + {Name: "GET_DD_PROPERTY_KEY_VALUE", Class: "Item_func_get_dd_property_key_value", Min: 2, Max: 2, Internal: true}, + {Name: "REMOVE_DD_PROPERTY_KEY", Class: "Item_func_remove_dd_property_key", Min: 2, Max: 2, Internal: true}, + {Name: "CONVERT_INTERVAL_TO_USER_INTERVAL", Class: "Item_func_convert_interval_to_user_interval", Min: 2, Max: 2, Internal: true}, + {Name: "INTERNAL_GET_DD_COLUMN_EXTRA", Class: "Item_func_internal_get_dd_column_extra", Min: 8, Max: 8, Internal: true}, + {Name: "INTERNAL_GET_USERNAME", Class: "Item_func_internal_get_username", Min: 0, Max: 1, Internal: true}, + {Name: "INTERNAL_GET_HOSTNAME", Class: "Item_func_internal_get_hostname", Min: 0, Max: 1, Internal: true}, + {Name: "INTERNAL_GET_ENABLED_ROLE_JSON", Class: "Item_func_internal_get_enabled_role_json", Min: 0, Max: 0, Internal: true}, + {Name: "INTERNAL_GET_MANDATORY_ROLES_JSON", Class: "Item_func_internal_get_mandatory_roles_json", Min: 0, Max: 0, Internal: true}, + {Name: "INTERNAL_IS_MANDATORY_ROLE", Class: "Item_func_internal_is_mandatory_role", Min: 2, Max: 2, Internal: true}, + {Name: "INTERNAL_USE_TERMINOLOGY_PREVIOUS", Class: "Item_func_internal_use_terminology_previous", Min: 0, Max: 0, Internal: true}, + {Name: "INTERNAL_IS_ENABLED_ROLE", Class: "Item_func_internal_is_enabled_role", Min: 2, Max: 2, Internal: true}, +} + +// Items is what the server declares about each Item class: its base, the family that +// fixes its result kind, and the declarative facts of its resolve_type(). +var Items = map[string]Item{ + "Item_asterisk": {Base: "Item_field", Family: "", FixFields: "own"}, + "Item_basic_constant": {Base: "Item", Family: ""}, + "Item_bin_string": {Base: "Item_hex_string", Family: ""}, + "Item_bool_func": {Base: "Item_int_func", Family: "Item_int_func", InheritsResolve: "Item_int_func", Facts: []string{"max_length=1", "set_data_type_bool()", "set_data_type_bool()", "set_data_type_bool()", "set_data_type_bool()", "set_data_type_bool()", "set_data_type_bool()", "set_data_type_bool()", "set_data_type_bool()", "set_data_type_bool()"}}, + "Item_bool_func2": {Base: "Item_bool_func", Family: "Item_bool_func", InheritsResolve: "Item_bool_func", Facts: []string{"agg_arg_charsets_for_comparison(cmp.cmp_collation,args,2)", "reject_geometry_args(arg_count,args,this)"}}, + "Item_charset_conversion": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_inherited()", "set_data_type_string((uint32)(m_cast_length>=0?m_cast_length:compute_max_char_length()))", "set_nullable(is_nullable()||max_length>thd->variables.max_allowed_packet)", "collation.set=m_cast_cs,DERIVATION_IMPLICIT)"}}, + "Item_cond": {Base: "Item_bool_func", Family: "Item_bool_func", FixFields: "own", FixNullable: true}, + "Item_cond_and": {Base: "Item_cond", Family: "Item_bool_func"}, + "Item_cond_or": {Base: "Item_cond", Family: "Item_bool_func"}, + "Item_cume_dist": {Base: "Item_non_framing_wf", Family: "Item_non_framing_wf"}, + "Item_date_add_interval": {Base: "Item_temporal_hybrid_func", Family: "Item_temporal_hybrid_func", Facts: []string{"set_nullable(true)", "param_type_is_default(thd,0,1,assumed_type)", "param_type_is_default(thd,1,2,arg1_type)", "set_data_type_datetime(dec)", "set_data_type_date()", "set_data_type_datetime(interval_dec)", "set_data_type_time(dec)", "set_data_type_datetime(dec)", "set_data_type_char(MAX_DATETIME_FULL_WIDTH,default_charset())"}}, + "Item_date_func": {Base: "Item_temporal_func", Family: "Item_temporal_func", Facts: []string{"set_data_type_date()", "set_data_type_date()", "set_data_type_date()", "set_data_type_date()", "set_data_type_date()"}}, + "Item_datetime_func": {Base: "Item_temporal_func", Family: "Item_temporal_func", Facts: []string{"set_data_type(MYSQL_TYPE_DATETIME)", "set_data_type(MYSQL_TYPE_DATETIME)", "set_data_type(MYSQL_TYPE_DATETIME)", "set_data_type(MYSQL_TYPE_DATETIME)", "set_data_type(MYSQL_TYPE_DATETIME)", "set_data_type(MYSQL_TYPE_DATETIME)", "set_data_type(MYSQL_TYPE_DATETIME)", "set_data_type(MYSQL_TYPE_DATETIME)", "set_data_type(MYSQL_TYPE_DATETIME)"}}, + "Item_dec_func": {Base: "Item_real_func", Family: "Item_real_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_DOUBLE)", "set_nullable(true)", "reject_geometry_args(arg_count,args,this)", "decimals=DECIMAL_NOT_SPECIFIED", "max_length=float_length(decimals)"}}, + "Item_decimal": {Base: "Item_num", Family: ""}, + "Item_default_value": {Base: "Item_field", Family: "", FixFields: "own"}, + "Item_exists_subselect": {Base: "Item_subselect", Family: "", FixFields: "Item_subselect", Facts: []string{"set_data_type_longlong()", "max_length=1", "set_nullable(true)"}}, + "Item_extract": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_DATETIME)", "set_nullable(true)", "max_length=5", "max_length=7", "max_length=2", "max_length=3", "max_length=3", "max_length=3", "max_length=9", "max_length=11", "max_length=13", "max_length=4", "max_length=6", "max_length=8", "max_length=3", "max_length=5", "max_length=3", "max_length=7", "max_length=20", "max_length=14", "max_length=11", "max_length=9"}}, + "Item_field": {Base: "Item_ident", Family: "", FixFields: "own", Facts: []string{"set_nullable(true)"}}, + "Item_first_last_value": {Base: "Item_sum", Family: "Item_sum", FixFields: "own", Facts: []string{"set_nullable(true)", "param_type_is_default(thd,0,1)", "set_data_type_from_item(args[0])"}}, + "Item_float": {Base: "Item_num", Family: "", Facts: []string{"set_data_type(MYSQL_TYPE_DOUBLE)", "decimals=(uint8)decimal_par", "max_length=length", "set_data_type(MYSQL_TYPE_DOUBLE)", "decimals=(uint8)decimal_par", "max_length=length", "set_data_type(MYSQL_TYPE_DOUBLE)", "decimals=(uint8)decimal_par", "max_length=float_length(decimal_par)"}}, + "Item_func": {Base: "Item_result_field", Family: "", FixFields: "own", Facts: []string{"param_type_uses_non_param(thd)"}}, + "Item_func_abs": {Base: "Item_func_num1", Family: "Item_func_num1", InheritsResolve: "Item_func_num1", Facts: []string{"unsigned_flag=args[0]->unsigned_flag"}}, + "Item_func_acos": {Base: "Item_dec_func", Family: "Item_dec_func"}, + "Item_func_add_time": {Base: "Item_temporal_hybrid_func", Family: "Item_temporal_hybrid_func", Facts: []string{"param_type_is_default(thd,0,1,m_datetime?MYSQL_TYPE_DATETIME:MYSQL_TYPE_TIME)", "param_type_is_default(thd,1,2,MYSQL_TYPE_TIME)", "set_data_type_time(dec)", "set_data_type_datetime(dec)", "set_data_type_char(MAX_DATETIME_FULL_WIDTH,default_charset())", "set_nullable(true)"}}, + "Item_func_additive_op": {Base: "Item_num_op", Family: "Item_num_op"}, + "Item_func_aes_decrypt": {Base: "Item_str_func", Family: "Item_str_func", InheritsResolve: "Item_str_func", Facts: []string{"set_data_type_string(args[0]->max_char_length())", "set_nullable(true)"}}, + "Item_func_aes_encrypt": {Base: "Item_str_func", Family: "Item_str_func", InheritsResolve: "Item_str_func", Facts: []string{"set_data_type_string(static_cast(my_aes_get_size(args[0]->max_length,(enummy_aes_opmode)aes_opmode)))"}}, + "Item_func_any_value": {Base: "Item_func_coalesce", Family: "Item_func_numhybrid"}, + "Item_func_as_geojson": {Base: "Item_json_func", Family: "Item_json_func", FixFields: "Item_json_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_as_wkb": {Base: "Item_geometry_func", Family: "Item_geometry_func", InheritsResolve: "Item_geometry_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_GEOMETRY)", "param_type_is_default(thd,1,2)", "set_data_type_blob(MYSQL_TYPE_LONG_BLOB,Field::MAX_LONG_BLOB_WIDTH)"}}, + "Item_func_as_wkt": {Base: "Item_str_ascii_func", Family: "Item_str_ascii_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_GEOMETRY)", "param_type_is_default(thd,1,2)", "set_data_type_string(uint32(MAX_BLOB_WIDTH))", "set_nullable(true)", "collation.set=default_charset(),DERIVATION_COERCIBLE,MY_REPERTOIRE_ASCII)"}}, + "Item_func_ascii": {Base: "Item_int_func", Family: "Item_int_func", InheritsResolve: "Item_int_func", Facts: []string{"max_length=3"}}, + "Item_func_asin": {Base: "Item_dec_func", Family: "Item_dec_func"}, + "Item_func_atan": {Base: "Item_dec_func", Family: "Item_dec_func"}, + "Item_func_benchmark": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_LONGLONG)", "param_type_is_default(thd,1,2)", "set_nullable(true)", "max_length=1"}}, + "Item_func_between": {Base: "Item_func_opt_neg", Family: "Item_int_func", InheritsResolve: "Item_func_opt_neg", FixFields: "Item_func_opt_neg", Facts: []string{"agg_arg_charsets_for_comparison(cmp_collation,args,3)", "reject_geometry_args(arg_count,args,this)", "max_length=1"}}, + "Item_func_bin_to_uuid": {Base: "Item_str_ascii_func", Family: "Item_str_ascii_func", InheritsResolve: "Item_str_func", Facts: []string{"param_type_is_default(thd,1,2,MYSQL_TYPE_LONGLONG)", "set_data_type_string(uint32(mysql::gtid::Uuid::TEXT_LENGTH),default_charset())", "set_nullable(true)"}}, + "Item_func_bit": {Base: "Item_func", Family: "Item_func", Facts: []string{"param_type_uses_non_param(thd)", "param_type_is_default(thd,0,1)", "param_type_is_default(thd,1,2,MYSQL_TYPE_LONGLONG)", "set_data_type_string(max(args[0]->max_length,second_arg?args[1]->max_length:0U),&my_charset_bin)", "set_data_type_longlong()", "reject_geometry_args(arg_count,args,this)", "unsigned_flag=true"}}, + "Item_func_bit_and": {Base: "Item_func_bit_two_param", Family: "Item_func_bit"}, + "Item_func_bit_count": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"max_length=MAX_BIGINT_WIDTH+1"}}, + "Item_func_bit_length": {Base: "Item_func_length", Family: "Item_int_func"}, + "Item_func_bit_neg": {Base: "Item_func_bit", Family: "Item_func_bit"}, + "Item_func_bit_or": {Base: "Item_func_bit_two_param", Family: "Item_func_bit"}, + "Item_func_bit_two_param": {Base: "Item_func_bit", Family: "Item_func_bit"}, + "Item_func_bit_xor": {Base: "Item_func_bit_two_param", Family: "Item_func_bit"}, + "Item_func_bool_const": {Base: "Item_bool_func", Family: "Item_bool_func", FixFields: "own", Facts: []string{"max_length=1", "max_length=1"}}, + "Item_func_buffer_strategy": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,1)", "param_type_is_default(thd,1,2,MYSQL_TYPE_DOUBLE)", "set_data_type_string(16,&my_charset_bin)", "set_nullable(true)"}}, + "Item_func_can_access_column": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_can_access_database": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_can_access_event": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_can_access_resource_group": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)", "max_length=1"}}, + "Item_func_can_access_routine": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)", "max_length=4"}}, + "Item_func_can_access_table": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_can_access_trigger": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)", "max_length=4"}}, + "Item_func_can_access_user": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_can_access_view": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_case": {Base: "Item_func", Family: "Item_func", FixFields: "Item_func", Facts: []string{"param_type_uses_non_param(thd)", "param_type_uses_non_param(thd)", "set_nullable(nullable)", "aggregate_type(func_name(),agg,nagg)", "agg_arg_charsets_for_comparison(cmp_collation,agg,nagg)"}}, + "Item_func_ceiling": {Base: "Item_func_int_val", Family: "Item_func_num1"}, + "Item_func_centroid": {Base: "Item_geometry_func", Family: "Item_geometry_func", InheritsResolve: "Item_geometry_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_GEOMETRY)"}}, + "Item_func_char": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_LONGLONG)", "set_data_type_string(arg_count*4U)", "collation.set=&my_charset_bin)", "collation.set=cs)", "collation.set=&my_charset_bin)"}}, + "Item_func_char_length": {Base: "Item_int_func", Family: "Item_int_func", InheritsResolve: "Item_int_func", Facts: []string{"max_length=10"}}, + "Item_func_charset": {Base: "Item_str_func", Family: "Item_str_func", InheritsResolve: "Item_str_func", Facts: []string{"set_data_type_string(64U,system_charset_info)", "set_nullable(false)"}}, + "Item_func_coalesce": {Base: "Item_func_numhybrid", Family: "Item_func_numhybrid", Facts: []string{"param_type_uses_non_param(thd)", "aggregate_type(func_name(),args,arg_count)", "set_nullable(false)"}}, + "Item_func_coercibility": {Base: "Item_int_func", Family: "Item_int_func", InheritsResolve: "Item_int_func", Facts: []string{"set_nullable(false)", "max_length=10"}}, + "Item_func_collation": {Base: "Item_str_func", Family: "Item_str_func", InheritsResolve: "Item_str_func", Facts: []string{"set_data_type_string(64U,system_charset_info)", "set_nullable(false)"}}, + "Item_func_compress": {Base: "Item_str_func", Family: "Item_str_func", InheritsResolve: "Item_str_func", Facts: []string{"set_data_type_string(5ULL+static_cast(compressBound(args[0]->max_length)))"}}, + "Item_func_concat": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,-1)", "agg_arg_charsets_for_string_result(collation,args,arg_count)", "set_data_type_string(char_length)", "set_nullable(is_nullable()||max_length>thd->variables.max_allowed_packet)"}}, + "Item_func_concat_ws": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,-1)", "agg_arg_charsets_for_string_result(collation,args,arg_count)", "set_data_type_string(char_length)", "set_nullable(is_nullable()||max_length>thd->variables.max_allowed_packet)"}}, + "Item_func_connection_id": {Base: "Item_int_func", Family: "Item_int_func", InheritsResolve: "Item_int_func", FixFields: "Item_int_func", Facts: []string{"unsigned_flag=true"}}, + "Item_func_conv": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_LONGLONG)", "set_data_type_string(CONV_MAX_LENGTH,default_charset())", "set_nullable(true)", "reject_geometry_args(arg_count,args,this)"}}, + "Item_func_conv_charset": {Base: "Item_charset_conversion", Family: "Item_str_func"}, + "Item_func_convert_cpu_id_mask": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_nullable(false)", "set_data_type_string(1024U,&my_charset_bin)"}}, + "Item_func_convert_interval_to_user_interval": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(256U,system_charset_info)", "set_nullable(true)"}}, + "Item_func_convert_tz": {Base: "Item_datetime_func", Family: "Item_datetime_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_DATETIME)", "param_type_is_default(thd,1,-1)", "set_data_type_datetime(args[0]->datetime_precision())", "set_nullable(true)"}}, + "Item_func_convex_hull": {Base: "Item_geometry_func", Family: "Item_geometry_func", InheritsResolve: "Item_geometry_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_GEOMETRY)"}}, + "Item_func_coordinate_mutator": {Base: "Item_geometry_func", Family: "Item_geometry_func", InheritsResolve: "Item_geometry_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_GEOMETRY)", "param_type_is_default(thd,1,2,MYSQL_TYPE_DOUBLE)"}}, + "Item_func_cos": {Base: "Item_dec_func", Family: "Item_dec_func"}, + "Item_func_cot": {Base: "Item_dec_func", Family: "Item_dec_func"}, + "Item_func_crc32": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1)", "max_length=10", "unsigned_flag=true", "unsigned_flag=true"}}, + "Item_func_curdate": {Base: "Item_date_func", Family: "Item_date_func", InheritsResolve: "Item_date_func"}, + "Item_func_curdate_local": {Base: "Item_func_curdate", Family: "Item_date_func"}, + "Item_func_curdate_utc": {Base: "Item_func_curdate", Family: "Item_date_func"}, + "Item_func_current_role": {Base: "Item_func_sysconst", Family: "Item_str_func", Facts: []string{"set_data_type_string(MAX_BLOB_WIDTH,system_charset_info)"}}, + "Item_func_current_user": {Base: "Item_func_user", Family: "Item_str_func"}, + "Item_func_curtime": {Base: "Item_time_func", Family: "Item_time_func", Facts: []string{"decimals=dec_arg", "set_data_type_time(decimals)"}}, + "Item_func_curtime_local": {Base: "Item_func_curtime", Family: "Item_time_func"}, + "Item_func_curtime_utc": {Base: "Item_func_curtime", Family: "Item_time_func"}, + "Item_func_database": {Base: "Item_func_sysconst", Family: "Item_str_func", Facts: []string{"set_data_type_string(uint32{NAME_CHAR_LEN})", "set_nullable(true)"}}, + "Item_func_date_format": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_DATETIME)", "param_type_is_default(thd,1,2)", "set_data_type_string(char_length)", "set_nullable(true)", "collation.set=cs,arg1->collation.derivation,repertoire)"}}, + "Item_func_dayname": {Base: "Item_func_weekday", Family: "Item_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_DATETIME)", "set_data_type_string(locale->max_day_name_length)", "set_nullable(true)", "collation.set=cs,DERIVATION_COERCIBLE,repertoire)"}}, + "Item_func_dayofmonth": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_DATETIME)", "fix_char_length(3)", "set_nullable(true)"}}, + "Item_func_dayofyear": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_DATETIME)", "fix_char_length(4)", "set_nullable(true)"}}, + "Item_func_degrees": {Base: "Item_func_units", Family: "Item_real_func"}, + "Item_func_dimension": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_GEOMETRY)", "set_nullable(true)", "max_length=10"}}, + "Item_func_distance": {Base: "Item_real_func", Family: "Item_real_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_GEOMETRY)", "set_nullable(true)", "set_nullable(true)"}}, + "Item_func_div": {Base: "Item_func_div_base", Family: "Item_num_op", InheritsResolve: "Item_num_op", Facts: []string{"set_data_type(MYSQL_TYPE_NEWDECIMAL)", "set_nullable(true)", "decimals=max(args[0]->decimals,args[1]->decimals)+m_prec_increment", "decimals=min(decimals,uint8(DECIMAL_NOT_SPECIFIED))", "decimals==DECIMAL_NOT_SPECIFIED)max_length=tmp", "max_length=args[0]->max_length-args[0]->decimals+decimals", "max_length=min(max_length,tmp)"}}, + "Item_func_div_base": {Base: "Item_num_op", Family: "Item_num_op"}, + "Item_func_div_int": {Base: "Item_func_div_base", Family: "Item_num_op", InheritsResolve: "Item_func_div_base", Facts: []string{"param_type_uses_non_param(thd,MYSQL_TYPE_LONGLONG)", "set_nullable(true)"}}, + "Item_func_elt": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_LONGLONG)", "param_type_is_default(thd,1,-1)", "agg_arg_charsets_for_string_result(collation,args+1,arg_count-1)", "set_data_type_string(char_length)", "set_nullable(true)", "decimals=0", "decimals=max(decimals,args[i]->decimals)"}}, + "Item_func_envelope": {Base: "Item_geometry_func", Family: "Item_geometry_func", InheritsResolve: "Item_geometry_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_GEOMETRY)"}}, + "Item_func_exp": {Base: "Item_dec_func", Family: "Item_dec_func"}, + "Item_func_export_set": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_LONGLONG)", "param_type_is_default(thd,1,4)", "param_type_is_default(thd,4,5,MYSQL_TYPE_LONGLONG)", "agg_arg_charsets_for_string_result(collation,args+1,min(4U,arg_count)-1)", "set_data_type_string(length*64U+sep_length*63U)", "set_nullable(is_nullable()||max_length>thd->variables.max_allowed_packet)"}}, + "Item_func_false": {Base: "Item_func_bool_const", Family: "Item_bool_func"}, + "Item_func_field": {Base: "Item_int_func", Family: "Item_int_func", InheritsResolve: "Item_int_func", Facts: []string{"set_nullable(false)", "agg_arg_charsets_for_string_result(collation,args,1)", "max_length=3"}}, + "Item_func_find_in_set": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,-1)", "agg_arg_charsets_for_comparison(cmp_collation,args,2)", "max_length=3"}}, + "Item_func_floor": {Base: "Item_func_int_val", Family: "Item_func_num1"}, + "Item_func_format": {Base: "Item_str_ascii_func", Family: "Item_str_ascii_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_NEWDECIMAL)", "param_type_is_default(thd,1,2,MYSQL_TYPE_LONGLONG)", "param_type_is_default(thd,2,3)", "set_data_type_string(char_length+max_sep_count+decimals,default_charset())", "reject_geometry_args(arg_count,args,this)", "decimals=FORMAT_MAX_DECIMALS"}}, + "Item_func_found_rows": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(false)"}}, + "Item_func_from_base64": {Base: "Item_str_func", Family: "Item_str_func", InheritsResolve: "Item_str_func", Facts: []string{"set_data_type_string(ulonglong(base64_decode_max_arg_length()))", "set_data_type_string(ulonglong(length))", "set_nullable(true)"}}, + "Item_func_from_days": {Base: "Item_date_func", Family: "Item_date_func", InheritsResolve: "Item_date_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_LONGLONG)"}}, + "Item_func_geohash": {Base: "Item_str_ascii_func", Family: "Item_str_ascii_func", FixFields: "Item_str_func", FixNullable: true, Facts: []string{"set_data_type_string(Item_func_geohash::upper_limit_output_length,default_charset())"}}, + "Item_func_geometry_from_text": {Base: "Item_geometry_func", Family: "Item_geometry_func", InheritsResolve: "Item_geometry_func", Facts: []string{"param_type_is_default(thd,0,1)", "param_type_is_default(thd,1,2,MYSQL_TYPE_LONGLONG)", "param_type_is_default(thd,2,3)"}}, + "Item_func_geometry_from_wkb": {Base: "Item_geometry_func", Family: "Item_geometry_func", InheritsResolve: "Item_geometry_func", Facts: []string{"param_type_is_default(thd,0,1)", "param_type_is_default(thd,1,2,MYSQL_TYPE_LONGLONG)", "param_type_is_default(thd,2,3)"}}, + "Item_func_geometry_type": {Base: "Item_str_ascii_func", Family: "Item_str_ascii_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_GEOMETRY)", "set_data_type_string(15,default_charset())", "set_nullable(true)"}}, + "Item_func_geomfromgeojson": {Base: "Item_geometry_func", Family: "Item_geometry_func", FixFields: "Item_geometry_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_get_dd_column_privileges": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(14U*11U,system_charset_info)", "set_nullable(true)"}}, + "Item_func_get_dd_create_options": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(256U,system_charset_info)", "set_nullable(false)"}}, + "Item_func_get_dd_index_private_data": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(256U)", "set_nullable(false)"}}, + "Item_func_get_dd_index_sub_part_length": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_get_dd_property_key_value": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(MAX_BLOB_WIDTH,system_charset_info)", "set_nullable(true)"}}, + "Item_func_get_dd_schema_options": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(256,system_charset_info)", "set_nullable(false)"}}, + "Item_func_get_dd_tablespace_private_data": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(256U)", "set_nullable(false)"}}, + "Item_func_get_format": {Base: "Item_str_ascii_func", Family: "Item_str_ascii_func", Facts: []string{"param_type_is_default(thd,0,-1)", "set_nullable(true)", "set_data_type_string(17,default_charset())"}}, + "Item_func_get_lock": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1)", "param_type_is_default(thd,1,2,MYSQL_TYPE_LONGLONG)", "set_nullable(true)", "max_length=1"}}, + "Item_func_get_partition_nodegroup": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(256U,system_charset_info)", "set_nullable(true)"}}, + "Item_func_group_concat": {Base: "Item_sum", Family: "Item_sum", FixFields: "own", Facts: []string{"set_nullable(true)"}}, + "Item_func_grouping": {Base: "Item_int_func", Family: "Item_int_func", FixFields: "Item_func"}, + "Item_func_gtid_subset": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,~0U)"}}, + "Item_func_gtid_subtract": {Base: "Item_str_ascii_func", Family: "Item_str_ascii_func", Facts: []string{"param_type_is_default(thd,0,-1)", "set_data_type_string(args[0]->max_length+max(args[1]->max_length-mysql::gtid::Uuid::TEXT_LENGTH,0)*5/2)", "collation.set=default_charset(),DERIVATION_COERCIBLE,MY_REPERTOIRE_ASCII)"}}, + "Item_func_hex": {Base: "Item_str_ascii_func", Family: "Item_str_ascii_func", Facts: []string{"param_type_is_default(thd,0,-1)", "set_data_type_string(sizeof(ulonglong)*2U,default_charset())", "set_data_type_string(args[0]->max_length*2U,default_charset())"}}, + "Item_func_hour": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_DATETIME)", "fix_char_length(4)", "set_nullable(true)"}}, + "Item_func_icu_version": {Base: "Item_static_string_func", Family: "Item_static_string_func"}, + "Item_func_if": {Base: "Item_func", Family: "Item_func", FixFields: "Item_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_LONGLONG)", "param_type_uses_non_param(thd)", "set_nullable(args[1]->is_nullable()||args[2]->is_nullable())", "aggregate_type(func_name(),args+1,2)"}}, + "Item_func_ifnull": {Base: "Item_func_coalesce", Family: "Item_func_numhybrid"}, + "Item_func_in": {Base: "Item_func_opt_neg", Family: "Item_int_func", InheritsResolve: "Item_func_opt_neg", FixFields: "Item_func_opt_neg", Facts: []string{"agg_arg_charsets_for_comparison(cmp_collation,args,arg_count)", "agg_arg_charsets_for_comparison(cmp_collation,args,arg_count)", "max_length=1"}}, + "Item_func_inet6_aton": {Base: "Item_func_inet_str_base", Family: "Item_str_ascii_func", Facts: []string{"param_type_is_default(thd,0,1)", "set_data_type_string(16,&my_charset_bin)", "set_nullable(true)"}}, + "Item_func_inet6_ntoa": {Base: "Item_func_inet_str_base", Family: "Item_str_ascii_func", Facts: []string{"param_type_is_default(thd,0,1)", "set_data_type_string(8*4+7,default_charset())", "set_nullable(true)"}}, + "Item_func_inet_aton": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1)", "set_nullable(true)", "unsigned_flag=true", "unsigned_flag=true"}}, + "Item_func_inet_bool_base": {Base: "Item_bool_func", Family: "Item_bool_func"}, + "Item_func_inet_ntoa": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_LONGLONG)", "set_data_type_string(3*8+7,default_charset())", "set_nullable(true)"}}, + "Item_func_inet_str_base": {Base: "Item_str_ascii_func", Family: "Item_str_ascii_func"}, + "Item_func_insert": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,1)", "param_type_is_default(thd,1,3,MYSQL_TYPE_LONGLONG)", "param_type_is_default(thd,3,4)", "agg_arg_charsets_for_string_result(collation,args,1)", "set_data_type_string(length)", "set_nullable(is_nullable()||max_length>thd->variables.max_allowed_packet)"}}, + "Item_func_instr": {Base: "Item_func_locate", Family: "Item_int_func"}, + "Item_func_int_val": {Base: "Item_func_num1", Family: "Item_func_num1", Facts: []string{"reject_geometry_args(arg_count,args,this)", "set_data_type_double()", "set_data_type_longlong()", "set_data_type_decimal(precision,0)", "set_data_type_longlong()", "unsigned_flag=args[0]->unsigned_flag"}}, + "Item_func_internal_auto_increment": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)", "unsigned_flag=true", "unsigned_flag=true"}}, + "Item_func_internal_avg_row_length": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)", "unsigned_flag=true", "unsigned_flag=true"}}, + "Item_func_internal_check_time": {Base: "Item_datetime_func", Family: "Item_datetime_func", Facts: []string{"set_data_type_datetime(0)", "set_nullable(true)"}}, + "Item_func_internal_checksum": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_internal_data_free": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)", "unsigned_flag=true", "unsigned_flag=true"}}, + "Item_func_internal_data_length": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)", "unsigned_flag=true", "unsigned_flag=true"}}, + "Item_func_internal_dd_char_length": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_internal_get_comment_or_error": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(2048U,system_charset_info)", "set_nullable(true)"}}, + "Item_func_internal_get_dd_column_extra": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(256U,system_charset_info)", "set_nullable(false)"}}, + "Item_func_internal_get_enabled_role_json": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(uint32(MAX_BLOB_WIDTH),system_charset_info)", "set_nullable(true)"}}, + "Item_func_internal_get_hostname": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(uint32(HOSTNAME_LENGTH+1),system_charset_info)", "set_nullable(true)"}}, + "Item_func_internal_get_mandatory_roles_json": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(uint32(MAX_BLOB_WIDTH),system_charset_info)", "set_nullable(true)"}}, + "Item_func_internal_get_username": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(uint32(USERNAME_LENGTH+1),system_charset_info)", "set_nullable(true)"}}, + "Item_func_internal_get_view_warning_or_error": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(false)", "max_length=1"}}, + "Item_func_internal_index_column_cardinality": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_internal_index_length": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)", "unsigned_flag=true", "unsigned_flag=true"}}, + "Item_func_internal_is_enabled_role": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_internal_is_mandatory_role": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_internal_keys_disabled": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(false)"}}, + "Item_func_internal_max_data_length": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)", "unsigned_flag=true", "unsigned_flag=true"}}, + "Item_func_internal_table_rows": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)", "unsigned_flag=true", "unsigned_flag=true"}}, + "Item_func_internal_tablespace_autoextend_size": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_internal_tablespace_data_free": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_internal_tablespace_extent_size": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_internal_tablespace_extra": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(256U,system_charset_info)", "set_nullable(true)"}}, + "Item_func_internal_tablespace_free_extents": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_internal_tablespace_id": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_internal_tablespace_initial_size": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_internal_tablespace_logfile_group_name": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(256U,system_charset_info)", "set_nullable(true)"}}, + "Item_func_internal_tablespace_logfile_group_number": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_internal_tablespace_maximum_size": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_internal_tablespace_row_format": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(256U,system_charset_info)", "set_nullable(true)"}}, + "Item_func_internal_tablespace_status": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(256U,system_charset_info)", "set_nullable(true)"}}, + "Item_func_internal_tablespace_total_extents": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_internal_tablespace_type": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(256U,system_charset_info)", "set_nullable(true)"}}, + "Item_func_internal_tablespace_version": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_internal_update_time": {Base: "Item_datetime_func", Family: "Item_datetime_func", Facts: []string{"set_data_type_datetime(0)", "set_nullable(true)"}}, + "Item_func_internal_use_terminology_previous": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_interval": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(false)", "max_length=2"}}, + "Item_func_is_free_lock": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1)", "set_nullable(true)", "max_length=1"}}, + "Item_func_is_ipv4": {Base: "Item_func_inet_bool_base", Family: "Item_bool_func"}, + "Item_func_is_ipv4_compat": {Base: "Item_func_inet_bool_base", Family: "Item_bool_func"}, + "Item_func_is_ipv4_mapped": {Base: "Item_func_inet_bool_base", Family: "Item_bool_func"}, + "Item_func_is_ipv6": {Base: "Item_func_inet_bool_base", Family: "Item_bool_func"}, + "Item_func_is_used_lock": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1)", "set_nullable(true)", "unsigned_flag=true", "unsigned_flag=true"}}, + "Item_func_is_uuid": {Base: "Item_bool_func", Family: "Item_bool_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_is_visible_dd_object": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(true)", "max_length=1"}}, + "Item_func_isclosed": {Base: "Item_bool_func", Family: "Item_bool_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_GEOMETRY)", "set_nullable(true)"}}, + "Item_func_isempty": {Base: "Item_bool_func", Family: "Item_bool_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_GEOMETRY)", "set_nullable(true)"}}, + "Item_func_isnotnull": {Base: "Item_bool_func", Family: "Item_bool_func", InheritsResolve: "Item_bool_func", Facts: []string{"set_nullable(false)"}}, + "Item_func_isnull": {Base: "Item_bool_func", Family: "Item_bool_func", InheritsResolve: "Item_bool_func", FixFields: "own", Facts: []string{"set_nullable(false)"}}, + "Item_func_isvalid": {Base: "Item_bool_func", Family: "Item_bool_func", InheritsResolve: "Item_bool_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_GEOMETRY)"}}, + "Item_func_json_array": {Base: "Item_json_func", Family: "Item_json_func", InheritsResolve: "Item_json_func", Facts: []string{"param_type_is_default(thd,0,-1)"}}, + "Item_func_json_array_append": {Base: "Item_func_modify_json_in_path", Family: "Item_json_func"}, + "Item_func_json_array_insert": {Base: "Item_func_modify_json_in_path", Family: "Item_json_func"}, + "Item_func_json_contains": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_JSON)", "param_type_is_default(thd,1,3)", "set_nullable(true)"}}, + "Item_func_json_contains_path": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_JSON)", "param_type_is_default(thd,1,-1)", "set_nullable(true)"}}, + "Item_func_json_depth": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_JSON)", "set_nullable(true)"}}, + "Item_func_json_extract": {Base: "Item_json_func", Family: "Item_json_func", InheritsResolve: "Item_json_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_JSON)", "param_type_is_default(thd,1,-1)"}}, + "Item_func_json_insert": {Base: "Item_func_modify_json_in_path", Family: "Item_json_func"}, + "Item_func_json_keys": {Base: "Item_json_func", Family: "Item_json_func", InheritsResolve: "Item_json_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_JSON)", "param_type_is_default(thd,1,2)"}}, + "Item_func_json_length": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_JSON)", "param_type_is_default(thd,1,2)", "set_nullable(true)"}}, + "Item_func_json_merge": {Base: "Item_func_json_merge_preserve", Family: "Item_json_func"}, + "Item_func_json_merge_patch": {Base: "Item_json_func", Family: "Item_json_func", InheritsResolve: "Item_json_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_JSON)"}}, + "Item_func_json_merge_preserve": {Base: "Item_json_func", Family: "Item_json_func", InheritsResolve: "Item_json_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_JSON)"}}, + "Item_func_json_overlaps": {Base: "Item_bool_func", Family: "Item_bool_func"}, + "Item_func_json_pretty": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_JSON)", "set_data_type_string(MAX_BLOB_WIDTH,&my_charset_utf8mb4_bin)"}}, + "Item_func_json_quote": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,-1)", "set_nullable(true)", "set_data_type_string(max_char_length,&my_charset_utf8mb4_bin)"}}, + "Item_func_json_remove": {Base: "Item_json_func", Family: "Item_json_func", InheritsResolve: "Item_json_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_JSON)", "param_type_is_default(thd,1,-1)"}}, + "Item_func_json_replace": {Base: "Item_func_json_set_replace", Family: "Item_json_func"}, + "Item_func_json_row_object": {Base: "Item_json_func", Family: "Item_json_func", InheritsResolve: "Item_json_func", Facts: []string{"param_type_is_default(thd,0,-1)"}}, + "Item_func_json_schema_valid": {Base: "Item_bool_func", Family: "Item_bool_func", FixFields: "Item_bool_func"}, + "Item_func_json_schema_validation_report": {Base: "Item_json_func", Family: "Item_json_func", FixFields: "Item_json_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_JSON)", "set_nullable(true)"}}, + "Item_func_json_search": {Base: "Item_json_func", Family: "Item_json_func", InheritsResolve: "Item_json_func", FixFields: "Item_json_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_JSON)", "param_type_is_default(thd,1,-1)"}}, + "Item_func_json_set": {Base: "Item_func_json_set_replace", Family: "Item_json_func"}, + "Item_func_json_set_replace": {Base: "Item_func_modify_json_in_path", Family: "Item_json_func"}, + "Item_func_json_storage_free": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_JSON)"}}, + "Item_func_json_storage_size": {Base: "Item_int_func", Family: "Item_int_func", InheritsResolve: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_JSON)", "set_nullable(true)"}}, + "Item_func_json_type": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_JSON)", "set_nullable(true)", "set_data_type_string(kMaxJsonTypeNameLength+1,&my_charset_utf8mb4_bin)"}}, + "Item_func_json_unquote": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,-1)", "set_nullable(true)", "set_data_type_string(args[0]->max_char_length(),&my_charset_utf8mb4_bin)"}}, + "Item_func_json_valid": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_JSON)", "set_nullable(true)"}}, + "Item_func_last_day": {Base: "Item_date_func", Family: "Item_date_func", InheritsResolve: "Item_date_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_DATETIME)", "set_nullable(true)"}}, + "Item_func_last_insert_id": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_LONGLONG)", "unsigned_flag=true", "unsigned_flag=true"}}, + "Item_func_latfromgeohash": {Base: "Item_func_latlongfromgeohash", Family: "Item_real_func"}, + "Item_func_latlongfromgeohash": {Base: "Item_real_func", Family: "Item_real_func", InheritsResolve: "Item_real_func", FixFields: "Item_real_func", FixNullable: true, Facts: []string{"param_type_is_default(thd,0,-1)", "unsigned_flag=false"}}, + "Item_func_left": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,1)", "param_type_is_default(thd,1,2,MYSQL_TYPE_LONGLONG)", "agg_arg_charsets_for_string_result(collation,args,1)"}}, + "Item_func_length": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1)", "max_length=10"}}, + "Item_func_like": {Base: "Item_bool_func2", Family: "Item_bool_func", FixFields: "Item_bool_func", Facts: []string{"agg_arg_charsets_for_comparison(cmp.cmp_collation,args,arg_count)", "reject_geometry_args(arg_count,args,this)", "max_length=1"}}, + "Item_func_like_range": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,1)", "param_type_is_default(thd,1,2,MYSQL_TYPE_LONGLONG)", "set_data_type_string(uint32{MAX_BLOB_WIDTH},args[0]->collation)", "set_nullable(true)"}}, + "Item_func_like_range_max": {Base: "Item_func_like_range", Family: "Item_str_func"}, + "Item_func_like_range_min": {Base: "Item_func_like_range", Family: "Item_str_func"}, + "Item_func_lineinterpolate": {Base: "Item_geometry_func", Family: "Item_geometry_func", InheritsResolve: "Item_geometry_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_GEOMETRY)", "param_type_is_default(thd,1,2,MYSQL_TYPE_DOUBLE)"}}, + "Item_func_lineinterpolatepoint": {Base: "Item_func_lineinterpolate", Family: "Item_geometry_func"}, + "Item_func_lineinterpolatepoints": {Base: "Item_func_lineinterpolate", Family: "Item_geometry_func"}, + "Item_func_ln": {Base: "Item_dec_func", Family: "Item_dec_func"}, + "Item_func_locate": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,2)", "param_type_is_default(thd,2,3,MYSQL_TYPE_LONGLONG)", "agg_arg_charsets_for_string_result(collation,args,1)", "max_length=MY_INT32_NUM_DECIMAL_DIGITS"}}, + "Item_func_log": {Base: "Item_dec_func", Family: "Item_dec_func"}, + "Item_func_log10": {Base: "Item_dec_func", Family: "Item_dec_func"}, + "Item_func_log2": {Base: "Item_dec_func", Family: "Item_dec_func"}, + "Item_func_longfromgeohash": {Base: "Item_func_latlongfromgeohash", Family: "Item_real_func"}, + "Item_func_lower": {Base: "Item_str_conv", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,-1)", "agg_arg_charsets_for_string_result(collation,args,1)", "set_data_type_string(args[0]->max_char_length()*multiply)"}}, + "Item_func_lpad": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,1,2,MYSQL_TYPE_LONGLONG)", "param_type_is_default(thd,0,-1)", "agg_arg_charsets_for_string_result(collation,args,1)", "set_data_type_string(char_length)", "set_nullable(is_nullable()||max_length>thd->variables.max_allowed_packet)", "set_data_type_string(uint32(MAX_BLOB_WIDTH))", "set_nullable(true)"}}, + "Item_func_ltrim": {Base: "Item_func_trim", Family: "Item_str_func"}, + "Item_func_make_envelope": {Base: "Item_geometry_func", Family: "Item_geometry_func", InheritsResolve: "Item_geometry_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_GEOMETRY)"}}, + "Item_func_make_set": {Base: "Item_str_func", Family: "Item_str_func", FixFields: "Item_func", Facts: []string{"param_type_is_default(thd,1,-1)", "agg_arg_charsets_for_string_result(collation,args+1,arg_count-1)", "set_data_type_string(char_length)"}}, + "Item_func_makedate": {Base: "Item_date_func", Family: "Item_date_func", InheritsResolve: "Item_date_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_LONGLONG)", "set_nullable(true)"}}, + "Item_func_maketime": {Base: "Item_time_func", Family: "Item_time_func", Facts: []string{"param_type_is_default(thd,0,2,MYSQL_TYPE_LONGLONG)", "param_type_is_default(thd,2,3,MYSQL_TYPE_NEWDECIMAL)", "set_data_type_time(std::min(args[2]->decimals,uint8{DATETIME_MAX_DECIMALS}))", "set_nullable(true)"}}, + "Item_func_match": {Base: "Item_real_func", Family: "Item_real_func", FixFields: "Item_func", Facts: []string{"set_nullable(true)"}}, + "Item_func_max": {Base: "Item_func_min_max", Family: "Item_func_numhybrid"}, + "Item_func_mbrcontains": {Base: "Item_func_spatial_relation", Family: "Item_bool_func"}, + "Item_func_mbrcoveredby": {Base: "Item_func_spatial_relation", Family: "Item_bool_func"}, + "Item_func_mbrcovers": {Base: "Item_func_spatial_relation", Family: "Item_bool_func"}, + "Item_func_mbrdisjoint": {Base: "Item_func_spatial_relation", Family: "Item_bool_func"}, + "Item_func_mbrequals": {Base: "Item_func_spatial_relation", Family: "Item_bool_func"}, + "Item_func_mbrintersects": {Base: "Item_func_spatial_relation", Family: "Item_bool_func"}, + "Item_func_mbroverlaps": {Base: "Item_func_spatial_relation", Family: "Item_bool_func"}, + "Item_func_mbrtouches": {Base: "Item_func_spatial_relation", Family: "Item_bool_func"}, + "Item_func_mbrwithin": {Base: "Item_func_spatial_relation", Family: "Item_bool_func"}, + "Item_func_md5": {Base: "Item_str_ascii_func", Family: "Item_str_ascii_func", Facts: []string{"param_type_is_default(thd,0,-1)", "set_data_type_string(32,default_charset())", "collation.set=cs,DERIVATION_COERCIBLE)"}}, + "Item_func_member_of": {Base: "Item_bool_func", Family: "Item_bool_func", Facts: []string{"param_type_is_default(thd,0,2,MYSQL_TYPE_JSON)"}}, + "Item_func_microsecond": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_DATETIME)", "set_nullable(true)"}}, + "Item_func_min": {Base: "Item_func_min_max", Family: "Item_func_numhybrid"}, + "Item_func_min_max": {Base: "Item_func_numhybrid", Family: "Item_func_numhybrid", Facts: []string{"reject_geometry_args(arg_count,args,this)", "param_type_uses_non_param(thd)", "aggregate_type(func_name(),args,arg_count)", "set_data_type_string(new_size)", "set_data_type(MYSQL_TYPE_VARCHAR)"}}, + "Item_func_minus": {Base: "Item_func_additive_op", Family: "Item_num_op", InheritsResolve: "Item_num_op", Facts: []string{"unsigned_flag=false"}}, + "Item_func_minute": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_DATETIME)", "fix_char_length(3)", "set_nullable(true)"}}, + "Item_func_mod": {Base: "Item_num_op", Family: "Item_num_op", InheritsResolve: "Item_num_op", Facts: []string{"set_nullable(true)", "unsigned_flag=args[0]->unsigned_flag"}}, + "Item_func_modify_json_in_path": {Base: "Item_json_func", Family: "Item_json_func", InheritsResolve: "Item_json_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_JSON)", "param_type_is_default(thd,1,-1,2,MYSQL_TYPE_VARCHAR)", "param_type_is_default(thd,2,-1,2,MYSQL_TYPE_JSON)"}}, + "Item_func_month": {Base: "Item_func", Family: "Item_func", Facts: []string{"set_data_type(MYSQL_TYPE_LONGLONG)", "param_type_is_default(thd,0,-1,MYSQL_TYPE_DATETIME)", "fix_char_length(3)", "set_nullable(true)"}}, + "Item_func_monthname": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_DATETIME)", "set_data_type_string(locale->max_month_name_length)", "set_nullable(true)", "collation.set=cs,DERIVATION_COERCIBLE,repertoire)"}}, + "Item_func_mul": {Base: "Item_num_op", Family: "Item_num_op"}, + "Item_func_neg": {Base: "Item_func_num1", Family: "Item_func_num1", InheritsResolve: "Item_func_num1", Facts: []string{"set_data_type_decimal(args[0]->decimal_precision(),0)", "unsigned_flag=false", "unsigned_flag=false"}}, + "Item_func_now": {Base: "Item_datetime_func", Family: "Item_datetime_func", Facts: []string{"decimals=dec_arg", "decimals=dec_arg", "set_data_type_datetime(decimals)"}}, + "Item_func_now_local": {Base: "Item_func_now", Family: "Item_datetime_func"}, + "Item_func_now_utc": {Base: "Item_func_now", Family: "Item_datetime_func"}, + "Item_func_nullif": {Base: "Item_bool_func2", Family: "Item_bool_func", InheritsResolve: "Item_bool_func2", Facts: []string{"set_data_type(MYSQL_TYPE_INVALID)", "set_nullable(true)", "set_data_type_from_item(args[0])", "set_data_type_string(args[0]->max_length)", "agg_arg_charsets_for_comparison(cmp.cmp_collation,args,arg_count)"}}, + "Item_func_num1": {Base: "Item_func_numhybrid", Family: "Item_func_numhybrid"}, + "Item_func_numgeometries": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_GEOMETRY)", "set_nullable(true)", "max_length=10"}}, + "Item_func_numhybrid": {Base: "Item_func", Family: "Item_func", Facts: []string{"reject_geometry_args(arg_count,args,this)"}}, + "Item_func_numinteriorring": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_GEOMETRY)", "set_nullable(true)", "max_length=10"}}, + "Item_func_numpoints": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_GEOMETRY)", "set_nullable(true)", "max_length=10"}}, + "Item_func_opt_neg": {Base: "Item_int_func", Family: "Item_int_func"}, + "Item_func_ord": {Base: "Item_int_func", Family: "Item_int_func"}, + "Item_func_period_add": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_LONGLONG)"}}, + "Item_func_period_diff": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_LONGLONG)"}}, + "Item_func_pfs_current_thread_id": {Base: "Item_int_func", Family: "Item_int_func", FixFields: "own", Facts: []string{"set_nullable(true)", "unsigned_flag=true"}}, + "Item_func_pfs_format_bytes": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_DOUBLE)", "set_data_type_string(11U,&my_charset_utf8mb3_general_ci)"}}, + "Item_func_pfs_format_pico_time": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_DOUBLE)", "set_nullable(true)", "set_data_type_string(11U,&my_charset_utf8mb3_general_ci)"}}, + "Item_func_pfs_thread_id": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_LONGLONG)", "set_nullable(true)", "unsigned_flag=true"}}, + "Item_func_pi": {Base: "Item_float", Family: "Item_float"}, + "Item_func_plus": {Base: "Item_func_additive_op", Family: "Item_num_op"}, + "Item_func_point": {Base: "Item_geometry_func", Family: "Item_geometry_func", InheritsResolve: "Item_geometry_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_DOUBLE)"}}, + "Item_func_pointfromgeohash": {Base: "Item_geometry_func", Family: "Item_geometry_func", InheritsResolve: "Item_geometry_func", FixFields: "Item_geometry_func", FixNullable: true, Facts: []string{"param_type_is_default(thd,0,1)", "param_type_is_default(thd,1,2,MYSQL_TYPE_LONGLONG)"}}, + "Item_func_pow": {Base: "Item_dec_func", Family: "Item_dec_func"}, + "Item_func_quarter": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_DATETIME)", "fix_char_length(2)", "set_nullable(true)"}}, + "Item_func_quote": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,-1)", "set_data_type_string(max_result_length)", "set_nullable(is_nullable()||max_length>thd->variables.max_allowed_packet)", "collation.set=args[0]->collation)", "collation.set=thd->variables.collation_connection)"}}, + "Item_func_radians": {Base: "Item_func_units", Family: "Item_real_func"}, + "Item_func_rand": {Base: "Item_real_func", Family: "Item_real_func", InheritsResolve: "Item_real_func", FixFields: "Item_real_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_DOUBLE)", "reject_geometry_args(arg_count,args,this)"}}, + "Item_func_random_bytes": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_LONGLONG)", "set_data_type_string(MAX_RANDOM_BYTES_BUFFER,&my_charset_bin)"}}, + "Item_func_regexp": {Base: "Item_func", Family: "Item_func", FixFields: "Item_func", Facts: []string{"param_type_is_default(thd,0,2)", "agg_arg_charsets_for_comparison(collation,args,2)"}}, + "Item_func_regexp_instr": {Base: "Item_func_regexp", Family: "Item_func", InheritsResolve: "Item_func_regexp", FixFields: "Item_func_regexp", Facts: []string{"set_data_type_longlong()", "param_type_is_default(thd,2,4,MYSQL_TYPE_LONGLONG)"}}, + "Item_func_regexp_like": {Base: "Item_func_regexp", Family: "Item_func", InheritsResolve: "Item_func_regexp", Facts: []string{"set_data_type_bool()"}}, + "Item_func_regexp_replace": {Base: "Item_func_regexp", Family: "Item_func", InheritsResolve: "Item_func_regexp", Facts: []string{"param_type_is_default(thd,2,3)", "param_type_is_default(thd,3,5,MYSQL_TYPE_LONGLONG)", "set_data_type_string(ulonglong{MAX_BLOB_WIDTH})"}}, + "Item_func_regexp_substr": {Base: "Item_func_regexp", Family: "Item_func", InheritsResolve: "Item_func_regexp", Facts: []string{"param_type_is_default(thd,2,4,MYSQL_TYPE_LONGLONG)", "set_data_type_string(subject()->max_char_length())", "set_nullable(true)"}}, + "Item_func_release_all_locks": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"unsigned_flag=true", "unsigned_flag=true"}}, + "Item_func_release_lock": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1)", "set_nullable(true)", "max_length=1"}}, + "Item_func_remove_dd_property_key": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(MAX_BLOB_WIDTH,system_charset_info)", "set_nullable(true)"}}, + "Item_func_repeat": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,1)", "param_type_is_default(thd,1,2,MYSQL_TYPE_LONGLONG)", "agg_arg_charsets_for_string_result(collation,args,1)", "set_data_type_string(char_length)", "set_nullable(is_nullable()||max_length>thd->variables.max_allowed_packet)", "set_data_type_string(uint32(MAX_BLOB_WIDTH))", "set_nullable(true)"}}, + "Item_func_replace": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,3)", "agg_arg_charsets_for_string_result(collation,args,1)", "set_data_type_string(char_length)", "set_nullable(is_nullable()||max_length>thd->variables.max_allowed_packet)"}}, + "Item_func_reverse": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,1)", "agg_arg_charsets_for_string_result(collation,args,1)", "set_data_type_string(args[0]->max_char_length())"}}, + "Item_func_right": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,1)", "param_type_is_default(thd,1,2,MYSQL_TYPE_LONGLONG)", "agg_arg_charsets_for_string_result(collation,args,1)"}}, + "Item_func_roles_graphml": {Base: "Item_func_sysconst", Family: "Item_str_func", Facts: []string{"set_data_type_string(MAX_BLOB_WIDTH,system_charset_info)"}}, + "Item_func_round": {Base: "Item_func_num1", Family: "Item_func_num1", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_NEWDECIMAL)", "param_type_is_default(thd,1,2,MYSQL_TYPE_LONGLONG)", "reject_geometry_args(arg_count,args,this)", "set_data_type_longlong()", "set_data_type_decimal(precision,new_scale)", "set_data_type_double()", "unsigned_flag=args[0]->unsigned_flag"}}, + "Item_func_row_count": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"set_nullable(false)"}}, + "Item_func_rpad": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,1,2,MYSQL_TYPE_LONGLONG)", "param_type_is_default(thd,0,-1)", "agg_arg_charsets_for_string_result(collation,args,1)", "set_data_type_string(char_length)", "set_nullable(is_nullable()||max_length>thd->variables.max_allowed_packet)", "set_data_type_string(uint32(MAX_BLOB_WIDTH))", "set_nullable(true)"}}, + "Item_func_rtrim": {Base: "Item_func_trim", Family: "Item_str_func"}, + "Item_func_sec_to_time": {Base: "Item_time_func", Family: "Item_time_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_NEWDECIMAL)", "set_data_type_time(std::min(args[0]->decimals,uint8{DATETIME_MAX_DECIMALS}))", "set_nullable(true)"}}, + "Item_func_second": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_DATETIME)", "fix_char_length(3)", "set_nullable(true)"}}, + "Item_func_set_collation": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(args[0]->max_char_length())", "collation.set=set_collation,DERIVATION_EXPLICIT,args[0]->collation.repertoire)"}}, + "Item_func_sha": {Base: "Item_str_ascii_func", Family: "Item_str_ascii_func", Facts: []string{"param_type_is_default(thd,0,1)", "set_data_type_string(SHA1_HASH_SIZE*2,default_charset())", "collation.set=cs,DERIVATION_COERCIBLE)"}}, + "Item_func_sha2": {Base: "Item_str_ascii_func", Family: "Item_str_ascii_func", Facts: []string{"param_type_is_default(thd,0,1)", "param_type_is_default(thd,1,2,MYSQL_TYPE_LONGLONG)", "set_nullable(true)", "set_data_type_string(SHA512_DIGEST_LENGTH*2,default_charset())", "set_data_type_string(SHA384_DIGEST_LENGTH*2,default_charset())", "set_data_type_string(SHA256_DIGEST_LENGTH*2,default_charset())", "set_data_type_string(SHA224_DIGEST_LENGTH*2,default_charset())", "set_data_type_string(SHA256_DIGEST_LENGTH*2,default_charset())", "collation.set=cs,DERIVATION_COERCIBLE)"}}, + "Item_func_shift": {Base: "Item_func_bit", Family: "Item_func_bit"}, + "Item_func_shift_left": {Base: "Item_func_shift", Family: "Item_func_bit"}, + "Item_func_shift_right": {Base: "Item_func_shift", Family: "Item_func_bit"}, + "Item_func_sign": {Base: "Item_int_func", Family: "Item_int_func", InheritsResolve: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_DOUBLE)", "reject_geometry_args(arg_count,args,this)"}}, + "Item_func_sin": {Base: "Item_dec_func", Family: "Item_dec_func"}, + "Item_func_sleep": {Base: "Item_int_func", Family: "Item_int_func", InheritsResolve: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_DOUBLE)"}}, + "Item_func_soundex": {Base: "Item_str_func", Family: "Item_str_func", InheritsResolve: "Item_str_func", Facts: []string{"agg_arg_charsets_for_string_result(collation,args,1)", "set_data_type_string(char_length)"}}, + "Item_func_space": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_LONGLONG)", "set_data_type_string(ulonglong(count))", "set_nullable(is_nullable()||max_length>thd->variables.max_allowed_packet)", "set_data_type_string(uint32(MAX_BLOB_WIDTH))", "set_nullable(true)", "collation.set=default_charset(),DERIVATION_COERCIBLE,MY_REPERTOIRE_ASCII)"}}, + "Item_func_spatial_collection": {Base: "Item_geometry_func", Family: "Item_geometry_func", InheritsResolve: "Item_geometry_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_GEOMETRY)"}}, + "Item_func_spatial_decomp": {Base: "Item_geometry_func", Family: "Item_geometry_func", InheritsResolve: "Item_geometry_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_GEOMETRY)"}}, + "Item_func_spatial_decomp_n": {Base: "Item_geometry_func", Family: "Item_geometry_func", InheritsResolve: "Item_geometry_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_GEOMETRY)", "param_type_is_default(thd,1,2,MYSQL_TYPE_LONGLONG)"}}, + "Item_func_spatial_operation": {Base: "Item_geometry_func", Family: "Item_geometry_func", InheritsResolve: "Item_geometry_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_GEOMETRY)"}}, + "Item_func_spatial_relation": {Base: "Item_bool_func2", Family: "Item_bool_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_GEOMETRY)", "set_nullable(true)"}}, + "Item_func_sqrt": {Base: "Item_dec_func", Family: "Item_dec_func"}, + "Item_func_st_area": {Base: "Item_real_func", Family: "Item_real_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_GEOMETRY)", "set_nullable(true)"}}, + "Item_func_st_buffer": {Base: "Item_geometry_func", Family: "Item_geometry_func", InheritsResolve: "Item_geometry_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_GEOMETRY)", "param_type_is_default(thd,1,2,MYSQL_TYPE_DOUBLE)", "param_type_is_default(thd,2,-1)"}}, + "Item_func_st_contains": {Base: "Item_func_spatial_relation", Family: "Item_bool_func"}, + "Item_func_st_crosses": {Base: "Item_func_spatial_relation", Family: "Item_bool_func"}, + "Item_func_st_difference": {Base: "Item_func_spatial_operation", Family: "Item_geometry_func"}, + "Item_func_st_disjoint": {Base: "Item_func_spatial_relation", Family: "Item_bool_func"}, + "Item_func_st_distance_sphere": {Base: "Item_real_func", Family: "Item_real_func", InheritsResolve: "Item_real_func", Facts: []string{"param_type_is_default(thd,0,2,MYSQL_TYPE_GEOMETRY)", "param_type_is_default(thd,2,3,MYSQL_TYPE_DOUBLE)"}}, + "Item_func_st_equals": {Base: "Item_func_spatial_relation", Family: "Item_bool_func"}, + "Item_func_st_frechet_distance": {Base: "Item_real_func", Family: "Item_real_func", InheritsResolve: "Item_real_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_GEOMETRY)", "set_nullable(true)"}}, + "Item_func_st_hausdorff_distance": {Base: "Item_real_func", Family: "Item_real_func", InheritsResolve: "Item_real_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_GEOMETRY)", "set_nullable(true)"}}, + "Item_func_st_intersection": {Base: "Item_func_spatial_operation", Family: "Item_geometry_func"}, + "Item_func_st_intersects": {Base: "Item_func_spatial_relation", Family: "Item_bool_func"}, + "Item_func_st_issimple": {Base: "Item_bool_func", Family: "Item_bool_func", InheritsResolve: "Item_bool_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_GEOMETRY)"}}, + "Item_func_st_latitude_mutator": {Base: "Item_func_coordinate_mutator", Family: "Item_geometry_func"}, + "Item_func_st_length": {Base: "Item_real_func", Family: "Item_real_func", InheritsResolve: "Item_real_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_GEOMETRY)", "param_type_is_default(thd,0,-1,MYSQL_TYPE_GEOMETRY)", "set_nullable(true)"}}, + "Item_func_st_longitude_mutator": {Base: "Item_func_coordinate_mutator", Family: "Item_geometry_func"}, + "Item_func_st_overlaps": {Base: "Item_func_spatial_relation", Family: "Item_bool_func"}, + "Item_func_st_pointatdistance": {Base: "Item_func_lineinterpolate", Family: "Item_geometry_func"}, + "Item_func_st_simplify": {Base: "Item_geometry_func", Family: "Item_geometry_func", InheritsResolve: "Item_geometry_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_GEOMETRY)", "param_type_is_default(thd,1,2,MYSQL_TYPE_DOUBLE)"}}, + "Item_func_st_srid_mutator": {Base: "Item_geometry_func", Family: "Item_geometry_func", InheritsResolve: "Item_geometry_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_GEOMETRY)", "param_type_is_default(thd,1,2,MYSQL_TYPE_LONGLONG)"}}, + "Item_func_st_symdifference": {Base: "Item_func_spatial_operation", Family: "Item_geometry_func"}, + "Item_func_st_touches": {Base: "Item_func_spatial_relation", Family: "Item_bool_func"}, + "Item_func_st_transform": {Base: "Item_geometry_func", Family: "Item_geometry_func"}, + "Item_func_st_union": {Base: "Item_func_spatial_operation", Family: "Item_geometry_func"}, + "Item_func_st_within": {Base: "Item_func_spatial_relation", Family: "Item_bool_func"}, + "Item_func_st_x_mutator": {Base: "Item_func_coordinate_mutator", Family: "Item_geometry_func"}, + "Item_func_st_y_mutator": {Base: "Item_func_coordinate_mutator", Family: "Item_geometry_func"}, + "Item_func_statement_digest": {Base: "Item_str_ascii_func", Family: "Item_str_ascii_func", Facts: []string{"param_type_is_default(thd,0,1)", "set_data_type_string(DIGEST_HASH_TO_STRING_LENGTH,default_charset())"}}, + "Item_func_statement_digest_text": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,1)", "set_data_type_string(MAX_BLOB_WIDTH,args[0]->collation)"}}, + "Item_func_str_to_date": {Base: "Item_temporal_hybrid_func", Family: "Item_temporal_hybrid_func", Facts: []string{"param_type_is_default(thd,0,2)", "set_nullable(true)", "set_data_type_datetime(DATETIME_MAX_DECIMALS)"}}, + "Item_func_strcmp": {Base: "Item_bool_func2", Family: "Item_bool_func", InheritsResolve: "Item_bool_func2", Facts: []string{"fix_char_length(2)", "unsigned_flag=false"}}, + "Item_func_substr": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,1)", "param_type_is_default(thd,1,3,MYSQL_TYPE_LONGLONG)", "agg_arg_charsets_for_string_result(collation,args,1)", "set_data_type_string(0U)", "set_data_type_string(max_char_length)"}}, + "Item_func_substr_index": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,2)", "param_type_is_default(thd,2,3,MYSQL_TYPE_LONGLONG)", "agg_arg_charsets_for_string_result(collation,args,1)", "set_data_type_string(args[0]->max_char_length())"}}, + "Item_func_swap_xy": {Base: "Item_geometry_func", Family: "Item_geometry_func", InheritsResolve: "Item_geometry_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_GEOMETRY)"}}, + "Item_func_sysconst": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"collation.set=system_charset_info,DERIVATION_SYSCONST)", "collation.set=system_charset_info,DERIVATION_SYSCONST)"}}, + "Item_func_tan": {Base: "Item_dec_func", Family: "Item_dec_func"}, + "Item_func_time_to_sec": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_TIME)", "fix_char_length(10)", "set_nullable(true)"}}, + "Item_func_timediff": {Base: "Item_time_func", Family: "Item_time_func", Facts: []string{"param_type_uses_non_param(thd)", "set_data_type_time(std::max(args[0]->time_precision(),args[1]->time_precision()))", "set_nullable(true)"}}, + "Item_func_timestamp_diff": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_DATETIME)", "set_nullable(true)"}}, + "Item_func_to_base64": {Base: "Item_str_ascii_func", Family: "Item_str_ascii_func", InheritsResolve: "Item_str_func", Facts: []string{"set_nullable(true)", "set_data_type_string((ulonglong)base64_encode_max_arg_length())", "set_data_type_string((ulonglong)length-1)", "set_nullable(args[0]->is_nullable()||max_length>thd->variables.max_allowed_packet)", "collation.set=default_charset(),DERIVATION_COERCIBLE,MY_REPERTOIRE_ASCII)"}}, + "Item_func_to_days": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_DATETIME)", "fix_char_length(8)", "set_nullable(true)"}}, + "Item_func_to_seconds": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_DATETIME)", "set_nullable(true)"}}, + "Item_func_trim": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,-1)", "agg_arg_charsets_for_string_result(collation,args,1)", "set_data_type_string(args[0]->max_char_length())"}}, + "Item_func_true": {Base: "Item_func_bool_const", Family: "Item_bool_func"}, + "Item_func_uncompress": {Base: "Item_str_func", Family: "Item_str_func", InheritsResolve: "Item_str_func", Facts: []string{"set_nullable(true)", "set_data_type_string(uint32{MAX_BLOB_WIDTH})"}}, + "Item_func_uncompressed_length": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1)", "max_length=10"}}, + "Item_func_unhex": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_nullable(true)", "param_type_is_default(thd,0,-1)", "set_data_type_string((uint64_t{1}+args[0]->max_length)/2,&my_charset_bin)"}}, + "Item_func_units": {Base: "Item_real_func", Family: "Item_real_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_DOUBLE)", "reject_geometry_args(arg_count,args,this)", "decimals=DECIMAL_NOT_SPECIFIED", "max_length=float_length(decimals)"}}, + "Item_func_unix_timestamp": {Base: "Item_timeval_func", Family: "Item_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_DATETIME)", "set_data_type_decimal(11+dec,dec)", "set_data_type_longlong()"}}, + "Item_func_upper": {Base: "Item_str_conv", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,-1)", "agg_arg_charsets_for_string_result(collation,args,1)", "set_data_type_string(args[0]->max_char_length()*multiply)"}}, + "Item_func_user": {Base: "Item_func_sysconst", Family: "Item_str_func", Facts: []string{"set_data_type_string(uint32{USERNAME_CHAR_LENGTH+HOSTNAME_LENGTH+1U})"}}, + "Item_func_uuid": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type_string(uint32(UUID_LENGTH))", "collation.set=system_charset_info,DERIVATION_COERCIBLE,MY_REPERTOIRE_ASCII)"}}, + "Item_func_uuid_short": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"unsigned_flag=true", "unsigned_flag=true"}}, + "Item_func_uuid_to_bin": {Base: "Item_str_func", Family: "Item_str_func", InheritsResolve: "Item_str_func", Facts: []string{"param_type_is_default(thd,1,2,MYSQL_TYPE_LONGLONG)", "set_data_type_string(uint32(mysql::gtid::Uuid::BYTE_LENGTH),&my_charset_bin)", "set_nullable(true)"}}, + "Item_func_validate": {Base: "Item_geometry_func", Family: "Item_geometry_func", InheritsResolve: "Item_geometry_func", Facts: []string{"param_type_is_default(thd,0,-1,MYSQL_TYPE_GEOMETRY)"}}, + "Item_func_validate_password_strength": {Base: "Item_int_func", Family: "Item_int_func", InheritsResolve: "Item_int_func", Facts: []string{"set_nullable(true)", "max_length=10"}}, + "Item_func_version": {Base: "Item_static_string_func", Family: "Item_static_string_func"}, + "Item_func_week": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_DATETIME)", "param_type_is_default(thd,1,2,MYSQL_TYPE_LONGLONG)", "fix_char_length(3)", "set_nullable(true)"}}, + "Item_func_weekday": {Base: "Item_func", Family: "Item_func", Facts: []string{"set_data_type(MYSQL_TYPE_LONGLONG)", "param_type_is_default(thd,0,1,MYSQL_TYPE_DATETIME)", "fix_char_length(2)", "set_nullable(true)"}}, + "Item_func_weight_string": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,1)", "set_data_type_string(len)", "set_nullable(true)", "collation.set=&my_charset_bin,args[0]->collation.derivation)"}}, + "Item_func_xml_extractvalue": {Base: "Item_xml_str_func", Family: "Item_str_func"}, + "Item_func_xml_update": {Base: "Item_xml_str_func", Family: "Item_str_func"}, + "Item_func_xor": {Base: "Item_bool_func2", Family: "Item_bool_func"}, + "Item_func_year": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_DATETIME)", "set_data_type_year()", "set_nullable(true)"}}, + "Item_func_yearweek": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_DATETIME)", "param_type_is_default(thd,1,2,MYSQL_TYPE_LONGLONG)", "fix_char_length(7)", "set_nullable(true)"}}, + "Item_geometry_func": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_data_type(MYSQL_TYPE_GEOMETRY)", "set_nullable(true)", "collation.set=&my_charset_bin)", "max_length=0xFFFFFFFFU"}}, + "Item_hex_string": {Base: "Item_basic_constant", Family: "", Facts: []string{"set_data_type(MYSQL_TYPE_VARCHAR)"}}, + "Item_ident": {Base: "Item", Family: ""}, + "Item_in_subselect": {Base: "Item_exists_subselect", Family: ""}, + "Item_insert_value": {Base: "Item_field", Family: "", FixFields: "own"}, + "Item_int": {Base: "Item_num", Family: "", Facts: []string{"set_data_type(MYSQL_TYPE_LONGLONG)", "set_data_type(MYSQL_TYPE_LONGLONG)", "set_data_type(MYSQL_TYPE_LONGLONG)", "set_data_type(MYSQL_TYPE_LONGLONG)", "unsigned_flag=true", "set_data_type(item_arg->data_type())", "max_length=item_arg->max_length", "set_data_type(MYSQL_TYPE_LONGLONG)", "set_data_type(MYSQL_TYPE_LONGLONG)", "set_data_type(MYSQL_TYPE_LONGLONG)", "set_data_type(MYSQL_TYPE_LONGLONG)", "unsigned_flag=true"}}, + "Item_int_func": {Base: "Item_func", Family: "Item_func", Facts: []string{"set_data_type_longlong()", "set_data_type_longlong()", "set_data_type_longlong()", "set_data_type_longlong()", "set_data_type_longlong()", "set_data_type_longlong()", "set_data_type_longlong()", "set_data_type_longlong()", "set_data_type_longlong()", "set_data_type_longlong()", "set_data_type_longlong()", "set_data_type_longlong()", "set_data_type_longlong()"}}, + "Item_json_func": {Base: "Item_func", Family: "Item_func", Facts: []string{"set_nullable(true)", "set_data_type_json()"}}, + "Item_lead_lag": {Base: "Item_non_framing_wf", Family: "Item_non_framing_wf", FixFields: "own", Facts: []string{"param_type_uses_non_param(thd)", "aggregate_type(func_name(),args,arg_count)", "set_nullable(args[1]->is_nullable()||args[0]->is_nullable())", "set_nullable(true)"}}, + "Item_load_file": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"param_type_is_default(thd,0,1)", "set_data_type_blob(MYSQL_TYPE_LONG_BLOB,MAX_BLOB_WIDTH)", "set_nullable(true)", "collation.set=&my_charset_bin,DERIVATION_COERCIBLE)", "collation.set=&my_charset_bin)"}}, + "Item_master_pos_wait": {Base: "Item_source_pos_wait", Family: "Item_int_func"}, + "Item_name_const": {Base: "Item", Family: "", FixFields: "own"}, + "Item_non_framing_wf": {Base: "Item_sum", Family: "Item_sum", FixFields: "own"}, + "Item_nth_value": {Base: "Item_sum", Family: "Item_sum", FixFields: "own", Facts: []string{"param_type_is_default(thd,0,1)", "set_nullable(true)", "set_data_type_from_item(args[0])"}}, + "Item_ntile": {Base: "Item_non_framing_wf", Family: "Item_non_framing_wf", FixFields: "own", Facts: []string{"set_data_type_longlong()", "unsigned_flag=true", "unsigned_flag=true"}}, + "Item_null": {Base: "Item_basic_constant", Family: ""}, + "Item_num": {Base: "Item_basic_constant", Family: ""}, + "Item_num_op": {Base: "Item_func_numhybrid", Family: "Item_func_numhybrid"}, + "Item_percent_rank": {Base: "Item_non_framing_wf", Family: "Item_non_framing_wf"}, + "Item_rank": {Base: "Item_non_framing_wf", Family: "Item_non_framing_wf", Facts: []string{"unsigned_flag=true", "unsigned_flag=true"}}, + "Item_real_func": {Base: "Item_func", Family: "Item_func", Facts: []string{"set_data_type_double()", "set_data_type_double()", "set_data_type_double()", "set_data_type_double()", "set_data_type_double()", "set_data_type_double()", "set_data_type_double()", "set_data_type_double()"}}, + "Item_result_field": {Base: "Item", Family: ""}, + "Item_row": {Base: "Item", Family: "", FixFields: "own", Facts: []string{"set_data_type(MYSQL_TYPE_INVALID)", "set_nullable(false)"}}, + "Item_row_number": {Base: "Item_non_framing_wf", Family: "Item_non_framing_wf", Facts: []string{"unsigned_flag=true", "unsigned_flag=true"}}, + "Item_source_pos_wait": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1)", "param_type_is_default(thd,1,3,MYSQL_TYPE_LONGLONG)", "param_type_is_default(thd,3,4)", "set_nullable(true)", "max_length=21"}}, + "Item_static_string_func": {Base: "Item_string", Family: "Item_string"}, + "Item_str_ascii_func": {Base: "Item_str_func", Family: "Item_str_func"}, + "Item_str_conv": {Base: "Item_str_func", Family: "Item_str_func"}, + "Item_str_func": {Base: "Item_func", Family: "Item_func", FixFields: "Item_func", FixNullable: true, Facts: []string{"param_type_is_default(thd,0,-1)"}}, + "Item_string": {Base: "Item_basic_constant", Family: "", Facts: []string{"set_data_type(MYSQL_TYPE_VARCHAR)", "set_data_type(MYSQL_TYPE_VARCHAR)", "collation.set=cs,dv)", "max_length=0", "decimals=DECIMAL_NOT_SPECIFIED", "set_data_type(MYSQL_TYPE_VARCHAR)", "collation.set=cs,dv,repertoire)", "max_length=static_cast(str_value.numchars()*cs->mbmaxlen)", "decimals=DECIMAL_NOT_SPECIFIED", "set_data_type(MYSQL_TYPE_VARCHAR)", "collation.set=cs,dv,repertoire)", "max_length=static_cast(str_value.numchars()*cs->mbmaxlen)", "decimals=DECIMAL_NOT_SPECIFIED", "set_data_type(MYSQL_TYPE_VARCHAR)", "collation.set=cs,dv,repertoire)", "max_length=static_cast(str_value.numchars()*cs->mbmaxlen)", "decimals=DECIMAL_NOT_SPECIFIED"}}, + "Item_subselect": {Base: "Item_result_field", Family: "", FixFields: "own"}, + "Item_sum": {Base: "Item_func", Family: "Item_func", Facts: []string{"param_type_is_default(thd,0,-1)", "set_nullable(true)", "reject_geometry_args(arg_count,args,this)"}}, + "Item_sum_and": {Base: "Item_sum_bit", Family: "Item_sum_bit"}, + "Item_sum_avg": {Base: "Item_sum_sum", Family: "Item_sum_sum", InheritsResolve: "Item_sum_sum", Facts: []string{"set_nullable(true)", "set_data_type_decimal(precision,scale)", "decimals=min(decimals+prec_increment,DECIMAL_NOT_SPECIFIED)", "max_length=float_length(decimals)"}}, + "Item_sum_bit": {Base: "Item_sum", Family: "Item_sum", FixFields: "own", Facts: []string{"set_data_type(MYSQL_TYPE_VARCHAR)", "set_data_type(MYSQL_TYPE_LONGLONG)", "set_nullable(false)", "reject_geometry_args(arg_count,args,this)", "max_length=(CONVERT_IF_BIGGER_TO_BLOB-1)", "max_length=0", "max_length=max(max_length,args[i]->max_length)", "max_length=MAX_BIGINT_WIDTH+1", "decimals=0", "unsigned_flag=true"}}, + "Item_sum_collect": {Base: "Item_sum", Family: "Item_sum", FixFields: "own", Facts: []string{"set_data_type_geometry()", "set_data_type_geometry()", "set_nullable(true)"}}, + "Item_sum_count": {Base: "Item_sum_int", Family: "Item_sum_int", Facts: []string{"param_type_is_default(thd,0,-1)", "set_nullable(false)"}}, + "Item_sum_hybrid": {Base: "Item_sum", Family: "Item_sum", FixFields: "own", Facts: []string{"collation.set=&my_charset_bin)", "collation.set=&my_charset_bin)", "collation.set=&my_charset_bin)", "set_nullable(true)"}}, + "Item_sum_int": {Base: "Item_sum_num", Family: "Item_sum_num", Facts: []string{"set_data_type_longlong()", "set_data_type_longlong()", "set_data_type_longlong()", "set_data_type_longlong()"}}, + "Item_sum_json": {Base: "Item_sum", Family: "Item_sum", FixFields: "own", Facts: []string{"set_nullable(true)"}}, + "Item_sum_json_array": {Base: "Item_sum_json", Family: "Item_sum"}, + "Item_sum_json_object": {Base: "Item_sum_json", Family: "Item_sum"}, + "Item_sum_max": {Base: "Item_sum_hybrid", Family: "Item_sum_hybrid"}, + "Item_sum_min": {Base: "Item_sum_hybrid", Family: "Item_sum_hybrid"}, + "Item_sum_num": {Base: "Item_sum", Family: "Item_sum", FixFields: "own", Facts: []string{"set_nullable(false)"}}, + "Item_sum_or": {Base: "Item_sum_bit", Family: "Item_sum_bit"}, + "Item_sum_std": {Base: "Item_sum_variance", Family: "Item_sum_num"}, + "Item_sum_sum": {Base: "Item_sum_num", Family: "Item_sum_num", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_DOUBLE)", "reject_geometry_args(arg_count,args,this)", "set_nullable(true)", "set_data_type_double()", "set_data_type_decimal(precision,args[0]->decimals)", "decimals=args[0]->decimals", "max_length=float_length(decimals)"}}, + "Item_sum_variance": {Base: "Item_sum_num", Family: "Item_sum_num", Facts: []string{"param_type_is_default(thd,0,1,MYSQL_TYPE_NEWDECIMAL)", "set_nullable(true)", "set_data_type_double()", "reject_geometry_args(arg_count,args,this)"}}, + "Item_sum_xor": {Base: "Item_sum_bit", Family: "Item_sum_bit"}, + "Item_temporal_func": {Base: "Item_func", Family: "Item_func"}, + "Item_temporal_hybrid_func": {Base: "Item_str_func", Family: "Item_str_func"}, + "Item_time_func": {Base: "Item_temporal_func", Family: "Item_temporal_func", Facts: []string{"set_data_type(MYSQL_TYPE_TIME)", "set_data_type(MYSQL_TYPE_TIME)", "set_data_type(MYSQL_TYPE_TIME)", "set_data_type(MYSQL_TYPE_TIME)", "set_data_type(MYSQL_TYPE_TIME)", "set_data_type(MYSQL_TYPE_TIME)"}}, + "Item_timeval_func": {Base: "Item_func", Family: "Item_func"}, + "Item_typecast_date": {Base: "Item_date_func", Family: "Item_date_func", InheritsResolve: "Item_date_func", Facts: []string{"set_nullable(true)", "set_nullable(true)"}}, + "Item_typecast_datetime": {Base: "Item_datetime_func", Family: "Item_datetime_func", Facts: []string{"set_data_type_datetime(detect_precision_from_arg?args[0]->datetime_precision():decimals)", "set_nullable(true)", "decimals=dec_arg", "decimals=dec_arg"}}, + "Item_typecast_time": {Base: "Item_time_func", Family: "Item_time_func", Facts: []string{"set_data_type_time(detect_precision_from_arg?args[0]->time_precision():decimals)", "set_nullable(true)", "decimals=dec_arg", "decimals=dec_arg"}}, + "Item_uint": {Base: "Item_int", Family: "Item_int", Facts: []string{"unsigned_flag=true", "unsigned_flag=true", "unsigned_flag=true", "unsigned_flag=true"}}, + "Item_user_var_as_out_param": {Base: "Item", Family: "", FixFields: "own"}, + "Item_wait_for_executed_gtid_set": {Base: "Item_int_func", Family: "Item_int_func", Facts: []string{"param_type_is_default(thd,0,1)", "param_type_is_default(thd,1,2,MYSQL_TYPE_DOUBLE)", "set_nullable(true)"}}, + "Item_xml_str_func": {Base: "Item_str_func", Family: "Item_str_func", Facts: []string{"set_nullable(true)", "set_nullable(true)", "param_type_is_default(thd,0,-1)", "agg_arg_charsets_for_comparison(collation,args,arg_count)", "set_data_type_string(uint32(MAX_BLOB_WIDTH))"}}, +} + +// FieldTypes is the index order of MergeRules and ResultKinds (sql/field.cc field_type2index): +// the enum_field_types names without their MYSQL_TYPE_ prefix. +var FieldTypes = [...]string{"DECIMAL", "TINY", "SHORT", "LONG", "FLOAT", "DOUBLE", "NULL", "TIMESTAMP", "LONGLONG", "INT24", "DATE", "TIME", "DATETIME", "YEAR", "NEWDATE", "VARCHAR", "BIT", "INVALID", "BOOL", "JSON", "NEWDECIMAL", "ENUM", "SET", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VAR_STRING", "STRING", "GEOMETRY"} + +// MergeRules is field_types_merge_rules: the type two values of types FieldTypes[i] and +// FieldTypes[j] aggregate to, as CASE, IF, COALESCE, GREATEST and UNION do. +var MergeRules = [...][30]string{ + /* DECIMAL */ {"NEWDECIMAL", "NEWDECIMAL", "NEWDECIMAL", "NEWDECIMAL", "DOUBLE", "DOUBLE", "NEWDECIMAL", "VARCHAR", "DECIMAL", "DECIMAL", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "NEWDECIMAL", "INVALID", "DECIMAL", "VARCHAR", "NEWDECIMAL", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "VARCHAR"}, + /* TINY */ {"NEWDECIMAL", "TINY", "SHORT", "LONG", "FLOAT", "DOUBLE", "TINY", "VARCHAR", "LONGLONG", "INT24", "VARCHAR", "VARCHAR", "VARCHAR", "TINY", "VARCHAR", "VARCHAR", "LONGLONG", "INVALID", "TINY", "VARCHAR", "NEWDECIMAL", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "VARCHAR"}, + /* SHORT */ {"NEWDECIMAL", "SHORT", "SHORT", "LONG", "FLOAT", "DOUBLE", "SHORT", "VARCHAR", "LONGLONG", "INT24", "VARCHAR", "VARCHAR", "VARCHAR", "SHORT", "VARCHAR", "VARCHAR", "LONGLONG", "INVALID", "SHORT", "VARCHAR", "NEWDECIMAL", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "VARCHAR"}, + /* LONG */ {"NEWDECIMAL", "LONG", "LONG", "LONG", "DOUBLE", "DOUBLE", "LONG", "VARCHAR", "LONGLONG", "LONG", "VARCHAR", "VARCHAR", "VARCHAR", "LONG", "VARCHAR", "VARCHAR", "LONGLONG", "INVALID", "LONG", "VARCHAR", "NEWDECIMAL", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "VARCHAR"}, + /* FLOAT */ {"DOUBLE", "FLOAT", "FLOAT", "DOUBLE", "FLOAT", "DOUBLE", "FLOAT", "VARCHAR", "FLOAT", "FLOAT", "VARCHAR", "VARCHAR", "VARCHAR", "FLOAT", "VARCHAR", "VARCHAR", "DOUBLE", "INVALID", "FLOAT", "VARCHAR", "DOUBLE", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "VARCHAR"}, + /* DOUBLE */ {"DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE", "VARCHAR", "DOUBLE", "DOUBLE", "VARCHAR", "VARCHAR", "VARCHAR", "DOUBLE", "VARCHAR", "VARCHAR", "DOUBLE", "INVALID", "DOUBLE", "VARCHAR", "DOUBLE", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "VARCHAR"}, + /* NULL */ {"NEWDECIMAL", "TINY", "SHORT", "LONG", "FLOAT", "DOUBLE", "NULL", "TIMESTAMP", "LONGLONG", "LONGLONG", "NEWDATE", "TIME", "DATETIME", "YEAR", "NEWDATE", "VARCHAR", "BIT", "INVALID", "BOOL", "JSON", "NEWDECIMAL", "ENUM", "SET", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "GEOMETRY"}, + /* TIMESTAMP */ {"VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "TIMESTAMP", "TIMESTAMP", "VARCHAR", "VARCHAR", "DATETIME", "DATETIME", "DATETIME", "VARCHAR", "DATETIME", "VARCHAR", "VARCHAR", "INVALID", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "VARCHAR"}, + /* LONGLONG */ {"NEWDECIMAL", "LONGLONG", "LONGLONG", "LONGLONG", "DOUBLE", "DOUBLE", "LONGLONG", "VARCHAR", "LONGLONG", "LONGLONG", "VARCHAR", "VARCHAR", "VARCHAR", "LONGLONG", "NEWDATE", "VARCHAR", "LONGLONG", "INVALID", "LONGLONG", "VARCHAR", "NEWDECIMAL", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "VARCHAR"}, + /* INT24 */ {"NEWDECIMAL", "INT24", "INT24", "LONG", "FLOAT", "DOUBLE", "INT24", "VARCHAR", "LONGLONG", "INT24", "VARCHAR", "VARCHAR", "VARCHAR", "INT24", "NEWDATE", "VARCHAR", "LONGLONG", "INVALID", "INT24", "VARCHAR", "NEWDECIMAL", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "VARCHAR"}, + /* DATE */ {"VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "NEWDATE", "DATETIME", "VARCHAR", "VARCHAR", "NEWDATE", "DATETIME", "DATETIME", "VARCHAR", "NEWDATE", "VARCHAR", "VARCHAR", "INVALID", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "VARCHAR"}, + /* TIME */ {"VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "TIME", "DATETIME", "VARCHAR", "VARCHAR", "DATETIME", "TIME", "DATETIME", "VARCHAR", "NEWDATE", "VARCHAR", "VARCHAR", "INVALID", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "VARCHAR"}, + /* DATETIME */ {"VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "DATETIME", "DATETIME", "VARCHAR", "VARCHAR", "DATETIME", "DATETIME", "DATETIME", "VARCHAR", "DATETIME", "VARCHAR", "VARCHAR", "INVALID", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "VARCHAR"}, + /* YEAR */ {"DECIMAL", "TINY", "SHORT", "LONG", "FLOAT", "DOUBLE", "YEAR", "VARCHAR", "LONGLONG", "INT24", "VARCHAR", "VARCHAR", "VARCHAR", "YEAR", "VARCHAR", "VARCHAR", "LONGLONG", "INVALID", "SHORT", "VARCHAR", "NEWDECIMAL", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "VARCHAR"}, + /* NEWDATE */ {"VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "NEWDATE", "DATETIME", "VARCHAR", "VARCHAR", "NEWDATE", "DATETIME", "DATETIME", "VARCHAR", "NEWDATE", "VARCHAR", "VARCHAR", "INVALID", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "VARCHAR"}, + /* VARCHAR */ {"VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "INVALID", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "VARCHAR", "VARCHAR"}, + /* BIT */ {"NEWDECIMAL", "LONGLONG", "LONGLONG", "LONGLONG", "DOUBLE", "DOUBLE", "BIT", "VARCHAR", "LONGLONG", "LONGLONG", "VARCHAR", "VARCHAR", "VARCHAR", "LONGLONG", "VARCHAR", "VARCHAR", "BIT", "INVALID", "LONGLONG", "VARCHAR", "NEWDECIMAL", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "VARCHAR"}, + /* INVALID */ {"INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID", "INVALID"}, + /* BOOL */ {"NEWDECIMAL", "TINY", "SHORT", "LONG", "FLOAT", "DOUBLE", "BOOL", "VARCHAR", "LONGLONG", "INT24", "VARCHAR", "VARCHAR", "VARCHAR", "SHORT", "VARCHAR", "VARCHAR", "LONGLONG", "INVALID", "BOOL", "VARCHAR", "NEWDECIMAL", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "VARCHAR"}, + /* JSON */ {"VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "JSON", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "INVALID", "VARCHAR", "JSON", "VARCHAR", "VARCHAR", "VARCHAR", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "VARCHAR", "STRING", "VARCHAR"}, + /* NEWDECIMAL */ {"NEWDECIMAL", "NEWDECIMAL", "NEWDECIMAL", "NEWDECIMAL", "DOUBLE", "DOUBLE", "NEWDECIMAL", "VARCHAR", "NEWDECIMAL", "NEWDECIMAL", "VARCHAR", "VARCHAR", "VARCHAR", "NEWDECIMAL", "VARCHAR", "VARCHAR", "NEWDECIMAL", "INVALID", "NEWDECIMAL", "VARCHAR", "NEWDECIMAL", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "VARCHAR"}, + /* ENUM */ {"VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "ENUM", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "INVALID", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "VARCHAR"}, + /* SET */ {"VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "SET", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "INVALID", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "VARCHAR"}, + /* TINY_BLOB */ {"TINY_BLOB", "TINY_BLOB", "TINY_BLOB", "TINY_BLOB", "TINY_BLOB", "TINY_BLOB", "TINY_BLOB", "TINY_BLOB", "TINY_BLOB", "TINY_BLOB", "TINY_BLOB", "TINY_BLOB", "TINY_BLOB", "TINY_BLOB", "TINY_BLOB", "TINY_BLOB", "TINY_BLOB", "INVALID", "TINY_BLOB", "LONG_BLOB", "TINY_BLOB", "TINY_BLOB", "TINY_BLOB", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "TINY_BLOB", "TINY_BLOB", "TINY_BLOB"}, + /* MEDIUM_BLOB */ {"MEDIUM_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "INVALID", "MEDIUM_BLOB", "LONG_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB", "MEDIUM_BLOB"}, + /* LONG_BLOB */ {"LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "INVALID", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB", "LONG_BLOB"}, + /* BLOB */ {"BLOB", "BLOB", "BLOB", "BLOB", "BLOB", "BLOB", "BLOB", "BLOB", "BLOB", "BLOB", "BLOB", "BLOB", "BLOB", "BLOB", "BLOB", "BLOB", "BLOB", "INVALID", "BLOB", "LONG_BLOB", "BLOB", "BLOB", "BLOB", "BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "BLOB", "BLOB", "BLOB"}, + /* VAR_STRING */ {"VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "INVALID", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "VARCHAR", "VARCHAR"}, + /* STRING */ {"STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "VARCHAR", "STRING", "INVALID", "STRING", "STRING", "STRING", "STRING", "STRING", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "STRING"}, + /* GEOMETRY */ {"VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "GEOMETRY", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "INVALID", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "TINY_BLOB", "MEDIUM_BLOB", "LONG_BLOB", "BLOB", "VARCHAR", "STRING", "GEOMETRY"}, +} + +// ResultKinds is field_types_result_type: the Item_result of each of FieldTypes. +var ResultKinds = [...]string{"DECIMAL_RESULT", "INT_RESULT", "INT_RESULT", "INT_RESULT", "REAL_RESULT", "REAL_RESULT", "STRING_RESULT", "STRING_RESULT", "INT_RESULT", "INT_RESULT", "STRING_RESULT", "STRING_RESULT", "STRING_RESULT", "INT_RESULT", "STRING_RESULT", "STRING_RESULT", "INT_RESULT", "INVALID_RESULT", "INT_RESULT", "STRING_RESULT", "DECIMAL_RESULT", "STRING_RESULT", "STRING_RESULT", "STRING_RESULT", "STRING_RESULT", "STRING_RESULT", "STRING_RESULT", "STRING_RESULT", "STRING_RESULT", "STRING_RESULT"} diff --git a/check/mysql/internal/catalog/sysvars.go b/check/mysql/internal/catalog/sysvars.go new file mode 100644 index 0000000..13dbd53 --- /dev/null +++ b/check/mysql/internal/catalog/sysvars.go @@ -0,0 +1,456 @@ +// Code generated from MySQL 8.4.6 (sql/sys_vars.cc) by parsegen. DO NOT EDIT. + +package catalog + +// SysVars is each system variable's scope: 's' session-only, 'g' global-only, +// 'b' both. A name outside the map (a plugin's or component's variable) is not judged. +var SysVars = map[string]byte{ + "activate_all_roles_on_login": 'g', + "admin_address": 'g', + "admin_port": 'g', + "admin_ssl_ca": 'g', + "admin_ssl_capath": 'g', + "admin_ssl_cert": 'g', + "admin_ssl_cipher": 'g', + "admin_ssl_crl": 'g', + "admin_ssl_crlpath": 'g', + "admin_ssl_key": 'g', + "admin_tls_ciphersuites": 'g', + "admin_tls_version": 'g', + "authentication_policy": 'g', + "auto_generate_certs": 'g', + "auto_increment_increment": 'b', + "auto_increment_offset": 'b', + "autocommit": 'b', + "automatic_sp_privileges": 'g', + "back_log": 'g', + "basedir": 'g', + "big_tables": 'b', + "bind_address": 'g', + "binlog_cache_size": 'g', + "binlog_checksum": 'g', + "binlog_direct_non_transactional_updates": 'b', + "binlog_encryption": 'g', + "binlog_error_action": 'g', + "binlog_expire_logs_auto_purge": 'g', + "binlog_expire_logs_seconds": 'g', + "binlog_format": 'b', + "binlog_group_commit_sync_delay": 'g', + "binlog_group_commit_sync_no_delay_count": 'g', + "binlog_gtid_simple_recovery": 'g', + "binlog_max_flush_queue_time": 'g', + "binlog_order_commits": 'g', + "binlog_rotate_encryption_master_key_at_startup": 'g', + "binlog_row_event_max_size": 'g', + "binlog_row_image": 'b', + "binlog_row_metadata": 'g', + "binlog_row_value_options": 'b', + "binlog_rows_query_log_events": 'b', + "binlog_stmt_cache_size": 'g', + "binlog_transaction_compression": 'b', + "binlog_transaction_compression_level_zstd": 'b', + "binlog_transaction_dependency_history_size": 'g', + "block_encryption_mode": 'b', + "build_id": 'g', + "bulk_insert_buffer_size": 'b', + "character_set_client": 'b', + "character_set_connection": 'b', + "character_set_database": 'b', + "character_set_filesystem": 'b', + "character_set_results": 'b', + "character_set_server": 'b', + "character_set_system": 'g', + "character_sets_dir": 'g', + "check_proxy_users": 'g', + "collation_connection": 'b', + "collation_database": 'b', + "collation_server": 'b', + "completion_type": 'b', + "concurrent_insert": 'g', + "connect_timeout": 'g', + "connection_memory_chunk_size": 'b', + "connection_memory_limit": 'b', + "core_file": 'g', + "create_admin_listener_thread": 'g', + "cte_max_recursion_depth": 'b', + "datadir": 'g', + "default_collation_for_utf8mb4": 'b', + "default_password_lifetime": 'g', + "default_storage_engine": 'b', + "default_table_encryption": 'b', + "default_tmp_storage_engine": 'b', + "default_week_format": 'b', + "delay_key_write": 'g', + "delayed_insert_limit": 'g', + "delayed_insert_timeout": 'g', + "delayed_queue_size": 'g', + "disabled_storage_engines": 'g', + "disconnect_on_expired_password": 'g', + "div_precision_increment": 'b', + "end_markers_in_json": 'b', + "enforce_gtid_consistency": 'g', + "eq_range_index_dive_limit": 'b', + "error_count": 's', + "event_scheduler": 'g', + "explain_format": 'b', + "explain_json_format_version": 'b', + "explicit_defaults_for_timestamp": 'b', + "external_user": 's', + "flush": 'g', + "flush_time": 'g', + "foreign_key_checks": 'b', + "ft_boolean_syntax": 'g', + "ft_max_word_len": 'g', + "ft_min_word_len": 'g', + "ft_query_expansion_limit": 'g', + "ft_stopword_file": 'g', + "general_log": 'g', + "general_log_file": 'g', + "generated_random_password_length": 'b', + "global_connection_memory_limit": 'g', + "global_connection_memory_tracking": 'b', + "group_concat_max_len": 'b', + "group_replication_consistency": 'b', + "gtid_executed": 'g', + "gtid_executed_compression_period": 'g', + "gtid_mode": 'g', + "gtid_next": 's', + "gtid_owned": 'b', + "gtid_purged": 'g', + "have_compress": 'g', + "have_dynamic_loading": 'g', + "have_geometry": 'g', + "have_profiling": 'g', + "have_query_cache": 'g', + "have_rtree_keys": 'g', + "have_statement_timeout": 'g', + "have_symlink": 'g', + "histogram_generation_max_mem_size": 'b', + "host_cache_size": 'g', + "hostname": 'g', + "identity": 's', + "immediate_server_version": 's', + "information_schema_stats_expiry": 'b', + "init_connect": 'g', + "init_file": 'g', + "init_replica": 'g', + "init_slave": 'g', + "insert_id": 's', + "interactive_timeout": 'b', + "internal_tmp_mem_storage_engine": 'b', + "join_buffer_size": 'b', + "keep_files_on_create": 'b', + "key_buffer_size": 'g', + "key_cache_age_threshold": 'g', + "key_cache_block_size": 'g', + "key_cache_division_limit": 'g', + "keyring_operations": 'g', + "large_files_support": 'g', + "large_page_size": 'g', + "large_pages": 'g', + "last_insert_id": 's', + "lc_messages": 'b', + "lc_messages_dir": 'g', + "lc_time_names": 'b', + "license": 'g', + "local_infile": 'g', + "lock_wait_timeout": 'b', + "locked_in_memory": 'g', + "log_bin": 'g', + "log_bin_basename": 'g', + "log_bin_index": 'g', + "log_bin_trust_function_creators": 'g', + "log_error": 'g', + "log_error_services": 'g', + "log_error_suppression_list": 'g', + "log_error_verbosity": 'g', + "log_output": 'g', + "log_queries_not_using_indexes": 'g', + "log_raw": 'g', + "log_replica_updates": 'g', + "log_slave_updates": 'g', + "log_slow_admin_statements": 'g', + "log_slow_extra": 'g', + "log_slow_replica_statements": 'g', + "log_slow_slave_statements": 'g', + "log_statements_unsafe_for_binlog": 'g', + "log_throttle_queries_not_using_indexes": 'g', + "log_timestamps": 'g', + "long_query_time": 'b', + "low_priority_updates": 'b', + "lower_case_file_system": 'g', + "lower_case_table_names": 'g', + "mandatory_roles": 'g', + "master_verify_checksum": 'g', + "max_allowed_packet": 'b', + "max_binlog_cache_size": 'g', + "max_binlog_size": 'g', + "max_binlog_stmt_cache_size": 'g', + "max_connect_errors": 'g', + "max_connections": 'g', + "max_delayed_threads": 'b', + "max_digest_length": 'g', + "max_error_count": 'b', + "max_execution_time": 'b', + "max_heap_table_size": 'b', + "max_insert_delayed_threads": 'b', + "max_join_size": 'b', + "max_length_for_sort_data": 'b', + "max_points_in_geometry": 'b', + "max_prepared_stmt_count": 'g', + "max_relay_log_size": 'g', + "max_seeks_for_key": 'b', + "max_sort_length": 'b', + "max_sp_recursion_depth": 'b', + "max_user_connections": 'b', + "max_write_lock_count": 'g', + "min_examined_row_limit": 'b', + "mysql_native_password_proxy_users": 'g', + "net_buffer_length": 'b', + "net_read_timeout": 'b', + "net_retry_count": 'b', + "net_write_timeout": 'b', + "offline_mode": 'g', + "old_alter_table": 'b', + "open_files_limit": 'g', + "optimizer_max_subgraph_pairs": 'b', + "optimizer_prune_level": 'b', + "optimizer_search_depth": 'b', + "optimizer_switch": 'b', + "optimizer_trace": 'b', + "optimizer_trace_features": 'b', + "optimizer_trace_limit": 'b', + "optimizer_trace_max_mem_size": 'b', + "optimizer_trace_offset": 'b', + "original_commit_timestamp": 's', + "original_server_version": 's', + "parser_max_mem_size": 'b', + "partial_revokes": 'g', + "password_history": 'g', + "password_require_current": 'g', + "password_reuse_interval": 'g', + "performance_schema": 'g', + "performance_schema_accounts_size": 'g', + "performance_schema_digests_size": 'g', + "performance_schema_error_size": 'g', + "performance_schema_events_stages_history_long_size": 'g', + "performance_schema_events_stages_history_size": 'g', + "performance_schema_events_statements_history_long_size": 'g', + "performance_schema_events_statements_history_size": 'g', + "performance_schema_events_transactions_history_long_size": 'g', + "performance_schema_events_transactions_history_size": 'g', + "performance_schema_events_waits_history_long_size": 'g', + "performance_schema_events_waits_history_size": 'g', + "performance_schema_hosts_size": 'g', + "performance_schema_max_cond_classes": 'g', + "performance_schema_max_cond_instances": 'g', + "performance_schema_max_digest_length": 'g', + "performance_schema_max_digest_sample_age": 'g', + "performance_schema_max_file_classes": 'g', + "performance_schema_max_file_handles": 'g', + "performance_schema_max_file_instances": 'g', + "performance_schema_max_index_stat": 'g', + "performance_schema_max_memory_classes": 'g', + "performance_schema_max_metadata_locks": 'g', + "performance_schema_max_meter_classes": 'g', + "performance_schema_max_metric_classes": 'g', + "performance_schema_max_mutex_classes": 'g', + "performance_schema_max_mutex_instances": 'g', + "performance_schema_max_prepared_statements_instances": 'g', + "performance_schema_max_program_instances": 'g', + "performance_schema_max_rwlock_classes": 'g', + "performance_schema_max_rwlock_instances": 'g', + "performance_schema_max_socket_classes": 'g', + "performance_schema_max_socket_instances": 'g', + "performance_schema_max_sql_text_length": 'g', + "performance_schema_max_stage_classes": 'g', + "performance_schema_max_statement_classes": 'g', + "performance_schema_max_statement_stack": 'g', + "performance_schema_max_table_handles": 'g', + "performance_schema_max_table_instances": 'g', + "performance_schema_max_table_lock_stat": 'g', + "performance_schema_max_thread_classes": 'g', + "performance_schema_max_thread_instances": 'g', + "performance_schema_session_connect_attrs_size": 'g', + "performance_schema_setup_actors_size": 'g', + "performance_schema_setup_objects_size": 'g', + "performance_schema_show_processlist": 'g', + "performance_schema_users_size": 'g', + "persist_only_admin_x509_subject": 'g', + "persist_sensitive_variables_in_plaintext": 'g', + "persisted_globals_load": 'g', + "pid_file": 'g', + "plugin_dir": 'g', + "port": 'g', + "preload_buffer_size": 'b', + "print_identified_with_as_hex": 'b', + "protocol_compression_algorithms": 'g', + "protocol_version": 'g', + "proxy_user": 's', + "pseudo_replica_mode": 's', + "pseudo_slave_mode": 's', + "pseudo_thread_id": 's', + "query_alloc_block_size": 'b', + "query_prealloc_size": 'b', + "rand_seed1": 's', + "rand_seed2": 's', + "range_alloc_block_size": 'b', + "range_optimizer_max_mem_size": 'b', + "rbr_exec_mode": 'b', + "read_buffer_size": 'b', + "read_only": 'g', + "read_rnd_buffer_size": 'b', + "regexp_stack_limit": 'g', + "regexp_time_limit": 'g', + "relay_log": 'g', + "relay_log_basename": 'g', + "relay_log_index": 'g', + "relay_log_purge": 'g', + "relay_log_recovery": 'g', + "relay_log_space_limit": 'g', + "replica_allow_batching": 'g', + "replica_checkpoint_group": 'g', + "replica_checkpoint_period": 'g', + "replica_compressed_protocol": 'g', + "replica_exec_mode": 'g', + "replica_load_tmpdir": 'g', + "replica_max_allowed_packet": 'g', + "replica_net_timeout": 'g', + "replica_parallel_type": 'g', + "replica_parallel_workers": 'g', + "replica_pending_jobs_size_max": 'g', + "replica_preserve_commit_order": 'g', + "replica_skip_errors": 'g', + "replica_sql_verify_checksum": 'g', + "replica_transaction_retries": 'g', + "replica_type_conversions": 'g', + "replication_optimize_for_static_plugin_config": 'g', + "replication_sender_observe_commit_only": 'g', + "report_host": 'g', + "report_password": 'g', + "report_port": 'g', + "report_user": 'g', + "require_row_format": 's', + "require_secure_transport": 'g', + "resultset_metadata": 's', + "rpl_read_size": 'g', + "rpl_stop_replica_timeout": 'g', + "rpl_stop_slave_timeout": 'g', + "schema_definition_cache": 'g', + "secondary_engine_cost_threshold": 'b', + "secure_file_priv": 'g', + "select_into_buffer_size": 'b', + "select_into_disk_sync": 'b', + "select_into_disk_sync_delay": 'b', + "server_id": 'g', + "server_id_bits": 'g', + "server_uuid": 'g', + "session_track_gtids": 'b', + "session_track_schema": 'b', + "session_track_state_change": 'b', + "session_track_system_variables": 'b', + "session_track_transaction_info": 'b', + "set_operations_buffer_size": 'b', + "sha256_password_proxy_users": 'g', + "show_create_table_skip_secondary_engine": 's', + "show_create_table_verbosity": 'b', + "show_gipk_in_create_table_and_information_schema": 'b', + "skip_external_locking": 'g', + "skip_name_resolve": 'g', + "skip_networking": 'g', + "skip_replica_start": 'g', + "skip_show_database": 'g', + "skip_slave_start": 'g', + "slave_allow_batching": 'g', + "slave_checkpoint_group": 'g', + "slave_checkpoint_period": 'g', + "slave_compressed_protocol": 'g', + "slave_exec_mode": 'g', + "slave_load_tmpdir": 'g', + "slave_max_allowed_packet": 'g', + "slave_net_timeout": 'g', + "slave_parallel_type": 'g', + "slave_parallel_workers": 'g', + "slave_pending_jobs_size_max": 'g', + "slave_preserve_commit_order": 'g', + "slave_skip_errors": 'g', + "slave_sql_verify_checksum": 'g', + "slave_transaction_retries": 'g', + "slave_type_conversions": 'g', + "slow_launch_time": 'g', + "slow_query_log": 'g', + "slow_query_log_file": 'g', + "socket": 'g', + "sort_buffer_size": 'b', + "source_verify_checksum": 'g', + "sql_auto_is_null": 'b', + "sql_big_selects": 'b', + "sql_buffer_result": 'b', + "sql_generate_invisible_primary_key": 'b', + "sql_log_bin": 's', + "sql_log_off": 'b', + "sql_mode": 'b', + "sql_notes": 'b', + "sql_quote_show_create": 'b', + "sql_replica_skip_counter": 'g', + "sql_safe_updates": 'b', + "sql_select_limit": 'b', + "sql_slave_skip_counter": 'g', + "sql_warnings": 'b', + "ssl_ca": 'g', + "ssl_capath": 'g', + "ssl_cert": 'g', + "ssl_cipher": 'g', + "ssl_crl": 'g', + "ssl_crlpath": 'g', + "ssl_fips_mode": 'g', + "ssl_key": 'g', + "ssl_session_cache_mode": 'g', + "ssl_session_cache_timeout": 'g', + "statement_id": 's', + "stored_program_cache": 'g', + "stored_program_definition_cache": 'g', + "super_read_only": 'g', + "sync_binlog": 'g', + "sync_master_info": 'g', + "sync_relay_log": 'g', + "sync_relay_log_info": 'g', + "sync_source_info": 'g', + "system_time_zone": 'g', + "table_definition_cache": 'g', + "table_encryption_privilege_check": 'g', + "table_open_cache": 'g', + "table_open_cache_instances": 'g', + "tablespace_definition_cache": 'g', + "temptable_max_mmap": 'g', + "temptable_max_ram": 'g', + "temptable_use_mmap": 'g', + "terminology_use_previous": 'b', + "thread_cache_size": 'g', + "thread_handling": 'g', + "thread_stack": 'g', + "time_zone": 'b', + "timestamp": 's', + "tls_certificates_enforced_validation": 'g', + "tls_ciphersuites": 'g', + "tls_version": 'g', + "tmp_table_size": 'b', + "tmpdir": 'g', + "transaction_alloc_block_size": 'b', + "transaction_allow_batching": 's', + "transaction_isolation": 'b', + "transaction_prealloc_size": 'b', + "transaction_read_only": 'b', + "unique_checks": 'b', + "updatable_views_with_limit": 'b', + "use_secondary_engine": 's', + "version": 'g', + "version_comment": 'g', + "version_compile_machine": 'g', + "version_compile_os": 'g', + "version_compile_zlib": 'g', + "wait_timeout": 'b', + "warning_count": 's', + "windowing_use_high_precision": 'b', + "xa_detach_on_prepare": 'b', +} diff --git a/check/mysql/internal/mysqlast/hooks.go b/check/mysql/internal/mysqlast/hooks.go new file mode 100644 index 0000000..9d48491 --- /dev/null +++ b/check/mysql/internal/mysqlast/hooks.go @@ -0,0 +1,63 @@ +package mysqlast + +import ( + "fmt" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlparse" +) + +// hooks are the alternatives whose server action parsegen could not read, written by +// hand against what sql_yacc.yy does. They are registered by rule name and right-hand +// side, so that a grammar version that reorders alternatives fails at init rather than +// binding a hook to the wrong one. `go run ./internal/parsegen/cmd/parsegen -actions +// -roots ...` lists the candidates. +var hooks = map[hookKey]Hook{} + +// register adds a hook for the alternative of rule whose right-hand side is syms. +func register(rule, syms string, h Hook) { + alt, ok := mysqlparse.Alternative(rule, syms) + if !ok { + panic(fmt.Sprintf("mysqlast: no alternative %s: %s in this grammar", rule, syms)) + } + hooks[hookKey{rule, alt}] = h +} + +// pass returns child i (1-based). +func pass(i int) Hook { + return func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { return kids[i-1], nil } +} + +// build returns a Node of class over the given children (1-based positions). +func build(class string, args ...int) Hook { + return func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + node := &Node{Class: class, Start: n.Start, End: n.End} + for _, i := range args { + node.Args = append(node.Args, kids[i-1]) + } + return node, nil + } +} + +// appendTo returns child list (1-based) with child elem appended. +func appendTo(list, elem int) Hook { + return func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + l, _ := kids[list-1].(List) + return append(l, kids[elem-1]), nil + } +} + +// listOf returns a List of the given children. +func listOf(elems ...int) Hook { + return func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + var l List + for _, i := range elems { + l = append(l, kids[i-1]) + } + return l, nil + } +} + +// constant returns a fixed constant. +func constant(c string) Hook { + return func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { return Const(c), nil } +} diff --git a/check/mysql/internal/mysqlast/hooks_ddl.go b/check/mysql/internal/mysqlast/hooks_ddl.go new file mode 100644 index 0000000..851ae25 --- /dev/null +++ b/check/mysql/internal/mysqlast/hooks_ddl.go @@ -0,0 +1,189 @@ +package mysqlast + +import ( + "strconv" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlparse" +) + +// Hand-written alternatives on the DDL side: column types with a charset the server +// resolves at parse time, ALTER TABLE's action-and-modifier lists, index options. +func init() { + // type: nchar / nvarchar -> PT_char_type in the national charset (utf8mb3), binary collation with BINARY + national := func(charType string, withLength bool) Hook { + return func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + var length Value + bin := kids[len(kids)-1] + if withLength { + length = kids[1] + } + cs := Const("national_charset_info") + if bin == Const("true") { + cs = Const("national_charset_info.bin") + } + return &Node{Class: "PT_char_type", Names: []string{"char_type", "length", "charset", "force_binary"}, + Args: []Value{Const("Char_type::" + charType), length, cs, Const("false")}, Start: n.Start, End: n.End}, nil + } + } + register("type", "nchar field_length opt_bin_mod", national("CHAR", true)) + register("type", "nchar opt_bin_mod", national("CHAR", false)) + register("type", "nvarchar field_length opt_bin_mod", national("VARCHAR", true)) + // type: YEAR_SYM opt_field_length field_options -> PT_year_type (the length is ignored, UNSIGNED deprecated) + register("type", "YEAR_SYM opt_field_length field_options", build("PT_year_type")) + // real_type: REAL_SYM -> DOUBLE, FLOAT under sql_mode REAL_AS_FLOAT + register("real_type", "REAL_SYM", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + if b.Mode&mysqlparse.RealAsFloat != 0 { + return Const("Numeric_type::FLOAT"), nil + } + return Const("Numeric_type::DOUBLE"), nil + }) + // unicode: UNICODE_SYM [BINARY] -> the ucs2 charset / ucs2_bin collation + register("unicode", "UNICODE_SYM", constant("ucs2")) + register("unicode", "UNICODE_SYM BINARY_SYM", constant("ucs2_bin")) + // opt_key_algo: ALGORITHM_SYM EQ real_ulong_num -> 1 / 2 are the only algorithms + register("opt_key_algo", "ALGORITHM_SYM EQ real_ulong_num", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + switch kids[2] { + case Number(1): + return Const("enum_key_algorithm::KEY_ALGORITHM_51"), nil + case Number(2): + return Const("enum_key_algorithm::KEY_ALGORITHM_55"), nil + } + return nil, &Unsupported{Rule: n.Kind.String(), Alt: n.Alt, Start: n.Start, End: n.End, Text: n.Text(b.SQL)} + }) + // ALTER TABLE's list of actions and modifiers: {flags, actions} + register("alter_list", "alter_list ',' create_table_options_space_separated", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + st, _ := kids[0].(*Struct) + acts, _ := st.Fields["actions"].(List) + opts, _ := kids[2].(List) + st.Fields["actions"] = append(acts, opts...) + return st, nil + }) + register("alter_commands_modifier_list", "alter_commands_modifier_list ',' alter_commands_modifier", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return mergeStructs(kids[0], kids[2]), nil + }) + register("opt_alter_command_list", "alter_commands_modifier_list ',' alter_list", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + st, _ := kids[2].(*Struct) + if st == nil { + st = &Struct{Fields: map[string]Value{}} + } + st.Fields["flags"] = mergeStructs(kids[0], st.Fields["flags"]) + return st, nil + }) + register("opt_alter_table_actions", "opt_alter_command_list alter_table_partition_options", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + st, _ := kids[0].(*Struct) + if st == nil { + st = &Struct{Fields: map[string]Value{}, Order: []string{"flags", "actions"}} + } + acts, _ := st.Fields["actions"].(List) + st.Fields["actions"] = append(acts, kids[1]) + return st, nil + }) + // name_list: name_list ',' ident -> the names with $3 appended (KEY (a, b) and + // RANGE/LIST COLUMNS (a, b) partitioning) + register("name_list", "name_list ',' ident", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + l, _ := kids[0].(List) + return append(l, field(kids[2], "str")), nil + }) + // standalone_alter_commands: DROP PARTITION and REORGANIZE PARTITION ... INTO read their + // ident_string_list through a pointer deref (*$N) in the server's action, which the + // generated shape keeps as marker text, so the two alternatives are folded by hand + register("standalone_alter_commands", "DROP PARTITION_SYM ident_string_list", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "PT_alter_table_drop_partition", Names: []string{"partitions"}, + Args: []Value{kids[2]}, Start: n.Start, End: n.End}, nil + }) + register("standalone_alter_commands", "REORGANIZE_SYM PARTITION_SYM opt_no_write_to_binlog ident_string_list INTO '(' part_def_list ')'", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "PT_alter_table_reorganize_partition_into", Names: []string{"no_write_to_binlog", "partition_names", "into"}, + Args: []Value{kids[2], kids[3], kids[6]}, Start: n.Start, End: n.End}, nil + }) + // ternary_option: ulong_num -> OFF / ON; any other value is the server's own syntax error + register("ternary_option", "ulong_num", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + switch kids[0] { + case Number(0): + return Const("Ternary_option::OFF"), nil + case Number(1): + return Const("Ternary_option::ON"), nil + } + return nil, &Unsupported{Rule: n.Kind.String(), Alt: n.Alt, Start: n.Start, End: n.End, Text: n.Text(b.SQL)} + }) + // fulltext_index_option: WITH PARSER_SYM IDENT_sys -> PT_fulltext_index_parser_name + register("fulltext_index_option", "WITH PARSER_SYM IDENT_sys", build("PT_fulltext_index_parser_name", 3)) + // opt_key_usage_list: %empty -> a list holding the empty hint (USE INDEX ()) + register("opt_key_usage_list", "", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return List{&Node{Class: "Index_hint", Names: []string{"str", "length"}, Args: []Value{nil, Number(0)}}}, nil + }) + // select_option_list: select_option_list select_option -> the options merged + register("select_option_list", "select_option_list select_option", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return mergeStructs(kids[0], kids[1]), nil + }) + // signed_literal: '-' NUM_literal -> the literal negated + register("signed_literal", "'-' NUM_literal", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "Item_func_neg", Names: []string{"a"}, Args: []Value{kids[1]}, Start: n.Start, End: n.End}, nil + }) + // size_number: IDENT_sys -> a byte count with an optional K/M/G/T/P/E suffix + register("size_number", "IDENT_sys", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + t, _ := kids[0].(Token) + s := t.Value + if s == "" { + s = t.Text + } + shift := 0 + if len(s) > 0 { + switch strings.ToUpper(s[len(s)-1:]) { + case "K": + shift = 10 + case "M": + shift = 20 + case "G": + shift = 30 + case "T": + shift = 40 + case "P": + shift = 50 + case "E": + shift = 60 + } + if shift > 0 { + s = s[:len(s)-1] + } + } + v, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return nil, &Unsupported{Rule: n.Kind.String(), Alt: n.Alt, Start: n.Start, End: n.End, Text: n.Text(b.SQL)} + } + return Number(int64(v << shift)), nil + }) + // query_primary: explicit_table (TABLE t) -> SELECT * FROM t + register("query_primary", "explicit_table", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + items := List{&Node{Class: "Item_asterisk", Names: []string{"opt_table_name", "opt_schema_name"}, Args: []Value{nil, nil}}} + return &Node{Class: "PT_explicit_table", Names: []string{"options", "item_list", "from_clause"}, Args: []Value{nil, items, kids[0]}, Start: n.Start, End: n.End}, nil + }) + register("explicit_table", "TABLE_SYM table_ident", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return List{&Node{Class: "PT_table_factor_table_ident", Names: []string{"table_ident", "opt_use_partition", "opt_table_alias", "opt_key_definition", "opt_tablesample"}, + Args: []Value{kids[1], nil, nil, nil, nil}, Start: n.Start, End: n.End}}, nil + }) + // json_attribute: TEXT_STRING_sys (validated as JSON by the server) + register("json_attribute", "TEXT_STRING_sys", pass(1)) +} + +// mergeStructs returns a's fields with b's set on top (nil operands allowed). +func mergeStructs(a, b Value) Value { + out := &Struct{Fields: map[string]Value{}} + for _, v := range []Value{a, b} { + if st, ok := v.(*Struct); ok { + for _, k := range st.Order { + if _, dup := out.Fields[k]; !dup { + out.Order = append(out.Order, k) + } + out.Fields[k] = st.Fields[k] + } + } + } + if len(out.Order) == 0 { + if a != nil { + return a + } + return b + } + return out +} diff --git a/check/mysql/internal/mysqlast/hooks_dml.go b/check/mysql/internal/mysqlast/hooks_dml.go new file mode 100644 index 0000000..89a7000 --- /dev/null +++ b/check/mysql/internal/mysqlast/hooks_dml.go @@ -0,0 +1,522 @@ +package mysqlast + +import ( + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlparse" +) + +// Hand-written alternatives reachable from the statements sqlshape reads. Each mirrors +// what the server's action in sql_yacc.yy builds; the comment quotes the rule. +func init() { + // select_item_list: '*' -> PT_select_item_list holding an Item_asterisk + register("select_item_list", "'*'", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return List{&Node{Class: "Item_asterisk", Args: []Value{nil, nil}, Start: n.Start, End: n.End}}, nil + }) + // window_spec_details: opt_existing_window_name opt_partition_clause opt_window_order_by_clause + // opt_window_frame_clause -> PT_window(partition, order, frame, name); a missing frame is + // the implicit RANGE UNBOUNDED PRECEDING .. CURRENT ROW (or UNBOUNDED FOLLOWING without ORDER BY) + register("window_spec_details", "opt_existing_window_name opt_partition_clause opt_window_order_by_clause opt_window_frame_clause", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + frame := kids[3] + if frame == nil { + end := Const("WBT_UNBOUNDED_FOLLOWING") + if kids[2] != nil { + end = Const("WBT_CURRENT_ROW") + } + frame = &Node{Class: "PT_frame", Args: []Value{Const("WFU_RANGE"), + &Node{Class: "PT_borders", Args: []Value{&Node{Class: "PT_border", Args: []Value{Const("WBT_UNBOUNDED_PRECEDING")}}, &Node{Class: "PT_border", Args: []Value{end}}}}, nil, Const("implicit")}} + } + return &Node{Class: "PT_window", Names: []string{"partition_by", "order_by", "frame", "inherit"}, Args: []Value{kids[1], kids[2], frame, kids[0]}, Start: n.Start, End: n.End}, nil + }) + // user: CURRENT_USER optional_braces -> the current user + register("user", "CURRENT_USER optional_braces", constant("CURRENT_USER")) + // opt_explain_options: the EXPLAIN flavour; sqlshape does not read EXPLAIN, keep the format + register("opt_explain_options", "ANALYZE_SYM opt_explain_format", pass(2)) + register("opt_explain_options", "opt_explain_format opt_explain_into", pass(1)) +} + +func init() { + // when_list: when_list WHEN_SYM expr THEN_SYM expr -> the list with the pair appended + // ($1->push_back($3); $1->push_back($5); $$ = $1) + register("when_list", "when_list WHEN_SYM expr THEN_SYM expr", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + l, _ := kids[0].(List) + return append(append(List{}, l...), kids[2], kids[4]), nil + }) + // predicate: bit_expr IN_SYM '(' expr ',' expr_list ')' -> Item_func_in([bit_expr, expr, expr_list...], negated) + register("predicate", "bit_expr IN_SYM '(' expr ',' expr_list ')'", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + l, _ := kids[5].(List) + return &Node{Class: "Item_func_in", Names: []string{"list", "is_negation"}, Args: []Value{append(List{kids[0], kids[3]}, l...), Const("false")}, Start: n.Start, End: n.End}, nil + }) + // simple_expr: simple_ident -> 'path' is JSON_EXTRACT(ident, 'path'); ->> wraps it in + // JSON_UNQUOTE. The grammar builds the Item classes directly; here they are the generic + // calls the analyzer types through the catalog. + jsonPath := func(n *mysqlparse.Node, ident Value, path Value) *Node { + lit := &Node{Class: "PTI_text_literal_text_string", Names: []string{"is_7bit", "literal"}, Args: []Value{Const("true"), path}, Start: n.Start, End: n.End} + return genericCall(n, "JSON_EXTRACT", ident, lit) + } + register("simple_expr", "simple_ident JSON_SEPARATOR_SYM TEXT_STRING_literal", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return jsonPath(n, kids[0], kids[2]), nil + }) + register("simple_expr", "simple_ident JSON_UNQUOTED_SEPARATOR_SYM TEXT_STRING_literal", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return genericCall(n, "JSON_UNQUOTE", jsonPath(n, kids[0], kids[2])), nil + }) + // predicate: bit_expr not IN_SYM table_subquery -> NOT (Item_in_subselect): two nodes in + // one action + register("predicate", "bit_expr not IN_SYM table_subquery", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + in := &Node{Class: "Item_in_subselect", Names: []string{"left_expr", "pt_subquery"}, Args: []Value{kids[0], kids[3]}, Start: n.Start, End: n.End} + return &Node{Class: "PTI_truth_transform", Names: []string{"expr", "truth_test"}, Args: []Value{in, Const("Item::BOOL_NEGATED")}, Start: n.Start, End: n.End}, nil + }) + // predicate: bit_expr not IN_SYM '(' expr ',' expr_list ')' -> Item_func_in(..., true) + register("predicate", "bit_expr not IN_SYM '(' expr ',' expr_list ')'", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + l, _ := kids[6].(List) + return &Node{Class: "Item_func_in", Names: []string{"list", "is_negation"}, Args: []Value{append(List{kids[0], kids[4]}, l...), Const("true")}, Start: n.Start, End: n.End}, nil + }) + // predicate: the LIKE / REGEXP / SOUNDS LIKE alternatives whose actions the shape reader + // cannot follow, folded to what the server itself builds: NOT LIKE is the LIKE negated + // the way NOT IN is, REGEXP is REGEXP_LIKE(a, b) (the documented synonym, typed through + // the catalog like any call), and SOUNDS LIKE is SOUNDEX(a) = SOUNDEX(b). + negated := func(n *mysqlparse.Node, inner *Node) *Node { + return &Node{Class: "PTI_truth_transform", Names: []string{"expr", "truth_test"}, Args: []Value{inner, Const("Item::BOOL_NEGATED")}, Start: n.Start, End: n.End} + } + register("predicate", "bit_expr not LIKE simple_expr", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return negated(n, &Node{Class: "Item_func_like", Names: []string{"a", "b"}, Args: []Value{kids[0], kids[3]}, Start: n.Start, End: n.End}), nil + }) + register("predicate", "bit_expr not LIKE simple_expr ESCAPE_SYM simple_expr", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return negated(n, &Node{Class: "Item_func_like", Names: []string{"a", "b", "escape"}, Args: []Value{kids[0], kids[3], kids[5]}, Start: n.Start, End: n.End}), nil + }) + register("predicate", "bit_expr REGEXP bit_expr", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return genericCall(n, "REGEXP_LIKE", kids[0], kids[2]), nil + }) + register("predicate", "bit_expr not REGEXP bit_expr", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return negated(n, genericCall(n, "REGEXP_LIKE", kids[0], kids[3])), nil + }) + register("predicate", "bit_expr SOUNDS_SYM LIKE bit_expr", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "PTI_comp_op", Names: []string{"left", "boolfunc2creator", "right"}, + Args: []Value{genericCall(n, "SOUNDEX", kids[0]), Const("&comp_eq_creator"), genericCall(n, "SOUNDEX", kids[3])}, Start: n.Start, End: n.End}, nil + }) + // opt_values_reference: AS ident opt_derived_column_list -> the row alias of an INSERT's + // VALUES rows (8.0.19); the action fills two Lex fields the shape reader cannot follow, + // so the two are folded by hand into the Struct PT_insert's shape reads them from + register("opt_values_reference", "AS ident opt_derived_column_list", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Struct{Fields: map[string]Value{"table_alias": kids[1], "column_list": kids[2]}, Order: []string{"table_alias", "column_list"}}, nil + }) + // update_list: update_elem | update_list ',' update_elem -> {column_list, value_list} + register("update_list", "update_elem", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + e, _ := kids[0].(*Struct) + st := &Struct{Fields: map[string]Value{"column_list": List{e.Fields["column"]}, "value_list": List{e.Fields["value"]}}, Order: []string{"column_list", "value_list"}} + return st, nil + }) + register("update_list", "update_list ',' update_elem", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + st, _ := kids[0].(*Struct) + e, _ := kids[2].(*Struct) + cols, _ := st.Fields["column_list"].(List) + vals, _ := st.Fields["value_list"].(List) + st.Fields["column_list"] = append(cols, e.Fields["column"]) + st.Fields["value_list"] = append(vals, e.Fields["value"]) + return st, nil + }) + // table_ident: ident '.' ident -> Table_ident(schema, table) + register("table_ident", "ident '.' ident", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "Table_ident", Names: []string{"db", "table"}, Args: []Value{field(kids[0], "str"), field(kids[2], "str")}, Start: n.Start, End: n.End}, nil + }) + // sp_name: ident '.' ident | ident -> sp_name(db, name) + register("sp_name", "ident '.' ident", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "sp_name", Args: []Value{field(kids[0], "str"), field(kids[2], "str")}, Start: n.Start, End: n.End}, nil + }) + register("sp_name", "ident", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "sp_name", Args: []Value{nil, field(kids[0], "str")}, Start: n.Start, End: n.End}, nil + }) + // schema: ident (checked as a database name) + register("schema", "ident", pass(1)) + // charset_name / collation_name: the name resolved against the registry; keep the name + register("charset_name", "ident_or_text", pass(1)) + register("collation_name", "ident_or_text", pass(1)) + // window_definition: window_name AS window_spec -> the spec, named + register("window_definition", "window_name AS window_spec", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + if w, ok := kids[2].(*Node); ok { + for i, k := range w.Names { + if k == "name" { + w.Args[i] = kids[0] + return w, nil + } + } + w.Names = append(w.Names, "name") + w.Args = append(w.Args, kids[0]) + return w, nil + } + return kids[2], nil + }) + // column_attribute_list: column_attribute -> a list of one (enforcement checked by the server) + register("column_attribute_list", "column_attribute", listOf(1)) + // opt_create_table_options_etc: create_table_options opt_create_partitioning_etc + register("opt_create_table_options_etc", "create_table_options opt_create_partitioning_etc", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + st, ok := kids[1].(*Struct) + if !ok { + st = &Struct{Fields: map[string]Value{}} + } + if _, dup := st.Fields["opt_create_table_options"]; !dup { + st.Order = append(st.Order, "opt_create_table_options") + } + st.Fields["opt_create_table_options"] = kids[0] + return st, nil + }) +} + +func init() { + // table_constraint_def: opt_constraint_name constraint_key_type opt_index_name_and_type '(' + // key_list_with_expression ')' opt_index_options -> PT_inline_index_definition(type, name, index type, parts, options) + register("table_constraint_def", "opt_constraint_name constraint_key_type opt_index_name_and_type '(' key_list_with_expression ')' opt_index_options", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + name := kids[0] + var indexType Value + if st, ok := kids[2].(*Struct); ok { + if v := st.Fields["name"]; v != nil { + name = v + } + indexType = st.Fields["type"] + } + return &Node{Class: "PT_inline_index_definition", Names: []string{"type_par", "name", "type", "cols", "options"}, Args: []Value{kids[1], name, indexType, kids[4], kids[6]}, Start: n.Start, End: n.End}, nil + }) + // column_attribute_list: column_attribute_list column_attribute; `CHECK (...) [NOT] ENFORCED` + // arrives as two attributes and the server folds the enforcement onto the CHECK + register("column_attribute_list", "column_attribute_list column_attribute", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + l, _ := kids[0].(List) + if enf, ok := kids[1].(*Node); ok && enf.Class == "PT_constraint_enforcement_attr" && len(l) > 0 { + if chk, ok := l[len(l)-1].(*Node); ok && chk.Class == "PT_check_constraint_column_attr" { + chk.Names = append(chk.Names, "enforced") + chk.Args = append(chk.Args, enf.Arg("enforced")) + return l, nil + } + return nil, &Unsupported{Rule: n.Kind.String(), Alt: n.Alt, Start: enf.Start, End: enf.End, Text: "ENFORCED without a preceding CHECK"} + } + return append(l, kids[1]), nil + }) + // opt_charset_with_opt_binary: character_set charset_name opt_bin_mod + register("opt_charset_with_opt_binary", "character_set charset_name opt_bin_mod", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Struct{Fields: map[string]Value{"charset": kids[1], "force_binary": Const("false"), "binary": kids[2]}, Order: []string{"charset", "force_binary", "binary"}}, nil + }) + // reference_list: ident -> [Key_part_spec(ident, 0, ORDER_ASC)]; a composite key appends + refPart := func(n *mysqlparse.Node, id Value) Value { + return &Node{Class: "Key_part_spec", Names: []string{"column_name", "prefix_length", "order"}, Args: []Value{field(id, "str"), Number(0), Const("ORDER_ASC")}, Start: n.Start, End: n.End} + } + register("reference_list", "ident", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return List{refPart(n, kids[0])}, nil + }) + register("reference_list", "reference_list ',' ident", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + l, _ := kids[0].(List) + return append(l, refPart(n, kids[2])), nil + }) + // ident_string_list: ident -> [ident]; ident_string_list ',' ident -> the list extended + // (DROP PARTITION p0, p1, a plain comma list this rule's only other alternative parsegen + // already read; only the recursive one needed a hand-written hook, measured: ALTER TABLE + // t DROP PARTITION p0, p1 failed to build without it) + register("ident_string_list", "ident", listOf(1)) + register("ident_string_list", "ident_string_list ',' ident", appendTo(1, 3)) + // xid: text_string -> XID(gtrid) + register("xid", "text_string", build("XID", 1)) + // insert_stmt: INSERT ... SET update_list ... -> PT_insert with the SET pairs as one row + register("insert_stmt", "INSERT_SYM insert_lock_option opt_ignore opt_INTO table_ident opt_use_partition SET_SYM update_list opt_values_reference opt_insert_update_list", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + ul, _ := kids[7].(*Struct) + vr, _ := kids[8].(*Struct) + iu, _ := kids[9].(*Struct) + get := func(st *Struct, k string) Value { + if st == nil { + return nil + } + return st.Fields[k] + } + return &Node{Class: "PT_insert", Start: n.Start, End: n.End, Args: []Value{Const("false"), kids[1], kids[2], kids[4], kids[5], + get(ul, "column_list"), List{get(ul, "value_list")}, nil, get(vr, "table_alias"), get(vr, "column_list"), get(iu, "column_list"), get(iu, "value_list")}}, nil + }) + // alter_list: alter_list_item | ... | create_table_options_space_separated -> {flags, actions} + register("alter_list", "alter_list_item", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Struct{Fields: map[string]Value{"flags": nil, "actions": List{kids[0]}}, Order: []string{"flags", "actions"}}, nil + }) + register("alter_list", "create_table_options_space_separated", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Struct{Fields: map[string]Value{"flags": nil, "actions": kids[0]}, Order: []string{"flags", "actions"}}, nil + }) +} + +func init() { + // DROP TABLE / DROP VIEW: legacy LEX; keep what the statement says + register("drop_table_stmt", "DROP opt_temporary table_or_tables if_exists table_list opt_restrict", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "Sql_cmd_drop_table", Names: []string{"temporary", "if_exists", "tables", "restrict"}, Args: []Value{kids[1], kids[3], kids[4], kids[5]}, Start: n.Start, End: n.End}, nil + }) + register("drop_view_stmt", "DROP VIEW_SYM if_exists table_list opt_restrict", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "Sql_cmd_drop_view", Names: []string{"if_exists", "views", "restrict"}, Args: []Value{kids[2], kids[3], kids[4]}, Start: n.Start, End: n.End}, nil + }) + // view_query_block: query_expression_with_opt_locking_clauses view_check_option + register("view_query_block", "query_expression_with_opt_locking_clauses view_check_option", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Struct{Fields: map[string]Value{"query": kids[0], "check_option": kids[1]}, Order: []string{"query", "check_option"}}, nil + }) + // alter_list: alter_list ',' alter_list_item | alter_list ',' alter_commands_modifier + register("alter_list", "alter_list ',' alter_list_item", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + st, _ := kids[0].(*Struct) + acts, _ := st.Fields["actions"].(List) + st.Fields["actions"] = append(acts, kids[2]) + return st, nil + }) + register("alter_list", "alter_list ',' alter_commands_modifier", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + st, _ := kids[0].(*Struct) + fl, _ := st.Fields["flags"].(List) + st.Fields["flags"] = append(fl, kids[2]) + return st, nil + }) + // key_part: ident '(' NUM ')' opt_ordering_direction -> PT_key_part_specification(name, direction, prefix length) + register("key_part", "ident '(' NUM ')' opt_ordering_direction", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + length, _ := number(kids[2]) + return &Node{Class: "PT_key_part_specification", Names: []string{"column_name", "order", "prefix_length"}, Args: []Value{field(kids[0], "str"), kids[4], length}, Start: n.Start, End: n.End}, nil + }) + // window_frame_extent: window_frame_start -> PT_borders(start, CURRENT ROW) + register("window_frame_extent", "window_frame_start", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "PT_borders", Args: []Value{kids[0], &Node{Class: "PT_border", Args: []Value{Const("WBT_CURRENT_ROW")}}}, Start: n.Start, End: n.End}, nil + }) + // window_func_call: LEAD / LAG '(' expr opt_lead_lag_info ')' opt_null_treatment windowing_clause + leadLag := func(isLead bool) Hook { + return func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + args := List{kids[2]} + if st, ok := kids[3].(*Struct); ok { + if v := st.Fields["offset"]; v != nil { + args = append(args, v) + } + if v := st.Fields["default_value"]; v != nil { + args = append(args, v) + } + } + return &Node{Class: "Item_lead_lag", Names: []string{"is_lead", "args", "null_treatment", "w"}, Args: []Value{Const(map[bool]string{true: "true", false: "false"}[isLead]), args, kids[5], kids[6]}, Start: n.Start, End: n.End}, nil + } + } + register("window_func_call", "LEAD_SYM '(' expr opt_lead_lag_info ')' opt_null_treatment windowing_clause", leadLag(true)) + register("window_func_call", "LAG_SYM '(' expr opt_lead_lag_info ')' opt_null_treatment windowing_clause", leadLag(false)) + // alter_algorithm_option_value / alter_lock_option_value: ident -> the matching enum; the + // server rejects any other word (ER_UNKNOWN_ALTER_ALGORITHM / _LOCK) + enumOf := func(prefix string, allowed ...string) Hook { + return func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + word := upperIdent(kids[0]) + for _, a := range allowed { + if word == a { + return Const(prefix + word), nil + } + } + return nil, &Unsupported{Rule: n.Kind.String(), Alt: n.Alt, Start: n.Start, End: n.End, Text: "unknown " + word} + } + } + register("alter_algorithm_option_value", "ident", enumOf("Alter_info::ALTER_TABLE_ALGORITHM_", "INPLACE", "INSTANT", "COPY")) + register("alter_lock_option_value", "ident", enumOf("Alter_info::ALTER_TABLE_LOCK_", "NONE", "SHARED", "EXCLUSIVE")) +} + +func upperIdent(v Value) string { + if t, ok := v.(Token); ok { + s := t.Value + if s == "" { + s = t.Text + } + return strings.ToUpper(s) + } + return "?" +} + +func init() { + // constraint_enforcement: opt_not ENFORCED_SYM -> !not + register("constraint_enforcement", "opt_not ENFORCED_SYM", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + if kids[0] == Const("true") { + return Const("false"), nil + } + return Const("true"), nil + }) + // param_marker: PARAM_MARKER -> Item_param(position) + register("param_marker", "PARAM_MARKER", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "Item_param", Names: []string{"pos_in_query"}, Args: []Value{Number(n.Start)}, Start: n.Start, End: n.End}, nil + }) + // joined_table: table_reference inner_join_type table_reference -> PT_cross_join(a, type, b) + // attached where the server's add_cross_join puts it: at the leftmost table of the right + // operand, so that `a JOIN b JOIN c ON p` reads ((a JOIN b) JOIN c ON p) + register("joined_table", "table_reference inner_join_type table_reference", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + cross := func(right Value) Value { + return &Node{Class: "PT_cross_join", Names: []string{"tab1_node", "type", "tab2_node"}, Args: []Value{kids[0], kids[1], right}, Start: n.Start, End: n.End} + } + right, ok := kids[2].(*Node) + if !ok || !strings.HasPrefix(right.Class, "PT_joined_table") && right.Class != "PT_cross_join" { + return cross(kids[2]), nil + } + // descend along the left operands to the leftmost table reference + leftmost := right + for { + l, ok := leftmost.Arg("tab1_node").(*Node) + if !ok || !strings.HasPrefix(l.Class, "PT_joined_table") && l.Class != "PT_cross_join" { + break + } + leftmost = l + } + for i, k := range leftmost.Names { + if k == "tab1_node" { + leftmost.Args[i] = cross(leftmost.Args[i]) + return right, nil + } + } + return cross(kids[2]), nil + }) + // index_hint_definition: USE / IGNORE / FORCE key_or_index index_hint_clause '(' list ')' + register("index_hint_definition", "index_hint_type key_or_index index_hint_clause '(' key_usage_list ')'", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "Index_hints", Args: []Value{kids[0], kids[2], kids[4]}, Start: n.Start, End: n.End}, nil + }) + register("index_hint_definition", "USE_SYM key_or_index index_hint_clause '(' opt_key_usage_list ')'", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "Index_hints", Args: []Value{Const("INDEX_HINT_USE"), kids[2], kids[4]}, Start: n.Start, End: n.End}, nil + }) + // common_table_expr: ident opt_derived_column_list AS table_subquery + register("common_table_expr", "ident opt_derived_column_list AS table_subquery", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + sub := n.Children[3] + return &Node{Class: "PT_common_table_expr", Args: []Value{field(kids[0], "str"), sub.Text(b.SQL), Number(sub.Start), kids[3], kids[1]}, Start: n.Start, End: n.End}, nil + }) + // cast_type: CHAR_SYM opt_field_length opt_charset_with_opt_binary + register("cast_type", "CHAR_SYM opt_field_length opt_charset_with_opt_binary", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + var charset Value + binary := false + if st, ok := kids[2].(*Struct); ok { + charset = st.Fields["charset"] + binary = st.Fields["binary"] != nil && st.Fields["binary"] != Const("false") + } + st := &Struct{Fields: map[string]Value{"target": Const("ITEM_CAST_CHAR"), "length": kids[1], "dec": nil, "charset": charset, "binary": Const(map[bool]string{true: "true", false: "false"}[binary])}, + Order: []string{"target", "length", "dec", "charset", "binary"}} + return st, nil + }) +} + +func init() { + // CREATE VIEW is legacy LEX all the way down: view_tail names the view and its column + // list, view_query_block carries the query and the check option, the outer alternative + // the OR REPLACE / ALGORITHM / DEFINER. They fold into one node here. + register("view_tail", "view_suid VIEW_SYM table_ident opt_derived_column_list AS view_query_block", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + var query, check Value + if st, ok := kids[5].(*Struct); ok { + query, check = st.Fields["query"], st.Fields["check_option"] + } + var suid Value + if st, ok := kids[0].(*Struct); ok { + suid = st.Fields["create_view_suid"] + } + return &Node{Class: "Sql_cmd_create_view", Names: []string{"suid", "name", "column_list", "query", "check_option"}, + Args: []Value{suid, kids[2], kids[3], query, check}, Start: n.Start, End: n.End}, nil + }) + viewHead := func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + view, ok := kids[len(kids)-1].(*Node) + if !ok || view.Class != "Sql_cmd_create_view" { + return kids[len(kids)-1], nil // a trigger, routine or event: left as the server's own nodes + } + var mode, algorithm Value + if len(kids) == 4 { + if st, ok := kids[0].(*Struct); ok { + mode, algorithm = st.Fields["create_view_mode"], st.Fields["create_view_algorithm"] + } + } + view.Names = append(view.Names, "replace", "algorithm", "definer") + view.Args = append(view.Args, mode, algorithm, kids[len(kids)-3]) + view.Start, view.End = n.Start, n.End + return view, nil + } + register("view_or_trigger_or_sp_or_event", "view_replace_or_algorithm definer_opt init_lex_create_info view_tail", viewHead) + // view_replace / view_algorithm are keyword-only rules whose server actions set + // create_view_mode / create_view_algorithm on the LEX; the generic fold keeps nothing of + // them, so viewHead's mode and algorithm read as nil for every view (OR REPLACE was a + // "view already exists" problem and ALGORITHM=TEMPTABLE never reached the analyzer, + // measured). They fold into the Struct viewHead expects. + register("view_replace", "OR_SYM REPLACE_SYM", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return Const("VIEW_CREATE_OR_REPLACE"), nil + }) + for _, alt := range []struct{ syms, algo string }{ + {"ALGORITHM_SYM EQ UNDEFINED_SYM", "VIEW_ALGORITHM_UNDEFINED"}, + {"ALGORITHM_SYM EQ MERGE_SYM", "VIEW_ALGORITHM_MERGE"}, + {"ALGORITHM_SYM EQ TEMPTABLE_SYM", "VIEW_ALGORITHM_TEMPTABLE"}, + } { + algo := alt.algo + register("view_algorithm", alt.syms, func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return Const(algo), nil + }) + } + viewMode := func(mode, algorithm Value) (Value, error) { + return &Struct{Fields: map[string]Value{"create_view_mode": mode, "create_view_algorithm": algorithm}, Order: []string{"create_view_mode", "create_view_algorithm"}}, nil + } + register("view_replace_or_algorithm", "view_replace", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return viewMode(kids[0], nil) + }) + register("view_replace_or_algorithm", "view_replace view_algorithm", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return viewMode(kids[0], kids[1]) + }) + register("view_replace_or_algorithm", "view_algorithm", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return viewMode(nil, kids[0]) + }) + register("view_or_trigger_or_sp_or_event", "definer init_lex_create_info definer_tail", viewHead) + register("view_or_trigger_or_sp_or_event", "no_definer init_lex_create_info no_definer_tail", viewHead) +} + +func init() { + // field_def: type opt_collate opt_generated_always AS '(' expr ')' opt_stored_attribute + // opt_column_attribute_list -> PT_generated_field_def; a COLLATE before AS becomes an attribute + register("field_def", "type opt_collate opt_generated_always AS '(' expr ')' opt_stored_attribute opt_column_attribute_list", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + attrs, _ := kids[8].(List) + if kids[1] != nil { + attrs = append(attrs, &Node{Class: "PT_collate_column_attr", Names: []string{"collation"}, Args: []Value{kids[1]}}) + } + return &Node{Class: "PT_generated_field_def", Names: []string{"type_node", "expr", "virtual_or_stored", "opt_attrs"}, + Args: []Value{kids[0], kids[5], kids[7], attrs}, Start: n.Start, End: n.End}, nil + }) +} + +func init() { + // INSERT / REPLACE ... SET a = 1, b = 2 -> PT_insert with the SET pairs as one row + // (the server wraps $N.value_list as the single row of a fresh PT_insert_values_list); + // the argument names mirror the generated VALUES alternatives so the analyzer reads + // both forms the same way. + insertNames := []string{"is_replace", "opt_hints", "lock_option", "ignore", + "table_ident", "opt_use_partition", "column_list", "row_value_list", "insert_query_expression", + "opt_values_table_alias", "opt_values_column_list", "opt_on_duplicate_column_list", "opt_on_duplicate_value_list"} + register("insert_stmt", "INSERT_SYM insert_lock_option opt_ignore opt_INTO table_ident opt_use_partition SET_SYM update_list opt_values_reference opt_insert_update_list", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "PT_insert", Names: insertNames, Args: []Value{ + Const("false"), kids[0], kids[1], kids[2], kids[4], kids[5], + field(kids[7], "column_list"), List{field(kids[7], "value_list")}, nil, + field(kids[8], "table_alias"), field(kids[8], "column_list"), + field(kids[9], "column_list"), field(kids[9], "value_list"), + }, Start: n.Start, End: n.End}, nil + }) + register("replace_stmt", "REPLACE_SYM replace_lock_option opt_INTO table_ident opt_use_partition SET_SYM update_list", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "PT_insert", Names: insertNames, Args: []Value{ + Const("true"), kids[0], kids[1], Const("false"), kids[3], kids[4], + field(kids[6], "column_list"), List{field(kids[6], "value_list")}, nil, + nil, nil, nil, nil, + }, Start: n.Start, End: n.End}, nil + }) + // table_wild: ident '.' ident '.' '*' -> Item_asterisk(schema, table) + register("table_wild", "ident '.' ident '.' '*'", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "Item_asterisk", Names: []string{"opt_schema_name", "opt_table_name"}, + Args: []Value{field(kids[0], "str"), field(kids[2], "str")}, Start: n.Start, End: n.End}, nil + }) + // sum_expr: JSON_ARRAYAGG(x) / JSON_OBJECTAGG(k, v) -> Item_sum_json_array / _object + register("sum_expr", "JSON_ARRAYAGG '(' in_sum_expr ')' opt_windowing_clause", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "Item_sum_json_array", Names: []string{"a", "w"}, Args: []Value{kids[2], kids[4]}, Start: n.Start, End: n.End}, nil + }) + register("sum_expr", "JSON_OBJECTAGG '(' in_sum_expr ',' in_sum_expr ')' opt_windowing_clause", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "Item_sum_json_object", Names: []string{"key", "value", "w"}, Args: []Value{kids[2], kids[4], kids[6]}, Start: n.Start, End: n.End}, nil + }) + // jt_column: name type [COLLATE] [EXISTS] PATH '...' [on empty/error] -> + // PT_json_table_column_with_path (the column with its declared type and path) + register("jt_column", "ident type opt_collate jt_column_type PATH_SYM text_literal opt_on_empty_or_error_json_table", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "PT_json_table_column_with_path", Names: []string{"name", "type", "collate", "column_type", "path", "on_empty_or_error"}, + Args: []Value{field(kids[0], "str"), kids[1], kids[2], kids[3], kids[5], kids[6]}, Start: n.Start, End: n.End}, nil + }) +} + +// genericCall builds the node of a generic function call `name(args...)` as the grammar's +// function_call_generic does. +func genericCall(n *mysqlparse.Node, name string, args ...Value) *Node { + list := make(List, 0, len(args)) + for _, a := range args { + list = append(list, &Node{Class: "PTI_udf_expr", Names: []string{"expr", "select_alias"}, Args: []Value{a, nil}, Start: n.Start, End: n.End}) + } + return &Node{Class: "PTI_function_call_generic_ident_sys", Names: []string{"ident", "opt_udf_expr_list"}, + Args: []Value{Token{Kind: identKind(), Text: name, Value: name}, list}, Start: n.Start, End: n.End} +} + +// identKind is the token kind of an identifier. +func identKind() mysqlparse.Kind { + k, _ := mysqlparse.KindOf("IDENT") + return k +} diff --git a/check/mysql/internal/mysqlast/hooks_event.go b/check/mysql/internal/mysqlast/hooks_event.go new file mode 100644 index 0000000..733f76b --- /dev/null +++ b/check/mysql/internal/mysqlast/hooks_event.go @@ -0,0 +1,60 @@ +package mysqlast + +import ( + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlparse" +) + +// Hand-written alternatives for CREATE EVENT's schedule and options. event_tail itself is +// ActDefault (the server's action only fills the LEX in the children), so the generic fold +// gives Node event_tail(if_not_exists, sp_name, ev_schedule, on_completion, status, +// comment, body) once these children stop being *Unsupported: the schedule's own +// alternatives write into a Event_parse_data object parsegen cannot read, and the option +// keywords have no readable payload at all. +func init() { + // ev_schedule_time: `AT expr` (a one-time event) or `EVERY expr interval [STARTS expr] + // [ENDS expr]` (a recurring one) -> Node ev_schedule{at, every, interval, starts, ends}, + // the fields the form does not have nil. + register("ev_schedule_time", "AT_SYM expr", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "ev_schedule", Names: []string{"at", "every", "interval", "starts", "ends"}, + Args: []Value{kids[1], nil, nil, nil, nil}, Start: n.Start, End: n.End}, nil + }) + register("ev_schedule_time", "EVERY_SYM expr interval ev_starts ev_ends", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "ev_schedule", Names: []string{"at", "every", "interval", "starts", "ends"}, + Args: []Value{nil, kids[1], kids[2], kids[3], kids[4]}, Start: n.Start, End: n.End}, nil + }) + // ev_starts / ev_ends: the expression alone (the empty alternative folds to nil). + register("ev_starts", "STARTS_SYM expr", pass(2)) + register("ev_ends", "ENDS_SYM expr", pass(2)) + // opt_ev_status: the status keyword as text; the empty alternative stays the grammar's + // own "0" (ENABLE, the server's default). + register("opt_ev_status", "ENABLE_SYM", constant("ENABLE")) + register("opt_ev_status", "DISABLE_SYM", constant("DISABLE")) + register("opt_ev_status", "DISABLE_SYM ON_SYM SLAVE", constant("DISABLE ON REPLICA")) + register("opt_ev_status", "DISABLE_SYM ON_SYM REPLICA_SYM", constant("DISABLE ON REPLICA")) + // ev_on_completion: PRESERVE / NOT PRESERVE as text; opt_ev_on_completion's empty + // alternative stays "0" (NOT PRESERVE, the server's default). + register("ev_on_completion", "ON_SYM COMPLETION_SYM PRESERVE_SYM", constant("PRESERVE")) + register("ev_on_completion", "ON_SYM COMPLETION_SYM NOT_SYM PRESERVE_SYM", constant("NOT PRESERVE")) + // opt_ev_comment: the string literal alone. + register("opt_ev_comment", "COMMENT_SYM TEXT_STRING_sys", pass(2)) +} + +// ALTER EVENT: alter_event_stmt is ActDefault (Node alter_event_stmt(definer, sp_name, +// schedule_completion, rename_to, status, comment, body)); its own children below carry +// only a "1" in the grammar's shapes, so they are folded here to the data they hold. +func init() { + // ev_alter_on_schedule_completion -> Node ev_alter_schedule{schedule, completion}, the + // one not written nil; the empty alternative stays the grammar's "0". + register("ev_alter_on_schedule_completion", "ON_SYM SCHEDULE_SYM ev_schedule_time", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "ev_alter_schedule", Names: []string{"schedule", "completion"}, Args: []Value{kids[2], nil}, Start: n.Start, End: n.End}, nil + }) + register("ev_alter_on_schedule_completion", "ev_on_completion", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "ev_alter_schedule", Names: []string{"schedule", "completion"}, Args: []Value{nil, kids[0]}, Start: n.Start, End: n.End}, nil + }) + register("ev_alter_on_schedule_completion", "ON_SYM SCHEDULE_SYM ev_schedule_time ev_on_completion", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "ev_alter_schedule", Names: []string{"schedule", "completion"}, Args: []Value{kids[2], kids[3]}, Start: n.Start, End: n.End}, nil + }) + // opt_ev_rename_to: the new sp_name alone; opt_ev_sql_stmt: the new body alone. + register("opt_ev_rename_to", "RENAME TO_SYM sp_name", pass(3)) + register("opt_ev_sql_stmt", "DO_SYM ev_sql_stmt", pass(2)) +} diff --git a/check/mysql/internal/mysqlast/hooks_load.go b/check/mysql/internal/mysqlast/hooks_load.go new file mode 100644 index 0000000..9ff06f7 --- /dev/null +++ b/check/mysql/internal/mysqlast/hooks_load.go @@ -0,0 +1,166 @@ +package mysqlast + +import "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlparse" + +// LOAD DATA / SELECT ... INTO OUTFILE separators and LOCK TABLES: the server's actions fill +// by-value structs (Field_separators / Line_separators, `$$.cleanup()` and field stores) or +// call add_table_to_list on the LEX, none of which parsegen reads. +func init() { + // field_term: one FIELDS clause item, as a Struct with the fields sql_yacc.yy stores + sep := func(fields ...string) Hook { + return func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + st := &Struct{Fields: map[string]Value{}} + for i := 0; i < len(fields); i += 2 { + k, v := fields[i], fields[i+1] + if v == "true" { + st.Fields[k] = Const("true") + } else { + st.Fields[k] = kids[len(kids)-1] // the text_string is always last + } + st.Order = append(st.Order, k) + } + return st, nil + } + } + register("field_term", "TERMINATED BY text_string", sep("field_term", "")) + register("field_term", "OPTIONALLY ENCLOSED BY text_string", sep("enclosed", "", "opt_enclosed", "true")) + register("field_term", "ENCLOSED BY text_string", sep("enclosed", "")) + register("field_term", "ESCAPED BY text_string", sep("escaped", "")) + register("line_term", "TERMINATED BY text_string", sep("line_term", "")) + register("line_term", "STARTING BY text_string", sep("line_start", "")) + // *_list: merge the items into one Struct (merge_field_separators / merge_line_separators: + // a later item's field wins) + merge := func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + out := &Struct{Fields: map[string]Value{}} + for _, k := range kids { + st, ok := k.(*Struct) + if !ok { + continue + } + for _, key := range st.Order { + if _, seen := out.Fields[key]; !seen { + out.Order = append(out.Order, key) + } + out.Fields[key] = st.Fields[key] + } + } + return out, nil + } + register("field_term_list", "field_term_list field_term", merge) + register("field_term_list", "field_term", pass(1)) + register("line_term_list", "line_term_list line_term", merge) + register("line_term_list", "line_term", pass(1)) + register("opt_field_term", "", constant("nil")) + register("opt_field_term", "COLUMNS field_term_list", pass(2)) + register("opt_line_term", "", constant("nil")) + register("opt_line_term", "LINES line_term_list", pass(2)) + + // LOCK TABLES: table_lock is table_ident opt_table_alias lock_option, folded to a + // node of the three; lock_option to the thr_lock_type the action picks + register("lock_option", "READ_SYM", constant("TL_READ_NO_INSERT")) + register("lock_option", "WRITE_SYM", constant("TL_WRITE_DEFAULT")) + register("lock_option", "READ_SYM LOCAL_SYM", constant("TL_READ")) + register("table_lock", "table_ident opt_table_alias lock_option", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "table_lock", Names: []string{"table_ident", "opt_table_alias", "lock_option"}, Args: []Value{kids[0], kids[1], kids[2]}, Start: n.Start, End: n.End}, nil + }) + register("table_lock_list", "table_lock", listOf(1)) + register("table_lock_list", "table_lock_list ',' table_lock", appendTo(1, 3)) +} + +func init() { + // LOCK TABLES: the `lock` rule's own action only sets sql_command on the LEX + register("lock", "LOCK_SYM table_or_tables table_lock_list", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "lock_tables", Names: []string{"tables"}, Args: []Value{kids[2]}, Start: n.Start, End: n.End}, nil + }) + // LOAD DATA's column list and SET clause: PT_load_table reads $22.set_var_list / + // .set_expr_list / .set_expr_str_list off the by-value struct load_data_set_list builds + register("fields_or_vars", "fields_or_vars ',' field_or_var", appendTo(1, 3)) + register("load_data_set_elem", "simple_ident_nospvar equal expr_or_default", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Struct{Fields: map[string]Value{"set_var": kids[0], "set_expr": kids[2], "set_expr_str": Token{Text: n.Text(b.SQL), Start: n.Start, End: n.End}}, + Order: []string{"set_var", "set_expr", "set_expr_str"}}, nil + }) + setList := func(elems ...Value) *Struct { + st := &Struct{Fields: map[string]Value{"set_var_list": List{}, "set_expr_list": List{}, "set_expr_str_list": List{}}, + Order: []string{"set_var_list", "set_expr_list", "set_expr_str_list"}} + for _, e := range elems { + el, ok := e.(*Struct) + if !ok { + continue + } + for _, k := range []string{"set_var", "set_expr", "set_expr_str"} { + st.Fields[k+"_list"] = append(st.Fields[k+"_list"].(List), el.Fields[k]) + } + } + return st + } + register("load_data_set_list", "load_data_set_elem", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return setList(kids[0]), nil + }) + register("load_data_set_list", "load_data_set_list ',' load_data_set_elem", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + st, ok := kids[0].(*Struct) + if !ok { + return setList(kids[2]), nil + } + el, ok := kids[2].(*Struct) + if !ok { + return st, nil + } + for _, k := range []string{"set_var", "set_expr", "set_expr_str"} { + st.Fields[k+"_list"] = append(st.Fields[k+"_list"].(List), el.Fields[k]) + } + return st, nil + }) + register("opt_load_data_set_spec", "", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return setList(), nil + }) +} + +func init() { + // select_stmt_with_into: two of its alternatives' PT_select_stmt constructors take the + // (qe, into) pair by position, which parsegen reads under the wrong parameter names + // (sql_command, qe); folded here to the names the other alternatives carry (the + // locking variants below). + register("select_stmt_with_into", "query_expression into_clause", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "PT_select_stmt", Names: []string{"qe", "into"}, Args: []Value{kids[0], kids[1]}, Start: n.Start, End: n.End}, nil + }) + // ALTER VIEW: the same view_tail as CREATE VIEW (Sql_cmd_create_view), under an + // alternative whose action only sets create_view_mode = VIEW_ALTER on the LEX. Folded + // to the CREATE node's shape under its own class, so the loader replaces the view the + // way OR REPLACE would, after checking it exists. + alterView := func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + view, ok := kids[len(kids)-1].(*Node) + if !ok || view.Class != "Sql_cmd_create_view" { + return kids[len(kids)-1], nil + } + var algorithm Value + if len(kids) == 4 { // ALTER view_algorithm definer_opt view_tail + algorithm = kids[1] + } + out := &Node{Class: "Sql_cmd_alter_view", Names: append(append([]string(nil), view.Names...), "replace", "algorithm", "definer"), + Args: append(append([]Value(nil), view.Args...), Const("VIEW_ALTER"), algorithm, kids[len(kids)-2]), Start: n.Start, End: n.End} + return out, nil + } + register("alter_view_stmt", "ALTER view_algorithm definer_opt view_tail", alterView) + register("alter_view_stmt", "ALTER definer_opt view_tail", alterView) +} + +func init() { + // A trailing locking clause (FOR UPDATE / FOR SHARE / LOCK IN SHARE MODE): the + // constructor wraps the query in a PT_locking built inline, which parsegen reads as + // unevaluated text and the generic fold kept as a constant in the query's place (the + // statement's query, and the tables it reads, were lost). Folded here to the plain + // PT_select_stmt with the clauses beside it under "locking". + locking := func(qe, lock, into int) Hook { + return func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + var intoV Value + if into > 0 { + intoV = kids[into-1] + } + return &Node{Class: "PT_select_stmt", Names: []string{"qe", "into", "locking", "has_trailing_locking_clauses"}, + Args: []Value{kids[qe-1], intoV, kids[lock-1], Const("true")}, Start: n.Start, End: n.End}, nil + } + } + register("select_stmt", "query_expression locking_clause_list", locking(1, 2, 0)) + register("select_stmt_with_into", "query_expression into_clause locking_clause_list", locking(1, 3, 2)) + register("select_stmt_with_into", "query_expression locking_clause_list into_clause", locking(1, 2, 3)) +} diff --git a/check/mysql/internal/mysqlast/hooks_setop.go b/check/mysql/internal/mysqlast/hooks_setop.go new file mode 100644 index 0000000..3427a32 --- /dev/null +++ b/check/mysql/internal/mysqlast/hooks_setop.go @@ -0,0 +1,51 @@ +package mysqlast + +import ( + "fmt" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlparse" +) + +// Set operations and quantified comparisons: actions parsegen reads as calls it does not +// interpret (flatten_equal_set_ops, PTI_comp_op_all with a manual syntax error). +func init() { + // query_expression_body: query_expression_body UNION_SYM union_option query_expression_body + // -> {flatten_equal_set_ops(lhs.body, distinct, rhs.body, rhs.is_parenthesized), false} + // (likewise EXCEPT / INTERSECT). flatten_equal_set_ops appends the right operand to the + // left one's list when it is the same operation with the same DISTINCT / ALL + // (X op Y op Z ==> op(X, Y, Z)); otherwise it makes a new node over the two. + for _, op := range []struct{ sym, class string }{{"UNION_SYM", "PT_union"}, {"EXCEPT_SYM", "PT_except"}, {"INTERSECT_SYM", "PT_intersect"}} { + class := op.class + register("query_expression_body", "query_expression_body "+op.sym+" union_option query_expression_body", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + left, right := field(kids[0], "body"), field(kids[3], "body") + distinct := Const("true") + if s := fmt.Sprint(kids[2]); s == "0" { + distinct = Const("false") + } + rightParens := field(kids[3], "is_parenthesized") + var node *Node + if l, ok := left.(*Node); ok && l.Class == class && l.Arg("is_distinct") == distinct { + list, _ := l.Arg("list").(List) + node = &Node{Class: class, Names: l.Names, Args: []Value{append(append(List{}, list...), right), distinct, rightParens}, Start: n.Start, End: n.End} + } else { + node = &Node{Class: class, Names: []string{"list", "is_distinct", "is_rhs_in_parentheses"}, Args: []Value{List{left, right}, distinct, rightParens}, Start: n.Start, End: n.End} + } + return &Struct{Fields: map[string]Value{"body": node, "is_parenthesized": Const("false")}, Order: []string{"body", "is_parenthesized"}}, nil + }) + } + // with_list: with_list ',' common_table_expr -> the list with the CTE appended; the + // single-element form parsegen reads as a PT_with_list it builds, which comes out a List + register("with_list", "with_list ',' common_table_expr", appendTo(1, 3)) + // bool_pri: bool_pri comp_op all_or_any table_subquery -> PTI_comp_op_all(left, comp_op, is_all, subquery); + // the server rejects <=> here with a manual syntax error + register("bool_pri", "bool_pri comp_op all_or_any table_subquery", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + if op, ok := kids[1].(Op); ok && op == "<=>" { + return nil, &Unsupported{Rule: "bool_pri", Start: n.Start, End: n.End, Text: "<=> ALL / ANY is a syntax error"} + } + all := Const("false") + if s := fmt.Sprint(kids[2]); s == "1" { + all = Const("true") + } + return &Node{Class: "PTI_comp_op_all", Names: []string{"left", "comp_op", "is_all", "subselect"}, Args: []Value{kids[0], kids[1], all, kids[3]}, Start: n.Start, End: n.End}, nil + }) +} diff --git a/check/mysql/internal/mysqlast/hooks_sp.go b/check/mysql/internal/mysqlast/hooks_sp.go new file mode 100644 index 0000000..901faa3 --- /dev/null +++ b/check/mysql/internal/mysqlast/hooks_sp.go @@ -0,0 +1,242 @@ +package mysqlast + +import ( + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlparse" +) + +// Hand-written alternatives for CREATE/DROP/ALTER TRIGGER, PROCEDURE and FUNCTION, and the +// body of a stored routine or trigger (the compound statement grammar: BEGIN ... END, +// DECLARE, IF/CASE/LOOP/WHILE/REPEAT, LEAVE/ITERATE, SIGNAL/RESIGNAL, handlers, cursors). +// +// trigger_tail and sf_tail/sp_tail themselves need no hook: they are ActDefault (the server +// writes no action of its own beyond building the LEX in the children), so the generic fold +// already turns them into an Implicit Node named after the rule, holding the children in +// grammar order once those children stop being *Unsupported. That is what this file fixes: +// the leaves and small building blocks the server's action either has no parsegen-readable +// shape for (ActUnknown) or whose auto-derived shape carries no real value (a bookkeeping +// counter, a raw action-text constant) rather than the data the AST needs. +func init() { + // sp_name: a possibly-qualified routine/trigger name -> Node{db, name}; db is nil when + // unqualified (the current schema at the time of the statement). + register("sp_name", "ident '.' ident", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "sp_name", Names: []string{"db", "name"}, Args: []Value{field(kids[0], "str"), field(kids[2], "str")}, Start: n.Start, End: n.End}, nil + }) + register("sp_name", "ident", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "sp_name", Names: []string{"db", "name"}, Args: []Value{nil, field(kids[0], "str")}, Start: n.Start, End: n.End}, nil + }) + + // sp_fdparam / sp_pdparam: one routine parameter. A function parameter has no mode + // keyword (IN is implied); a procedure parameter's mode is sp_opt_inout's constant. + register("sp_fdparam", "ident type opt_collate", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "sp_param", Names: []string{"mode", "name", "type"}, + Args: []Value{Const("sp_variable::MODE_IN"), field(kids[0], "str"), kids[1]}, Start: n.Start, End: n.End}, nil + }) + register("sp_pdparam", "sp_opt_inout ident type opt_collate", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "sp_param", Names: []string{"mode", "name", "type"}, + Args: []Value{kids[0], field(kids[1], "str"), kids[2]}, Start: n.Start, End: n.End}, nil + }) + + // sp_fdparams / sp_pdparams: the parameter list, left-recursive; folded into a List (the + // server instead counts and registers each parameter in its own parse context). + register("sp_fdparams", "sp_fdparam", listOf(1)) + register("sp_fdparams", "sp_fdparams ',' sp_fdparam", appendTo(1, 3)) + register("sp_pdparams", "sp_pdparam", listOf(1)) + register("sp_pdparams", "sp_pdparams ',' sp_pdparam", appendTo(1, 3)) + + // sp_decls: the routine/trigger body's DECLAREs, left-recursive; the server's own shape + // (ActStruct summing "vars"/"conds"/"hndlrs"/"curs" counts) does not carry the + // declarations themselves, since those texts are arithmetic on the previous struct that + // this package cannot evaluate. Folded into a List of the sp_decl values instead. + register("sp_decls", "", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { return List(nil), nil }) + register("sp_decls", "sp_decls sp_decl ';'", appendTo(1, 2)) + + // sp_decl: the four DECLARE forms. + register("sp_decl", "DECLARE_SYM sp_decl_idents type opt_collate sp_opt_default", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + var def Value + if st, ok := kids[4].(*Struct); ok { + def = st.Fields["expr"] + } + return &Node{Class: "sp_decl_var", Names: []string{"names", "type", "default"}, Args: []Value{kids[1], kids[2], def}, Start: n.Start, End: n.End}, nil + }) + register("sp_decl", "DECLARE_SYM ident CONDITION_SYM FOR_SYM sp_cond", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "sp_decl_condition", Names: []string{"name", "value"}, Args: []Value{field(kids[1], "str"), kids[4]}, Start: n.Start, End: n.End}, nil + }) + register("sp_decl", "DECLARE_SYM sp_handler_type HANDLER_SYM FOR_SYM sp_hcond_list sp_proc_stmt", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "sp_decl_handler", Names: []string{"type", "conditions", "body"}, Args: []Value{kids[1], kids[4], kids[5]}, Start: n.Start, End: n.End}, nil + }) + register("sp_decl", "DECLARE_SYM ident CURSOR_SYM FOR_SYM select_stmt", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "sp_decl_cursor", Names: []string{"name", "query"}, Args: []Value{field(kids[1], "str"), kids[4]}, Start: n.Start, End: n.End}, nil + }) + + // sp_decl_idents: the comma-separated names of one DECLARE (several variables can share + // a type and default), folded into a List of their names. + register("sp_decl_idents", "ident", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return List{field(kids[0], "str")}, nil + }) + register("sp_decl_idents", "sp_decl_idents ',' ident", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + l, _ := kids[0].(List) + return append(l, field(kids[2], "str")), nil + }) + + // sp_proc_stmts / sp_proc_stmts1: a sequence of body statements, left-recursive (1 + // requires at least one, used by THEN/ELSE/WHEN/LOOP/WHILE/REPEAT bodies where an empty + // body is not allowed); folded into a List of the statements. + register("sp_proc_stmts", "", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { return List(nil), nil }) + register("sp_proc_stmts", "sp_proc_stmts sp_proc_stmt ';'", appendTo(1, 2)) + register("sp_proc_stmts1", "sp_proc_stmt ';'", listOf(1)) + register("sp_proc_stmts1", "sp_proc_stmts1 sp_proc_stmt ';'", appendTo(1, 2)) + + // sp_proc_stmt_statement: a plain SQL statement in the body (INSERT/UPDATE/DELETE/SELECT + // INTO/SET/CALL and the like): the mid-rule action only resets the parser's lex, so the + // value is simple_statement's own. + register("sp_proc_stmt_statement", "simple_statement", pass(1)) + // sp_proc_stmt_return: RETURN expr (stored functions only; a PROCEDURE's RETURN with no + // expression is a different, plain grammar alternative already handled elsewhere). + register("sp_proc_stmt_return", "RETURN_SYM expr", build("sp_return", 2)) + // LEAVE / ITERATE name the enclosing (or an outer) label. + register("sp_proc_stmt_leave", "LEAVE_SYM label_ident", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "sp_leave", Names: []string{"label"}, Args: []Value{field(kids[1], "str")}, Start: n.Start, End: n.End}, nil + }) + register("sp_proc_stmt_iterate", "ITERATE_SYM label_ident", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "sp_iterate", Names: []string{"label"}, Args: []Value{field(kids[1], "str")}, Start: n.Start, End: n.End}, nil + }) + + // sp_labeled_block / sp_labeled_control: `label: BEGIN ... END [label]` / `label: LOOP + // ... END LOOP [label]` (the trailing label is a repetition the server checks matches; + // the AST keeps only the label once). + register("sp_labeled_block", "label_ident ':' sp_block_content sp_opt_label", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "sp_labeled_block", Names: []string{"label", "body"}, Args: []Value{field(kids[0], "str"), kids[2]}, Start: n.Start, End: n.End}, nil + }) + register("sp_labeled_control", "label_ident ':' sp_unlabeled_control sp_opt_label", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "sp_labeled_control", Names: []string{"label", "body"}, Args: []Value{field(kids[0], "str"), kids[2]}, Start: n.Start, End: n.End}, nil + }) + + // stored_routine_body: AS 'routine_string' is a form this AST does not otherwise model + // (an external routine body); kept as the literal text rather than left Unsupported. + register("stored_routine_body", "AS routine_string", pass(2)) + + // signal_value / sp_hcond: a bare identifier names a DECLARE CONDITION, resolved later + // against the enclosing block's declarations (this package does no scope resolution). + register("signal_value", "ident", conditionName) + register("sp_hcond", "ident", conditionName) + + // sp_hcond_element: validates and registers the handler in the server's parse context; + // the value is its condition (sp_hcond passed through unchanged). + register("sp_hcond_element", "sp_hcond", pass(1)) + // sp_hcond_list: the comma-separated conditions of one HANDLER, folded into a List (the + // server instead counts them). + register("sp_hcond_list", "sp_hcond_element", listOf(1)) + register("sp_hcond_list", "sp_hcond_list ',' sp_hcond_element", appendTo(1, 3)) + + // signal_stmt / resignal_stmt: SIGNAL / RESIGNAL, folded from their condition value and + // SET information items (the server's own shape only records the SQLCOM_* code and an + // unevaluated `NEW_PTNSql_cmd_signal($2,$3)` action-text constant). + register("signal_stmt", "SIGNAL_SYM signal_value opt_set_signal_information", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "sp_signal", Names: []string{"condition", "info"}, Args: []Value{kids[1], kids[2]}, Start: n.Start, End: n.End}, nil + }) + register("resignal_stmt", "RESIGNAL_SYM opt_signal_value opt_set_signal_information", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "sp_resignal", Names: []string{"condition", "info"}, Args: []Value{kids[1], kids[2]}, Start: n.Start, End: n.End}, nil + }) + + // signal_information_item_list: the SET item = value list of a SIGNAL/RESIGNAL, folded + // into a List of {name, expr} nodes (the server's own Set_signal_information is a fixed + // array indexed by item, mutated in place). + register("signal_information_item_list", "signal_condition_information_item_name EQ signal_allowed_expr", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return List{&Node{Class: "sp_signal_item", Names: []string{"name", "expr"}, Args: []Value{kids[0], kids[2]}, Start: n.Start, End: n.End}}, nil + }) + register("signal_information_item_list", "signal_information_item_list ',' signal_condition_information_item_name EQ signal_allowed_expr", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + l, _ := kids[0].(List) + return append(l, &Node{Class: "sp_signal_item", Names: []string{"name", "expr"}, Args: []Value{kids[2], kids[4]}, Start: n.Start, End: n.End}), nil + }) + + // simple_target_specification: GET DIAGNOSTICS' target, a local variable or a `@user` + // variable. + register("simple_target_specification", "ident", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "sp_variable_ref", Names: []string{"name"}, Args: []Value{field(kids[0], "str")}, Start: n.Start, End: n.End}, nil + }) + register("simple_target_specification", "'@' ident_or_text", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "user_var_ref", Names: []string{"name"}, Args: []Value{kids[1]}, Start: n.Start, End: n.End}, nil + }) + + // drop_function_stmt: DROP FUNCTION [IF EXISTS] [db.]name -- folded into the same + // {if_exists, spname} shape drop_procedure_stmt/drop_trigger_stmt already build. + register("drop_function_stmt", "DROP FUNCTION_SYM if_exists ident '.' ident", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + spname := &Node{Class: "sp_name", Names: []string{"db", "name"}, Args: []Value{field(kids[3], "str"), field(kids[5], "str")}} + return &Node{Class: "drop_function_stmt", Names: []string{"if_exists", "spname"}, Args: []Value{kids[2], spname}, Start: n.Start, End: n.End}, nil + }) + register("drop_function_stmt", "DROP FUNCTION_SYM if_exists ident", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + spname := &Node{Class: "sp_name", Names: []string{"db", "name"}, Args: []Value{nil, field(kids[3], "str")}} + return &Node{Class: "drop_function_stmt", Names: []string{"if_exists", "spname"}, Args: []Value{kids[2], spname}, Start: n.Start, End: n.End}, nil + }) +} + +// conditionName builds a reference to a DECLARE ... CONDITION FOR ... by name (in a SIGNAL +// value or a HANDLER FOR condition); resolving it against the enclosing block's +// declarations is left to the analyzer. +func conditionName(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "sp_condition_name", Names: []string{"name"}, Args: []Value{field(kids[0], "str")}, Start: n.Start, End: n.End}, nil +} + +// sp_chistic / sp_c_chistic / sp_suid: a stored routine's (or trigger's, though a trigger +// carries none in the grammar) COMMENT / LANGUAGE / SQL DATA ACCESS / SQL SECURITY / +// DETERMINISTIC characteristics. The generic fold has nothing to offer most of these +// alternatives: their server action mutates Lex->sp_chistics in place rather than +// building a Value the grammar's $$ carries, so the auto-derived shape is nil (a keyword +// with no readable payload). Folded here into a uniform {kind, value} tag instead, and +// sp_c_chistics/sp_a_chistics into a List of them (both %empty/repetition rules the server +// itself only counts). +func init() { + register("sp_chistic", "COMMENT_SYM TEXT_STRING_sys", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return chisticTag("COMMENT", kids[1]), nil + }) + register("sp_chistic", "LANGUAGE_SYM SQL_SYM", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return chisticTag("LANGUAGE", Const("SQL")), nil + }) + register("sp_chistic", "LANGUAGE_SYM ident", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return chisticTag("LANGUAGE", field(kids[1], "str")), nil + }) + register("sp_chistic", "NO_SYM SQL_SYM", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return chisticTag("SQL_DATA_ACCESS", Const("NO_SQL")), nil + }) + register("sp_chistic", "CONTAINS_SYM SQL_SYM", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return chisticTag("SQL_DATA_ACCESS", Const("CONTAINS_SQL")), nil + }) + register("sp_chistic", "READS_SYM SQL_SYM DATA_SYM", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return chisticTag("SQL_DATA_ACCESS", Const("READS_SQL_DATA")), nil + }) + register("sp_chistic", "MODIFIES_SYM SQL_SYM DATA_SYM", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return chisticTag("SQL_DATA_ACCESS", Const("MODIFIES_SQL_DATA")), nil + }) + register("sp_chistic", "sp_suid", pass(1)) + register("sp_suid", "SQL_SYM SECURITY_SYM DEFINER_SYM", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return chisticTag("SQL_SECURITY", Const("DEFINER")), nil + }) + register("sp_suid", "SQL_SYM SECURITY_SYM INVOKER_SYM", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return chisticTag("SQL_SECURITY", Const("INVOKER")), nil + }) + register("sp_c_chistic", "sp_chistic", pass(1)) + register("sp_c_chistic", "DETERMINISTIC_SYM", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return chisticTag("DETERMINISTIC", Const("true")), nil + }) + register("sp_c_chistic", "not DETERMINISTIC_SYM", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return chisticTag("DETERMINISTIC", Const("false")), nil + }) + register("sp_c_chistics", "", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { return List(nil), nil }) + register("sp_c_chistics", "sp_c_chistics sp_c_chistic", appendTo(1, 2)) +} + +// chisticTag builds the {kind, value} tag a sp_chistic/sp_c_chistic alternative folds to. +func chisticTag(kind string, value Value) Value { + return &Node{Class: "sp_chistic", Names: []string{"kind", "value"}, Args: []Value{Const(kind), value}} +} + +// trigger_follows_precedes_clause: FOLLOWS/PRECEDES , or none. The non-empty +// alternative's own derived shape has no readable anchor_trigger_name (the action reads +// $2.str/$2.length directly, a C++ expression parsegen keeps as literal text rather than a +// value); folded here from ident_or_text's own value instead, keeping the same {Struct} +// shape the %empty alternative already has. +func init() { + register("trigger_follows_precedes_clause", "trigger_action_order ident_or_text", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Struct{Fields: map[string]Value{"ordering_clause": kids[0], "anchor_trigger_name": kids[1]}, Order: []string{"ordering_clause", "anchor_trigger_name"}}, nil + }) +} diff --git a/check/mysql/internal/mysqlast/hooks_sp_test.go b/check/mysql/internal/mysqlast/hooks_sp_test.go new file mode 100644 index 0000000..90c1551 --- /dev/null +++ b/check/mysql/internal/mysqlast/hooks_sp_test.go @@ -0,0 +1,168 @@ +package mysqlast + +import ( + "strings" + "testing" +) + +// TestCreateTrigger checks that a CREATE TRIGGER with a DEFINER clause, IF NOT EXISTS, a +// FOLLOWS clause and a BEGIN body (DECLARE, a handler, IF/ELSEIF/ELSE, SIGNAL, WHILE, a +// labeled LOOP with LEAVE, and a searched CASE) folds without an *Unsupported. +func TestCreateTrigger(t *testing.T) { + sql := `CREATE DEFINER=root@localhost TRIGGER IF NOT EXISTS trg BEFORE INSERT ON t FOR EACH ROW +BEGIN + DECLARE v INT DEFAULT 0; + DECLARE CONTINUE HANDLER FOR NOT FOUND SET v = -1; + IF NEW.a > 0 THEN + SET v = 1; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'bad', MYSQL_ERRNO = 30001; + ELSEIF NEW.a < 0 THEN + SET NEW.a = 0; + ELSE + CALL p1(NEW.a, @x); + END IF; + lp: LOOP + LEAVE lp; + END LOOP; + CASE v + WHEN 1 THEN SET v = 2; + ELSE SET v = 3; + END CASE; +END` + got := Sprint(mustBuild(t, sql)) + for _, want := range []string{ + `trigger_tail(true, sp_name(db=nil, name="trg"), TRG_ACTION_BEFORE, TRG_EVENT_INSERT, Table_ident(table=t)`, + `sp_decl_var(names=["v"], type=`, + `sp_decl_handler(type=sp_handler::CONTINUE, conditions=[sp_condition_value(_mysqlerr=sp_condition_value::NOT_FOUND)]`, + `sp_if(`, + `sp_signal(condition=sp_condition_value(_mysqlerr="45000"), info=[sp_signal_item(name=CIN_MESSAGE_TEXT`, + `sp_signal_item(name=CIN_MYSQL_ERRNO, expr=Item_int(i=30001))`, + `PT_call(proc_name=sp_name(db=nil, name="p1")`, + `sp_labeled_control(label="lp", body=[sp_leave(label="lp")])`, + `simple_case_stmt(`, + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in\n%s", want, got) + } + } +} + +// TestCreateTriggerFollows checks the FOLLOWS/PRECEDES clause and a single-statement (no +// BEGIN/END) body. +func TestCreateTriggerFollows(t *testing.T) { + got := Sprint(mustBuild(t, "CREATE TRIGGER trg2 AFTER UPDATE ON t FOR EACH ROW FOLLOWS trg1 SET @x = 1")) + for _, want := range []string{ + "TRG_ACTION_AFTER", "TRG_EVENT_UPDATE", + "ordering_clause: TRG_ORDER_FOLLOWS", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in\n%s", want, got) + } + } +} + +// TestCreateProcedure checks IN/OUT/INOUT parameters and a body with a cursor (DECLARE +// CURSOR / OPEN / FETCH / CLOSE), SELECT ... INTO and RESIGNAL. +func TestCreateProcedure(t *testing.T) { + sql := `CREATE PROCEDURE p1(IN a INT, OUT b INT, INOUT c INT) +BEGIN + DECLARE cur CURSOR FOR SELECT 1; + OPEN cur; + FETCH cur INTO b; + CLOSE cur; + SELECT a INTO b; + RESIGNAL; +END` + got := Sprint(mustBuild(t, sql)) + for _, want := range []string{ + `sp_tail(false, sp_name(db=nil, name="p1")`, + `sp_param(mode=sp_variable::MODE_IN, name="a"`, + `sp_param(mode=sp_variable::MODE_OUT, name="b"`, + `sp_param(mode=sp_variable::MODE_INOUT, name="c"`, + `sp_decl_cursor(name="cur", query=`, + `opt_into1=[PT_select_sp_var(name=b)]`, + `sp_resignal(condition=nil, info=Set_signal_information())`, + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in\n%s", want, got) + } + } +} + +// TestCreateFunction checks a function's RETURNS type, a characteristic (DETERMINISTIC), +// a BEGIN ... RETURN body and the single-statement `RETURN expr` body. +func TestCreateFunction(t *testing.T) { + got := Sprint(mustBuild(t, "CREATE FUNCTION f1(a INT) RETURNS INT DETERMINISTIC\nBEGIN\n RETURN a + 1;\nEND")) + for _, want := range []string{ + `sf_tail(false, sp_name(db=nil, name="f1")`, + `sp_param(mode=sp_variable::MODE_IN, name="a"`, + `sp_return(Item_func_plus(a=PTI_simple_ident_ident(ident=a), b=Item_int(i=1)))`, + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in\n%s", want, got) + } + } + + got2 := Sprint(mustBuild(t, "CREATE FUNCTION f2(a INT) RETURNS INT\nRETURN a + 1")) + if !strings.Contains(got2, `sp_return(Item_func_plus(a=PTI_simple_ident_ident(ident=a), b=Item_int(i=1)))`) { + t.Errorf("missing bare RETURN body in\n%s", got2) + } +} + +// TestDropFunction checks DROP FUNCTION IF EXISTS name and the db-qualified form. +func TestDropFunction(t *testing.T) { + got := Sprint(mustBuild(t, "DROP FUNCTION IF EXISTS f1")) + if !strings.Contains(got, `drop_function_stmt(if_exists=1, spname=sp_name(db=nil, name="f1"))`) { + t.Errorf("unqualified: %s", got) + } + got2 := Sprint(mustBuild(t, "DROP FUNCTION db1.f1")) + if !strings.Contains(got2, `drop_function_stmt(if_exists=0, spname=sp_name(db="db1", name="f1"))`) { + t.Errorf("qualified: %s", got2) + } +} + +// TestDropTriggerProcedure checks that DROP TRIGGER / DROP PROCEDURE, already supported, +// keep working (they use the same sp_name this file adds hooks for). +func TestDropTriggerProcedure(t *testing.T) { + got := Sprint(mustBuild(t, "DROP TRIGGER IF EXISTS trg")) + if !strings.Contains(got, `spname: sp_name(db=nil, name="trg")`) { + t.Errorf("drop trigger: %s", got) + } + got2 := Sprint(mustBuild(t, "DROP PROCEDURE IF EXISTS p1")) + if !strings.Contains(got2, `spname: sp_name(db=nil, name="p1")`) { + t.Errorf("drop procedure: %s", got2) + } +} + +// TestAlterProcedureFunction checks ALTER PROCEDURE / FUNCTION's characteristics-only form. +func TestAlterProcedureFunction(t *testing.T) { + got := Sprint(mustBuild(t, "ALTER PROCEDURE p1 COMMENT 'x' SQL SECURITY INVOKER")) + if !strings.Contains(got, `spname: sp_name(db=nil, name="p1")`) { + t.Errorf("alter procedure: %s", got) + } + got2 := Sprint(mustBuild(t, "ALTER FUNCTION f1 COMMENT 'y'")) + if !strings.Contains(got2, `spname: sp_name(db=nil, name="f1")`) { + t.Errorf("alter function: %s", got2) + } +} + +// TestSpDeclConditionAndCall checks DECLARE ... CONDITION FOR and CALL with output +// parameters bound to a `@user` variable. +func TestSpDeclConditionAndCall(t *testing.T) { + sql := `CREATE PROCEDURE p2() +BEGIN + DECLARE too_big CONDITION FOR SQLSTATE '45000'; + DECLARE EXIT HANDLER FOR too_big RESIGNAL; + CALL p1(1, @out); +END` + got := Sprint(mustBuild(t, sql)) + for _, want := range []string{ + `sp_decl_condition(name="too_big", value=sp_condition_value(_mysqlerr="45000"))`, + `sp_decl_handler(type=sp_handler::EXIT, conditions=[sp_condition_name(name="too_big")]`, + `PT_call(proc_name=sp_name(db=nil, name="p1")`, + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in\n%s", want, got) + } + } +} diff --git a/check/mysql/internal/mysqlast/hooks_stmt.go b/check/mysql/internal/mysqlast/hooks_stmt.go new file mode 100644 index 0000000..9d78359 --- /dev/null +++ b/check/mysql/internal/mysqlast/hooks_stmt.go @@ -0,0 +1,140 @@ +package mysqlast + +import ( + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlparse" +) + +// Hand-written alternatives for statements whose action fills LEX by hand rather than +// building a parse-tree class: their generic fold is a by-value Struct carrying +// sql_command (the way DROP TRIGGER's already is), or the class the action names. +// They are reached mostly through dumped routine bodies (SHOW CREATE PROCEDURE), where +// the walker skips what it does not analyze; the fold only has to be faithful. +func init() { + // a statement the action writes into LEX: {sql_command: SQLCOM_*} plus whatever the + // alternative read, mirroring the fields the fold of DROP TRIGGER and friends carries + command := func(cmd string, fields ...string) func(kids []Value, at ...int) *Struct { + return func(kids []Value, at ...int) *Struct { + st := &Struct{Fields: map[string]Value{"sql_command": Const(cmd)}, Order: []string{"sql_command"}} + for i, name := range fields { + st.Order = append(st.Order, name) + st.Fields[name] = kids[at[i]-1] + } + return st + } + } + + // execute_var_ident: '@' ident_or_text -> the user variable's name, pushed onto + // LEX::prepared_stmt_params (EXECUTE stmt USING @a, @b) + register("execute_var_ident", "'@' ident_or_text", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return field(kids[1], "str"), nil + }) + + // get_diagnostics: GET [CURRENT|STACKED] DIAGNOSTICS ... -> SQLCOM_GET_DIAGNOSTICS + // over Sql_cmd_get_diagnostics($4) with $2 as the area + register("get_diagnostics", "GET_SYM which_area DIAGNOSTICS_SYM diagnostics_information", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return command("SQLCOM_GET_DIAGNOSTICS", "which_area", "info")(kids, 2, 4), nil + }) + + // kill: KILL [CONNECTION|QUERY] expr -> SQLCOM_KILL with the id expression + register("kill", "KILL_SYM kill_option expr", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return command("SQLCOM_KILL", "kill_option", "expr")(kids, 2, 3), nil + }) + + // checksum: CHECKSUM TABLE t1, t2 [QUICK|EXTENDED] -> SQLCOM_CHECKSUM + register("checksum", "CHECKSUM_SYM table_or_tables table_list opt_checksum_type", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return command("SQLCOM_CHECKSUM", "tables", "flags")(kids, 3, 4), nil + }) + + // install_stmt: INSTALL PLUGIN name SONAME 'lib' -> SQLCOM_INSTALL_PLUGIN + // (Sql_cmd_install_plugin; INSTALL COMPONENT has a shape of its own) + register("install_stmt", "INSTALL_SYM PLUGIN_SYM ident SONAME_SYM TEXT_STRING_sys", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return command("SQLCOM_INSTALL_PLUGIN", "name", "soname")(kids, 3, 5), nil + }) + + // analyze_table_stmt -> PT_analyze_table_stmt(no_write_to_binlog, tables, histogram) + register("analyze_table_stmt", "ANALYZE_SYM opt_no_write_to_binlog table_or_tables table_list opt_histogram", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "PT_analyze_table_stmt", Names: []string{"no_write_to_binlog", "tables", "histogram"}, + Args: []Value{kids[1], kids[3], kids[4]}, Start: n.Start, End: n.End}, nil + }) + // opt_histogram_num_buckets: WITH n BUCKETS -> n + register("opt_histogram_num_buckets", "WITH NUM BUCKETS_SYM", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return number(kids[1]) + }) + // opt_histogram: UPDATE HISTOGRAM ON cols [param] -> {command, columns, param} + register("opt_histogram", "UPDATE_SYM HISTOGRAM_SYM ON_SYM ident_string_list opt_histogram_update_param", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Struct{Fields: map[string]Value{"command": Const("Histogram_command::UPDATE_HISTOGRAM"), "columns": kids[3], "param": kids[4]}, + Order: []string{"command", "columns", "param"}}, nil + }) + + // drop_tablespace_stmt -> the tablespace family's shared SQLCOM_ALTER_TABLESPACE + register("drop_tablespace_stmt", "DROP TABLESPACE_SYM ident opt_drop_ts_options", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return command("SQLCOM_ALTER_TABLESPACE")(kids), nil + }) + + // show_keys_stmt -> PT_show_keys(extended, table [in opt_db], where) + register("show_keys_stmt", "SHOW opt_extended keys_or_index from_or_in table_ident opt_db opt_where_clause", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "PT_show_keys", Names: []string{"extended", "table", "opt_db", "where"}, + Args: []Value{kids[1], kids[4], kids[5], kids[6]}, Start: n.Start, End: n.End}, nil + }) + + // show_columns_stmt -> PT_show_fields(cmd_type, table [in opt_db], wild, where) + register("show_columns_stmt", "SHOW opt_show_cmd_type COLUMNS from_or_in table_ident opt_db opt_wild_or_where", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return &Node{Class: "PT_show_fields", Names: []string{"cmd_type", "table", "opt_db", "wild", "where"}, + Args: []Value{kids[1], kids[4], kids[5], field(kids[6], "wild"), field(kids[6], "where")}, Start: n.Start, End: n.End}, nil + }) + + // opt_explain_format: FORMAT = JSON|TRADITIONAL|TREE -> the format's name + register("opt_explain_format", "FORMAT_SYM EQ ident_or_text", pass(3)) + + // create: the alternatives that fill LEX by hand (the tablespace family shares + // SQLCOM_ALTER_TABLESPACE the way the server's actions do) + register("create", "CREATE DATABASE opt_if_not_exists ident opt_create_database_options", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + st := command("SQLCOM_CREATE_DB", "name", "options")(kids, 4, 5) + st.Order = append(st.Order, "if_not_exists") + st.Fields["if_not_exists"] = kids[2] + return st, nil + }) + register("create", "CREATE USER opt_if_not_exists create_user_list default_role_clause require_clause connect_options opt_account_lock_password_expire_options opt_user_attribute", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return command("SQLCOM_CREATE_USER", "users")(kids, 4), nil + }) + register("create", "CREATE LOGFILE_SYM GROUP_SYM ident ADD lg_undofile opt_logfile_group_options", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return command("SQLCOM_ALTER_TABLESPACE")(kids), nil + }) + register("create", "CREATE TABLESPACE_SYM ident opt_ts_datafile_name opt_logfile_group_name opt_tablespace_options", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return command("SQLCOM_ALTER_TABLESPACE")(kids), nil + }) + register("create", "CREATE UNDO_SYM TABLESPACE_SYM ident ADD ts_datafile opt_undo_tablespace_options", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return command("SQLCOM_ALTER_TABLESPACE")(kids), nil + }) + register("create", "CREATE SERVER_SYM ident_or_text FOREIGN DATA_SYM WRAPPER_SYM ident_or_text OPTIONS_SYM '(' server_options_list ')'", func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) { + return command("SQLCOM_CREATE_SERVER")(kids), nil + }) + // server_option: the CREATE / ALTER SERVER options fill Lex->server_options by hand; + // nothing reads them, so each folds to its value + for _, syms := range []string{"USER TEXT_STRING_sys", "HOST_SYM TEXT_STRING_sys", "DATABASE TEXT_STRING_sys", + "OWNER_SYM TEXT_STRING_sys", "PASSWORD TEXT_STRING_sys", "SOCKET_SYM TEXT_STRING_sys", "PORT_SYM ulong_num"} { + register("server_option", syms, pass(2)) + } + + // create_user: the user with its authentication attached; the fold keeps the user + // (the name is what a reader of a CREATE USER list needs, the auth methods are not read) + register("create_user", "user identification opt_create_user_with_mfa", pass(1)) + register("create_user", "user identified_with_plugin opt_initial_auth", pass(1)) + register("create_user", "user opt_create_user_with_mfa", pass(1)) + register("create_user_list", "create_user", listOf(1)) + register("create_user_list", "create_user_list ',' create_user", appendTo(1, 3)) + // the identification family builds a LEX_MFA (an authentication method); nothing reads + // its fields, so the fold is a bare marker node + mfa := build("LEX_MFA") + register("identified_by_password", "IDENTIFIED_SYM BY TEXT_STRING_password", mfa) + register("identified_by_random_password", "IDENTIFIED_SYM BY RANDOM_SYM PASSWORD", mfa) + register("identified_with_plugin", "IDENTIFIED_SYM WITH ident_or_text", mfa) + register("identified_with_plugin_as_auth", "IDENTIFIED_SYM WITH ident_or_text AS TEXT_STRING_hash", mfa) + register("identified_with_plugin_by_password", "IDENTIFIED_SYM WITH ident_or_text BY TEXT_STRING_password", mfa) + register("identified_with_plugin_by_random_password", "IDENTIFIED_SYM WITH ident_or_text BY RANDOM_SYM PASSWORD", mfa) + register("opt_create_user_with_mfa", "AND_SYM identification", mfa) + register("opt_create_user_with_mfa", "AND_SYM identification AND_SYM identification", mfa) + register("opt_initial_auth", "INITIAL_SYM AUTHENTICATION_SYM identified_by_random_password", pass(3)) + register("opt_initial_auth", "INITIAL_SYM AUTHENTICATION_SYM identified_with_plugin_as_auth", pass(3)) + register("opt_initial_auth", "INITIAL_SYM AUTHENTICATION_SYM identified_by_password", pass(3)) +} diff --git a/check/mysql/internal/mysqlast/hooks_stmt_test.go b/check/mysql/internal/mysqlast/hooks_stmt_test.go new file mode 100644 index 0000000..3bec426 --- /dev/null +++ b/check/mysql/internal/mysqlast/hooks_stmt_test.go @@ -0,0 +1,83 @@ +package mysqlast + +import ( + "strings" + "testing" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlparse" +) + +// The statement-level constructs whose server action fills LEX by hand fold to a Struct +// carrying sql_command (or the class the action names), so a dumped routine body that +// carries one of them folds instead of failing as *Unsupported. +func TestStatementFolds(t *testing.T) { + cases := []struct { + sql string + want []string + }{ + {"KILL QUERY 5", []string{"sql_command: SQLCOM_KILL", "kill_option: QUERY"}}, + {"CHECKSUM TABLE t1, t2 EXTENDED", []string{"sql_command: SQLCOM_CHECKSUM", "Table_ident(table=t1)", "Table_ident(table=t2)"}}, + {"INSTALL PLUGIN foo SONAME 'foo.so'", []string{"sql_command: SQLCOM_INSTALL_PLUGIN"}}, + {"ANALYZE TABLE t UPDATE HISTOGRAM ON a, b WITH 8 BUCKETS", []string{"PT_analyze_table_stmt(", "command: Histogram_command::UPDATE_HISTOGRAM", "columns: [a, b]"}}, + {"SHOW FULL COLUMNS FROM t IN db LIKE 'x%'", []string{"PT_show_fields("}}, + {"CREATE DATABASE IF NOT EXISTS d DEFAULT CHARACTER SET utf8mb4", []string{"sql_command: SQLCOM_CREATE_DB", "name: d"}}, + {"CREATE USER u@'%' IDENTIFIED BY 'x'", []string{"sql_command: SQLCOM_CREATE_USER"}}, + {"CREATE USER v IDENTIFIED WITH caching_sha2_password", []string{"sql_command: SQLCOM_CREATE_USER"}}, + {"CREATE TABLESPACE ts ADD DATAFILE 'f.ibd'", []string{"sql_command: SQLCOM_ALTER_TABLESPACE"}}, + {"CREATE SERVER s FOREIGN DATA WRAPPER mysql OPTIONS (HOST 'h')", []string{"sql_command: SQLCOM_CREATE_SERVER"}}, + {"GET DIAGNOSTICS @n = NUMBER, @r = ROW_COUNT", []string{"sql_command: SQLCOM_GET_DIAGNOSTICS", "Statement_information("}}, + {"GET STACKED DIAGNOSTICS CONDITION 1 @t = MESSAGE_TEXT", []string{"which_area: Diagnostics_information::STACKED_AREA", "Condition_information("}}, + // EXECUTE ... USING pushes each @var's name onto LEX::prepared_stmt_params + {"EXECUTE stmt USING @a, @b", []string{`"a"`, `"b"`}}, + // INSERT / REPLACE ... SET fold to the same PT_insert the VALUES form builds, + // with the SET pairs as the single row + {"REPLACE INTO t SET a = 1, b = 2", []string{"PT_insert(is_replace=true", "column_list=[PTI_simple_ident_nospvar_ident(ident=a), PTI_simple_ident_nospvar_ident(ident=b)]", "row_value_list=[[Item_int("}}, + {"INSERT INTO t SET a = 1 ON DUPLICATE KEY UPDATE b = 2", []string{"PT_insert(is_replace=false", "opt_on_duplicate_column_list=[PTI_simple_ident_nospvar_ident(ident=b)]"}}, + {"SELECT db.t.* FROM db.t", []string{`Item_asterisk(opt_schema_name="db", opt_table_name="t")`}}, + {"SELECT JSON_ARRAYAGG(a) FROM t", []string{"Item_sum_json_array(a=PTI_in_sum_expr(expr=PTI_simple_ident_ident(ident=a))"}}, + {"SELECT JSON_OBJECTAGG(k, v) FROM t", []string{"Item_sum_json_object(key=PTI_in_sum_expr(expr=PTI_simple_ident_ident(ident=k)), value=PTI_in_sum_expr(expr=PTI_simple_ident_ident(ident=v))"}}, + {"SELECT * FROM JSON_TABLE(j, '$[*]' COLUMNS (x INT PATH '$.x' DEFAULT '0' ON EMPTY)) AS jt", + []string{`PT_json_table_column_with_path(name="x"`}}, + // name_list: multi-column KEY partitioning and RANGE COLUMNS + {"CREATE TABLE t (a INT, b INT) PARTITION BY KEY (a, b) PARTITIONS 2", []string{`opt_columns=["a", "b"]`}}, + {"CREATE TABLE t (a INT, b INT) PARTITION BY RANGE COLUMNS (a, b) (PARTITION p0 VALUES LESS THAN (1, 2))", []string{`columns=["a", "b"]`}}, + // ternary_option: 0 / 1 are OFF / ON + {"CREATE TABLE t (a INT) PACK_KEYS=0 STATS_PERSISTENT=1", []string{"PT_create_pack_keys_option(value=Ternary_option::OFF)", "PT_create_stats_persistent_option(value=Ternary_option::ON)"}}, + } + for _, c := range cases { + got := Sprint(mustBuild(t, c.sql)) + for _, want := range c.want { + if !strings.Contains(got, want) { + t.Errorf("%s:\nmissing %q in\n%s", c.sql, want, got) + } + } + } +} + +// A body carrying these statements folds too (the dumped SHOW CREATE PROCEDURE case). +func TestStatementFoldsInBody(t *testing.T) { + for _, sql := range []string{ + "CREATE PROCEDURE p() BEGIN DECLARE n INT; GET DIAGNOSTICS n = NUMBER; END", + "CREATE PROCEDURE p() BEGIN PREPARE s FROM 'SELECT ?'; EXECUTE s USING @a; END", + "CREATE PROCEDURE p() KILL CONNECTION 7", + "CREATE PROCEDURE p() CHECKSUM TABLE t", + "CREATE PROCEDURE p() ANALYZE TABLE t", + "CREATE PROCEDURE p() REPLACE INTO t SET a = 1", + "CREATE PROCEDURE p() CREATE DATABASE d", + } { + mustBuild(t, sql) + } +} + +// ternary_option refuses what the server's own action does: any value but 0 or 1 is its +// syntax error, kept explicit here as *Unsupported. +func TestTernaryOptionOutOfRange(t *testing.T) { + sql := "CREATE TABLE t (a INT) PACK_KEYS=2" + root, err := mysqlparse.Parse(sql, 0) + if err != nil { + t.Fatal(err) + } + if _, err := Build(sql, root); err == nil { + t.Errorf("want an error for PACK_KEYS=2, got none") + } +} diff --git a/check/mysql/internal/mysqlast/load_test.go b/check/mysql/internal/mysqlast/load_test.go new file mode 100644 index 0000000..6ce8f7d --- /dev/null +++ b/check/mysql/internal/mysqlast/load_test.go @@ -0,0 +1,34 @@ +package mysqlast + +import ( + "strings" + "testing" +) + +// The statements the syntax gaps of the third adversarial round left unbuildable (LOCK +// TABLES, LOAD DATA and SELECT ... INTO OUTFILE with FIELDS / LINES clauses, ALTER VIEW), in +// the spellings mysql-test/t uses, fold to nodes the loader and the analyzer read. +func TestLoadLockOutfileAlterViewFold(t *testing.T) { + for sql, wants := range map[string][]string{ + "LOCK TABLES t1 WRITE": {"lock_tables(tables=[table_lock(table_ident=Table_ident(table=t1), opt_table_alias=nil, lock_option=TL_WRITE_DEFAULT)])"}, + "lock table t1 read": {"lock_option=TL_READ_NO_INSERT"}, + "LOCK TABLES t3 WRITE, t2 AS x READ LOCAL": {"table=t3), opt_table_alias=nil, lock_option=TL_WRITE_DEFAULT)", "table=t2), opt_table_alias=x, lock_option=TL_READ)"}, + "UNLOCK TABLES": {"unlock("}, + "load data infile '../../std_data/loaddata1.dat' ignore into table t1 fields terminated by ',' LINES STARTING BY ',' (b,c,d)": {"PT_load_table(", "on_duplicate=On_duplicate::IGNORE_DUP", `opt_field_separators={field_term: String(str=","`, `opt_line_separators={line_start: String(str=","`, "opt_fields_or_vars=[PTI_simple_ident_nospvar_ident(ident=b), PTI_simple_ident_nospvar_ident(ident=c), PTI_simple_ident_nospvar_ident(ident=d)]"}, + "LOAD DATA LOCAL INFILE 'f' REPLACE INTO TABLE t (a, @b) SET c = @b + 1, d = DEFAULT": {"is_local_file=true", "On_duplicate::REPLACE_DUP", "opt_fields_or_vars=[PTI_simple_ident_nospvar_ident(ident=a), Item_user_var_as_out_param(a=b)]", "opt_set_fields=[PTI_simple_ident_nospvar_ident(ident=c), PTI_simple_ident_nospvar_ident(ident=d)]", "opt_set_exprs=[Item_func_plus(", "Item_default_value()]", "opt_set_expr_strings=[c = @b + 1, d = DEFAULT]"}, + "SELECT a, b INTO OUTFILE '/tmp/x' FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' FROM t": {"opt_into1=PT_into_destination_outfile(file_name=TEXT_STRING=\"/tmp/x\", charset=nil, field_term={field_term: String(str=\",\"", "enclosed: String(str=\"\\\"\"", "opt_enclosed: true", "escaped: String(", "line_term={line_term: String(str=\"\\n\""}, + "SELECT a FROM t INTO OUTFILE '/tmp/x'": {"PT_select_stmt(qe=PT_query_expression(", "into=PT_into_destination_outfile(file_name=TEXT_STRING=\"/tmp/x\""}, + "SELECT a FROM t FOR UPDATE": {"PT_select_stmt(qe=PT_query_expression(", "locking=[PT_query_block_locking_clause(", "has_trailing_locking_clauses=true"}, + "SELECT a FROM t FOR SHARE NOWAIT INTO OUTFILE '/tmp/x'": {"PT_select_stmt(qe=PT_query_expression(", "into=PT_into_destination_outfile(", "locking=[PT_query_block_locking_clause("}, + "SELECT a INTO DUMPFILE '/tmp/x' FROM t LOCK IN SHARE MODE": {"PT_select_stmt(qe=PT_query_expression(", "opt_into1=PT_into_destination_dumpfile(", "locking=[PT_query_block_locking_clause("}, + "ALTER VIEW v1 AS SELECT f2 FROM t1": {"Sql_cmd_alter_view(suid=nil, name=Table_ident(table=v1), column_list=[], query=PT_query_expression(", "check_option=VIEW_CHECK_NONE, replace=VIEW_ALTER, algorithm=nil"}, + "ALTER ALGORITHM=TEMPTABLE DEFINER=CURRENT_USER VIEW v1 (x) AS SELECT f2 FROM t1 WITH LOCAL CHECK OPTION": {"Sql_cmd_alter_view(", "column_list=[x]", "check_option=VIEW_CHECK_LOCAL, replace=VIEW_ALTER, algorithm=VIEW_ALGORITHM_TEMPTABLE"}, + } { + got := Sprint(mustBuild(t, sql)) + for _, want := range wants { + if !strings.Contains(got, want) { + t.Errorf("%s: missing %q in\n%s", sql, want, got) + } + } + } +} diff --git a/check/mysql/internal/mysqlast/mergeable.go b/check/mysql/internal/mysqlast/mergeable.go new file mode 100644 index 0000000..8cf4652 --- /dev/null +++ b/check/mysql/internal/mysqlast/mergeable.go @@ -0,0 +1,73 @@ +package mysqlast + +import ( + "fmt" + "strings" +) + +// Mergeable reports whether the server merges a derived table or view of this query +// expression into the outer query (Query_expression::is_mergeable and merge_heuristic): +// a single SELECT over at least one table, without GROUP BY, HAVING, DISTINCT, LIMIT, +// window functions, or a subquery in its select list. What is not merged is +// materialized into a temporary table, whose columns are typed by analyze's materialized. +func Mergeable(v Value) bool { + qe, ok := v.(*Node) + if !ok || qe.Class != "PT_query_expression" { + return false + } + if qe.Arg("limit") != nil { + return false + } + body, ok := qe.Arg("body").(*Node) + if !ok { + return false + } + if body.Class == "PT_query_expression" { + return Mergeable(body) + } + if body.Class != "PT_query_specification" { + return false + } + from, _ := body.Arg("from_clause").(List) + if len(from) == 0 || body.Arg("opt_group_clause") != nil || body.Arg("opt_having_clause") != nil || body.Arg("opt_window_clause") != nil { + return false + } + if strings.Contains(fmt.Sprint(body.Arg("options")), "SELECT_DISTINCT") { + return false + } + items, _ := body.Arg("item_list").(List) + for _, item := range items { + if ContainsClass(item, "PT_window") || ContainsClass(item, "PT_subquery") { + return false + } + } + return true +} + +// ContainsClass reports whether a node of the class occurs in v. +func ContainsClass(v Value, class string) bool { + switch x := v.(type) { + case *Node: + if x.Class == class { + return true + } + for _, a := range x.Args { + if ContainsClass(a, class) { + return true + } + } + case List: + for _, e := range x { + if ContainsClass(e, class) { + return true + } + } + case *Struct: + for _, e := range x.Fields { + if ContainsClass(e, class) { + return true + } + } + } + return false +} diff --git a/check/mysql/internal/mysqlast/mysqlast.go b/check/mysql/internal/mysqlast/mysqlast.go new file mode 100644 index 0000000..87b8340 --- /dev/null +++ b/check/mysql/internal/mysqlast/mysqlast.go @@ -0,0 +1,506 @@ +// Package mysqlast folds the concrete syntax tree of package mysqlparse into an abstract +// one, following the server's own semantic actions. +// +// Every rule node of the CST knows which alternative it reduced, and parsegen recorded +// what the server's action for that alternative built (mysqlparse.Shape): a parse-tree +// class over some of the children, a list, a constant, or just one child passed up. This +// package replays those shapes generically, so the AST has the server's classes +// (PT_select_stmt, PTI_where, Item_func_eq ...) with the children in constructor-argument +// order, without a hand-written rule per construct. +// +// The actions parsegen could not read are the hand-written scope: hooks keyed by rule and +// alternative. A construct with no shape and no hook is reported as *Unsupported, so what +// the AST cannot yet express is explicit and measurable rather than silently wrong. +package mysqlast + +import ( + "fmt" + "strconv" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlparse" +) + +// Value is a node of the AST: *Node, List, Token, Const, Number, Flags, *Struct, or nil. +type Value any + +// Node is a server parse-tree class built from its constructor arguments. Position +// arguments (@$) are dropped; the span is Start/End. +type Node struct { + Class string + Args []Value + // Names are the constructor's parameter names aligned with Args, when the server's + // headers gave them (nil otherwise; hooks may set them). + Names []string + Start int + End int + // Implicit marks a rule that had no action and more than one child: the node is named + // after the rule and holds every child, which is bison's default only for the first. + Implicit bool +} + +// Arg returns the argument named name, or nil. +func (n *Node) Arg(name string) Value { + for i, k := range n.Names { + if k == name && i < len(n.Args) { + return n.Args[i] + } + } + return nil +} + +// List is a server List / Mem_root_array: the elements in order. +type List []Value + +// Token is a terminal: its kind, its text and, for identifiers and literals, the lexer's +// value (unquoted, unescaped). +type Token struct { + Kind mysqlparse.Kind + Text string + Value string + Start int + End int +} + +// Const is a constant the action wrote: an enum value ("JTT_LEFT"), a literal +// ("true", "0"), or a helper expression this package does not interpret. +type Const string + +// Op is a comparison operator the action passed as a creator function +// (`&comp_eq_creator`): its SQL spelling. +type Op string + +// compOps maps the server's comparison creators to the operators they build +// (sql/item_cmpfunc.h: Eq_creator ... Ne_creator). +var compOps = map[string]Op{ + "&comp_eq_creator": "=", "&comp_equal_creator": "<=>", "&comp_ne_creator": "<>", + "&comp_gt_creator": ">", "&comp_ge_creator": ">=", "&comp_lt_creator": "<", "&comp_le_creator": "<=", +} + +// Number is a numeric token the action converted with my_strtoll10. +type Number int64 + +// Flags is a set of option constants or'ed together. +type Flags []Const + +// Struct is a by-value struct the action filled field by field. +type Struct struct { + Fields map[string]Value + Order []string +} + +// Unsupported is a construct whose action parsegen could not read and no hook covers. +type Unsupported struct { + Rule string + Alt int + Action string // the action as parsegen normalized it + Start int + End int + Text string +} + +func (u *Unsupported) Error() string { + return fmt.Sprintf("mysqlast: unsupported construct %s/%d at byte %d: %q", u.Rule, u.Alt, u.Start, u.Text) +} + +// Hook builds the value of one alternative the shapes do not cover. kids are the values of +// the RHS children, in order; the hook may return nil for "no value". +type Hook func(b *Builder, n *mysqlparse.Node, kids []Value) (Value, error) + +// Builder folds one statement. +type Builder struct { + SQL string + Mode mysqlparse.Mode // the sql_mode the statement was parsed under + hooks map[hookKey]Hook +} + +type hookKey struct { + rule string + alt int +} + +// Build folds the CST of sql into an AST: the statement's node, without the grammar's +// start-rule wrapping and its END_OF_INPUT. +func Build(sql string, root *mysqlparse.Node) (Value, error) { return BuildMode(sql, root, 0) } + +// BuildMode is Build under a sql_mode: REAL_AS_FLOAT decides what REAL means. +func BuildMode(sql string, root *mysqlparse.Node, mode mysqlparse.Mode) (Value, error) { + b := &Builder{SQL: sql, Mode: mode, hooks: hooks} + v, err := b.Value(root) + if err != nil { + return nil, err + } + for { + n, ok := v.(*Node) + if !ok || !n.Implicit || len(n.Args) != 1 { + return v, nil + } + v = n.Args[0] + } +} + +// Value folds one CST node. +func (b *Builder) Value(n *mysqlparse.Node) (Value, error) { + if n.IsLeaf() { + return Token{Kind: n.Kind, Text: n.Text(b.SQL), Value: n.Value, Start: n.Start, End: n.End}, nil + } + shape := n.Shape() + kids := make([]Value, len(n.Children)) + for i, c := range n.Children { + v, err := b.Value(c) + if err != nil { + return nil, err + } + kids[i] = v + } + child := func(a mysqlparse.Arg) (Value, error) { + if a.Child == 0 { + return b.constant(a.Text), nil + } + if a.Child > len(kids) && len(n.Children) == 0 && a.Child == 1 { + return nil, nil // an empty alternative passing "$1": nothing + } + if a.Child > len(kids) { + return nil, fmt.Errorf("mysqlast: %s/%d: argument $%d beyond %d children", n.Kind, n.Alt, a.Child, len(kids)) + } + v := kids[a.Child-1] + if a.Field != "" { + return field(v, a.Field), nil + } + return v, nil + } + // a hand-written hook takes precedence over whatever parsegen read for the alternative + if h := b.hooks[hookKey{n.Kind.String(), n.Alt}]; h != nil { + return h(b, n, kids) + } + switch shape.Kind { + case mysqlparse.ActDefault: + // bison's default is $$ = $1; a rule without an action and several children keeps + // them all, minus the end-of-input marker, which carries nothing + var real, values []Value + for _, k := range kids { + if t, ok := k.(Token); ok { + if t.Kind.String() == "END_OF_INPUT" { + continue + } + if isKeyword(t.Kind) { + real = append(real, k) + continue + } + } + real = append(real, k) + values = append(values, k) + } + // `CREATE view_definition`: the keyword says nothing the child does not + if len(values) > 0 && len(values) < len(real) { + real = values + } + switch len(real) { + case 0: + return nil, nil + case 1: + return real[0], nil + } + return &Node{Class: n.Kind.String(), Args: real, Start: n.Start, End: n.End, Implicit: true}, nil + case mysqlparse.ActEmpty: + // `simple_statement: create { $$= nullptr; }`: the legacy statements build their LEX + // in the child and hand up nothing; the AST keeps the child when it is a value + if len(kids) == 1 { + if _, isToken := kids[0].(Token); !isToken && kids[0] != nil { + return kids[0], nil + } + } + return nil, nil + case mysqlparse.ActPass: + return child(shape.Args[0]) + case mysqlparse.ActConst: + return b.constant(shape.Const), nil + case mysqlparse.ActNumber: + v, err := child(shape.Args[0]) + if err != nil { + return nil, err + } + if shape.Const == "16" { + return numberBase(v, 16) + } + return number(v) + case mysqlparse.ActFlags: + var out Flags + for _, a := range shape.Args { + v, err := child(a) + if err != nil { + return nil, err + } + out = append(out, flags(v)...) + } + return out, nil + case mysqlparse.ActNew: + node := &Node{Class: shape.Class, Start: n.Start, End: n.End} + for i, a := range shape.Args { + if isPosition(a) { + continue + } + v, err := child(a) + if err != nil { + return nil, err + } + node.Args = append(node.Args, v) + if shape.Params != nil { + name := "" + if i < len(shape.Params) { + name = shape.Params[i] + } + node.Names = append(node.Names, name) + } + } + // flatten_associative_operator: `a AND b AND c` is one Item_cond_and over three + if strings.HasPrefix(node.Class, "Item_cond_") && len(node.Args) == 2 { + var args []Value + for _, v := range node.Args { + if c, ok := v.(*Node); ok && c.Class == node.Class { + args = append(args, c.Args...) + } else { + args = append(args, v) + } + } + node.Args = args + } + return node, nil + case mysqlparse.ActListNew: + var out List + for _, a := range shape.Args { + if isPosition(a) || a.Child == 0 && (a.Text == "YYMEM_ROOT" || a.Text == "YYTHD->mem_root") { + continue + } + v, err := child(a) + if err != nil { + return nil, err + } + out = append(out, v) + } + return out, nil + case mysqlparse.ActListAppend: + lst, err := child(shape.Args[0]) + if err != nil { + return nil, err + } + elem, err := child(shape.Args[1]) + if err != nil { + return nil, err + } + l, _ := lst.(List) + return append(l, elem), nil + case mysqlparse.ActStruct: + st := &Struct{Fields: map[string]Value{}} + for _, f := range shape.Fields { + v, err := child(f.Arg) + if err != nil { + return nil, err + } + if f.Name == "$base" { // `$$= $N; $$.x= ...`: start from the child's struct + if base, ok := v.(*Struct); ok { + for _, k := range base.Order { + st.Order = append(st.Order, k) + st.Fields[k] = base.Fields[k] + } + } else if v != nil { + st.Order = append(st.Order, "$base") + st.Fields["$base"] = v + } + continue + } + if _, dup := st.Fields[f.Name]; !dup { + st.Order = append(st.Order, f.Name) + } + st.Fields[f.Name] = v + } + return st, nil + } + return nil, &Unsupported{Rule: n.Kind.String(), Alt: n.Alt, Start: n.Start, End: n.End, Text: n.Text(b.SQL)} +} + +// isKeyword reports a token that is a reserved or non-reserved word or punctuation, not +// an identifier, literal or placeholder. +func isKeyword(k mysqlparse.Kind) bool { + switch k.String() { + case "IDENT", "IDENT_QUOTED", "TEXT_STRING", "NCHAR_STRING", "NUM", "LONG_NUM", "ULONGLONG_NUM", "DECIMAL_NUM", "FLOAT_NUM", + "HEX_NUM", "BIN_NUM", "LEX_HOSTNAME", "UNDERSCORE_CHARSET", "PARAM_MARKER", "DOLLAR_QUOTED_STRING_SYM": + return false + } + return true +} + +// constant interprets an argument the action wrote as text. +func (b *Builder) constant(text string) Value { + switch text { + case "nullptr", "NULL", "", "{}", "NULL_STR", "NULL_CSTR", "null_lex_str", "EMPTY_CSTR", "EMPTY_STR": + return nil + } + if strings.HasPrefix(text, `"`) && strings.HasSuffix(text, `"`) && len(text) >= 2 { + return Const(text[1 : len(text)-1]) + } + if op, ok := compOps[text]; ok { + return op + } + return Const(text) +} + +// isPosition reports an argument that carries no meaning for the AST: a position, the +// thread, the memory arena. +func isPosition(a mysqlparse.Arg) bool { + if a.Child != 0 { + return false + } + switch a.Text { + case "@$", "POS()", "YYTHD", "YYMEM_ROOT", "YYTHD->mem_root", "thd": + return true + } + return strings.HasPrefix(a.Text, "@") && len(a.Text) > 1 && a.Text[1] >= '0' && a.Text[1] <= '9' +} + +// field applies a `.str` / `.column_list` / `.flags.algo` access the action wrote on a child. +func field(v Value, f string) Value { + if v == nil { + return nil + } + f = strings.TrimLeft(f, ".->") + if head, rest, ok := strings.Cut(f, "."); ok { // a path: one step at a time + return field(field(v, head), rest) + } + switch x := v.(type) { + case Token: + switch f { + case "str": + if x.Value != "" || x.Kind.String() == "IDENT_QUOTED" || x.Kind.String() == "TEXT_STRING" { + return x.Value + } + return x.Text + case "length": + return Number(len(x.Value)) + } + case *Struct: + return x.Fields[f] // a field the alternative did not set is unset (nil) + case *Node: + if f == "node" || f == "value" { + return x + } + case List: + return x // `&$1->value`: the row's item list is the list itself here + } + // a field access this package does not model: keep it visible + return &Node{Class: "." + f, Args: []Value{v}} +} + +func number(v Value) (Value, error) { return numberBase(v, 10) } + +func numberBase(v Value, base int) (Value, error) { + switch x := v.(type) { + case Token: + s := x.Value + if s == "" { + s = x.Text + } + n, err := strconv.ParseInt(s, base, 64) + if err != nil { + u, err2 := strconv.ParseUint(s, base, 64) + if err2 != nil { + return nil, fmt.Errorf("mysqlast: number %q: %v", s, err) + } + return Number(int64(u)), nil + } + return Number(n), nil + case Number: + return x, nil + case string: + n, err := strconv.ParseInt(x, 10, 64) + if err != nil { + return nil, err + } + return Number(n), nil + } + return nil, fmt.Errorf("mysqlast: number from %T", v) +} + +func flags(v Value) Flags { + switch x := v.(type) { + case Flags: + return x + case Const: + return Flags{x} + case nil: + return nil + } + return Flags{Const(fmt.Sprint(v))} +} + +// Sprint renders a value compactly for tests and debugging: Class(arg, ...), [a, b], +// tokens as their text, constants as written. +func Sprint(v Value) string { + var b strings.Builder + sprint(&b, v) + return b.String() +} + +func sprint(b *strings.Builder, v Value) { + switch x := v.(type) { + case nil: + b.WriteString("nil") + case *Node: + b.WriteString(x.Class) + b.WriteByte('(') + for i, a := range x.Args { + if i > 0 { + b.WriteString(", ") + } + if i < len(x.Names) && x.Names[i] != "" { + b.WriteString(x.Names[i]) + b.WriteByte('=') + } + sprint(b, a) + } + b.WriteByte(')') + case List: + b.WriteByte('[') + for i, a := range x { + if i > 0 { + b.WriteString(", ") + } + sprint(b, a) + } + b.WriteByte(']') + case Token: + if x.Value != "" && x.Value != x.Text { + fmt.Fprintf(b, "%s=%q", x.Kind, x.Value) + } else { + b.WriteString(x.Text) + } + case Const: + b.WriteString(string(x)) + case Op: + b.WriteString(string(x)) + case Number: + b.WriteString(strconv.FormatInt(int64(x), 10)) + case Flags: + for i, f := range x { + if i > 0 { + b.WriteByte('|') + } + b.WriteString(string(f)) + } + case *Struct: + b.WriteByte('{') + for i, k := range x.Order { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(k) + b.WriteString(": ") + sprint(b, x.Fields[k]) + } + b.WriteByte('}') + case string: + fmt.Fprintf(b, "%q", x) + default: + fmt.Fprintf(b, "%v", x) + } +} diff --git a/check/mysql/internal/mysqlast/mysqlast_test.go b/check/mysql/internal/mysqlast/mysqlast_test.go new file mode 100644 index 0000000..76d6e5d --- /dev/null +++ b/check/mysql/internal/mysqlast/mysqlast_test.go @@ -0,0 +1,232 @@ +package mysqlast + +import ( + "errors" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlparse" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/parsegen" +) + +func mustBuild(t *testing.T, sql string) Value { + t.Helper() + root, err := mysqlparse.Parse(sql, 0) + if err != nil { + t.Fatal(err) + } + v, err := Build(sql, root) + if err != nil { + t.Fatal(err) + } + return v +} + +func TestSelect(t *testing.T) { + v := mustBuild(t, "SELECT a, `b` AS c FROM t WHERE d = 1 ORDER BY a LIMIT 10") + got := Sprint(v) + for _, want := range []string{ + "PT_select_stmt(qe=PT_query_expression(body=PT_query_specification(", + "item_list=[PTI_expr_with_alias(expr=PTI_simple_ident_ident(ident=a), alias=nil)", + `PTI_expr_with_alias(expr=PTI_simple_ident_ident(ident=IDENT_QUOTED="b"), alias=c)`, + "from_clause=[PT_table_factor_table_ident(table_ident=Table_ident(table=t)", + "opt_where_clause=PTI_where(expr=PTI_comp_op(left=PTI_simple_ident_ident(ident=d), boolfunc2creator==, right=Item_int(", + "order=PT_order(order_list=[PT_order_expr(item=PTI_simple_ident_ident(ident=a), dir=ORDER_NOT_RELEVANT)])", + "limit=PT_limit_clause(limit_options={limit: Item_uint(", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in\n%s", want, got) + } + } +} + +func TestInsertUpdateDelete(t *testing.T) { + for sql, wants := range map[string][]string{ + "INSERT INTO t (a, b) VALUES (1, 'x'), (2, 'y')": {"PT_insert(is_replace=false, ", "table_ident=Table_ident(table=t)", "column_list=[PTI_simple_ident_nospvar_ident(ident=a), PTI_simple_ident_nospvar_ident(ident=b)]", "row_value_list=[[Item_int(", "PTI_text_literal_text_string("}, + "UPDATE t SET a = a + 1, b = ? WHERE id = 3": {"PT_update(", "value_list=[Item_func_plus(a=PTI_simple_ident_ident(ident=a), b=Item_int(i=1)), Item_param(pos_in_query=28)]", "opt_where_clause=PTI_where(expr=PTI_comp_op(left=PTI_simple_ident_ident(ident=id), boolfunc2creator==, right=Item_int(i=3)))"}, + "DELETE FROM t WHERE id IN (SELECT id FROM u)": {"PT_delete(", "Item_in_subselect(left_expr=PTI_simple_ident_ident(ident=id), pt_subquery=PT_subquery(query_expression=PT_query_expression("}, + } { + got := Sprint(mustBuild(t, sql)) + for _, want := range wants { + if !strings.Contains(got, want) { + t.Errorf("%s: missing %q in\n%s", sql, want, got) + } + } + } +} + +// TestCorpus measures how much of mysql-test/t builds: the statements the parser accepts +// that fold without an Unsupported construct, and which constructs are missing most. +func TestCorpus(t *testing.T) { + home, _ := os.UserHomeDir() + dir := filepath.Join(home, ".cache", "sqlshape", "mysql-server", "mysql-test", "t") + if _, err := os.Stat(dir); err != nil { + t.Skip("no MySQL source at", dir) + } + stmts, _, err := parsegen.SplitTestDir(dir) + if err != nil { + t.Fatal(err) + } + roots := map[string]bool{"select_stmt": true, "insert_stmt": true, "replace_stmt": true, "update_stmt": true, "delete_stmt": true, + "create_table_stmt": true, "create_index_stmt": true, "alter_table_stmt": true, "drop_table_stmt": true, "drop_index_stmt": true, + "drop_view_stmt": true, "call_stmt": true} + var parsed, built, dml, dmlBuilt int + missing := map[string]int{} + example := map[string]string{} + for _, s := range stmts { + if s.Expect != "" { + continue + } + cst, err := mysqlparse.Parse(s.SQL, 0) + if err != nil { + continue + } + parsed++ + isDML := statementRule(cst, roots) + if isDML { + dml++ + } + _, err = Build(s.SQL, cst) + var u *Unsupported + switch { + case err == nil: + built++ + if isDML { + dmlBuilt++ + } + case errors.As(err, &u): + k := u.Rule + "/" + itoa(u.Alt) + missing[k]++ + if example[k] == "" { + example[k] = strings.ReplaceAll(u.Text, "\n", " ") + } + default: + t.Fatalf("%s:%d: %v", s.File, s.Line, err) + } + } + type kv struct { + k string + n int + } + var top []kv + for k, n := range missing { + top = append(top, kv{k, n}) + } + sort.Slice(top, func(i, j int) bool { return top[i].n > top[j].n }) + t.Logf("built %d of %d parsed statements (%.1f%%); DML/DDL sqlshape reads: %d of %d (%.1f%%)", built, parsed, 100*float64(built)/float64(parsed), dmlBuilt, dml, 100*float64(dmlBuilt)/float64(max(dml, 1))) + if rate := float64(dmlBuilt) / float64(max(dml, 1)); rate < 0.97 { + t.Errorf("the statements sqlshape reads build at %.1f%%, below the 97%% held so far", rate*100) + } + // constructs a hook covers on purpose (hooks_load.go): the corpus must not report them + // missing again + for _, k := range []string{"table_lock/0", "opt_field_term/0", "field_term/0", "line_term/0", "fields_or_vars/0", "load_data_set_elem/0"} { + if n := missing[k]; n > 0 { + t.Errorf("%s is missing in %d statements: %s", k, n, example[k]) + } + } + for i, kv := range top { + if i >= 40 { + break + } + ex := example[kv.k] + if len(ex) > 70 { + ex = ex[:70] + } + t.Logf(" %5d %-40s %s", kv.n, kv.k, ex) + } +} + +// statementRule reports whether the statement's rule is one sqlshape reads. +func statementRule(cst *mysqlparse.Node, roots map[string]bool) bool { + found := false + var walk func(n *mysqlparse.Node, depth int) + walk = func(n *mysqlparse.Node, depth int) { + if found || n.IsLeaf() || depth > 6 { + return + } + if roots[n.Kind.String()] { + found = true + return + } + for _, c := range n.Children { + walk(c, depth+1) + } + } + walk(cst, 0) + return found +} + +func itoa(i int) string { return strconv.Itoa(i) } + +func TestJoinAssociativity(t *testing.T) { + // the server attaches `a JOIN b` at the leftmost table of `b JOIN c ON p` + got := Sprint(mustBuild(t, "SELECT 1 FROM a JOIN b JOIN c ON b.x = c.x")) + want := "PT_joined_table_on(tab1_node=PT_cross_join(tab1_node=PT_table_factor_table_ident(table_ident=Table_ident(table=a)" + if !strings.Contains(got, want) { + t.Errorf("missing %q in\n%s", want, got) + } +} + +func TestOperatorsAndRoot(t *testing.T) { + v := mustBuild(t, "SELECT 1 FROM t WHERE a <> 1 AND b <=> 2") + n, ok := v.(*Node) + if !ok || n.Class != "PT_select_stmt" { + t.Fatalf("root %T %v", v, Sprint(v)) + } + got := Sprint(v) + for _, want := range []string{"boolfunc2creator=<>", "boolfunc2creator=<=>", "Item_cond_and(i1=PTI_comp_op(", "), i2=PTI_comp_op("} { + if !strings.Contains(got, want) { + t.Errorf("missing %q in\n%s", want, got) + } + } +} + +func TestCheckEnforcement(t *testing.T) { + got := Sprint(mustBuild(t, "CREATE TABLE t (a INT CHECK (a > 0) NOT ENFORCED)")) + if !strings.Contains(got, "PT_check_constraint_column_attr(name=nil, expr=PTI_comp_op(") || !strings.Contains(got, "enforced=false)") { + t.Errorf("enforcement not folded onto the CHECK:\n%s", got) + } +} + +func TestUnknownAlterAlgorithm(t *testing.T) { + sql := "ALTER TABLE t ADD COLUMN b INT, ALGORITHM=SIDEWAYS" + root, err := mysqlparse.Parse(sql, 0) + if err != nil { + t.Fatal(err) + } + _, err = Build(sql, root) + var u *Unsupported + if !errors.As(err, &u) || u.Rule != "alter_algorithm_option_value" { + t.Errorf("want Unsupported for an unknown ALGORITHM, got %v", err) + } +} + +func TestViews(t *testing.T) { + sql := "SELECT a FROM t WHERE b = 1" + stmt, ok := AsPTSelectStmt(mustBuild(t, sql)) + if !ok { + t.Fatal("not a PT_select_stmt") + } + qe, ok := AsPTQueryExpression(stmt.Qe()) + if !ok { + t.Fatal("no query expression") + } + // `$1.body` in the server's action already picked the field of the by-value struct + spec, ok := AsPTQuerySpecification(qe.Body()) + if !ok { + t.Fatalf("no query specification in %s", Sprint(qe.Body())) + } + items, _ := spec.ItemList().(List) + where, ok := AsPTIWhere(spec.OptWhereClause()) + if len(items) != 1 || !ok { + t.Fatalf("items=%d where=%v (%s)", len(items), ok, Sprint(spec.OptWhereClause())) + } + cmp, ok := AsPTICompOp(where.Expr()) + if !ok || cmp.Boolfunc2creator() != Op("=") { + t.Errorf("comparison: %s", Sprint(where.Expr())) + } +} diff --git a/check/mysql/internal/mysqlast/views.go b/check/mysql/internal/mysqlast/views.go new file mode 100644 index 0000000..2264ad3 --- /dev/null +++ b/check/mysql/internal/mysqlast/views.go @@ -0,0 +1,6744 @@ +// Code generated by parsegen from MySQL 8.4.6. DO NOT EDIT. + +package mysqlast + +// The typed views: one per parse-tree class the server builds, an accessor per +// constructor parameter. Class names and parameter names are the server's. + +// MemRootDeque views a mem_root_deque node. +type MemRootDeque struct{ N *Node } + +// AsMemRootDeque returns the view when v is a mem_root_deque. +func AsMemRootDeque(v Value) (MemRootDeque, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "mem_root_deque" { + return MemRootDeque{}, false + } + return MemRootDeque{n}, true +} + +// PTCreateTableStmt views a PT_create_table_stmt node. +type PTCreateTableStmt struct{ N *Node } + +// AsPTCreateTableStmt returns the view when v is a PT_create_table_stmt. +func AsPTCreateTableStmt(v Value) (PTCreateTableStmt, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_table_stmt" { + return PTCreateTableStmt{}, false + } + return PTCreateTableStmt{n}, true +} + +func (x PTCreateTableStmt) IsTemporary() Value { return x.N.Arg("is_temporary") } +func (x PTCreateTableStmt) OnlyIfNotExists() Value { return x.N.Arg("only_if_not_exists") } +func (x PTCreateTableStmt) TableName() Value { return x.N.Arg("table_name") } +func (x PTCreateTableStmt) OptTableElementList() Value { return x.N.Arg("opt_table_element_list") } +func (x PTCreateTableStmt) OptCreateTableOptions() Value { return x.N.Arg("opt_create_table_options") } +func (x PTCreateTableStmt) OptPartitioning() Value { return x.N.Arg("opt_partitioning") } +func (x PTCreateTableStmt) OnDuplicate() Value { return x.N.Arg("on_duplicate") } +func (x PTCreateTableStmt) OptQueryExpression() Value { return x.N.Arg("opt_query_expression") } +func (x PTCreateTableStmt) OptLikeClause() Value { return x.N.Arg("opt_like_clause") } + +// PTCreateRole views a PT_create_role node. +type PTCreateRole struct{ N *Node } + +// AsPTCreateRole returns the view when v is a PT_create_role. +func AsPTCreateRole(v Value) (PTCreateRole, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_role" { + return PTCreateRole{}, false + } + return PTCreateRole{n}, true +} + +func (x PTCreateRole) IfNotExists() Value { return x.N.Arg("if_not_exists") } +func (x PTCreateRole) Roles() Value { return x.N.Arg("roles") } + +// PTCreateResourceGroup views a PT_create_resource_group node. +type PTCreateResourceGroup struct{ N *Node } + +// AsPTCreateResourceGroup returns the view when v is a PT_create_resource_group. +func AsPTCreateResourceGroup(v Value) (PTCreateResourceGroup, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_resource_group" { + return PTCreateResourceGroup{}, false + } + return PTCreateResourceGroup{n}, true +} + +func (x PTCreateResourceGroup) Name() Value { return x.N.Arg("name") } +func (x PTCreateResourceGroup) Type() Value { return x.N.Arg("type") } +func (x PTCreateResourceGroup) CpuList() Value { return x.N.Arg("cpu_list") } +func (x PTCreateResourceGroup) OptPriority() Value { return x.N.Arg("opt_priority") } +func (x PTCreateResourceGroup) Enabled() Value { return x.N.Arg("enabled") } + +// PTCreateSrs views a PT_create_srs node. +type PTCreateSrs struct{ N *Node } + +// AsPTCreateSrs returns the view when v is a PT_create_srs. +func AsPTCreateSrs(v Value) (PTCreateSrs, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_srs" { + return PTCreateSrs{}, false + } + return PTCreateSrs{n}, true +} + +func (x PTCreateSrs) Srid() Value { return x.N.Arg("srid") } +func (x PTCreateSrs) Attributes() Value { return x.N.Arg("attributes") } +func (x PTCreateSrs) OrReplace() Value { return x.N.Arg("or_replace") } +func (x PTCreateSrs) IfNotExists() Value { return x.N.Arg("if_not_exists") } + +// PTCreateIndexStmt views a PT_create_index_stmt node. +type PTCreateIndexStmt struct{ N *Node } + +// AsPTCreateIndexStmt returns the view when v is a PT_create_index_stmt. +func AsPTCreateIndexStmt(v Value) (PTCreateIndexStmt, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_index_stmt" { + return PTCreateIndexStmt{}, false + } + return PTCreateIndexStmt{n}, true +} + +func (x PTCreateIndexStmt) TypePar() Value { return x.N.Arg("type_par") } +func (x PTCreateIndexStmt) Name() Value { return x.N.Arg("name") } +func (x PTCreateIndexStmt) Type() Value { return x.N.Arg("type") } +func (x PTCreateIndexStmt) TableIdent() Value { return x.N.Arg("table_ident") } +func (x PTCreateIndexStmt) Cols() Value { return x.N.Arg("cols") } +func (x PTCreateIndexStmt) Options() Value { return x.N.Arg("options") } +func (x PTCreateIndexStmt) Algo() Value { return x.N.Arg("algo") } +func (x PTCreateIndexStmt) Lock() Value { return x.N.Arg("lock") } + +// PTCall views a PT_call node. +type PTCall struct{ N *Node } + +// AsPTCall returns the view when v is a PT_call. +func AsPTCall(v Value) (PTCall, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_call" { + return PTCall{}, false + } + return PTCall{n}, true +} + +func (x PTCall) ProcName() Value { return x.N.Arg("proc_name") } +func (x PTCall) OptExprList() Value { return x.N.Arg("opt_expr_list") } + +// SpConditionValue views a sp_condition_value node. +type SpConditionValue struct{ N *Node } + +// AsSpConditionValue returns the view when v is a sp_condition_value. +func AsSpConditionValue(v Value) (SpConditionValue, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "sp_condition_value" { + return SpConditionValue{}, false + } + return SpConditionValue{n}, true +} + +func (x SpConditionValue) Mysqlerr() Value { return x.N.Arg("_mysqlerr") } + +// StatementInformation views a Statement_information node. +type StatementInformation struct{ N *Node } + +// AsStatementInformation returns the view when v is a Statement_information. +func AsStatementInformation(v Value) (StatementInformation, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Statement_information" { + return StatementInformation{}, false + } + return StatementInformation{n}, true +} + +func (x StatementInformation) Items() Value { return x.N.Arg("items") } + +// ConditionInformation views a Condition_information node. +type ConditionInformation struct{ N *Node } + +// AsConditionInformation returns the view when v is a Condition_information. +func AsConditionInformation(v Value) (ConditionInformation, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Condition_information" { + return ConditionInformation{}, false + } + return ConditionInformation{n}, true +} + +func (x ConditionInformation) CondNumberExpr() Value { return x.N.Arg("cond_number_expr") } +func (x ConditionInformation) Items() Value { return x.N.Arg("items") } + +// StatementInformationItem views a Statement_information_item node. +type StatementInformationItem struct{ N *Node } + +// AsStatementInformationItem returns the view when v is a Statement_information_item. +func AsStatementInformationItem(v Value) (StatementInformationItem, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Statement_information_item" { + return StatementInformationItem{}, false + } + return StatementInformationItem{n}, true +} + +func (x StatementInformationItem) Name() Value { return x.N.Arg("name") } +func (x StatementInformationItem) Target() Value { return x.N.Arg("target") } + +// ConditionInformationItem views a Condition_information_item node. +type ConditionInformationItem struct{ N *Node } + +// AsConditionInformationItem returns the view when v is a Condition_information_item. +func AsConditionInformationItem(v Value) (ConditionInformationItem, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Condition_information_item" { + return ConditionInformationItem{}, false + } + return ConditionInformationItem{n}, true +} + +func (x ConditionInformationItem) Name() Value { return x.N.Arg("name") } +func (x ConditionInformationItem) Target() Value { return x.N.Arg("target") } + +// PTAlterTablespaceOptionInitialSize views a PT_alter_tablespace_option_initial_size node. +type PTAlterTablespaceOptionInitialSize struct{ N *Node } + +// AsPTAlterTablespaceOptionInitialSize returns the view when v is a PT_alter_tablespace_option_initial_size. +func AsPTAlterTablespaceOptionInitialSize(v Value) (PTAlterTablespaceOptionInitialSize, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_tablespace_option_initial_size" { + return PTAlterTablespaceOptionInitialSize{}, false + } + return PTAlterTablespaceOptionInitialSize{n}, true +} + +func (x PTAlterTablespaceOptionInitialSize) Value() Value { return x.N.Arg("value") } + +// PTAlterTablespaceOptionAutoextendSize views a PT_alter_tablespace_option_autoextend_size node. +type PTAlterTablespaceOptionAutoextendSize struct{ N *Node } + +// AsPTAlterTablespaceOptionAutoextendSize returns the view when v is a PT_alter_tablespace_option_autoextend_size. +func AsPTAlterTablespaceOptionAutoextendSize(v Value) (PTAlterTablespaceOptionAutoextendSize, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_tablespace_option_autoextend_size" { + return PTAlterTablespaceOptionAutoextendSize{}, false + } + return PTAlterTablespaceOptionAutoextendSize{n}, true +} + +func (x PTAlterTablespaceOptionAutoextendSize) Value() Value { return x.N.Arg("value") } + +// PTAlterTablespaceOptionMaxSize views a PT_alter_tablespace_option_max_size node. +type PTAlterTablespaceOptionMaxSize struct{ N *Node } + +// AsPTAlterTablespaceOptionMaxSize returns the view when v is a PT_alter_tablespace_option_max_size. +func AsPTAlterTablespaceOptionMaxSize(v Value) (PTAlterTablespaceOptionMaxSize, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_tablespace_option_max_size" { + return PTAlterTablespaceOptionMaxSize{}, false + } + return PTAlterTablespaceOptionMaxSize{n}, true +} + +func (x PTAlterTablespaceOptionMaxSize) Value() Value { return x.N.Arg("value") } + +// PTAlterTablespaceOptionExtentSize views a PT_alter_tablespace_option_extent_size node. +type PTAlterTablespaceOptionExtentSize struct{ N *Node } + +// AsPTAlterTablespaceOptionExtentSize returns the view when v is a PT_alter_tablespace_option_extent_size. +func AsPTAlterTablespaceOptionExtentSize(v Value) (PTAlterTablespaceOptionExtentSize, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_tablespace_option_extent_size" { + return PTAlterTablespaceOptionExtentSize{}, false + } + return PTAlterTablespaceOptionExtentSize{n}, true +} + +func (x PTAlterTablespaceOptionExtentSize) Value() Value { return x.N.Arg("value") } + +// PTAlterTablespaceOptionUndoBufferSize views a PT_alter_tablespace_option_undo_buffer_size node. +type PTAlterTablespaceOptionUndoBufferSize struct{ N *Node } + +// AsPTAlterTablespaceOptionUndoBufferSize returns the view when v is a PT_alter_tablespace_option_undo_buffer_size. +func AsPTAlterTablespaceOptionUndoBufferSize(v Value) (PTAlterTablespaceOptionUndoBufferSize, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_tablespace_option_undo_buffer_size" { + return PTAlterTablespaceOptionUndoBufferSize{}, false + } + return PTAlterTablespaceOptionUndoBufferSize{n}, true +} + +func (x PTAlterTablespaceOptionUndoBufferSize) Value() Value { return x.N.Arg("value") } + +// PTAlterTablespaceOptionRedoBufferSize views a PT_alter_tablespace_option_redo_buffer_size node. +type PTAlterTablespaceOptionRedoBufferSize struct{ N *Node } + +// AsPTAlterTablespaceOptionRedoBufferSize returns the view when v is a PT_alter_tablespace_option_redo_buffer_size. +func AsPTAlterTablespaceOptionRedoBufferSize(v Value) (PTAlterTablespaceOptionRedoBufferSize, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_tablespace_option_redo_buffer_size" { + return PTAlterTablespaceOptionRedoBufferSize{}, false + } + return PTAlterTablespaceOptionRedoBufferSize{n}, true +} + +func (x PTAlterTablespaceOptionRedoBufferSize) Value() Value { return x.N.Arg("value") } + +// PTAlterTablespaceOptionNodegroup views a PT_alter_tablespace_option_nodegroup node. +type PTAlterTablespaceOptionNodegroup struct{ N *Node } + +// AsPTAlterTablespaceOptionNodegroup returns the view when v is a PT_alter_tablespace_option_nodegroup. +func AsPTAlterTablespaceOptionNodegroup(v Value) (PTAlterTablespaceOptionNodegroup, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_tablespace_option_nodegroup" { + return PTAlterTablespaceOptionNodegroup{}, false + } + return PTAlterTablespaceOptionNodegroup{n}, true +} + +func (x PTAlterTablespaceOptionNodegroup) NodegroupId() Value { return x.N.Arg("nodegroup_id") } + +// PTAlterTablespaceOptionComment views a PT_alter_tablespace_option_comment node. +type PTAlterTablespaceOptionComment struct{ N *Node } + +// AsPTAlterTablespaceOptionComment returns the view when v is a PT_alter_tablespace_option_comment. +func AsPTAlterTablespaceOptionComment(v Value) (PTAlterTablespaceOptionComment, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_tablespace_option_comment" { + return PTAlterTablespaceOptionComment{}, false + } + return PTAlterTablespaceOptionComment{n}, true +} + +func (x PTAlterTablespaceOptionComment) Comment() Value { return x.N.Arg("comment") } + +// PTAlterTablespaceOptionEngine views a PT_alter_tablespace_option_engine node. +type PTAlterTablespaceOptionEngine struct{ N *Node } + +// AsPTAlterTablespaceOptionEngine returns the view when v is a PT_alter_tablespace_option_engine. +func AsPTAlterTablespaceOptionEngine(v Value) (PTAlterTablespaceOptionEngine, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_tablespace_option_engine" { + return PTAlterTablespaceOptionEngine{}, false + } + return PTAlterTablespaceOptionEngine{n}, true +} + +func (x PTAlterTablespaceOptionEngine) EngineName() Value { return x.N.Arg("engine_name") } + +// PTAlterTablespaceOptionFileBlockSize views a PT_alter_tablespace_option_file_block_size node. +type PTAlterTablespaceOptionFileBlockSize struct{ N *Node } + +// AsPTAlterTablespaceOptionFileBlockSize returns the view when v is a PT_alter_tablespace_option_file_block_size. +func AsPTAlterTablespaceOptionFileBlockSize(v Value) (PTAlterTablespaceOptionFileBlockSize, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_tablespace_option_file_block_size" { + return PTAlterTablespaceOptionFileBlockSize{}, false + } + return PTAlterTablespaceOptionFileBlockSize{n}, true +} + +func (x PTAlterTablespaceOptionFileBlockSize) FileBlockSize() Value { + return x.N.Arg("file_block_size") +} + +// PTAlterTablespaceOptionWaitUntilCompleted views a PT_alter_tablespace_option_wait_until_completed node. +type PTAlterTablespaceOptionWaitUntilCompleted struct{ N *Node } + +// AsPTAlterTablespaceOptionWaitUntilCompleted returns the view when v is a PT_alter_tablespace_option_wait_until_completed. +func AsPTAlterTablespaceOptionWaitUntilCompleted(v Value) (PTAlterTablespaceOptionWaitUntilCompleted, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_tablespace_option_wait_until_completed" { + return PTAlterTablespaceOptionWaitUntilCompleted{}, false + } + return PTAlterTablespaceOptionWaitUntilCompleted{n}, true +} + +func (x PTAlterTablespaceOptionWaitUntilCompleted) Value() Value { return x.N.Arg("value") } + +// PTAlterTablespaceOptionEncryption views a PT_alter_tablespace_option_encryption node. +type PTAlterTablespaceOptionEncryption struct{ N *Node } + +// AsPTAlterTablespaceOptionEncryption returns the view when v is a PT_alter_tablespace_option_encryption. +func AsPTAlterTablespaceOptionEncryption(v Value) (PTAlterTablespaceOptionEncryption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_tablespace_option_encryption" { + return PTAlterTablespaceOptionEncryption{}, false + } + return PTAlterTablespaceOptionEncryption{n}, true +} + +func (x PTAlterTablespaceOptionEncryption) Value() Value { return x.N.Arg("value") } + +// MakeTablespaceEngineAttribute views a make_tablespace_engine_attribute node. +type MakeTablespaceEngineAttribute struct{ N *Node } + +// AsMakeTablespaceEngineAttribute returns the view when v is a make_tablespace_engine_attribute. +func AsMakeTablespaceEngineAttribute(v Value) (MakeTablespaceEngineAttribute, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "make_tablespace_engine_attribute" { + return MakeTablespaceEngineAttribute{}, false + } + return MakeTablespaceEngineAttribute{n}, true +} + +// PTPartition views a PT_partition node. +type PTPartition struct{ N *Node } + +// AsPTPartition returns the view when v is a PT_partition. +func AsPTPartition(v Value) (PTPartition, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_partition" { + return PTPartition{}, false + } + return PTPartition{n}, true +} + +func (x PTPartition) PartTypeDef() Value { return x.N.Arg("part_type_def") } +func (x PTPartition) OptNumParts() Value { return x.N.Arg("opt_num_parts") } +func (x PTPartition) OptSubPart() Value { return x.N.Arg("opt_sub_part") } +func (x PTPartition) PartDefs() Value { return x.N.Arg("part_defs") } + +// PTPartTypeDefKey views a PT_part_type_def_key node. +type PTPartTypeDefKey struct{ N *Node } + +// AsPTPartTypeDefKey returns the view when v is a PT_part_type_def_key. +func AsPTPartTypeDefKey(v Value) (PTPartTypeDefKey, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_part_type_def_key" { + return PTPartTypeDefKey{}, false + } + return PTPartTypeDefKey{n}, true +} + +func (x PTPartTypeDefKey) IsLinear() Value { return x.N.Arg("is_linear") } +func (x PTPartTypeDefKey) KeyAlgo() Value { return x.N.Arg("key_algo") } +func (x PTPartTypeDefKey) OptColumns() Value { return x.N.Arg("opt_columns") } + +// PTPartTypeDefHash views a PT_part_type_def_hash node. +type PTPartTypeDefHash struct{ N *Node } + +// AsPTPartTypeDefHash returns the view when v is a PT_part_type_def_hash. +func AsPTPartTypeDefHash(v Value) (PTPartTypeDefHash, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_part_type_def_hash" { + return PTPartTypeDefHash{}, false + } + return PTPartTypeDefHash{n}, true +} + +func (x PTPartTypeDefHash) IsLinear() Value { return x.N.Arg("is_linear") } +func (x PTPartTypeDefHash) Expr() Value { return x.N.Arg("expr") } + +// PTPartTypeDefRangeExpr views a PT_part_type_def_range_expr node. +type PTPartTypeDefRangeExpr struct{ N *Node } + +// AsPTPartTypeDefRangeExpr returns the view when v is a PT_part_type_def_range_expr. +func AsPTPartTypeDefRangeExpr(v Value) (PTPartTypeDefRangeExpr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_part_type_def_range_expr" { + return PTPartTypeDefRangeExpr{}, false + } + return PTPartTypeDefRangeExpr{n}, true +} + +func (x PTPartTypeDefRangeExpr) Expr() Value { return x.N.Arg("expr") } + +// PTPartTypeDefRangeColumns views a PT_part_type_def_range_columns node. +type PTPartTypeDefRangeColumns struct{ N *Node } + +// AsPTPartTypeDefRangeColumns returns the view when v is a PT_part_type_def_range_columns. +func AsPTPartTypeDefRangeColumns(v Value) (PTPartTypeDefRangeColumns, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_part_type_def_range_columns" { + return PTPartTypeDefRangeColumns{}, false + } + return PTPartTypeDefRangeColumns{n}, true +} + +func (x PTPartTypeDefRangeColumns) Columns() Value { return x.N.Arg("columns") } + +// PTPartTypeDefListExpr views a PT_part_type_def_list_expr node. +type PTPartTypeDefListExpr struct{ N *Node } + +// AsPTPartTypeDefListExpr returns the view when v is a PT_part_type_def_list_expr. +func AsPTPartTypeDefListExpr(v Value) (PTPartTypeDefListExpr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_part_type_def_list_expr" { + return PTPartTypeDefListExpr{}, false + } + return PTPartTypeDefListExpr{n}, true +} + +func (x PTPartTypeDefListExpr) Expr() Value { return x.N.Arg("expr") } + +// PTPartTypeDefListColumns views a PT_part_type_def_list_columns node. +type PTPartTypeDefListColumns struct{ N *Node } + +// AsPTPartTypeDefListColumns returns the view when v is a PT_part_type_def_list_columns. +func AsPTPartTypeDefListColumns(v Value) (PTPartTypeDefListColumns, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_part_type_def_list_columns" { + return PTPartTypeDefListColumns{}, false + } + return PTPartTypeDefListColumns{n}, true +} + +func (x PTPartTypeDefListColumns) Columns() Value { return x.N.Arg("columns") } + +// PTSubPartitionByHash views a PT_sub_partition_by_hash node. +type PTSubPartitionByHash struct{ N *Node } + +// AsPTSubPartitionByHash returns the view when v is a PT_sub_partition_by_hash. +func AsPTSubPartitionByHash(v Value) (PTSubPartitionByHash, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_sub_partition_by_hash" { + return PTSubPartitionByHash{}, false + } + return PTSubPartitionByHash{n}, true +} + +func (x PTSubPartitionByHash) IsLinear() Value { return x.N.Arg("is_linear") } +func (x PTSubPartitionByHash) Hash() Value { return x.N.Arg("hash") } +func (x PTSubPartitionByHash) OptNumSubparts() Value { return x.N.Arg("opt_num_subparts") } + +// PTSubPartitionByKey views a PT_sub_partition_by_key node. +type PTSubPartitionByKey struct{ N *Node } + +// AsPTSubPartitionByKey returns the view when v is a PT_sub_partition_by_key. +func AsPTSubPartitionByKey(v Value) (PTSubPartitionByKey, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_sub_partition_by_key" { + return PTSubPartitionByKey{}, false + } + return PTSubPartitionByKey{n}, true +} + +func (x PTSubPartitionByKey) IsLinear() Value { return x.N.Arg("is_linear") } +func (x PTSubPartitionByKey) KeyAlgo() Value { return x.N.Arg("key_algo") } +func (x PTSubPartitionByKey) FieldNames() Value { return x.N.Arg("field_names") } +func (x PTSubPartitionByKey) OptNumSubparts() Value { return x.N.Arg("opt_num_subparts") } + +// PTPartDefinition views a PT_part_definition node. +type PTPartDefinition struct{ N *Node } + +// AsPTPartDefinition returns the view when v is a PT_part_definition. +func AsPTPartDefinition(v Value) (PTPartDefinition, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_part_definition" { + return PTPartDefinition{}, false + } + return PTPartDefinition{n}, true +} + +func (x PTPartDefinition) Name() Value { return x.N.Arg("name") } +func (x PTPartDefinition) Type() Value { return x.N.Arg("type") } +func (x PTPartDefinition) OptPartValues() Value { return x.N.Arg("opt_part_values") } +func (x PTPartDefinition) OptPartOptions() Value { return x.N.Arg("opt_part_options") } +func (x PTPartDefinition) OptSubPartitions() Value { return x.N.Arg("opt_sub_partitions") } + +// PTPartValuesInItem views a PT_part_values_in_item node. +type PTPartValuesInItem struct{ N *Node } + +// AsPTPartValuesInItem returns the view when v is a PT_part_values_in_item. +func AsPTPartValuesInItem(v Value) (PTPartValuesInItem, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_part_values_in_item" { + return PTPartValuesInItem{}, false + } + return PTPartValuesInItem{n}, true +} + +func (x PTPartValuesInItem) Item() Value { return x.N.Arg("item") } + +// PTPartValuesInList views a PT_part_values_in_list node. +type PTPartValuesInList struct{ N *Node } + +// AsPTPartValuesInList returns the view when v is a PT_part_values_in_list. +func AsPTPartValuesInList(v Value) (PTPartValuesInList, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_part_values_in_list" { + return PTPartValuesInList{}, false + } + return PTPartValuesInList{n}, true +} + +func (x PTPartValuesInList) List() Value { return x.N.Arg("list") } + +// PTPartValueItemListParen views a PT_part_value_item_list_paren node. +type PTPartValueItemListParen struct{ N *Node } + +// AsPTPartValueItemListParen returns the view when v is a PT_part_value_item_list_paren. +func AsPTPartValueItemListParen(v Value) (PTPartValueItemListParen, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_part_value_item_list_paren" { + return PTPartValueItemListParen{}, false + } + return PTPartValueItemListParen{n}, true +} + +func (x PTPartValueItemListParen) Values() Value { return x.N.Arg("values") } + +// PTPartValueItemMax views a PT_part_value_item_max node. +type PTPartValueItemMax struct{ N *Node } + +// AsPTPartValueItemMax returns the view when v is a PT_part_value_item_max. +func AsPTPartValueItemMax(v Value) (PTPartValueItemMax, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_part_value_item_max" { + return PTPartValueItemMax{}, false + } + return PTPartValueItemMax{n}, true +} + +// PTPartValueItemExpr views a PT_part_value_item_expr node. +type PTPartValueItemExpr struct{ N *Node } + +// AsPTPartValueItemExpr returns the view when v is a PT_part_value_item_expr. +func AsPTPartValueItemExpr(v Value) (PTPartValueItemExpr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_part_value_item_expr" { + return PTPartValueItemExpr{}, false + } + return PTPartValueItemExpr{n}, true +} + +func (x PTPartValueItemExpr) Expr() Value { return x.N.Arg("expr") } + +// PTSubpartition views a PT_subpartition node. +type PTSubpartition struct{ N *Node } + +// AsPTSubpartition returns the view when v is a PT_subpartition. +func AsPTSubpartition(v Value) (PTSubpartition, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_subpartition" { + return PTSubpartition{}, false + } + return PTSubpartition{n}, true +} + +func (x PTSubpartition) Name() Value { return x.N.Arg("name") } +func (x PTSubpartition) Options() Value { return x.N.Arg("options") } + +// PTPartitionTablespace views a PT_partition_tablespace node. +type PTPartitionTablespace struct{ N *Node } + +// AsPTPartitionTablespace returns the view when v is a PT_partition_tablespace. +func AsPTPartitionTablespace(v Value) (PTPartitionTablespace, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_partition_tablespace" { + return PTPartitionTablespace{}, false + } + return PTPartitionTablespace{n}, true +} + +func (x PTPartitionTablespace) Tablespace() Value { return x.N.Arg("tablespace") } + +// PTPartitionEngine views a PT_partition_engine node. +type PTPartitionEngine struct{ N *Node } + +// AsPTPartitionEngine returns the view when v is a PT_partition_engine. +func AsPTPartitionEngine(v Value) (PTPartitionEngine, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_partition_engine" { + return PTPartitionEngine{}, false + } + return PTPartitionEngine{n}, true +} + +func (x PTPartitionEngine) Name() Value { return x.N.Arg("name") } + +// PTPartitionNodegroup views a PT_partition_nodegroup node. +type PTPartitionNodegroup struct{ N *Node } + +// AsPTPartitionNodegroup returns the view when v is a PT_partition_nodegroup. +func AsPTPartitionNodegroup(v Value) (PTPartitionNodegroup, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_partition_nodegroup" { + return PTPartitionNodegroup{}, false + } + return PTPartitionNodegroup{n}, true +} + +func (x PTPartitionNodegroup) Nodegroup() Value { return x.N.Arg("nodegroup") } + +// PTPartitionMaxRows views a PT_partition_max_rows node. +type PTPartitionMaxRows struct{ N *Node } + +// AsPTPartitionMaxRows returns the view when v is a PT_partition_max_rows. +func AsPTPartitionMaxRows(v Value) (PTPartitionMaxRows, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_partition_max_rows" { + return PTPartitionMaxRows{}, false + } + return PTPartitionMaxRows{n}, true +} + +func (x PTPartitionMaxRows) MaxRows() Value { return x.N.Arg("max_rows") } + +// PTPartitionMinRows views a PT_partition_min_rows node. +type PTPartitionMinRows struct{ N *Node } + +// AsPTPartitionMinRows returns the view when v is a PT_partition_min_rows. +func AsPTPartitionMinRows(v Value) (PTPartitionMinRows, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_partition_min_rows" { + return PTPartitionMinRows{}, false + } + return PTPartitionMinRows{n}, true +} + +func (x PTPartitionMinRows) MinRows() Value { return x.N.Arg("min_rows") } + +// PTPartitionDataDirectory views a PT_partition_data_directory node. +type PTPartitionDataDirectory struct{ N *Node } + +// AsPTPartitionDataDirectory returns the view when v is a PT_partition_data_directory. +func AsPTPartitionDataDirectory(v Value) (PTPartitionDataDirectory, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_partition_data_directory" { + return PTPartitionDataDirectory{}, false + } + return PTPartitionDataDirectory{n}, true +} + +func (x PTPartitionDataDirectory) DataDirectory() Value { return x.N.Arg("data_directory") } + +// PTPartitionIndexDirectory views a PT_partition_index_directory node. +type PTPartitionIndexDirectory struct{ N *Node } + +// AsPTPartitionIndexDirectory returns the view when v is a PT_partition_index_directory. +func AsPTPartitionIndexDirectory(v Value) (PTPartitionIndexDirectory, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_partition_index_directory" { + return PTPartitionIndexDirectory{}, false + } + return PTPartitionIndexDirectory{n}, true +} + +func (x PTPartitionIndexDirectory) IndexDirectory() Value { return x.N.Arg("index_directory") } + +// PTPartitionComment views a PT_partition_comment node. +type PTPartitionComment struct{ N *Node } + +// AsPTPartitionComment returns the view when v is a PT_partition_comment. +func AsPTPartitionComment(v Value) (PTPartitionComment, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_partition_comment" { + return PTPartitionComment{}, false + } + return PTPartitionComment{n}, true +} + +func (x PTPartitionComment) Comment() Value { return x.N.Arg("comment") } + +// PTCreateTableEngineOption views a PT_create_table_engine_option node. +type PTCreateTableEngineOption struct{ N *Node } + +// AsPTCreateTableEngineOption returns the view when v is a PT_create_table_engine_option. +func AsPTCreateTableEngineOption(v Value) (PTCreateTableEngineOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_table_engine_option" { + return PTCreateTableEngineOption{}, false + } + return PTCreateTableEngineOption{n}, true +} + +func (x PTCreateTableEngineOption) Engine() Value { return x.N.Arg("engine") } + +// PTCreateTableSecondaryEngineOption views a PT_create_table_secondary_engine_option node. +type PTCreateTableSecondaryEngineOption struct{ N *Node } + +// AsPTCreateTableSecondaryEngineOption returns the view when v is a PT_create_table_secondary_engine_option. +func AsPTCreateTableSecondaryEngineOption(v Value) (PTCreateTableSecondaryEngineOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_table_secondary_engine_option" { + return PTCreateTableSecondaryEngineOption{}, false + } + return PTCreateTableSecondaryEngineOption{n}, true +} + +func (x PTCreateTableSecondaryEngineOption) SecondaryEngine() Value { + return x.N.Arg("secondary_engine") +} + +// PTCreateMaxRowsOption views a PT_create_max_rows_option node. +type PTCreateMaxRowsOption struct{ N *Node } + +// AsPTCreateMaxRowsOption returns the view when v is a PT_create_max_rows_option. +func AsPTCreateMaxRowsOption(v Value) (PTCreateMaxRowsOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_max_rows_option" { + return PTCreateMaxRowsOption{}, false + } + return PTCreateMaxRowsOption{n}, true +} + +func (x PTCreateMaxRowsOption) Value() Value { return x.N.Arg("value") } + +// PTCreateMinRowsOption views a PT_create_min_rows_option node. +type PTCreateMinRowsOption struct{ N *Node } + +// AsPTCreateMinRowsOption returns the view when v is a PT_create_min_rows_option. +func AsPTCreateMinRowsOption(v Value) (PTCreateMinRowsOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_min_rows_option" { + return PTCreateMinRowsOption{}, false + } + return PTCreateMinRowsOption{n}, true +} + +func (x PTCreateMinRowsOption) Value() Value { return x.N.Arg("value") } + +// PTCreateAvgRowLengthOption views a PT_create_avg_row_length_option node. +type PTCreateAvgRowLengthOption struct{ N *Node } + +// AsPTCreateAvgRowLengthOption returns the view when v is a PT_create_avg_row_length_option. +func AsPTCreateAvgRowLengthOption(v Value) (PTCreateAvgRowLengthOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_avg_row_length_option" { + return PTCreateAvgRowLengthOption{}, false + } + return PTCreateAvgRowLengthOption{n}, true +} + +func (x PTCreateAvgRowLengthOption) Value() Value { return x.N.Arg("value") } + +// PTCreatePasswordOption views a PT_create_password_option node. +type PTCreatePasswordOption struct{ N *Node } + +// AsPTCreatePasswordOption returns the view when v is a PT_create_password_option. +func AsPTCreatePasswordOption(v Value) (PTCreatePasswordOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_password_option" { + return PTCreatePasswordOption{}, false + } + return PTCreatePasswordOption{n}, true +} + +func (x PTCreatePasswordOption) Value() Value { return x.N.Arg("value") } + +// PTCreateCommenOption views a PT_create_commen_option node. +type PTCreateCommenOption struct{ N *Node } + +// AsPTCreateCommenOption returns the view when v is a PT_create_commen_option. +func AsPTCreateCommenOption(v Value) (PTCreateCommenOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_commen_option" { + return PTCreateCommenOption{}, false + } + return PTCreateCommenOption{n}, true +} + +func (x PTCreateCommenOption) Value() Value { return x.N.Arg("value") } + +// PTCreateCompressOption views a PT_create_compress_option node. +type PTCreateCompressOption struct{ N *Node } + +// AsPTCreateCompressOption returns the view when v is a PT_create_compress_option. +func AsPTCreateCompressOption(v Value) (PTCreateCompressOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_compress_option" { + return PTCreateCompressOption{}, false + } + return PTCreateCompressOption{n}, true +} + +func (x PTCreateCompressOption) Value() Value { return x.N.Arg("value") } + +// PTCreateEncryptionOption views a PT_create_encryption_option node. +type PTCreateEncryptionOption struct{ N *Node } + +// AsPTCreateEncryptionOption returns the view when v is a PT_create_encryption_option. +func AsPTCreateEncryptionOption(v Value) (PTCreateEncryptionOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_encryption_option" { + return PTCreateEncryptionOption{}, false + } + return PTCreateEncryptionOption{n}, true +} + +func (x PTCreateEncryptionOption) Value() Value { return x.N.Arg("value") } + +// PTCreateAutoIncrementOption views a PT_create_auto_increment_option node. +type PTCreateAutoIncrementOption struct{ N *Node } + +// AsPTCreateAutoIncrementOption returns the view when v is a PT_create_auto_increment_option. +func AsPTCreateAutoIncrementOption(v Value) (PTCreateAutoIncrementOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_auto_increment_option" { + return PTCreateAutoIncrementOption{}, false + } + return PTCreateAutoIncrementOption{n}, true +} + +func (x PTCreateAutoIncrementOption) Value() Value { return x.N.Arg("value") } + +// PTCreatePackKeysOption views a PT_create_pack_keys_option node. +type PTCreatePackKeysOption struct{ N *Node } + +// AsPTCreatePackKeysOption returns the view when v is a PT_create_pack_keys_option. +func AsPTCreatePackKeysOption(v Value) (PTCreatePackKeysOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_pack_keys_option" { + return PTCreatePackKeysOption{}, false + } + return PTCreatePackKeysOption{n}, true +} + +func (x PTCreatePackKeysOption) Value() Value { return x.N.Arg("value") } + +// PTCreateStatsAutoRecalcOption views a PT_create_stats_auto_recalc_option node. +type PTCreateStatsAutoRecalcOption struct{ N *Node } + +// AsPTCreateStatsAutoRecalcOption returns the view when v is a PT_create_stats_auto_recalc_option. +func AsPTCreateStatsAutoRecalcOption(v Value) (PTCreateStatsAutoRecalcOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_stats_auto_recalc_option" { + return PTCreateStatsAutoRecalcOption{}, false + } + return PTCreateStatsAutoRecalcOption{n}, true +} + +func (x PTCreateStatsAutoRecalcOption) Value() Value { return x.N.Arg("value") } + +// PTCreateStatsPersistentOption views a PT_create_stats_persistent_option node. +type PTCreateStatsPersistentOption struct{ N *Node } + +// AsPTCreateStatsPersistentOption returns the view when v is a PT_create_stats_persistent_option. +func AsPTCreateStatsPersistentOption(v Value) (PTCreateStatsPersistentOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_stats_persistent_option" { + return PTCreateStatsPersistentOption{}, false + } + return PTCreateStatsPersistentOption{n}, true +} + +func (x PTCreateStatsPersistentOption) Value() Value { return x.N.Arg("value") } + +// PTCreateStatsStablePages views a PT_create_stats_stable_pages node. +type PTCreateStatsStablePages struct{ N *Node } + +// AsPTCreateStatsStablePages returns the view when v is a PT_create_stats_stable_pages. +func AsPTCreateStatsStablePages(v Value) (PTCreateStatsStablePages, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_stats_stable_pages" { + return PTCreateStatsStablePages{}, false + } + return PTCreateStatsStablePages{n}, true +} + +func (x PTCreateStatsStablePages) Value() Value { return x.N.Arg("value") } + +// PTCreateChecksumOption views a PT_create_checksum_option node. +type PTCreateChecksumOption struct{ N *Node } + +// AsPTCreateChecksumOption returns the view when v is a PT_create_checksum_option. +func AsPTCreateChecksumOption(v Value) (PTCreateChecksumOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_checksum_option" { + return PTCreateChecksumOption{}, false + } + return PTCreateChecksumOption{n}, true +} + +func (x PTCreateChecksumOption) Value() Value { return x.N.Arg("value") } + +// PTCreateDelayKeyWriteOption views a PT_create_delay_key_write_option node. +type PTCreateDelayKeyWriteOption struct{ N *Node } + +// AsPTCreateDelayKeyWriteOption returns the view when v is a PT_create_delay_key_write_option. +func AsPTCreateDelayKeyWriteOption(v Value) (PTCreateDelayKeyWriteOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_delay_key_write_option" { + return PTCreateDelayKeyWriteOption{}, false + } + return PTCreateDelayKeyWriteOption{n}, true +} + +func (x PTCreateDelayKeyWriteOption) Value() Value { return x.N.Arg("value") } + +// PTCreateRowFormatOption views a PT_create_row_format_option node. +type PTCreateRowFormatOption struct{ N *Node } + +// AsPTCreateRowFormatOption returns the view when v is a PT_create_row_format_option. +func AsPTCreateRowFormatOption(v Value) (PTCreateRowFormatOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_row_format_option" { + return PTCreateRowFormatOption{}, false + } + return PTCreateRowFormatOption{n}, true +} + +func (x PTCreateRowFormatOption) Value() Value { return x.N.Arg("value") } + +// PTCreateUnionOption views a PT_create_union_option node. +type PTCreateUnionOption struct{ N *Node } + +// AsPTCreateUnionOption returns the view when v is a PT_create_union_option. +func AsPTCreateUnionOption(v Value) (PTCreateUnionOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_union_option" { + return PTCreateUnionOption{}, false + } + return PTCreateUnionOption{n}, true +} + +func (x PTCreateUnionOption) Tables() Value { return x.N.Arg("tables") } + +// PTCreateTableDefaultCharset views a PT_create_table_default_charset node. +type PTCreateTableDefaultCharset struct{ N *Node } + +// AsPTCreateTableDefaultCharset returns the view when v is a PT_create_table_default_charset. +func AsPTCreateTableDefaultCharset(v Value) (PTCreateTableDefaultCharset, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_table_default_charset" { + return PTCreateTableDefaultCharset{}, false + } + return PTCreateTableDefaultCharset{n}, true +} + +func (x PTCreateTableDefaultCharset) Value() Value { return x.N.Arg("value") } + +// PTCreateTableDefaultCollation views a PT_create_table_default_collation node. +type PTCreateTableDefaultCollation struct{ N *Node } + +// AsPTCreateTableDefaultCollation returns the view when v is a PT_create_table_default_collation. +func AsPTCreateTableDefaultCollation(v Value) (PTCreateTableDefaultCollation, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_table_default_collation" { + return PTCreateTableDefaultCollation{}, false + } + return PTCreateTableDefaultCollation{n}, true +} + +func (x PTCreateTableDefaultCollation) Value() Value { return x.N.Arg("value") } + +// PTCreateInsertMethodOption views a PT_create_insert_method_option node. +type PTCreateInsertMethodOption struct{ N *Node } + +// AsPTCreateInsertMethodOption returns the view when v is a PT_create_insert_method_option. +func AsPTCreateInsertMethodOption(v Value) (PTCreateInsertMethodOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_insert_method_option" { + return PTCreateInsertMethodOption{}, false + } + return PTCreateInsertMethodOption{n}, true +} + +func (x PTCreateInsertMethodOption) Value() Value { return x.N.Arg("value") } + +// PTCreateDataDirectoryOption views a PT_create_data_directory_option node. +type PTCreateDataDirectoryOption struct{ N *Node } + +// AsPTCreateDataDirectoryOption returns the view when v is a PT_create_data_directory_option. +func AsPTCreateDataDirectoryOption(v Value) (PTCreateDataDirectoryOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_data_directory_option" { + return PTCreateDataDirectoryOption{}, false + } + return PTCreateDataDirectoryOption{n}, true +} + +func (x PTCreateDataDirectoryOption) Value() Value { return x.N.Arg("value") } + +// PTCreateIndexDirectoryOption views a PT_create_index_directory_option node. +type PTCreateIndexDirectoryOption struct{ N *Node } + +// AsPTCreateIndexDirectoryOption returns the view when v is a PT_create_index_directory_option. +func AsPTCreateIndexDirectoryOption(v Value) (PTCreateIndexDirectoryOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_index_directory_option" { + return PTCreateIndexDirectoryOption{}, false + } + return PTCreateIndexDirectoryOption{n}, true +} + +func (x PTCreateIndexDirectoryOption) Value() Value { return x.N.Arg("value") } + +// PTCreateTablespaceOption views a PT_create_tablespace_option node. +type PTCreateTablespaceOption struct{ N *Node } + +// AsPTCreateTablespaceOption returns the view when v is a PT_create_tablespace_option. +func AsPTCreateTablespaceOption(v Value) (PTCreateTablespaceOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_tablespace_option" { + return PTCreateTablespaceOption{}, false + } + return PTCreateTablespaceOption{n}, true +} + +func (x PTCreateTablespaceOption) Value() Value { return x.N.Arg("value") } + +// PTCreateStorageOption views a PT_create_storage_option node. +type PTCreateStorageOption struct{ N *Node } + +// AsPTCreateStorageOption returns the view when v is a PT_create_storage_option. +func AsPTCreateStorageOption(v Value) (PTCreateStorageOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_storage_option" { + return PTCreateStorageOption{}, false + } + return PTCreateStorageOption{n}, true +} + +func (x PTCreateStorageOption) Value() Value { return x.N.Arg("value") } + +// PTCreateConnectionOption views a PT_create_connection_option node. +type PTCreateConnectionOption struct{ N *Node } + +// AsPTCreateConnectionOption returns the view when v is a PT_create_connection_option. +func AsPTCreateConnectionOption(v Value) (PTCreateConnectionOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_connection_option" { + return PTCreateConnectionOption{}, false + } + return PTCreateConnectionOption{n}, true +} + +func (x PTCreateConnectionOption) Value() Value { return x.N.Arg("value") } + +// PTCreateKeyBlockSizeOption views a PT_create_key_block_size_option node. +type PTCreateKeyBlockSizeOption struct{ N *Node } + +// AsPTCreateKeyBlockSizeOption returns the view when v is a PT_create_key_block_size_option. +func AsPTCreateKeyBlockSizeOption(v Value) (PTCreateKeyBlockSizeOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_key_block_size_option" { + return PTCreateKeyBlockSizeOption{}, false + } + return PTCreateKeyBlockSizeOption{n}, true +} + +func (x PTCreateKeyBlockSizeOption) Value() Value { return x.N.Arg("value") } + +// PTCreateStartTransactionOption views a PT_create_start_transaction_option node. +type PTCreateStartTransactionOption struct{ N *Node } + +// AsPTCreateStartTransactionOption returns the view when v is a PT_create_start_transaction_option. +func AsPTCreateStartTransactionOption(v Value) (PTCreateStartTransactionOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_start_transaction_option" { + return PTCreateStartTransactionOption{}, false + } + return PTCreateStartTransactionOption{n}, true +} + +func (x PTCreateStartTransactionOption) Value() Value { return x.N.Arg("value") } + +// MakeTableEngineAttribute views a make_table_engine_attribute node. +type MakeTableEngineAttribute struct{ N *Node } + +// AsMakeTableEngineAttribute returns the view when v is a make_table_engine_attribute. +func AsMakeTableEngineAttribute(v Value) (MakeTableEngineAttribute, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "make_table_engine_attribute" { + return MakeTableEngineAttribute{}, false + } + return MakeTableEngineAttribute{n}, true +} + +// MakeTableSecondaryEngineAttribute views a make_table_secondary_engine_attribute node. +type MakeTableSecondaryEngineAttribute struct{ N *Node } + +// AsMakeTableSecondaryEngineAttribute returns the view when v is a make_table_secondary_engine_attribute. +func AsMakeTableSecondaryEngineAttribute(v Value) (MakeTableSecondaryEngineAttribute, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "make_table_secondary_engine_attribute" { + return MakeTableSecondaryEngineAttribute{}, false + } + return MakeTableSecondaryEngineAttribute{n}, true +} + +// PTCreateTsAutoextendSizeOption views a PT_create_ts_autoextend_size_option node. +type PTCreateTsAutoextendSizeOption struct{ N *Node } + +// AsPTCreateTsAutoextendSizeOption returns the view when v is a PT_create_ts_autoextend_size_option. +func AsPTCreateTsAutoextendSizeOption(v Value) (PTCreateTsAutoextendSizeOption, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_create_ts_autoextend_size_option" { + return PTCreateTsAutoextendSizeOption{}, false + } + return PTCreateTsAutoextendSizeOption{n}, true +} + +func (x PTCreateTsAutoextendSizeOption) Value() Value { return x.N.Arg("value") } + +// PTColumnDef views a PT_column_def node. +type PTColumnDef struct{ N *Node } + +// AsPTColumnDef returns the view when v is a PT_column_def. +func AsPTColumnDef(v Value) (PTColumnDef, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_column_def" { + return PTColumnDef{}, false + } + return PTColumnDef{n}, true +} + +func (x PTColumnDef) FieldIdent() Value { return x.N.Arg("field_ident") } +func (x PTColumnDef) FieldDef() Value { return x.N.Arg("field_def") } +func (x PTColumnDef) OptColumnConstraint() Value { return x.N.Arg("opt_column_constraint") } + +// PTInlineIndexDefinition views a PT_inline_index_definition node. +type PTInlineIndexDefinition struct{ N *Node } + +// AsPTInlineIndexDefinition returns the view when v is a PT_inline_index_definition. +func AsPTInlineIndexDefinition(v Value) (PTInlineIndexDefinition, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_inline_index_definition" { + return PTInlineIndexDefinition{}, false + } + return PTInlineIndexDefinition{n}, true +} + +func (x PTInlineIndexDefinition) TypePar() Value { return x.N.Arg("type_par") } +func (x PTInlineIndexDefinition) Name() Value { return x.N.Arg("name") } +func (x PTInlineIndexDefinition) Type() Value { return x.N.Arg("type") } +func (x PTInlineIndexDefinition) Cols() Value { return x.N.Arg("cols") } +func (x PTInlineIndexDefinition) Options() Value { return x.N.Arg("options") } + +// PTForeignKeyDefinition views a PT_foreign_key_definition node. +type PTForeignKeyDefinition struct{ N *Node } + +// AsPTForeignKeyDefinition returns the view when v is a PT_foreign_key_definition. +func AsPTForeignKeyDefinition(v Value) (PTForeignKeyDefinition, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_foreign_key_definition" { + return PTForeignKeyDefinition{}, false + } + return PTForeignKeyDefinition{n}, true +} + +func (x PTForeignKeyDefinition) ConstraintName() Value { return x.N.Arg("constraint_name") } +func (x PTForeignKeyDefinition) KeyName() Value { return x.N.Arg("key_name") } +func (x PTForeignKeyDefinition) Columns() Value { return x.N.Arg("columns") } +func (x PTForeignKeyDefinition) ReferencedTable() Value { return x.N.Arg("referenced_table") } +func (x PTForeignKeyDefinition) RefList() Value { return x.N.Arg("ref_list") } +func (x PTForeignKeyDefinition) FkMatchOption() Value { return x.N.Arg("fk_match_option") } +func (x PTForeignKeyDefinition) FkUpdateOpt() Value { return x.N.Arg("fk_update_opt") } +func (x PTForeignKeyDefinition) FkDeleteOpt() Value { return x.N.Arg("fk_delete_opt") } + +// PTCheckConstraint views a PT_check_constraint node. +type PTCheckConstraint struct{ N *Node } + +// AsPTCheckConstraint returns the view when v is a PT_check_constraint. +func AsPTCheckConstraint(v Value) (PTCheckConstraint, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_check_constraint" { + return PTCheckConstraint{}, false + } + return PTCheckConstraint{n}, true +} + +func (x PTCheckConstraint) Name() Value { return x.N.Arg("name") } +func (x PTCheckConstraint) Expr() Value { return x.N.Arg("expr") } +func (x PTCheckConstraint) IsEnforced() Value { return x.N.Arg("is_enforced") } + +// PTFieldDef views a PT_field_def node. +type PTFieldDef struct{ N *Node } + +// AsPTFieldDef returns the view when v is a PT_field_def. +func AsPTFieldDef(v Value) (PTFieldDef, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_field_def" { + return PTFieldDef{}, false + } + return PTFieldDef{n}, true +} + +func (x PTFieldDef) TypeNode() Value { return x.N.Arg("type_node") } +func (x PTFieldDef) OptAttrs() Value { return x.N.Arg("opt_attrs") } + +// PTNumericType views a PT_numeric_type node. +type PTNumericType struct{ N *Node } + +// AsPTNumericType returns the view when v is a PT_numeric_type. +func AsPTNumericType(v Value) (PTNumericType, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_numeric_type" { + return PTNumericType{}, false + } + return PTNumericType{n}, true +} + +func (x PTNumericType) Type() Value { return x.N.Arg("type") } +func (x PTNumericType) Length() Value { return x.N.Arg("length") } +func (x PTNumericType) Options() Value { return x.N.Arg("options") } +func (x PTNumericType) Dec() Value { return x.N.Arg("dec") } + +// PTBitType views a PT_bit_type node. +type PTBitType struct{ N *Node } + +// AsPTBitType returns the view when v is a PT_bit_type. +func AsPTBitType(v Value) (PTBitType, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_bit_type" { + return PTBitType{}, false + } + return PTBitType{n}, true +} + +func (x PTBitType) Length() Value { return x.N.Arg("length") } + +// PTBooleanType views a PT_boolean_type node. +type PTBooleanType struct{ N *Node } + +// AsPTBooleanType returns the view when v is a PT_boolean_type. +func AsPTBooleanType(v Value) (PTBooleanType, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_boolean_type" { + return PTBooleanType{}, false + } + return PTBooleanType{n}, true +} + +// PTCharType views a PT_char_type node. +type PTCharType struct{ N *Node } + +// AsPTCharType returns the view when v is a PT_char_type. +func AsPTCharType(v Value) (PTCharType, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_char_type" { + return PTCharType{}, false + } + return PTCharType{n}, true +} + +func (x PTCharType) CharType() Value { return x.N.Arg("char_type") } +func (x PTCharType) Length() Value { return x.N.Arg("length") } +func (x PTCharType) Charset() Value { return x.N.Arg("charset") } +func (x PTCharType) ForceBinary() Value { return x.N.Arg("force_binary") } + +// PTDateType views a PT_date_type node. +type PTDateType struct{ N *Node } + +// AsPTDateType returns the view when v is a PT_date_type. +func AsPTDateType(v Value) (PTDateType, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_date_type" { + return PTDateType{}, false + } + return PTDateType{n}, true +} + +// PTTimeType views a PT_time_type node. +type PTTimeType struct{ N *Node } + +// AsPTTimeType returns the view when v is a PT_time_type. +func AsPTTimeType(v Value) (PTTimeType, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_time_type" { + return PTTimeType{}, false + } + return PTTimeType{n}, true +} + +func (x PTTimeType) TimeType() Value { return x.N.Arg("time_type") } +func (x PTTimeType) Dec() Value { return x.N.Arg("dec") } + +// PTTimestampType views a PT_timestamp_type node. +type PTTimestampType struct{ N *Node } + +// AsPTTimestampType returns the view when v is a PT_timestamp_type. +func AsPTTimestampType(v Value) (PTTimestampType, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_timestamp_type" { + return PTTimestampType{}, false + } + return PTTimestampType{n}, true +} + +func (x PTTimestampType) Dec() Value { return x.N.Arg("dec") } + +// PTBlobType views a PT_blob_type node. +type PTBlobType struct{ N *Node } + +// AsPTBlobType returns the view when v is a PT_blob_type. +func AsPTBlobType(v Value) (PTBlobType, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_blob_type" { + return PTBlobType{}, false + } + return PTBlobType{n}, true +} + +func (x PTBlobType) BlobType() Value { return x.N.Arg("blob_type") } +func (x PTBlobType) Charset() Value { return x.N.Arg("charset") } +func (x PTBlobType) Length() Value { return x.N.Arg("length") } +func (x PTBlobType) ForceBinary() Value { return x.N.Arg("force_binary") } + +// PTEnumType views a PT_enum_type node. +type PTEnumType struct{ N *Node } + +// AsPTEnumType returns the view when v is a PT_enum_type. +func AsPTEnumType(v Value) (PTEnumType, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_enum_type" { + return PTEnumType{}, false + } + return PTEnumType{n}, true +} + +func (x PTEnumType) IntervalList() Value { return x.N.Arg("interval_list") } +func (x PTEnumType) Charset() Value { return x.N.Arg("charset") } +func (x PTEnumType) ForceBinary() Value { return x.N.Arg("force_binary") } + +// PTSetType views a PT_set_type node. +type PTSetType struct{ N *Node } + +// AsPTSetType returns the view when v is a PT_set_type. +func AsPTSetType(v Value) (PTSetType, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_set_type" { + return PTSetType{}, false + } + return PTSetType{n}, true +} + +func (x PTSetType) IntervalList() Value { return x.N.Arg("interval_list") } +func (x PTSetType) Charset() Value { return x.N.Arg("charset") } +func (x PTSetType) ForceBinary() Value { return x.N.Arg("force_binary") } + +// PTSerialType views a PT_serial_type node. +type PTSerialType struct{ N *Node } + +// AsPTSerialType returns the view when v is a PT_serial_type. +func AsPTSerialType(v Value) (PTSerialType, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_serial_type" { + return PTSerialType{}, false + } + return PTSerialType{n}, true +} + +// PTJsonType views a PT_json_type node. +type PTJsonType struct{ N *Node } + +// AsPTJsonType returns the view when v is a PT_json_type. +func AsPTJsonType(v Value) (PTJsonType, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_json_type" { + return PTJsonType{}, false + } + return PTJsonType{n}, true +} + +// PTSpacialType views a PT_spacial_type node. +type PTSpacialType struct{ N *Node } + +// AsPTSpacialType returns the view when v is a PT_spacial_type. +func AsPTSpacialType(v Value) (PTSpacialType, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_spacial_type" { + return PTSpacialType{}, false + } + return PTSpacialType{n}, true +} + +func (x PTSpacialType) GeoType() Value { return x.N.Arg("geo_type") } + +// PTNullColumnAttr views a PT_null_column_attr node. +type PTNullColumnAttr struct{ N *Node } + +// AsPTNullColumnAttr returns the view when v is a PT_null_column_attr. +func AsPTNullColumnAttr(v Value) (PTNullColumnAttr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_null_column_attr" { + return PTNullColumnAttr{}, false + } + return PTNullColumnAttr{n}, true +} + +// PTNotNullColumnAttr views a PT_not_null_column_attr node. +type PTNotNullColumnAttr struct{ N *Node } + +// AsPTNotNullColumnAttr returns the view when v is a PT_not_null_column_attr. +func AsPTNotNullColumnAttr(v Value) (PTNotNullColumnAttr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_not_null_column_attr" { + return PTNotNullColumnAttr{}, false + } + return PTNotNullColumnAttr{n}, true +} + +// PTSecondaryColumnAttr views a PT_secondary_column_attr node. +type PTSecondaryColumnAttr struct{ N *Node } + +// AsPTSecondaryColumnAttr returns the view when v is a PT_secondary_column_attr. +func AsPTSecondaryColumnAttr(v Value) (PTSecondaryColumnAttr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_secondary_column_attr" { + return PTSecondaryColumnAttr{}, false + } + return PTSecondaryColumnAttr{n}, true +} + +// PTDefaultColumnAttr views a PT_default_column_attr node. +type PTDefaultColumnAttr struct{ N *Node } + +// AsPTDefaultColumnAttr returns the view when v is a PT_default_column_attr. +func AsPTDefaultColumnAttr(v Value) (PTDefaultColumnAttr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_default_column_attr" { + return PTDefaultColumnAttr{}, false + } + return PTDefaultColumnAttr{n}, true +} + +func (x PTDefaultColumnAttr) Item() Value { return x.N.Arg("item") } + +// PTGeneratedDefaultValColumnAttr views a PT_generated_default_val_column_attr node. +type PTGeneratedDefaultValColumnAttr struct{ N *Node } + +// AsPTGeneratedDefaultValColumnAttr returns the view when v is a PT_generated_default_val_column_attr. +func AsPTGeneratedDefaultValColumnAttr(v Value) (PTGeneratedDefaultValColumnAttr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_generated_default_val_column_attr" { + return PTGeneratedDefaultValColumnAttr{}, false + } + return PTGeneratedDefaultValColumnAttr{n}, true +} + +func (x PTGeneratedDefaultValColumnAttr) Expr() Value { return x.N.Arg("expr") } + +// PTOnUpdateColumnAttr views a PT_on_update_column_attr node. +type PTOnUpdateColumnAttr struct{ N *Node } + +// AsPTOnUpdateColumnAttr returns the view when v is a PT_on_update_column_attr. +func AsPTOnUpdateColumnAttr(v Value) (PTOnUpdateColumnAttr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_on_update_column_attr" { + return PTOnUpdateColumnAttr{}, false + } + return PTOnUpdateColumnAttr{n}, true +} + +func (x PTOnUpdateColumnAttr) Precision() Value { return x.N.Arg("precision") } + +// PTAutoIncrementColumnAttr views a PT_auto_increment_column_attr node. +type PTAutoIncrementColumnAttr struct{ N *Node } + +// AsPTAutoIncrementColumnAttr returns the view when v is a PT_auto_increment_column_attr. +func AsPTAutoIncrementColumnAttr(v Value) (PTAutoIncrementColumnAttr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_auto_increment_column_attr" { + return PTAutoIncrementColumnAttr{}, false + } + return PTAutoIncrementColumnAttr{n}, true +} + +// PTSerialDefaultValueColumnAttr views a PT_serial_default_value_column_attr node. +type PTSerialDefaultValueColumnAttr struct{ N *Node } + +// AsPTSerialDefaultValueColumnAttr returns the view when v is a PT_serial_default_value_column_attr. +func AsPTSerialDefaultValueColumnAttr(v Value) (PTSerialDefaultValueColumnAttr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_serial_default_value_column_attr" { + return PTSerialDefaultValueColumnAttr{}, false + } + return PTSerialDefaultValueColumnAttr{n}, true +} + +// PTPrimaryKeyColumnAttr views a PT_primary_key_column_attr node. +type PTPrimaryKeyColumnAttr struct{ N *Node } + +// AsPTPrimaryKeyColumnAttr returns the view when v is a PT_primary_key_column_attr. +func AsPTPrimaryKeyColumnAttr(v Value) (PTPrimaryKeyColumnAttr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_primary_key_column_attr" { + return PTPrimaryKeyColumnAttr{}, false + } + return PTPrimaryKeyColumnAttr{n}, true +} + +// PTUniqueKeyColumnAttr views a PT_unique_key_column_attr node. +type PTUniqueKeyColumnAttr struct{ N *Node } + +// AsPTUniqueKeyColumnAttr returns the view when v is a PT_unique_key_column_attr. +func AsPTUniqueKeyColumnAttr(v Value) (PTUniqueKeyColumnAttr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_unique_key_column_attr" { + return PTUniqueKeyColumnAttr{}, false + } + return PTUniqueKeyColumnAttr{n}, true +} + +// PTCommentColumnAttr views a PT_comment_column_attr node. +type PTCommentColumnAttr struct{ N *Node } + +// AsPTCommentColumnAttr returns the view when v is a PT_comment_column_attr. +func AsPTCommentColumnAttr(v Value) (PTCommentColumnAttr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_comment_column_attr" { + return PTCommentColumnAttr{}, false + } + return PTCommentColumnAttr{n}, true +} + +func (x PTCommentColumnAttr) Comment() Value { return x.N.Arg("comment") } + +// PTCollateColumnAttr views a PT_collate_column_attr node. +type PTCollateColumnAttr struct{ N *Node } + +// AsPTCollateColumnAttr returns the view when v is a PT_collate_column_attr. +func AsPTCollateColumnAttr(v Value) (PTCollateColumnAttr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_collate_column_attr" { + return PTCollateColumnAttr{}, false + } + return PTCollateColumnAttr{n}, true +} + +func (x PTCollateColumnAttr) Collation() Value { return x.N.Arg("collation") } + +// PTColumnFormatColumnAttr views a PT_column_format_column_attr node. +type PTColumnFormatColumnAttr struct{ N *Node } + +// AsPTColumnFormatColumnAttr returns the view when v is a PT_column_format_column_attr. +func AsPTColumnFormatColumnAttr(v Value) (PTColumnFormatColumnAttr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_column_format_column_attr" { + return PTColumnFormatColumnAttr{}, false + } + return PTColumnFormatColumnAttr{n}, true +} + +func (x PTColumnFormatColumnAttr) Format() Value { return x.N.Arg("format") } + +// PTStorageMediaColumnAttr views a PT_storage_media_column_attr node. +type PTStorageMediaColumnAttr struct{ N *Node } + +// AsPTStorageMediaColumnAttr returns the view when v is a PT_storage_media_column_attr. +func AsPTStorageMediaColumnAttr(v Value) (PTStorageMediaColumnAttr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_storage_media_column_attr" { + return PTStorageMediaColumnAttr{}, false + } + return PTStorageMediaColumnAttr{n}, true +} + +func (x PTStorageMediaColumnAttr) Media() Value { return x.N.Arg("media") } + +// PTSridColumnAttr views a PT_srid_column_attr node. +type PTSridColumnAttr struct{ N *Node } + +// AsPTSridColumnAttr returns the view when v is a PT_srid_column_attr. +func AsPTSridColumnAttr(v Value) (PTSridColumnAttr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_srid_column_attr" { + return PTSridColumnAttr{}, false + } + return PTSridColumnAttr{n}, true +} + +func (x PTSridColumnAttr) Srid() Value { return x.N.Arg("srid") } + +// PTCheckConstraintColumnAttr views a PT_check_constraint_column_attr node. +type PTCheckConstraintColumnAttr struct{ N *Node } + +// AsPTCheckConstraintColumnAttr returns the view when v is a PT_check_constraint_column_attr. +func AsPTCheckConstraintColumnAttr(v Value) (PTCheckConstraintColumnAttr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_check_constraint_column_attr" { + return PTCheckConstraintColumnAttr{}, false + } + return PTCheckConstraintColumnAttr{n}, true +} + +func (x PTCheckConstraintColumnAttr) Name() Value { return x.N.Arg("name") } +func (x PTCheckConstraintColumnAttr) Expr() Value { return x.N.Arg("expr") } + +// PTConstraintEnforcementAttr views a PT_constraint_enforcement_attr node. +type PTConstraintEnforcementAttr struct{ N *Node } + +// AsPTConstraintEnforcementAttr returns the view when v is a PT_constraint_enforcement_attr. +func AsPTConstraintEnforcementAttr(v Value) (PTConstraintEnforcementAttr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_constraint_enforcement_attr" { + return PTConstraintEnforcementAttr{}, false + } + return PTConstraintEnforcementAttr{n}, true +} + +func (x PTConstraintEnforcementAttr) Enforced() Value { return x.N.Arg("enforced") } + +// MakeColumnEngineAttribute views a make_column_engine_attribute node. +type MakeColumnEngineAttribute struct{ N *Node } + +// AsMakeColumnEngineAttribute returns the view when v is a make_column_engine_attribute. +func AsMakeColumnEngineAttribute(v Value) (MakeColumnEngineAttribute, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "make_column_engine_attribute" { + return MakeColumnEngineAttribute{}, false + } + return MakeColumnEngineAttribute{n}, true +} + +// MakeColumnSecondaryEngineAttribute views a make_column_secondary_engine_attribute node. +type MakeColumnSecondaryEngineAttribute struct{ N *Node } + +// AsMakeColumnSecondaryEngineAttribute returns the view when v is a make_column_secondary_engine_attribute. +func AsMakeColumnSecondaryEngineAttribute(v Value) (MakeColumnSecondaryEngineAttribute, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "make_column_secondary_engine_attribute" { + return MakeColumnSecondaryEngineAttribute{}, false + } + return MakeColumnSecondaryEngineAttribute{n}, true +} + +// PTColumnVisibilityAttr views a PT_column_visibility_attr node. +type PTColumnVisibilityAttr struct{ N *Node } + +// AsPTColumnVisibilityAttr returns the view when v is a PT_column_visibility_attr. +func AsPTColumnVisibilityAttr(v Value) (PTColumnVisibilityAttr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_column_visibility_attr" { + return PTColumnVisibilityAttr{}, false + } + return PTColumnVisibilityAttr{n}, true +} + +func (x PTColumnVisibilityAttr) IsVisible() Value { return x.N.Arg("is_visible") } + +// ItemFuncNowLocal views a Item_func_now_local node. +type ItemFuncNowLocal struct{ N *Node } + +// AsItemFuncNowLocal returns the view when v is a Item_func_now_local. +func AsItemFuncNowLocal(v Value) (ItemFuncNowLocal, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_now_local" { + return ItemFuncNowLocal{}, false + } + return ItemFuncNowLocal{n}, true +} + +func (x ItemFuncNowLocal) Dec() Value { return x.N.Arg("dec") } + +// PTBlockSize views a PT_block_size node. +type PTBlockSize struct{ N *Node } + +// AsPTBlockSize returns the view when v is a PT_block_size. +func AsPTBlockSize(v Value) (PTBlockSize, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_block_size" { + return PTBlockSize{}, false + } + return PTBlockSize{n}, true +} + +func (x PTBlockSize) OptionValue() Value { return x.N.Arg("option_value") } + +// PTIndexComment views a PT_index_comment node. +type PTIndexComment struct{ N *Node } + +// AsPTIndexComment returns the view when v is a PT_index_comment. +func AsPTIndexComment(v Value) (PTIndexComment, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_index_comment" { + return PTIndexComment{}, false + } + return PTIndexComment{n}, true +} + +func (x PTIndexComment) OptionValue() Value { return x.N.Arg("option_value") } + +// PTIndexVisibility views a PT_index_visibility node. +type PTIndexVisibility struct{ N *Node } + +// AsPTIndexVisibility returns the view when v is a PT_index_visibility. +func AsPTIndexVisibility(v Value) (PTIndexVisibility, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_index_visibility" { + return PTIndexVisibility{}, false + } + return PTIndexVisibility{n}, true +} + +func (x PTIndexVisibility) OptionValue() Value { return x.N.Arg("option_value") } + +// MakeIndexEngineAttribute views a make_index_engine_attribute node. +type MakeIndexEngineAttribute struct{ N *Node } + +// AsMakeIndexEngineAttribute returns the view when v is a make_index_engine_attribute. +func AsMakeIndexEngineAttribute(v Value) (MakeIndexEngineAttribute, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "make_index_engine_attribute" { + return MakeIndexEngineAttribute{}, false + } + return MakeIndexEngineAttribute{n}, true +} + +// MakeIndexSecondaryEngineAttribute views a make_index_secondary_engine_attribute node. +type MakeIndexSecondaryEngineAttribute struct{ N *Node } + +// AsMakeIndexSecondaryEngineAttribute returns the view when v is a make_index_secondary_engine_attribute. +func AsMakeIndexSecondaryEngineAttribute(v Value) (MakeIndexSecondaryEngineAttribute, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "make_index_secondary_engine_attribute" { + return MakeIndexSecondaryEngineAttribute{}, false + } + return MakeIndexSecondaryEngineAttribute{n}, true +} + +// PTIndexType views a PT_index_type node. +type PTIndexType struct{ N *Node } + +// AsPTIndexType returns the view when v is a PT_index_type. +func AsPTIndexType(v Value) (PTIndexType, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_index_type" { + return PTIndexType{}, false + } + return PTIndexType{n}, true +} + +func (x PTIndexType) OptionValue() Value { return x.N.Arg("option_value") } + +// PTKeyPartSpecification views a PT_key_part_specification node. +type PTKeyPartSpecification struct{ N *Node } + +// AsPTKeyPartSpecification returns the view when v is a PT_key_part_specification. +func AsPTKeyPartSpecification(v Value) (PTKeyPartSpecification, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_key_part_specification" { + return PTKeyPartSpecification{}, false + } + return PTKeyPartSpecification{n}, true +} + +func (x PTKeyPartSpecification) ColumnName() Value { return x.N.Arg("column_name") } +func (x PTKeyPartSpecification) Order() Value { return x.N.Arg("order") } +func (x PTKeyPartSpecification) PrefixLength() Value { return x.N.Arg("prefix_length") } +func (x PTKeyPartSpecification) Expression() Value { return x.N.Arg("expression") } + +// PTAlterTableStmt views a PT_alter_table_stmt node. +type PTAlterTableStmt struct{ N *Node } + +// AsPTAlterTableStmt returns the view when v is a PT_alter_table_stmt. +func AsPTAlterTableStmt(v Value) (PTAlterTableStmt, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_stmt" { + return PTAlterTableStmt{}, false + } + return PTAlterTableStmt{n}, true +} + +func (x PTAlterTableStmt) TableName() Value { return x.N.Arg("table_name") } +func (x PTAlterTableStmt) OptActions() Value { return x.N.Arg("opt_actions") } +func (x PTAlterTableStmt) Algo() Value { return x.N.Arg("algo") } +func (x PTAlterTableStmt) Lock() Value { return x.N.Arg("lock") } +func (x PTAlterTableStmt) Validation() Value { return x.N.Arg("validation") } + +// PTAlterTableStandaloneStmt views a PT_alter_table_standalone_stmt node. +type PTAlterTableStandaloneStmt struct{ N *Node } + +// AsPTAlterTableStandaloneStmt returns the view when v is a PT_alter_table_standalone_stmt. +func AsPTAlterTableStandaloneStmt(v Value) (PTAlterTableStandaloneStmt, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_standalone_stmt" { + return PTAlterTableStandaloneStmt{}, false + } + return PTAlterTableStandaloneStmt{n}, true +} + +func (x PTAlterTableStandaloneStmt) TableName() Value { return x.N.Arg("table_name") } +func (x PTAlterTableStandaloneStmt) Action() Value { return x.N.Arg("action") } +func (x PTAlterTableStandaloneStmt) Algo() Value { return x.N.Arg("algo") } +func (x PTAlterTableStandaloneStmt) Lock() Value { return x.N.Arg("lock") } +func (x PTAlterTableStandaloneStmt) Validation() Value { return x.N.Arg("validation") } + +// PTAlterResourceGroup views a PT_alter_resource_group node. +type PTAlterResourceGroup struct{ N *Node } + +// AsPTAlterResourceGroup returns the view when v is a PT_alter_resource_group. +func AsPTAlterResourceGroup(v Value) (PTAlterResourceGroup, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_resource_group" { + return PTAlterResourceGroup{}, false + } + return PTAlterResourceGroup{n}, true +} + +func (x PTAlterResourceGroup) Name() Value { return x.N.Arg("name") } +func (x PTAlterResourceGroup) CpuList() Value { return x.N.Arg("cpu_list") } +func (x PTAlterResourceGroup) OptPriority() Value { return x.N.Arg("opt_priority") } +func (x PTAlterResourceGroup) Enable() Value { return x.N.Arg("enable") } +func (x PTAlterResourceGroup) Force() Value { return x.N.Arg("force") } + +// PTAlterTablePartitionBy views a PT_alter_table_partition_by node. +type PTAlterTablePartitionBy struct{ N *Node } + +// AsPTAlterTablePartitionBy returns the view when v is a PT_alter_table_partition_by. +func AsPTAlterTablePartitionBy(v Value) (PTAlterTablePartitionBy, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_partition_by" { + return PTAlterTablePartitionBy{}, false + } + return PTAlterTablePartitionBy{n}, true +} + +func (x PTAlterTablePartitionBy) Partition() Value { return x.N.Arg("partition") } + +// PTAlterTableRemovePartitioning views a PT_alter_table_remove_partitioning node. +type PTAlterTableRemovePartitioning struct{ N *Node } + +// AsPTAlterTableRemovePartitioning returns the view when v is a PT_alter_table_remove_partitioning. +func AsPTAlterTableRemovePartitioning(v Value) (PTAlterTableRemovePartitioning, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_remove_partitioning" { + return PTAlterTableRemovePartitioning{}, false + } + return PTAlterTableRemovePartitioning{n}, true +} + +// PTAlterTableDiscardTablespace views a PT_alter_table_discard_tablespace node. +type PTAlterTableDiscardTablespace struct{ N *Node } + +// AsPTAlterTableDiscardTablespace returns the view when v is a PT_alter_table_discard_tablespace. +func AsPTAlterTableDiscardTablespace(v Value) (PTAlterTableDiscardTablespace, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_discard_tablespace" { + return PTAlterTableDiscardTablespace{}, false + } + return PTAlterTableDiscardTablespace{n}, true +} + +// PTAlterTableImportTablespace views a PT_alter_table_import_tablespace node. +type PTAlterTableImportTablespace struct{ N *Node } + +// AsPTAlterTableImportTablespace returns the view when v is a PT_alter_table_import_tablespace. +func AsPTAlterTableImportTablespace(v Value) (PTAlterTableImportTablespace, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_import_tablespace" { + return PTAlterTableImportTablespace{}, false + } + return PTAlterTableImportTablespace{n}, true +} + +// PTAlterTableAddPartition views a PT_alter_table_add_partition node. +type PTAlterTableAddPartition struct{ N *Node } + +// AsPTAlterTableAddPartition returns the view when v is a PT_alter_table_add_partition. +func AsPTAlterTableAddPartition(v Value) (PTAlterTableAddPartition, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_add_partition" { + return PTAlterTableAddPartition{}, false + } + return PTAlterTableAddPartition{n}, true +} + +func (x PTAlterTableAddPartition) NoWriteToBinlog() Value { return x.N.Arg("no_write_to_binlog") } + +// PTAlterTableAddPartitionDefList views a PT_alter_table_add_partition_def_list node. +type PTAlterTableAddPartitionDefList struct{ N *Node } + +// AsPTAlterTableAddPartitionDefList returns the view when v is a PT_alter_table_add_partition_def_list. +func AsPTAlterTableAddPartitionDefList(v Value) (PTAlterTableAddPartitionDefList, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_add_partition_def_list" { + return PTAlterTableAddPartitionDefList{}, false + } + return PTAlterTableAddPartitionDefList{n}, true +} + +func (x PTAlterTableAddPartitionDefList) NoWriteToBinlog() Value { + return x.N.Arg("no_write_to_binlog") +} +func (x PTAlterTableAddPartitionDefList) DefList() Value { return x.N.Arg("def_list") } + +// PTAlterTableAddPartitionNum views a PT_alter_table_add_partition_num node. +type PTAlterTableAddPartitionNum struct{ N *Node } + +// AsPTAlterTableAddPartitionNum returns the view when v is a PT_alter_table_add_partition_num. +func AsPTAlterTableAddPartitionNum(v Value) (PTAlterTableAddPartitionNum, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_add_partition_num" { + return PTAlterTableAddPartitionNum{}, false + } + return PTAlterTableAddPartitionNum{n}, true +} + +func (x PTAlterTableAddPartitionNum) NoWriteToBinlog() Value { return x.N.Arg("no_write_to_binlog") } +func (x PTAlterTableAddPartitionNum) NumParts() Value { return x.N.Arg("num_parts") } + +// PTAlterTableDropPartition views a PT_alter_table_drop_partition node. +type PTAlterTableDropPartition struct{ N *Node } + +// AsPTAlterTableDropPartition returns the view when v is a PT_alter_table_drop_partition. +func AsPTAlterTableDropPartition(v Value) (PTAlterTableDropPartition, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_drop_partition" { + return PTAlterTableDropPartition{}, false + } + return PTAlterTableDropPartition{n}, true +} + +func (x PTAlterTableDropPartition) Partitions() Value { return x.N.Arg("partitions") } + +// PTAlterTableRebuildPartition views a PT_alter_table_rebuild_partition node. +type PTAlterTableRebuildPartition struct{ N *Node } + +// AsPTAlterTableRebuildPartition returns the view when v is a PT_alter_table_rebuild_partition. +func AsPTAlterTableRebuildPartition(v Value) (PTAlterTableRebuildPartition, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_rebuild_partition" { + return PTAlterTableRebuildPartition{}, false + } + return PTAlterTableRebuildPartition{n}, true +} + +func (x PTAlterTableRebuildPartition) NoWriteToBinlog() Value { return x.N.Arg("no_write_to_binlog") } +func (x PTAlterTableRebuildPartition) OptPartitionList() Value { return x.N.Arg("opt_partition_list") } + +// PTAlterTableOptimizePartition views a PT_alter_table_optimize_partition node. +type PTAlterTableOptimizePartition struct{ N *Node } + +// AsPTAlterTableOptimizePartition returns the view when v is a PT_alter_table_optimize_partition. +func AsPTAlterTableOptimizePartition(v Value) (PTAlterTableOptimizePartition, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_optimize_partition" { + return PTAlterTableOptimizePartition{}, false + } + return PTAlterTableOptimizePartition{n}, true +} + +func (x PTAlterTableOptimizePartition) NoWriteToBinlog() Value { return x.N.Arg("no_write_to_binlog") } +func (x PTAlterTableOptimizePartition) OptPartitionList() Value { return x.N.Arg("opt_partition_list") } + +// PTAlterTableAnalyzePartition views a PT_alter_table_analyze_partition node. +type PTAlterTableAnalyzePartition struct{ N *Node } + +// AsPTAlterTableAnalyzePartition returns the view when v is a PT_alter_table_analyze_partition. +func AsPTAlterTableAnalyzePartition(v Value) (PTAlterTableAnalyzePartition, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_analyze_partition" { + return PTAlterTableAnalyzePartition{}, false + } + return PTAlterTableAnalyzePartition{n}, true +} + +func (x PTAlterTableAnalyzePartition) NoWriteToBinlog() Value { return x.N.Arg("no_write_to_binlog") } +func (x PTAlterTableAnalyzePartition) OptPartitionList() Value { return x.N.Arg("opt_partition_list") } + +// PTAlterTableCheckPartition views a PT_alter_table_check_partition node. +type PTAlterTableCheckPartition struct{ N *Node } + +// AsPTAlterTableCheckPartition returns the view when v is a PT_alter_table_check_partition. +func AsPTAlterTableCheckPartition(v Value) (PTAlterTableCheckPartition, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_check_partition" { + return PTAlterTableCheckPartition{}, false + } + return PTAlterTableCheckPartition{n}, true +} + +func (x PTAlterTableCheckPartition) OptPartitionList() Value { return x.N.Arg("opt_partition_list") } +func (x PTAlterTableCheckPartition) Flags() Value { return x.N.Arg("flags") } +func (x PTAlterTableCheckPartition) SqlFlags() Value { return x.N.Arg("sql_flags") } + +// PTAlterTableRepairPartition views a PT_alter_table_repair_partition node. +type PTAlterTableRepairPartition struct{ N *Node } + +// AsPTAlterTableRepairPartition returns the view when v is a PT_alter_table_repair_partition. +func AsPTAlterTableRepairPartition(v Value) (PTAlterTableRepairPartition, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_repair_partition" { + return PTAlterTableRepairPartition{}, false + } + return PTAlterTableRepairPartition{n}, true +} + +func (x PTAlterTableRepairPartition) NoWriteToBinlog() Value { return x.N.Arg("no_write_to_binlog") } +func (x PTAlterTableRepairPartition) OptPartitionList() Value { return x.N.Arg("opt_partition_list") } +func (x PTAlterTableRepairPartition) Flags() Value { return x.N.Arg("flags") } +func (x PTAlterTableRepairPartition) SqlFlags() Value { return x.N.Arg("sql_flags") } + +// PTAlterTableCoalescePartition views a PT_alter_table_coalesce_partition node. +type PTAlterTableCoalescePartition struct{ N *Node } + +// AsPTAlterTableCoalescePartition returns the view when v is a PT_alter_table_coalesce_partition. +func AsPTAlterTableCoalescePartition(v Value) (PTAlterTableCoalescePartition, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_coalesce_partition" { + return PTAlterTableCoalescePartition{}, false + } + return PTAlterTableCoalescePartition{n}, true +} + +func (x PTAlterTableCoalescePartition) NoWriteToBinlog() Value { return x.N.Arg("no_write_to_binlog") } +func (x PTAlterTableCoalescePartition) NumParts() Value { return x.N.Arg("num_parts") } + +// PTAlterTableTruncatePartition views a PT_alter_table_truncate_partition node. +type PTAlterTableTruncatePartition struct{ N *Node } + +// AsPTAlterTableTruncatePartition returns the view when v is a PT_alter_table_truncate_partition. +func AsPTAlterTableTruncatePartition(v Value) (PTAlterTableTruncatePartition, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_truncate_partition" { + return PTAlterTableTruncatePartition{}, false + } + return PTAlterTableTruncatePartition{n}, true +} + +func (x PTAlterTableTruncatePartition) OptPartitionList() Value { return x.N.Arg("opt_partition_list") } + +// PTAlterTableReorganizePartition views a PT_alter_table_reorganize_partition node. +type PTAlterTableReorganizePartition struct{ N *Node } + +// AsPTAlterTableReorganizePartition returns the view when v is a PT_alter_table_reorganize_partition. +func AsPTAlterTableReorganizePartition(v Value) (PTAlterTableReorganizePartition, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_reorganize_partition" { + return PTAlterTableReorganizePartition{}, false + } + return PTAlterTableReorganizePartition{n}, true +} + +func (x PTAlterTableReorganizePartition) NoWriteToBinlog() Value { + return x.N.Arg("no_write_to_binlog") +} + +// PTAlterTableReorganizePartitionInto views a PT_alter_table_reorganize_partition_into node. +type PTAlterTableReorganizePartitionInto struct{ N *Node } + +// AsPTAlterTableReorganizePartitionInto returns the view when v is a PT_alter_table_reorganize_partition_into. +func AsPTAlterTableReorganizePartitionInto(v Value) (PTAlterTableReorganizePartitionInto, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_reorganize_partition_into" { + return PTAlterTableReorganizePartitionInto{}, false + } + return PTAlterTableReorganizePartitionInto{n}, true +} + +func (x PTAlterTableReorganizePartitionInto) NoWriteToBinlog() Value { + return x.N.Arg("no_write_to_binlog") +} +func (x PTAlterTableReorganizePartitionInto) PartitionNames() Value { + return x.N.Arg("partition_names") +} +func (x PTAlterTableReorganizePartitionInto) Into() Value { return x.N.Arg("into") } + +// PTAlterTableExchangePartition views a PT_alter_table_exchange_partition node. +type PTAlterTableExchangePartition struct{ N *Node } + +// AsPTAlterTableExchangePartition returns the view when v is a PT_alter_table_exchange_partition. +func AsPTAlterTableExchangePartition(v Value) (PTAlterTableExchangePartition, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_exchange_partition" { + return PTAlterTableExchangePartition{}, false + } + return PTAlterTableExchangePartition{n}, true +} + +func (x PTAlterTableExchangePartition) PartitionName() Value { return x.N.Arg("partition_name") } +func (x PTAlterTableExchangePartition) TableName() Value { return x.N.Arg("table_name") } +func (x PTAlterTableExchangePartition) Validation() Value { return x.N.Arg("validation") } + +// PTAlterTableDiscardPartitionTablespace views a PT_alter_table_discard_partition_tablespace node. +type PTAlterTableDiscardPartitionTablespace struct{ N *Node } + +// AsPTAlterTableDiscardPartitionTablespace returns the view when v is a PT_alter_table_discard_partition_tablespace. +func AsPTAlterTableDiscardPartitionTablespace(v Value) (PTAlterTableDiscardPartitionTablespace, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_discard_partition_tablespace" { + return PTAlterTableDiscardPartitionTablespace{}, false + } + return PTAlterTableDiscardPartitionTablespace{n}, true +} + +func (x PTAlterTableDiscardPartitionTablespace) OptPartitionList() Value { + return x.N.Arg("opt_partition_list") +} + +// PTAlterTableImportPartitionTablespace views a PT_alter_table_import_partition_tablespace node. +type PTAlterTableImportPartitionTablespace struct{ N *Node } + +// AsPTAlterTableImportPartitionTablespace returns the view when v is a PT_alter_table_import_partition_tablespace. +func AsPTAlterTableImportPartitionTablespace(v Value) (PTAlterTableImportPartitionTablespace, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_import_partition_tablespace" { + return PTAlterTableImportPartitionTablespace{}, false + } + return PTAlterTableImportPartitionTablespace{n}, true +} + +func (x PTAlterTableImportPartitionTablespace) OptPartitionList() Value { + return x.N.Arg("opt_partition_list") +} + +// PTAlterTableSecondaryLoad views a PT_alter_table_secondary_load node. +type PTAlterTableSecondaryLoad struct{ N *Node } + +// AsPTAlterTableSecondaryLoad returns the view when v is a PT_alter_table_secondary_load. +func AsPTAlterTableSecondaryLoad(v Value) (PTAlterTableSecondaryLoad, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_secondary_load" { + return PTAlterTableSecondaryLoad{}, false + } + return PTAlterTableSecondaryLoad{n}, true +} + +func (x PTAlterTableSecondaryLoad) OptUsePartition() Value { return x.N.Arg("opt_use_partition") } + +// PTAlterTableSecondaryUnload views a PT_alter_table_secondary_unload node. +type PTAlterTableSecondaryUnload struct{ N *Node } + +// AsPTAlterTableSecondaryUnload returns the view when v is a PT_alter_table_secondary_unload. +func AsPTAlterTableSecondaryUnload(v Value) (PTAlterTableSecondaryUnload, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_secondary_unload" { + return PTAlterTableSecondaryUnload{}, false + } + return PTAlterTableSecondaryUnload{n}, true +} + +func (x PTAlterTableSecondaryUnload) OptUsePartition() Value { return x.N.Arg("opt_use_partition") } + +// PTAlterTableAddColumn views a PT_alter_table_add_column node. +type PTAlterTableAddColumn struct{ N *Node } + +// AsPTAlterTableAddColumn returns the view when v is a PT_alter_table_add_column. +func AsPTAlterTableAddColumn(v Value) (PTAlterTableAddColumn, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_add_column" { + return PTAlterTableAddColumn{}, false + } + return PTAlterTableAddColumn{n}, true +} + +func (x PTAlterTableAddColumn) FieldIdent() Value { return x.N.Arg("field_ident") } +func (x PTAlterTableAddColumn) FieldDef() Value { return x.N.Arg("field_def") } +func (x PTAlterTableAddColumn) OptColumnConstraint() Value { return x.N.Arg("opt_column_constraint") } +func (x PTAlterTableAddColumn) OptPlace() Value { return x.N.Arg("opt_place") } + +// PTAlterTableAddColumns views a PT_alter_table_add_columns node. +type PTAlterTableAddColumns struct{ N *Node } + +// AsPTAlterTableAddColumns returns the view when v is a PT_alter_table_add_columns. +func AsPTAlterTableAddColumns(v Value) (PTAlterTableAddColumns, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_add_columns" { + return PTAlterTableAddColumns{}, false + } + return PTAlterTableAddColumns{n}, true +} + +func (x PTAlterTableAddColumns) Columns() Value { return x.N.Arg("columns") } + +// PTAlterTableAddConstraint views a PT_alter_table_add_constraint node. +type PTAlterTableAddConstraint struct{ N *Node } + +// AsPTAlterTableAddConstraint returns the view when v is a PT_alter_table_add_constraint. +func AsPTAlterTableAddConstraint(v Value) (PTAlterTableAddConstraint, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_add_constraint" { + return PTAlterTableAddConstraint{}, false + } + return PTAlterTableAddConstraint{n}, true +} + +func (x PTAlterTableAddConstraint) Constraint() Value { return x.N.Arg("constraint") } + +// PTAlterTableChangeColumn views a PT_alter_table_change_column node. +type PTAlterTableChangeColumn struct{ N *Node } + +// AsPTAlterTableChangeColumn returns the view when v is a PT_alter_table_change_column. +func AsPTAlterTableChangeColumn(v Value) (PTAlterTableChangeColumn, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_change_column" { + return PTAlterTableChangeColumn{}, false + } + return PTAlterTableChangeColumn{n}, true +} + +func (x PTAlterTableChangeColumn) OldName() Value { return x.N.Arg("old_name") } +func (x PTAlterTableChangeColumn) NewName() Value { return x.N.Arg("new_name") } +func (x PTAlterTableChangeColumn) FieldDef() Value { return x.N.Arg("field_def") } +func (x PTAlterTableChangeColumn) OptPlace() Value { return x.N.Arg("opt_place") } +func (x PTAlterTableChangeColumn) Name() Value { return x.N.Arg("name") } + +// PTAlterTableDropColumn views a PT_alter_table_drop_column node. +type PTAlterTableDropColumn struct{ N *Node } + +// AsPTAlterTableDropColumn returns the view when v is a PT_alter_table_drop_column. +func AsPTAlterTableDropColumn(v Value) (PTAlterTableDropColumn, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_drop_column" { + return PTAlterTableDropColumn{}, false + } + return PTAlterTableDropColumn{n}, true +} + +func (x PTAlterTableDropColumn) Name() Value { return x.N.Arg("name") } + +// PTAlterTableDropForeignKey views a PT_alter_table_drop_foreign_key node. +type PTAlterTableDropForeignKey struct{ N *Node } + +// AsPTAlterTableDropForeignKey returns the view when v is a PT_alter_table_drop_foreign_key. +func AsPTAlterTableDropForeignKey(v Value) (PTAlterTableDropForeignKey, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_drop_foreign_key" { + return PTAlterTableDropForeignKey{}, false + } + return PTAlterTableDropForeignKey{n}, true +} + +func (x PTAlterTableDropForeignKey) Name() Value { return x.N.Arg("name") } + +// PTAlterTableDropKey views a PT_alter_table_drop_key node. +type PTAlterTableDropKey struct{ N *Node } + +// AsPTAlterTableDropKey returns the view when v is a PT_alter_table_drop_key. +func AsPTAlterTableDropKey(v Value) (PTAlterTableDropKey, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_drop_key" { + return PTAlterTableDropKey{}, false + } + return PTAlterTableDropKey{n}, true +} + +func (x PTAlterTableDropKey) Name() Value { return x.N.Arg("name") } + +// PTAlterTableDropCheckConstraint views a PT_alter_table_drop_check_constraint node. +type PTAlterTableDropCheckConstraint struct{ N *Node } + +// AsPTAlterTableDropCheckConstraint returns the view when v is a PT_alter_table_drop_check_constraint. +func AsPTAlterTableDropCheckConstraint(v Value) (PTAlterTableDropCheckConstraint, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_drop_check_constraint" { + return PTAlterTableDropCheckConstraint{}, false + } + return PTAlterTableDropCheckConstraint{n}, true +} + +func (x PTAlterTableDropCheckConstraint) Name() Value { return x.N.Arg("name") } + +// PTAlterTableDropConstraint views a PT_alter_table_drop_constraint node. +type PTAlterTableDropConstraint struct{ N *Node } + +// AsPTAlterTableDropConstraint returns the view when v is a PT_alter_table_drop_constraint. +func AsPTAlterTableDropConstraint(v Value) (PTAlterTableDropConstraint, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_drop_constraint" { + return PTAlterTableDropConstraint{}, false + } + return PTAlterTableDropConstraint{n}, true +} + +func (x PTAlterTableDropConstraint) Name() Value { return x.N.Arg("name") } + +// PTAlterTableEnableKeys views a PT_alter_table_enable_keys node. +type PTAlterTableEnableKeys struct{ N *Node } + +// AsPTAlterTableEnableKeys returns the view when v is a PT_alter_table_enable_keys. +func AsPTAlterTableEnableKeys(v Value) (PTAlterTableEnableKeys, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_enable_keys" { + return PTAlterTableEnableKeys{}, false + } + return PTAlterTableEnableKeys{n}, true +} + +func (x PTAlterTableEnableKeys) Enable() Value { return x.N.Arg("enable") } + +// PTAlterTableSetDefault views a PT_alter_table_set_default node. +type PTAlterTableSetDefault struct{ N *Node } + +// AsPTAlterTableSetDefault returns the view when v is a PT_alter_table_set_default. +func AsPTAlterTableSetDefault(v Value) (PTAlterTableSetDefault, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_set_default" { + return PTAlterTableSetDefault{}, false + } + return PTAlterTableSetDefault{n}, true +} + +func (x PTAlterTableSetDefault) ColName() Value { return x.N.Arg("col_name") } +func (x PTAlterTableSetDefault) OptDefaultExpr() Value { return x.N.Arg("opt_default_expr") } + +// PTAlterTableColumnVisibility views a PT_alter_table_column_visibility node. +type PTAlterTableColumnVisibility struct{ N *Node } + +// AsPTAlterTableColumnVisibility returns the view when v is a PT_alter_table_column_visibility. +func AsPTAlterTableColumnVisibility(v Value) (PTAlterTableColumnVisibility, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_column_visibility" { + return PTAlterTableColumnVisibility{}, false + } + return PTAlterTableColumnVisibility{n}, true +} + +func (x PTAlterTableColumnVisibility) ColName() Value { return x.N.Arg("col_name") } +func (x PTAlterTableColumnVisibility) IsVisible() Value { return x.N.Arg("is_visible") } + +// PTAlterTableIndexVisible views a PT_alter_table_index_visible node. +type PTAlterTableIndexVisible struct{ N *Node } + +// AsPTAlterTableIndexVisible returns the view when v is a PT_alter_table_index_visible. +func AsPTAlterTableIndexVisible(v Value) (PTAlterTableIndexVisible, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_index_visible" { + return PTAlterTableIndexVisible{}, false + } + return PTAlterTableIndexVisible{n}, true +} + +func (x PTAlterTableIndexVisible) Name() Value { return x.N.Arg("name") } +func (x PTAlterTableIndexVisible) Visible() Value { return x.N.Arg("visible") } + +// PTAlterTableEnforceCheckConstraint views a PT_alter_table_enforce_check_constraint node. +type PTAlterTableEnforceCheckConstraint struct{ N *Node } + +// AsPTAlterTableEnforceCheckConstraint returns the view when v is a PT_alter_table_enforce_check_constraint. +func AsPTAlterTableEnforceCheckConstraint(v Value) (PTAlterTableEnforceCheckConstraint, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_enforce_check_constraint" { + return PTAlterTableEnforceCheckConstraint{}, false + } + return PTAlterTableEnforceCheckConstraint{n}, true +} + +func (x PTAlterTableEnforceCheckConstraint) Name() Value { return x.N.Arg("name") } +func (x PTAlterTableEnforceCheckConstraint) IsEnforced() Value { return x.N.Arg("is_enforced") } + +// PTAlterTableEnforceConstraint views a PT_alter_table_enforce_constraint node. +type PTAlterTableEnforceConstraint struct{ N *Node } + +// AsPTAlterTableEnforceConstraint returns the view when v is a PT_alter_table_enforce_constraint. +func AsPTAlterTableEnforceConstraint(v Value) (PTAlterTableEnforceConstraint, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_enforce_constraint" { + return PTAlterTableEnforceConstraint{}, false + } + return PTAlterTableEnforceConstraint{n}, true +} + +func (x PTAlterTableEnforceConstraint) Name() Value { return x.N.Arg("name") } +func (x PTAlterTableEnforceConstraint) IsEnforced() Value { return x.N.Arg("is_enforced") } + +// PTAlterTableRename views a PT_alter_table_rename node. +type PTAlterTableRename struct{ N *Node } + +// AsPTAlterTableRename returns the view when v is a PT_alter_table_rename. +func AsPTAlterTableRename(v Value) (PTAlterTableRename, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_rename" { + return PTAlterTableRename{}, false + } + return PTAlterTableRename{n}, true +} + +func (x PTAlterTableRename) Ident() Value { return x.N.Arg("ident") } + +// PTAlterTableRenameKey views a PT_alter_table_rename_key node. +type PTAlterTableRenameKey struct{ N *Node } + +// AsPTAlterTableRenameKey returns the view when v is a PT_alter_table_rename_key. +func AsPTAlterTableRenameKey(v Value) (PTAlterTableRenameKey, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_rename_key" { + return PTAlterTableRenameKey{}, false + } + return PTAlterTableRenameKey{n}, true +} + +func (x PTAlterTableRenameKey) From() Value { return x.N.Arg("from") } +func (x PTAlterTableRenameKey) To() Value { return x.N.Arg("to") } + +// PTAlterTableRenameColumn views a PT_alter_table_rename_column node. +type PTAlterTableRenameColumn struct{ N *Node } + +// AsPTAlterTableRenameColumn returns the view when v is a PT_alter_table_rename_column. +func AsPTAlterTableRenameColumn(v Value) (PTAlterTableRenameColumn, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_rename_column" { + return PTAlterTableRenameColumn{}, false + } + return PTAlterTableRenameColumn{n}, true +} + +func (x PTAlterTableRenameColumn) From() Value { return x.N.Arg("from") } +func (x PTAlterTableRenameColumn) To() Value { return x.N.Arg("to") } + +// PTAlterTableConvertToCharset views a PT_alter_table_convert_to_charset node. +type PTAlterTableConvertToCharset struct{ N *Node } + +// AsPTAlterTableConvertToCharset returns the view when v is a PT_alter_table_convert_to_charset. +func AsPTAlterTableConvertToCharset(v Value) (PTAlterTableConvertToCharset, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_convert_to_charset" { + return PTAlterTableConvertToCharset{}, false + } + return PTAlterTableConvertToCharset{n}, true +} + +func (x PTAlterTableConvertToCharset) Charset() Value { return x.N.Arg("charset") } +func (x PTAlterTableConvertToCharset) OptCollation() Value { return x.N.Arg("opt_collation") } + +// PTAlterTableForce views a PT_alter_table_force node. +type PTAlterTableForce struct{ N *Node } + +// AsPTAlterTableForce returns the view when v is a PT_alter_table_force. +func AsPTAlterTableForce(v Value) (PTAlterTableForce, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_force" { + return PTAlterTableForce{}, false + } + return PTAlterTableForce{n}, true +} + +// PTAlterTableOrder views a PT_alter_table_order node. +type PTAlterTableOrder struct{ N *Node } + +// AsPTAlterTableOrder returns the view when v is a PT_alter_table_order. +func AsPTAlterTableOrder(v Value) (PTAlterTableOrder, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_table_order" { + return PTAlterTableOrder{}, false + } + return PTAlterTableOrder{n}, true +} + +func (x PTAlterTableOrder) Order() Value { return x.N.Arg("order") } + +// PTRepairTableStmt views a PT_repair_table_stmt node. +type PTRepairTableStmt struct{ N *Node } + +// AsPTRepairTableStmt returns the view when v is a PT_repair_table_stmt. +func AsPTRepairTableStmt(v Value) (PTRepairTableStmt, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_repair_table_stmt" { + return PTRepairTableStmt{}, false + } + return PTRepairTableStmt{n}, true +} + +func (x PTRepairTableStmt) NoWriteToBinlog() Value { return x.N.Arg("no_write_to_binlog") } +func (x PTRepairTableStmt) TableList() Value { return x.N.Arg("table_list") } +func (x PTRepairTableStmt) Flags() Value { return x.N.Arg("flags") } +func (x PTRepairTableStmt) SqlFlags() Value { return x.N.Arg("sql_flags") } + +// PTCheckTableStmt views a PT_check_table_stmt node. +type PTCheckTableStmt struct{ N *Node } + +// AsPTCheckTableStmt returns the view when v is a PT_check_table_stmt. +func AsPTCheckTableStmt(v Value) (PTCheckTableStmt, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_check_table_stmt" { + return PTCheckTableStmt{}, false + } + return PTCheckTableStmt{n}, true +} + +func (x PTCheckTableStmt) TableList() Value { return x.N.Arg("table_list") } +func (x PTCheckTableStmt) Flags() Value { return x.N.Arg("flags") } +func (x PTCheckTableStmt) SqlFlags() Value { return x.N.Arg("sql_flags") } + +// PTOptimizeTableStmt views a PT_optimize_table_stmt node. +type PTOptimizeTableStmt struct{ N *Node } + +// AsPTOptimizeTableStmt returns the view when v is a PT_optimize_table_stmt. +func AsPTOptimizeTableStmt(v Value) (PTOptimizeTableStmt, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_optimize_table_stmt" { + return PTOptimizeTableStmt{}, false + } + return PTOptimizeTableStmt{n}, true +} + +func (x PTOptimizeTableStmt) NoWriteToBinlog() Value { return x.N.Arg("no_write_to_binlog") } +func (x PTOptimizeTableStmt) TableList() Value { return x.N.Arg("table_list") } + +// PTCacheIndexStmt views a PT_cache_index_stmt node. +type PTCacheIndexStmt struct{ N *Node } + +// AsPTCacheIndexStmt returns the view when v is a PT_cache_index_stmt. +func AsPTCacheIndexStmt(v Value) (PTCacheIndexStmt, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_cache_index_stmt" { + return PTCacheIndexStmt{}, false + } + return PTCacheIndexStmt{n}, true +} + +func (x PTCacheIndexStmt) TblIndexLists() Value { return x.N.Arg("tbl_index_lists") } +func (x PTCacheIndexStmt) KeyCacheName() Value { return x.N.Arg("key_cache_name") } + +// PTCacheIndexPartitionsStmt views a PT_cache_index_partitions_stmt node. +type PTCacheIndexPartitionsStmt struct{ N *Node } + +// AsPTCacheIndexPartitionsStmt returns the view when v is a PT_cache_index_partitions_stmt. +func AsPTCacheIndexPartitionsStmt(v Value) (PTCacheIndexPartitionsStmt, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_cache_index_partitions_stmt" { + return PTCacheIndexPartitionsStmt{}, false + } + return PTCacheIndexPartitionsStmt{n}, true +} + +func (x PTCacheIndexPartitionsStmt) Table() Value { return x.N.Arg("table") } +func (x PTCacheIndexPartitionsStmt) Partitions() Value { return x.N.Arg("partitions") } +func (x PTCacheIndexPartitionsStmt) OptKeyUsageList() Value { return x.N.Arg("opt_key_usage_list") } +func (x PTCacheIndexPartitionsStmt) KeyCacheName() Value { return x.N.Arg("key_cache_name") } + +// PTAssignToKeycache views a PT_assign_to_keycache node. +type PTAssignToKeycache struct{ N *Node } + +// AsPTAssignToKeycache returns the view when v is a PT_assign_to_keycache. +func AsPTAssignToKeycache(v Value) (PTAssignToKeycache, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_assign_to_keycache" { + return PTAssignToKeycache{}, false + } + return PTAssignToKeycache{n}, true +} + +func (x PTAssignToKeycache) Table() Value { return x.N.Arg("table") } +func (x PTAssignToKeycache) IndexHints() Value { return x.N.Arg("index_hints") } + +// PTLoadIndexPartitionsStmt views a PT_load_index_partitions_stmt node. +type PTLoadIndexPartitionsStmt struct{ N *Node } + +// AsPTLoadIndexPartitionsStmt returns the view when v is a PT_load_index_partitions_stmt. +func AsPTLoadIndexPartitionsStmt(v Value) (PTLoadIndexPartitionsStmt, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_load_index_partitions_stmt" { + return PTLoadIndexPartitionsStmt{}, false + } + return PTLoadIndexPartitionsStmt{n}, true +} + +func (x PTLoadIndexPartitionsStmt) Table() Value { return x.N.Arg("table") } +func (x PTLoadIndexPartitionsStmt) Partitions() Value { return x.N.Arg("partitions") } +func (x PTLoadIndexPartitionsStmt) OptCacheKeyList() Value { return x.N.Arg("opt_cache_key_list") } +func (x PTLoadIndexPartitionsStmt) IgnoreLeaves() Value { return x.N.Arg("ignore_leaves") } + +// PTLoadIndexStmt views a PT_load_index_stmt node. +type PTLoadIndexStmt struct{ N *Node } + +// AsPTLoadIndexStmt returns the view when v is a PT_load_index_stmt. +func AsPTLoadIndexStmt(v Value) (PTLoadIndexStmt, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_load_index_stmt" { + return PTLoadIndexStmt{}, false + } + return PTLoadIndexStmt{n}, true +} + +func (x PTLoadIndexStmt) PreloadList() Value { return x.N.Arg("preload_list") } + +// PTPreloadKeys views a PT_preload_keys node. +type PTPreloadKeys struct{ N *Node } + +// AsPTPreloadKeys returns the view when v is a PT_preload_keys. +func AsPTPreloadKeys(v Value) (PTPreloadKeys, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_preload_keys" { + return PTPreloadKeys{}, false + } + return PTPreloadKeys{n}, true +} + +func (x PTPreloadKeys) Table() Value { return x.N.Arg("table") } +func (x PTPreloadKeys) OptCacheKeyList() Value { return x.N.Arg("opt_cache_key_list") } +func (x PTPreloadKeys) IgnoreLeaves() Value { return x.N.Arg("ignore_leaves") } + +// PTAdmPartition views a PT_adm_partition node. +type PTAdmPartition struct{ N *Node } + +// AsPTAdmPartition returns the view when v is a PT_adm_partition. +func AsPTAdmPartition(v Value) (PTAdmPartition, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_adm_partition" { + return PTAdmPartition{}, false + } + return PTAdmPartition{n}, true +} + +func (x PTAdmPartition) OptPartitions() Value { return x.N.Arg("opt_partitions") } + +// PTSelectStmt views a PT_select_stmt node. +type PTSelectStmt struct{ N *Node } + +// AsPTSelectStmt returns the view when v is a PT_select_stmt. +func AsPTSelectStmt(v Value) (PTSelectStmt, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_select_stmt" { + return PTSelectStmt{}, false + } + return PTSelectStmt{n}, true +} + +func (x PTSelectStmt) Qe() Value { return x.N.Arg("qe") } +func (x PTSelectStmt) Into() Value { return x.N.Arg("into") } +func (x PTSelectStmt) HasTrailingLockingClauses() Value { + return x.N.Arg("has_trailing_locking_clauses") +} +func (x PTSelectStmt) SqlCommand() Value { return x.N.Arg("sql_command") } + +// PTQueryExpression views a PT_query_expression node. +type PTQueryExpression struct{ N *Node } + +// AsPTQueryExpression returns the view when v is a PT_query_expression. +func AsPTQueryExpression(v Value) (PTQueryExpression, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_query_expression" { + return PTQueryExpression{}, false + } + return PTQueryExpression{n}, true +} + +func (x PTQueryExpression) Body() Value { return x.N.Arg("body") } +func (x PTQueryExpression) Order() Value { return x.N.Arg("order") } +func (x PTQueryExpression) Limit() Value { return x.N.Arg("limit") } +func (x PTQueryExpression) WithClause() Value { return x.N.Arg("with_clause") } + +// PTTableValueConstructor views a PT_table_value_constructor node. +type PTTableValueConstructor struct{ N *Node } + +// AsPTTableValueConstructor returns the view when v is a PT_table_value_constructor. +func AsPTTableValueConstructor(v Value) (PTTableValueConstructor, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_table_value_constructor" { + return PTTableValueConstructor{}, false + } + return PTTableValueConstructor{n}, true +} + +func (x PTTableValueConstructor) RowValueList() Value { return x.N.Arg("row_value_list") } + +// PTQuerySpecification views a PT_query_specification node. +type PTQuerySpecification struct{ N *Node } + +// AsPTQuerySpecification returns the view when v is a PT_query_specification. +func AsPTQuerySpecification(v Value) (PTQuerySpecification, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_query_specification" { + return PTQuerySpecification{}, false + } + return PTQuerySpecification{n}, true +} + +func (x PTQuerySpecification) OptHints() Value { return x.N.Arg("opt_hints") } +func (x PTQuerySpecification) Options() Value { return x.N.Arg("options") } +func (x PTQuerySpecification) ItemList() Value { return x.N.Arg("item_list") } +func (x PTQuerySpecification) OptInto1() Value { return x.N.Arg("opt_into1") } +func (x PTQuerySpecification) FromClause() Value { return x.N.Arg("from_clause") } +func (x PTQuerySpecification) OptWhereClause() Value { return x.N.Arg("opt_where_clause") } +func (x PTQuerySpecification) OptGroupClause() Value { return x.N.Arg("opt_group_clause") } +func (x PTQuerySpecification) OptHavingClause() Value { return x.N.Arg("opt_having_clause") } +func (x PTQuerySpecification) OptWindowClause() Value { return x.N.Arg("opt_window_clause") } +func (x PTQuerySpecification) OptQualifyClause() Value { return x.N.Arg("opt_qualify_clause") } + +// PTQueryBlockLockingClause views a PT_query_block_locking_clause node. +type PTQueryBlockLockingClause struct{ N *Node } + +// AsPTQueryBlockLockingClause returns the view when v is a PT_query_block_locking_clause. +func AsPTQueryBlockLockingClause(v Value) (PTQueryBlockLockingClause, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_query_block_locking_clause" { + return PTQueryBlockLockingClause{}, false + } + return PTQueryBlockLockingClause{n}, true +} + +func (x PTQueryBlockLockingClause) Strength() Value { return x.N.Arg("strength") } +func (x PTQueryBlockLockingClause) Action() Value { return x.N.Arg("action") } + +// PTTableLockingClause views a PT_table_locking_clause node. +type PTTableLockingClause struct{ N *Node } + +// AsPTTableLockingClause returns the view when v is a PT_table_locking_clause. +func AsPTTableLockingClause(v Value) (PTTableLockingClause, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_table_locking_clause" { + return PTTableLockingClause{}, false + } + return PTTableLockingClause{n}, true +} + +func (x PTTableLockingClause) Strength() Value { return x.N.Arg("strength") } +func (x PTTableLockingClause) Tables() Value { return x.N.Arg("tables") } +func (x PTTableLockingClause) Action() Value { return x.N.Arg("action") } + +// PTIExprWithAlias views a PTI_expr_with_alias node. +type PTIExprWithAlias struct{ N *Node } + +// AsPTIExprWithAlias returns the view when v is a PTI_expr_with_alias. +func AsPTIExprWithAlias(v Value) (PTIExprWithAlias, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_expr_with_alias" { + return PTIExprWithAlias{}, false + } + return PTIExprWithAlias{n}, true +} + +func (x PTIExprWithAlias) Expr() Value { return x.N.Arg("expr") } +func (x PTIExprWithAlias) Alias() Value { return x.N.Arg("alias") } + +// ItemCondOr views a Item_cond_or node. +type ItemCondOr struct{ N *Node } + +// AsItemCondOr returns the view when v is a Item_cond_or. +func AsItemCondOr(v Value) (ItemCondOr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_cond_or" { + return ItemCondOr{}, false + } + return ItemCondOr{n}, true +} + +func (x ItemCondOr) I1() Value { return x.N.Arg("i1") } +func (x ItemCondOr) I2() Value { return x.N.Arg("i2") } + +// ItemFuncXor views a Item_func_xor node. +type ItemFuncXor struct{ N *Node } + +// AsItemFuncXor returns the view when v is a Item_func_xor. +func AsItemFuncXor(v Value) (ItemFuncXor, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_xor" { + return ItemFuncXor{}, false + } + return ItemFuncXor{n}, true +} + +func (x ItemFuncXor) I1() Value { return x.N.Arg("i1") } +func (x ItemFuncXor) I2() Value { return x.N.Arg("i2") } + +// ItemCondAnd views a Item_cond_and node. +type ItemCondAnd struct{ N *Node } + +// AsItemCondAnd returns the view when v is a Item_cond_and. +func AsItemCondAnd(v Value) (ItemCondAnd, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_cond_and" { + return ItemCondAnd{}, false + } + return ItemCondAnd{n}, true +} + +func (x ItemCondAnd) I1() Value { return x.N.Arg("i1") } +func (x ItemCondAnd) I2() Value { return x.N.Arg("i2") } + +// PTITruthTransform views a PTI_truth_transform node. +type PTITruthTransform struct{ N *Node } + +// AsPTITruthTransform returns the view when v is a PTI_truth_transform. +func AsPTITruthTransform(v Value) (PTITruthTransform, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_truth_transform" { + return PTITruthTransform{}, false + } + return PTITruthTransform{n}, true +} + +func (x PTITruthTransform) Expr() Value { return x.N.Arg("expr") } +func (x PTITruthTransform) TruthTest() Value { return x.N.Arg("truth_test") } + +// ItemFuncIsnull views a Item_func_isnull node. +type ItemFuncIsnull struct{ N *Node } + +// AsItemFuncIsnull returns the view when v is a Item_func_isnull. +func AsItemFuncIsnull(v Value) (ItemFuncIsnull, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_isnull" { + return ItemFuncIsnull{}, false + } + return ItemFuncIsnull{n}, true +} + +func (x ItemFuncIsnull) A() Value { return x.N.Arg("a") } + +// ItemFuncIsnotnull views a Item_func_isnotnull node. +type ItemFuncIsnotnull struct{ N *Node } + +// AsItemFuncIsnotnull returns the view when v is a Item_func_isnotnull. +func AsItemFuncIsnotnull(v Value) (ItemFuncIsnotnull, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_isnotnull" { + return ItemFuncIsnotnull{}, false + } + return ItemFuncIsnotnull{n}, true +} + +func (x ItemFuncIsnotnull) A() Value { return x.N.Arg("a") } + +// PTICompOp views a PTI_comp_op node. +type PTICompOp struct{ N *Node } + +// AsPTICompOp returns the view when v is a PTI_comp_op. +func AsPTICompOp(v Value) (PTICompOp, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_comp_op" { + return PTICompOp{}, false + } + return PTICompOp{n}, true +} + +func (x PTICompOp) Left() Value { return x.N.Arg("left") } +func (x PTICompOp) Boolfunc2creator() Value { return x.N.Arg("boolfunc2creator") } +func (x PTICompOp) Right() Value { return x.N.Arg("right") } + +// ItemInSubselect views a Item_in_subselect node. +type ItemInSubselect struct{ N *Node } + +// AsItemInSubselect returns the view when v is a Item_in_subselect. +func AsItemInSubselect(v Value) (ItemInSubselect, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_in_subselect" { + return ItemInSubselect{}, false + } + return ItemInSubselect{n}, true +} + +func (x ItemInSubselect) LeftExpr() Value { return x.N.Arg("left_expr") } +func (x ItemInSubselect) PtSubquery() Value { return x.N.Arg("pt_subquery") } + +// PTIHandleSql2003Note184Exception views a PTI_handle_sql2003_note184_exception node. +type PTIHandleSql2003Note184Exception struct{ N *Node } + +// AsPTIHandleSql2003Note184Exception returns the view when v is a PTI_handle_sql2003_note184_exception. +func AsPTIHandleSql2003Note184Exception(v Value) (PTIHandleSql2003Note184Exception, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_handle_sql2003_note184_exception" { + return PTIHandleSql2003Note184Exception{}, false + } + return PTIHandleSql2003Note184Exception{n}, true +} + +func (x PTIHandleSql2003Note184Exception) Left() Value { return x.N.Arg("left") } +func (x PTIHandleSql2003Note184Exception) IsNegation() Value { return x.N.Arg("is_negation") } +func (x PTIHandleSql2003Note184Exception) Right() Value { return x.N.Arg("right") } + +// ItemFuncMemberOf views a Item_func_member_of node. +type ItemFuncMemberOf struct{ N *Node } + +// AsItemFuncMemberOf returns the view when v is a Item_func_member_of. +func AsItemFuncMemberOf(v Value) (ItemFuncMemberOf, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_member_of" { + return ItemFuncMemberOf{}, false + } + return ItemFuncMemberOf{n}, true +} + +func (x ItemFuncMemberOf) A() Value { return x.N.Arg("a") } +func (x ItemFuncMemberOf) B() Value { return x.N.Arg("b") } + +// ItemFuncBetween views a Item_func_between node. +type ItemFuncBetween struct{ N *Node } + +// AsItemFuncBetween returns the view when v is a Item_func_between. +func AsItemFuncBetween(v Value) (ItemFuncBetween, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_between" { + return ItemFuncBetween{}, false + } + return ItemFuncBetween{n}, true +} + +func (x ItemFuncBetween) A() Value { return x.N.Arg("a") } +func (x ItemFuncBetween) B() Value { return x.N.Arg("b") } +func (x ItemFuncBetween) C() Value { return x.N.Arg("c") } +func (x ItemFuncBetween) IsNegation() Value { return x.N.Arg("is_negation") } + +// ItemFuncLike views a Item_func_like node. +type ItemFuncLike struct{ N *Node } + +// AsItemFuncLike returns the view when v is a Item_func_like. +func AsItemFuncLike(v Value) (ItemFuncLike, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_like" { + return ItemFuncLike{}, false + } + return ItemFuncLike{n}, true +} + +func (x ItemFuncLike) A() Value { return x.N.Arg("a") } +func (x ItemFuncLike) B() Value { return x.N.Arg("b") } +func (x ItemFuncLike) Escape() Value { return x.N.Arg("escape") } + +// ItemFuncBitOr views a Item_func_bit_or node. +type ItemFuncBitOr struct{ N *Node } + +// AsItemFuncBitOr returns the view when v is a Item_func_bit_or. +func AsItemFuncBitOr(v Value) (ItemFuncBitOr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_bit_or" { + return ItemFuncBitOr{}, false + } + return ItemFuncBitOr{n}, true +} + +func (x ItemFuncBitOr) A() Value { return x.N.Arg("a") } +func (x ItemFuncBitOr) B() Value { return x.N.Arg("b") } + +// ItemFuncBitAnd views a Item_func_bit_and node. +type ItemFuncBitAnd struct{ N *Node } + +// AsItemFuncBitAnd returns the view when v is a Item_func_bit_and. +func AsItemFuncBitAnd(v Value) (ItemFuncBitAnd, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_bit_and" { + return ItemFuncBitAnd{}, false + } + return ItemFuncBitAnd{n}, true +} + +func (x ItemFuncBitAnd) A() Value { return x.N.Arg("a") } +func (x ItemFuncBitAnd) B() Value { return x.N.Arg("b") } + +// ItemFuncShiftLeft views a Item_func_shift_left node. +type ItemFuncShiftLeft struct{ N *Node } + +// AsItemFuncShiftLeft returns the view when v is a Item_func_shift_left. +func AsItemFuncShiftLeft(v Value) (ItemFuncShiftLeft, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_shift_left" { + return ItemFuncShiftLeft{}, false + } + return ItemFuncShiftLeft{n}, true +} + +func (x ItemFuncShiftLeft) A() Value { return x.N.Arg("a") } +func (x ItemFuncShiftLeft) B() Value { return x.N.Arg("b") } + +// ItemFuncShiftRight views a Item_func_shift_right node. +type ItemFuncShiftRight struct{ N *Node } + +// AsItemFuncShiftRight returns the view when v is a Item_func_shift_right. +func AsItemFuncShiftRight(v Value) (ItemFuncShiftRight, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_shift_right" { + return ItemFuncShiftRight{}, false + } + return ItemFuncShiftRight{n}, true +} + +func (x ItemFuncShiftRight) A() Value { return x.N.Arg("a") } +func (x ItemFuncShiftRight) B() Value { return x.N.Arg("b") } + +// ItemFuncPlus views a Item_func_plus node. +type ItemFuncPlus struct{ N *Node } + +// AsItemFuncPlus returns the view when v is a Item_func_plus. +func AsItemFuncPlus(v Value) (ItemFuncPlus, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_plus" { + return ItemFuncPlus{}, false + } + return ItemFuncPlus{n}, true +} + +func (x ItemFuncPlus) A() Value { return x.N.Arg("a") } +func (x ItemFuncPlus) B() Value { return x.N.Arg("b") } + +// ItemFuncMinus views a Item_func_minus node. +type ItemFuncMinus struct{ N *Node } + +// AsItemFuncMinus returns the view when v is a Item_func_minus. +func AsItemFuncMinus(v Value) (ItemFuncMinus, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_minus" { + return ItemFuncMinus{}, false + } + return ItemFuncMinus{n}, true +} + +func (x ItemFuncMinus) A() Value { return x.N.Arg("a") } +func (x ItemFuncMinus) B() Value { return x.N.Arg("b") } + +// ItemDateAddInterval views a Item_date_add_interval node. +type ItemDateAddInterval struct{ N *Node } + +// AsItemDateAddInterval returns the view when v is a Item_date_add_interval. +func AsItemDateAddInterval(v Value) (ItemDateAddInterval, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_date_add_interval" { + return ItemDateAddInterval{}, false + } + return ItemDateAddInterval{n}, true +} + +func (x ItemDateAddInterval) A() Value { return x.N.Arg("a") } +func (x ItemDateAddInterval) B() Value { return x.N.Arg("b") } +func (x ItemDateAddInterval) Type() Value { return x.N.Arg("type") } +func (x ItemDateAddInterval) Subtract() Value { return x.N.Arg("subtract") } + +// ItemFuncMul views a Item_func_mul node. +type ItemFuncMul struct{ N *Node } + +// AsItemFuncMul returns the view when v is a Item_func_mul. +func AsItemFuncMul(v Value) (ItemFuncMul, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_mul" { + return ItemFuncMul{}, false + } + return ItemFuncMul{n}, true +} + +func (x ItemFuncMul) A() Value { return x.N.Arg("a") } +func (x ItemFuncMul) B() Value { return x.N.Arg("b") } + +// ItemFuncDiv views a Item_func_div node. +type ItemFuncDiv struct{ N *Node } + +// AsItemFuncDiv returns the view when v is a Item_func_div. +func AsItemFuncDiv(v Value) (ItemFuncDiv, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_div" { + return ItemFuncDiv{}, false + } + return ItemFuncDiv{n}, true +} + +func (x ItemFuncDiv) A() Value { return x.N.Arg("a") } +func (x ItemFuncDiv) B() Value { return x.N.Arg("b") } + +// ItemFuncMod views a Item_func_mod node. +type ItemFuncMod struct{ N *Node } + +// AsItemFuncMod returns the view when v is a Item_func_mod. +func AsItemFuncMod(v Value) (ItemFuncMod, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_mod" { + return ItemFuncMod{}, false + } + return ItemFuncMod{n}, true +} + +func (x ItemFuncMod) A() Value { return x.N.Arg("a") } +func (x ItemFuncMod) B() Value { return x.N.Arg("b") } + +// ItemFuncDivInt views a Item_func_div_int node. +type ItemFuncDivInt struct{ N *Node } + +// AsItemFuncDivInt returns the view when v is a Item_func_div_int. +func AsItemFuncDivInt(v Value) (ItemFuncDivInt, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_div_int" { + return ItemFuncDivInt{}, false + } + return ItemFuncDivInt{n}, true +} + +func (x ItemFuncDivInt) A() Value { return x.N.Arg("a") } +func (x ItemFuncDivInt) B() Value { return x.N.Arg("b") } + +// ItemFuncBitXor views a Item_func_bit_xor node. +type ItemFuncBitXor struct{ N *Node } + +// AsItemFuncBitXor returns the view when v is a Item_func_bit_xor. +func AsItemFuncBitXor(v Value) (ItemFuncBitXor, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_bit_xor" { + return ItemFuncBitXor{}, false + } + return ItemFuncBitXor{n}, true +} + +func (x ItemFuncBitXor) A() Value { return x.N.Arg("a") } +func (x ItemFuncBitXor) B() Value { return x.N.Arg("b") } + +// ItemFuncSetCollation views a Item_func_set_collation node. +type ItemFuncSetCollation struct{ N *Node } + +// AsItemFuncSetCollation returns the view when v is a Item_func_set_collation. +func AsItemFuncSetCollation(v Value) (ItemFuncSetCollation, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_set_collation" { + return ItemFuncSetCollation{}, false + } + return ItemFuncSetCollation{n}, true +} + +func (x ItemFuncSetCollation) A() Value { return x.N.Arg("a") } +func (x ItemFuncSetCollation) CollationString() Value { return x.N.Arg("collation_string") } + +// ItemFuncConcat views a Item_func_concat node. +type ItemFuncConcat struct{ N *Node } + +// AsItemFuncConcat returns the view when v is a Item_func_concat. +func AsItemFuncConcat(v Value) (ItemFuncConcat, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_concat" { + return ItemFuncConcat{}, false + } + return ItemFuncConcat{n}, true +} + +func (x ItemFuncConcat) A() Value { return x.N.Arg("a") } +func (x ItemFuncConcat) B() Value { return x.N.Arg("b") } + +// ItemFuncNeg views a Item_func_neg node. +type ItemFuncNeg struct{ N *Node } + +// AsItemFuncNeg returns the view when v is a Item_func_neg. +func AsItemFuncNeg(v Value) (ItemFuncNeg, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_neg" { + return ItemFuncNeg{}, false + } + return ItemFuncNeg{n}, true +} + +func (x ItemFuncNeg) A() Value { return x.N.Arg("a") } + +// ItemFuncBitNeg views a Item_func_bit_neg node. +type ItemFuncBitNeg struct{ N *Node } + +// AsItemFuncBitNeg returns the view when v is a Item_func_bit_neg. +func AsItemFuncBitNeg(v Value) (ItemFuncBitNeg, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_bit_neg" { + return ItemFuncBitNeg{}, false + } + return ItemFuncBitNeg{n}, true +} + +func (x ItemFuncBitNeg) A() Value { return x.N.Arg("a") } + +// PTISinglerowSubselect views a PTI_singlerow_subselect node. +type PTISinglerowSubselect struct{ N *Node } + +// AsPTISinglerowSubselect returns the view when v is a PTI_singlerow_subselect. +func AsPTISinglerowSubselect(v Value) (PTISinglerowSubselect, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_singlerow_subselect" { + return PTISinglerowSubselect{}, false + } + return PTISinglerowSubselect{n}, true +} + +func (x PTISinglerowSubselect) Subselect() Value { return x.N.Arg("subselect") } + +// ItemRow views a Item_row node. +type ItemRow struct{ N *Node } + +// AsItemRow returns the view when v is a Item_row. +func AsItemRow(v Value) (ItemRow, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_row" { + return ItemRow{}, false + } + return ItemRow{n}, true +} + +func (x ItemRow) Head() Value { return x.N.Arg("head") } +func (x ItemRow) Tail() Value { return x.N.Arg("tail") } + +// PTIExistsSubselect views a PTI_exists_subselect node. +type PTIExistsSubselect struct{ N *Node } + +// AsPTIExistsSubselect returns the view when v is a PTI_exists_subselect. +func AsPTIExistsSubselect(v Value) (PTIExistsSubselect, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_exists_subselect" { + return PTIExistsSubselect{}, false + } + return PTIExistsSubselect{n}, true +} + +func (x PTIExistsSubselect) Subselect() Value { return x.N.Arg("subselect") } + +// PTIOdbcDate views a PTI_odbc_date node. +type PTIOdbcDate struct{ N *Node } + +// AsPTIOdbcDate returns the view when v is a PTI_odbc_date. +func AsPTIOdbcDate(v Value) (PTIOdbcDate, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_odbc_date" { + return PTIOdbcDate{}, false + } + return PTIOdbcDate{n}, true +} + +func (x PTIOdbcDate) Ident() Value { return x.N.Arg("ident") } +func (x PTIOdbcDate) Expr() Value { return x.N.Arg("expr") } + +// ItemFuncMatch views a Item_func_match node. +type ItemFuncMatch struct{ N *Node } + +// AsItemFuncMatch returns the view when v is a Item_func_match. +func AsItemFuncMatch(v Value) (ItemFuncMatch, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_match" { + return ItemFuncMatch{}, false + } + return ItemFuncMatch{n}, true +} + +func (x ItemFuncMatch) A() Value { return x.N.Arg("a") } +func (x ItemFuncMatch) Against() Value { return x.N.Arg("against") } +func (x ItemFuncMatch) B() Value { return x.N.Arg("b") } + +// CreateFuncCast views a create_func_cast node. +type CreateFuncCast struct{ N *Node } + +// AsCreateFuncCast returns the view when v is a create_func_cast. +func AsCreateFuncCast(v Value) (CreateFuncCast, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "create_func_cast" { + return CreateFuncCast{}, false + } + return CreateFuncCast{n}, true +} + +func (x CreateFuncCast) Arg() Value { return x.N.Arg("arg") } +func (x CreateFuncCast) Type() Value { return x.N.Arg("type") } +func (x CreateFuncCast) AsArray() Value { return x.N.Arg("as_array") } + +// ItemFuncCase views a Item_func_case node. +type ItemFuncCase struct{ N *Node } + +// AsItemFuncCase returns the view when v is a Item_func_case. +func AsItemFuncCase(v Value) (ItemFuncCase, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_case" { + return ItemFuncCase{}, false + } + return ItemFuncCase{n}, true +} + +func (x ItemFuncCase) List() Value { return x.N.Arg("list") } +func (x ItemFuncCase) FirstExpr() Value { return x.N.Arg("first_expr") } +func (x ItemFuncCase) ElseExpr() Value { return x.N.Arg("else_expr") } + +// ItemFuncConvCharset views a Item_func_conv_charset node. +type ItemFuncConvCharset struct{ N *Node } + +// AsItemFuncConvCharset returns the view when v is a Item_func_conv_charset. +func AsItemFuncConvCharset(v Value) (ItemFuncConvCharset, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_conv_charset" { + return ItemFuncConvCharset{}, false + } + return ItemFuncConvCharset{n}, true +} + +func (x ItemFuncConvCharset) A() Value { return x.N.Arg("a") } +func (x ItemFuncConvCharset) Cs() Value { return x.N.Arg("cs") } + +// ItemDefaultValue views a Item_default_value node. +type ItemDefaultValue struct{ N *Node } + +// AsItemDefaultValue returns the view when v is a Item_default_value. +func AsItemDefaultValue(v Value) (ItemDefaultValue, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_default_value" { + return ItemDefaultValue{}, false + } + return ItemDefaultValue{n}, true +} + +func (x ItemDefaultValue) A() Value { return x.N.Arg("a") } + +// ItemInsertValue views a Item_insert_value node. +type ItemInsertValue struct{ N *Node } + +// AsItemInsertValue returns the view when v is a Item_insert_value. +func AsItemInsertValue(v Value) (ItemInsertValue, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_insert_value" { + return ItemInsertValue{}, false + } + return ItemInsertValue{n}, true +} + +func (x ItemInsertValue) A() Value { return x.N.Arg("a") } + +// ItemFuncChar views a Item_func_char node. +type ItemFuncChar struct{ N *Node } + +// AsItemFuncChar returns the view when v is a Item_func_char. +func AsItemFuncChar(v Value) (ItemFuncChar, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_char" { + return ItemFuncChar{}, false + } + return ItemFuncChar{n}, true +} + +func (x ItemFuncChar) List() Value { return x.N.Arg("list") } +func (x ItemFuncChar) Cs() Value { return x.N.Arg("cs") } + +// ItemFuncCurrentUser views a Item_func_current_user node. +type ItemFuncCurrentUser struct{ N *Node } + +// AsItemFuncCurrentUser returns the view when v is a Item_func_current_user. +func AsItemFuncCurrentUser(v Value) (ItemFuncCurrentUser, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_current_user" { + return ItemFuncCurrentUser{}, false + } + return ItemFuncCurrentUser{n}, true +} + +// ItemTypecastDate views a Item_typecast_date node. +type ItemTypecastDate struct{ N *Node } + +// AsItemTypecastDate returns the view when v is a Item_typecast_date. +func AsItemTypecastDate(v Value) (ItemTypecastDate, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_typecast_date" { + return ItemTypecastDate{}, false + } + return ItemTypecastDate{n}, true +} + +func (x ItemTypecastDate) A() Value { return x.N.Arg("a") } + +// ItemFuncDayofmonth views a Item_func_dayofmonth node. +type ItemFuncDayofmonth struct{ N *Node } + +// AsItemFuncDayofmonth returns the view when v is a Item_func_dayofmonth. +func AsItemFuncDayofmonth(v Value) (ItemFuncDayofmonth, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_dayofmonth" { + return ItemFuncDayofmonth{}, false + } + return ItemFuncDayofmonth{n}, true +} + +func (x ItemFuncDayofmonth) A() Value { return x.N.Arg("a") } + +// ItemFuncHour views a Item_func_hour node. +type ItemFuncHour struct{ N *Node } + +// AsItemFuncHour returns the view when v is a Item_func_hour. +func AsItemFuncHour(v Value) (ItemFuncHour, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_hour" { + return ItemFuncHour{}, false + } + return ItemFuncHour{n}, true +} + +func (x ItemFuncHour) A() Value { return x.N.Arg("a") } + +// ItemFuncInsert views a Item_func_insert node. +type ItemFuncInsert struct{ N *Node } + +// AsItemFuncInsert returns the view when v is a Item_func_insert. +func AsItemFuncInsert(v Value) (ItemFuncInsert, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_insert" { + return ItemFuncInsert{}, false + } + return ItemFuncInsert{n}, true +} + +func (x ItemFuncInsert) Org() Value { return x.N.Arg("org") } +func (x ItemFuncInsert) Start() Value { return x.N.Arg("start") } +func (x ItemFuncInsert) Length() Value { return x.N.Arg("length") } +func (x ItemFuncInsert) NewStr() Value { return x.N.Arg("new_str") } + +// ItemFuncInterval views a Item_func_interval node. +type ItemFuncInterval struct{ N *Node } + +// AsItemFuncInterval returns the view when v is a Item_func_interval. +func AsItemFuncInterval(v Value) (ItemFuncInterval, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_interval" { + return ItemFuncInterval{}, false + } + return ItemFuncInterval{n}, true +} + +func (x ItemFuncInterval) Expr1() Value { return x.N.Arg("expr1") } +func (x ItemFuncInterval) Expr2() Value { return x.N.Arg("expr2") } +func (x ItemFuncInterval) OptExprList() Value { return x.N.Arg("opt_expr_list") } + +// CreateFuncJsonValue views a create_func_json_value node. +type CreateFuncJsonValue struct{ N *Node } + +// AsCreateFuncJsonValue returns the view when v is a create_func_json_value. +func AsCreateFuncJsonValue(v Value) (CreateFuncJsonValue, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "create_func_json_value" { + return CreateFuncJsonValue{}, false + } + return CreateFuncJsonValue{n}, true +} + +func (x CreateFuncJsonValue) Arg() Value { return x.N.Arg("arg") } +func (x CreateFuncJsonValue) Path() Value { return x.N.Arg("path") } +func (x CreateFuncJsonValue) Type() Value { return x.N.Arg("type") } +func (x CreateFuncJsonValue) OnEmptyType() Value { return x.N.Arg("on_empty_type") } +func (x CreateFuncJsonValue) OnEmptyDefault() Value { return x.N.Arg("on_empty_default") } +func (x CreateFuncJsonValue) OnErrorType() Value { return x.N.Arg("on_error_type") } +func (x CreateFuncJsonValue) OnErrorDefault() Value { return x.N.Arg("on_error_default") } + +// ItemFuncLeft views a Item_func_left node. +type ItemFuncLeft struct{ N *Node } + +// AsItemFuncLeft returns the view when v is a Item_func_left. +func AsItemFuncLeft(v Value) (ItemFuncLeft, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_left" { + return ItemFuncLeft{}, false + } + return ItemFuncLeft{n}, true +} + +func (x ItemFuncLeft) A() Value { return x.N.Arg("a") } +func (x ItemFuncLeft) B() Value { return x.N.Arg("b") } + +// ItemFuncMinute views a Item_func_minute node. +type ItemFuncMinute struct{ N *Node } + +// AsItemFuncMinute returns the view when v is a Item_func_minute. +func AsItemFuncMinute(v Value) (ItemFuncMinute, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_minute" { + return ItemFuncMinute{}, false + } + return ItemFuncMinute{n}, true +} + +func (x ItemFuncMinute) A() Value { return x.N.Arg("a") } + +// ItemFuncMonth views a Item_func_month node. +type ItemFuncMonth struct{ N *Node } + +// AsItemFuncMonth returns the view when v is a Item_func_month. +func AsItemFuncMonth(v Value) (ItemFuncMonth, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_month" { + return ItemFuncMonth{}, false + } + return ItemFuncMonth{n}, true +} + +func (x ItemFuncMonth) A() Value { return x.N.Arg("a") } + +// ItemFuncRight views a Item_func_right node. +type ItemFuncRight struct{ N *Node } + +// AsItemFuncRight returns the view when v is a Item_func_right. +func AsItemFuncRight(v Value) (ItemFuncRight, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_right" { + return ItemFuncRight{}, false + } + return ItemFuncRight{n}, true +} + +func (x ItemFuncRight) A() Value { return x.N.Arg("a") } +func (x ItemFuncRight) B() Value { return x.N.Arg("b") } + +// ItemFuncSecond views a Item_func_second node. +type ItemFuncSecond struct{ N *Node } + +// AsItemFuncSecond returns the view when v is a Item_func_second. +func AsItemFuncSecond(v Value) (ItemFuncSecond, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_second" { + return ItemFuncSecond{}, false + } + return ItemFuncSecond{n}, true +} + +func (x ItemFuncSecond) A() Value { return x.N.Arg("a") } + +// ItemTypecastTime views a Item_typecast_time node. +type ItemTypecastTime struct{ N *Node } + +// AsItemTypecastTime returns the view when v is a Item_typecast_time. +func AsItemTypecastTime(v Value) (ItemTypecastTime, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_typecast_time" { + return ItemTypecastTime{}, false + } + return ItemTypecastTime{n}, true +} + +func (x ItemTypecastTime) A() Value { return x.N.Arg("a") } + +// ItemTypecastDatetime views a Item_typecast_datetime node. +type ItemTypecastDatetime struct{ N *Node } + +// AsItemTypecastDatetime returns the view when v is a Item_typecast_datetime. +func AsItemTypecastDatetime(v Value) (ItemTypecastDatetime, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_typecast_datetime" { + return ItemTypecastDatetime{}, false + } + return ItemTypecastDatetime{n}, true +} + +func (x ItemTypecastDatetime) A() Value { return x.N.Arg("a") } + +// ItemFuncAddTime views a Item_func_add_time node. +type ItemFuncAddTime struct{ N *Node } + +// AsItemFuncAddTime returns the view when v is a Item_func_add_time. +func AsItemFuncAddTime(v Value) (ItemFuncAddTime, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_add_time" { + return ItemFuncAddTime{}, false + } + return ItemFuncAddTime{n}, true +} + +func (x ItemFuncAddTime) A() Value { return x.N.Arg("a") } +func (x ItemFuncAddTime) B() Value { return x.N.Arg("b") } +func (x ItemFuncAddTime) Datetime() Value { return x.N.Arg("datetime") } +func (x ItemFuncAddTime) Negate() Value { return x.N.Arg("negate") } + +// ItemFuncTrim views a Item_func_trim node. +type ItemFuncTrim struct{ N *Node } + +// AsItemFuncTrim returns the view when v is a Item_func_trim. +func AsItemFuncTrim(v Value) (ItemFuncTrim, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_trim" { + return ItemFuncTrim{}, false + } + return ItemFuncTrim{n}, true +} + +func (x ItemFuncTrim) A() Value { return x.N.Arg("a") } +func (x ItemFuncTrim) Tm() Value { return x.N.Arg("tm") } +func (x ItemFuncTrim) B() Value { return x.N.Arg("b") } + +// ItemFuncUser views a Item_func_user node. +type ItemFuncUser struct{ N *Node } + +// AsItemFuncUser returns the view when v is a Item_func_user. +func AsItemFuncUser(v Value) (ItemFuncUser, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_user" { + return ItemFuncUser{}, false + } + return ItemFuncUser{n}, true +} + +// ItemFuncYear views a Item_func_year node. +type ItemFuncYear struct{ N *Node } + +// AsItemFuncYear returns the view when v is a Item_func_year. +func AsItemFuncYear(v Value) (ItemFuncYear, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_year" { + return ItemFuncYear{}, false + } + return ItemFuncYear{n}, true +} + +func (x ItemFuncYear) A() Value { return x.N.Arg("a") } + +// ItemFuncCurdateLocal views a Item_func_curdate_local node. +type ItemFuncCurdateLocal struct{ N *Node } + +// AsItemFuncCurdateLocal returns the view when v is a Item_func_curdate_local. +func AsItemFuncCurdateLocal(v Value) (ItemFuncCurdateLocal, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_curdate_local" { + return ItemFuncCurdateLocal{}, false + } + return ItemFuncCurdateLocal{n}, true +} + +// ItemFuncCurtimeLocal views a Item_func_curtime_local node. +type ItemFuncCurtimeLocal struct{ N *Node } + +// AsItemFuncCurtimeLocal returns the view when v is a Item_func_curtime_local. +func AsItemFuncCurtimeLocal(v Value) (ItemFuncCurtimeLocal, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_curtime_local" { + return ItemFuncCurtimeLocal{}, false + } + return ItemFuncCurtimeLocal{n}, true +} + +func (x ItemFuncCurtimeLocal) Dec() Value { return x.N.Arg("dec") } + +// ItemExtract views a Item_extract node. +type ItemExtract struct{ N *Node } + +// AsItemExtract returns the view when v is a Item_extract. +func AsItemExtract(v Value) (ItemExtract, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_extract" { + return ItemExtract{}, false + } + return ItemExtract{n}, true +} + +func (x ItemExtract) Type() Value { return x.N.Arg("type") } +func (x ItemExtract) A() Value { return x.N.Arg("a") } + +// ItemFuncGetFormat views a Item_func_get_format node. +type ItemFuncGetFormat struct{ N *Node } + +// AsItemFuncGetFormat returns the view when v is a Item_func_get_format. +func AsItemFuncGetFormat(v Value) (ItemFuncGetFormat, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_get_format" { + return ItemFuncGetFormat{}, false + } + return ItemFuncGetFormat{n}, true +} + +func (x ItemFuncGetFormat) Type() Value { return x.N.Arg("type") } +func (x ItemFuncGetFormat) A() Value { return x.N.Arg("a") } + +// ItemFuncLog views a Item_func_log node. +type ItemFuncLog struct{ N *Node } + +// AsItemFuncLog returns the view when v is a Item_func_log. +func AsItemFuncLog(v Value) (ItemFuncLog, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_log" { + return ItemFuncLog{}, false + } + return ItemFuncLog{n}, true +} + +func (x ItemFuncLog) A() Value { return x.N.Arg("a") } +func (x ItemFuncLog) B() Value { return x.N.Arg("b") } + +// PTIFunctionCallNonkeywordNow views a PTI_function_call_nonkeyword_now node. +type PTIFunctionCallNonkeywordNow struct{ N *Node } + +// AsPTIFunctionCallNonkeywordNow returns the view when v is a PTI_function_call_nonkeyword_now. +func AsPTIFunctionCallNonkeywordNow(v Value) (PTIFunctionCallNonkeywordNow, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_function_call_nonkeyword_now" { + return PTIFunctionCallNonkeywordNow{}, false + } + return PTIFunctionCallNonkeywordNow{n}, true +} + +func (x PTIFunctionCallNonkeywordNow) Dec() Value { return x.N.Arg("dec") } + +// ItemFuncLocate views a Item_func_locate node. +type ItemFuncLocate struct{ N *Node } + +// AsItemFuncLocate returns the view when v is a Item_func_locate. +func AsItemFuncLocate(v Value) (ItemFuncLocate, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_locate" { + return ItemFuncLocate{}, false + } + return ItemFuncLocate{n}, true +} + +func (x ItemFuncLocate) A() Value { return x.N.Arg("a") } +func (x ItemFuncLocate) B() Value { return x.N.Arg("b") } + +// ItemFuncSubstr views a Item_func_substr node. +type ItemFuncSubstr struct{ N *Node } + +// AsItemFuncSubstr returns the view when v is a Item_func_substr. +func AsItemFuncSubstr(v Value) (ItemFuncSubstr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_substr" { + return ItemFuncSubstr{}, false + } + return ItemFuncSubstr{n}, true +} + +func (x ItemFuncSubstr) A() Value { return x.N.Arg("a") } +func (x ItemFuncSubstr) B() Value { return x.N.Arg("b") } +func (x ItemFuncSubstr) C() Value { return x.N.Arg("c") } + +// PTIFunctionCallNonkeywordSysdate views a PTI_function_call_nonkeyword_sysdate node. +type PTIFunctionCallNonkeywordSysdate struct{ N *Node } + +// AsPTIFunctionCallNonkeywordSysdate returns the view when v is a PTI_function_call_nonkeyword_sysdate. +func AsPTIFunctionCallNonkeywordSysdate(v Value) (PTIFunctionCallNonkeywordSysdate, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_function_call_nonkeyword_sysdate" { + return PTIFunctionCallNonkeywordSysdate{}, false + } + return PTIFunctionCallNonkeywordSysdate{n}, true +} + +func (x PTIFunctionCallNonkeywordSysdate) Dec() Value { return x.N.Arg("dec") } + +// ItemFuncTimestampDiff views a Item_func_timestamp_diff node. +type ItemFuncTimestampDiff struct{ N *Node } + +// AsItemFuncTimestampDiff returns the view when v is a Item_func_timestamp_diff. +func AsItemFuncTimestampDiff(v Value) (ItemFuncTimestampDiff, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_timestamp_diff" { + return ItemFuncTimestampDiff{}, false + } + return ItemFuncTimestampDiff{n}, true +} + +func (x ItemFuncTimestampDiff) A() Value { return x.N.Arg("a") } +func (x ItemFuncTimestampDiff) B() Value { return x.N.Arg("b") } +func (x ItemFuncTimestampDiff) Type() Value { return x.N.Arg("type") } + +// ItemFuncCurdateUtc views a Item_func_curdate_utc node. +type ItemFuncCurdateUtc struct{ N *Node } + +// AsItemFuncCurdateUtc returns the view when v is a Item_func_curdate_utc. +func AsItemFuncCurdateUtc(v Value) (ItemFuncCurdateUtc, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_curdate_utc" { + return ItemFuncCurdateUtc{}, false + } + return ItemFuncCurdateUtc{n}, true +} + +// ItemFuncCurtimeUtc views a Item_func_curtime_utc node. +type ItemFuncCurtimeUtc struct{ N *Node } + +// AsItemFuncCurtimeUtc returns the view when v is a Item_func_curtime_utc. +func AsItemFuncCurtimeUtc(v Value) (ItemFuncCurtimeUtc, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_curtime_utc" { + return ItemFuncCurtimeUtc{}, false + } + return ItemFuncCurtimeUtc{n}, true +} + +func (x ItemFuncCurtimeUtc) Dec() Value { return x.N.Arg("dec") } + +// ItemFuncNowUtc views a Item_func_now_utc node. +type ItemFuncNowUtc struct{ N *Node } + +// AsItemFuncNowUtc returns the view when v is a Item_func_now_utc. +func AsItemFuncNowUtc(v Value) (ItemFuncNowUtc, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_now_utc" { + return ItemFuncNowUtc{}, false + } + return ItemFuncNowUtc{n}, true +} + +func (x ItemFuncNowUtc) Dec() Value { return x.N.Arg("dec") } + +// ItemFuncAscii views a Item_func_ascii node. +type ItemFuncAscii struct{ N *Node } + +// AsItemFuncAscii returns the view when v is a Item_func_ascii. +func AsItemFuncAscii(v Value) (ItemFuncAscii, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_ascii" { + return ItemFuncAscii{}, false + } + return ItemFuncAscii{n}, true +} + +func (x ItemFuncAscii) A() Value { return x.N.Arg("a") } + +// ItemFuncCharset views a Item_func_charset node. +type ItemFuncCharset struct{ N *Node } + +// AsItemFuncCharset returns the view when v is a Item_func_charset. +func AsItemFuncCharset(v Value) (ItemFuncCharset, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_charset" { + return ItemFuncCharset{}, false + } + return ItemFuncCharset{n}, true +} + +func (x ItemFuncCharset) A() Value { return x.N.Arg("a") } + +// ItemFuncCoalesce views a Item_func_coalesce node. +type ItemFuncCoalesce struct{ N *Node } + +// AsItemFuncCoalesce returns the view when v is a Item_func_coalesce. +func AsItemFuncCoalesce(v Value) (ItemFuncCoalesce, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_coalesce" { + return ItemFuncCoalesce{}, false + } + return ItemFuncCoalesce{n}, true +} + +func (x ItemFuncCoalesce) A() Value { return x.N.Arg("a") } + +// ItemFuncCollation views a Item_func_collation node. +type ItemFuncCollation struct{ N *Node } + +// AsItemFuncCollation returns the view when v is a Item_func_collation. +func AsItemFuncCollation(v Value) (ItemFuncCollation, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_collation" { + return ItemFuncCollation{}, false + } + return ItemFuncCollation{n}, true +} + +func (x ItemFuncCollation) A() Value { return x.N.Arg("a") } + +// ItemFuncDatabase views a Item_func_database node. +type ItemFuncDatabase struct{ N *Node } + +// AsItemFuncDatabase returns the view when v is a Item_func_database. +func AsItemFuncDatabase(v Value) (ItemFuncDatabase, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_database" { + return ItemFuncDatabase{}, false + } + return ItemFuncDatabase{n}, true +} + +// ItemFuncIf views a Item_func_if node. +type ItemFuncIf struct{ N *Node } + +// AsItemFuncIf returns the view when v is a Item_func_if. +func AsItemFuncIf(v Value) (ItemFuncIf, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_if" { + return ItemFuncIf{}, false + } + return ItemFuncIf{n}, true +} + +func (x ItemFuncIf) A() Value { return x.N.Arg("a") } +func (x ItemFuncIf) B() Value { return x.N.Arg("b") } +func (x ItemFuncIf) C() Value { return x.N.Arg("c") } + +// ItemFuncFormat views a Item_func_format node. +type ItemFuncFormat struct{ N *Node } + +// AsItemFuncFormat returns the view when v is a Item_func_format. +func AsItemFuncFormat(v Value) (ItemFuncFormat, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_format" { + return ItemFuncFormat{}, false + } + return ItemFuncFormat{n}, true +} + +func (x ItemFuncFormat) Org() Value { return x.N.Arg("org") } +func (x ItemFuncFormat) Dec() Value { return x.N.Arg("dec") } +func (x ItemFuncFormat) Lang() Value { return x.N.Arg("lang") } + +// ItemFuncMicrosecond views a Item_func_microsecond node. +type ItemFuncMicrosecond struct{ N *Node } + +// AsItemFuncMicrosecond returns the view when v is a Item_func_microsecond. +func AsItemFuncMicrosecond(v Value) (ItemFuncMicrosecond, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_microsecond" { + return ItemFuncMicrosecond{}, false + } + return ItemFuncMicrosecond{n}, true +} + +func (x ItemFuncMicrosecond) A() Value { return x.N.Arg("a") } + +// ItemFuncQuarter views a Item_func_quarter node. +type ItemFuncQuarter struct{ N *Node } + +// AsItemFuncQuarter returns the view when v is a Item_func_quarter. +func AsItemFuncQuarter(v Value) (ItemFuncQuarter, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_quarter" { + return ItemFuncQuarter{}, false + } + return ItemFuncQuarter{n}, true +} + +func (x ItemFuncQuarter) A() Value { return x.N.Arg("a") } + +// ItemFuncRepeat views a Item_func_repeat node. +type ItemFuncRepeat struct{ N *Node } + +// AsItemFuncRepeat returns the view when v is a Item_func_repeat. +func AsItemFuncRepeat(v Value) (ItemFuncRepeat, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_repeat" { + return ItemFuncRepeat{}, false + } + return ItemFuncRepeat{n}, true +} + +func (x ItemFuncRepeat) Arg1() Value { return x.N.Arg("arg1") } +func (x ItemFuncRepeat) Arg2() Value { return x.N.Arg("arg2") } + +// ItemFuncReplace views a Item_func_replace node. +type ItemFuncReplace struct{ N *Node } + +// AsItemFuncReplace returns the view when v is a Item_func_replace. +func AsItemFuncReplace(v Value) (ItemFuncReplace, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_replace" { + return ItemFuncReplace{}, false + } + return ItemFuncReplace{n}, true +} + +func (x ItemFuncReplace) Org() Value { return x.N.Arg("org") } +func (x ItemFuncReplace) Find() Value { return x.N.Arg("find") } +func (x ItemFuncReplace) Replace() Value { return x.N.Arg("replace") } + +// ItemFuncReverse views a Item_func_reverse node. +type ItemFuncReverse struct{ N *Node } + +// AsItemFuncReverse returns the view when v is a Item_func_reverse. +func AsItemFuncReverse(v Value) (ItemFuncReverse, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_reverse" { + return ItemFuncReverse{}, false + } + return ItemFuncReverse{n}, true +} + +func (x ItemFuncReverse) A() Value { return x.N.Arg("a") } + +// ItemFuncRowCount views a Item_func_row_count node. +type ItemFuncRowCount struct{ N *Node } + +// AsItemFuncRowCount returns the view when v is a Item_func_row_count. +func AsItemFuncRowCount(v Value) (ItemFuncRowCount, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_row_count" { + return ItemFuncRowCount{}, false + } + return ItemFuncRowCount{n}, true +} + +// ItemFuncRound views a Item_func_round node. +type ItemFuncRound struct{ N *Node } + +// AsItemFuncRound returns the view when v is a Item_func_round. +func AsItemFuncRound(v Value) (ItemFuncRound, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_round" { + return ItemFuncRound{}, false + } + return ItemFuncRound{n}, true +} + +func (x ItemFuncRound) A() Value { return x.N.Arg("a") } +func (x ItemFuncRound) B() Value { return x.N.Arg("b") } +func (x ItemFuncRound) Trunc() Value { return x.N.Arg("trunc") } + +// ItemFuncWeek views a Item_func_week node. +type ItemFuncWeek struct{ N *Node } + +// AsItemFuncWeek returns the view when v is a Item_func_week. +func AsItemFuncWeek(v Value) (ItemFuncWeek, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_week" { + return ItemFuncWeek{}, false + } + return ItemFuncWeek{n}, true +} + +func (x ItemFuncWeek) A() Value { return x.N.Arg("a") } +func (x ItemFuncWeek) B() Value { return x.N.Arg("b") } + +// ItemFuncWeightString views a Item_func_weight_string node. +type ItemFuncWeightString struct{ N *Node } + +// AsItemFuncWeightString returns the view when v is a Item_func_weight_string. +func AsItemFuncWeightString(v Value) (ItemFuncWeightString, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_weight_string" { + return ItemFuncWeightString{}, false + } + return ItemFuncWeightString{n}, true +} + +func (x ItemFuncWeightString) A() Value { return x.N.Arg("a") } +func (x ItemFuncWeightString) ResultLength() Value { return x.N.Arg("result_length") } +func (x ItemFuncWeightString) NumCodepoints() Value { return x.N.Arg("num_codepoints") } +func (x ItemFuncWeightString) Flags() Value { return x.N.Arg("flags") } +func (x ItemFuncWeightString) AsBinary() Value { return x.N.Arg("as_binary") } + +// ItemFuncSpatialCollection views a Item_func_spatial_collection node. +type ItemFuncSpatialCollection struct{ N *Node } + +// AsItemFuncSpatialCollection returns the view when v is a Item_func_spatial_collection. +func AsItemFuncSpatialCollection(v Value) (ItemFuncSpatialCollection, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_spatial_collection" { + return ItemFuncSpatialCollection{}, false + } + return ItemFuncSpatialCollection{n}, true +} + +func (x ItemFuncSpatialCollection) List() Value { return x.N.Arg("list") } +func (x ItemFuncSpatialCollection) Ct() Value { return x.N.Arg("ct") } +func (x ItemFuncSpatialCollection) It() Value { return x.N.Arg("it") } + +// ItemFuncPoint views a Item_func_point node. +type ItemFuncPoint struct{ N *Node } + +// AsItemFuncPoint returns the view when v is a Item_func_point. +func AsItemFuncPoint(v Value) (ItemFuncPoint, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_point" { + return ItemFuncPoint{}, false + } + return ItemFuncPoint{n}, true +} + +func (x ItemFuncPoint) A() Value { return x.N.Arg("a") } +func (x ItemFuncPoint) B() Value { return x.N.Arg("b") } + +// PTIFunctionCallGenericIdentSys views a PTI_function_call_generic_ident_sys node. +type PTIFunctionCallGenericIdentSys struct{ N *Node } + +// AsPTIFunctionCallGenericIdentSys returns the view when v is a PTI_function_call_generic_ident_sys. +func AsPTIFunctionCallGenericIdentSys(v Value) (PTIFunctionCallGenericIdentSys, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_function_call_generic_ident_sys" { + return PTIFunctionCallGenericIdentSys{}, false + } + return PTIFunctionCallGenericIdentSys{n}, true +} + +func (x PTIFunctionCallGenericIdentSys) Ident() Value { return x.N.Arg("ident") } +func (x PTIFunctionCallGenericIdentSys) OptUdfExprList() Value { return x.N.Arg("opt_udf_expr_list") } + +// PTIFunctionCallGeneric2d views a PTI_function_call_generic_2d node. +type PTIFunctionCallGeneric2d struct{ N *Node } + +// AsPTIFunctionCallGeneric2d returns the view when v is a PTI_function_call_generic_2d. +func AsPTIFunctionCallGeneric2d(v Value) (PTIFunctionCallGeneric2d, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_function_call_generic_2d" { + return PTIFunctionCallGeneric2d{}, false + } + return PTIFunctionCallGeneric2d{n}, true +} + +func (x PTIFunctionCallGeneric2d) Db() Value { return x.N.Arg("db") } +func (x PTIFunctionCallGeneric2d) Func() Value { return x.N.Arg("func") } +func (x PTIFunctionCallGeneric2d) OptExprList() Value { return x.N.Arg("opt_expr_list") } + +// PTIUdfExpr views a PTI_udf_expr node. +type PTIUdfExpr struct{ N *Node } + +// AsPTIUdfExpr returns the view when v is a PTI_udf_expr. +func AsPTIUdfExpr(v Value) (PTIUdfExpr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_udf_expr" { + return PTIUdfExpr{}, false + } + return PTIUdfExpr{n}, true +} + +func (x PTIUdfExpr) Expr() Value { return x.N.Arg("expr") } +func (x PTIUdfExpr) SelectAlias() Value { return x.N.Arg("select_alias") } + +// ItemSumAvg views a Item_sum_avg node. +type ItemSumAvg struct{ N *Node } + +// AsItemSumAvg returns the view when v is a Item_sum_avg. +func AsItemSumAvg(v Value) (ItemSumAvg, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_sum_avg" { + return ItemSumAvg{}, false + } + return ItemSumAvg{n}, true +} + +func (x ItemSumAvg) ItemPar() Value { return x.N.Arg("item_par") } +func (x ItemSumAvg) Distinct() Value { return x.N.Arg("distinct") } +func (x ItemSumAvg) W() Value { return x.N.Arg("w") } + +// ItemSumAnd views a Item_sum_and node. +type ItemSumAnd struct{ N *Node } + +// AsItemSumAnd returns the view when v is a Item_sum_and. +func AsItemSumAnd(v Value) (ItemSumAnd, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_sum_and" { + return ItemSumAnd{}, false + } + return ItemSumAnd{n}, true +} + +func (x ItemSumAnd) ItemPar() Value { return x.N.Arg("item_par") } +func (x ItemSumAnd) W() Value { return x.N.Arg("w") } + +// ItemSumOr views a Item_sum_or node. +type ItemSumOr struct{ N *Node } + +// AsItemSumOr returns the view when v is a Item_sum_or. +func AsItemSumOr(v Value) (ItemSumOr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_sum_or" { + return ItemSumOr{}, false + } + return ItemSumOr{n}, true +} + +func (x ItemSumOr) ItemPar() Value { return x.N.Arg("item_par") } +func (x ItemSumOr) W() Value { return x.N.Arg("w") } + +// ItemSumCollect views a Item_sum_collect node. +type ItemSumCollect struct{ N *Node } + +// AsItemSumCollect returns the view when v is a Item_sum_collect. +func AsItemSumCollect(v Value) (ItemSumCollect, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_sum_collect" { + return ItemSumCollect{}, false + } + return ItemSumCollect{n}, true +} + +func (x ItemSumCollect) A() Value { return x.N.Arg("a") } +func (x ItemSumCollect) W() Value { return x.N.Arg("w") } +func (x ItemSumCollect) Distinct() Value { return x.N.Arg("distinct") } + +// ItemSumXor views a Item_sum_xor node. +type ItemSumXor struct{ N *Node } + +// AsItemSumXor returns the view when v is a Item_sum_xor. +func AsItemSumXor(v Value) (ItemSumXor, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_sum_xor" { + return ItemSumXor{}, false + } + return ItemSumXor{n}, true +} + +func (x ItemSumXor) ItemPar() Value { return x.N.Arg("item_par") } +func (x ItemSumXor) W() Value { return x.N.Arg("w") } + +// PTICountSym views a PTI_count_sym node. +type PTICountSym struct{ N *Node } + +// AsPTICountSym returns the view when v is a PTI_count_sym. +func AsPTICountSym(v Value) (PTICountSym, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_count_sym" { + return PTICountSym{}, false + } + return PTICountSym{n}, true +} + +func (x PTICountSym) W() Value { return x.N.Arg("w") } + +// ItemSumCount views a Item_sum_count node. +type ItemSumCount struct{ N *Node } + +// AsItemSumCount returns the view when v is a Item_sum_count. +func AsItemSumCount(v Value) (ItemSumCount, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_sum_count" { + return ItemSumCount{}, false + } + return ItemSumCount{n}, true +} + +func (x ItemSumCount) ItemPar() Value { return x.N.Arg("item_par") } +func (x ItemSumCount) W() Value { return x.N.Arg("w") } + +// ItemSumMin views a Item_sum_min node. +type ItemSumMin struct{ N *Node } + +// AsItemSumMin returns the view when v is a Item_sum_min. +func AsItemSumMin(v Value) (ItemSumMin, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_sum_min" { + return ItemSumMin{}, false + } + return ItemSumMin{n}, true +} + +func (x ItemSumMin) ItemPar() Value { return x.N.Arg("item_par") } +func (x ItemSumMin) W() Value { return x.N.Arg("w") } + +// ItemSumMax views a Item_sum_max node. +type ItemSumMax struct{ N *Node } + +// AsItemSumMax returns the view when v is a Item_sum_max. +func AsItemSumMax(v Value) (ItemSumMax, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_sum_max" { + return ItemSumMax{}, false + } + return ItemSumMax{n}, true +} + +func (x ItemSumMax) ItemPar() Value { return x.N.Arg("item_par") } +func (x ItemSumMax) W() Value { return x.N.Arg("w") } + +// ItemSumStd views a Item_sum_std node. +type ItemSumStd struct{ N *Node } + +// AsItemSumStd returns the view when v is a Item_sum_std. +func AsItemSumStd(v Value) (ItemSumStd, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_sum_std" { + return ItemSumStd{}, false + } + return ItemSumStd{n}, true +} + +func (x ItemSumStd) ItemPar() Value { return x.N.Arg("item_par") } +func (x ItemSumStd) Sample() Value { return x.N.Arg("sample") } +func (x ItemSumStd) W() Value { return x.N.Arg("w") } + +// ItemSumVariance views a Item_sum_variance node. +type ItemSumVariance struct{ N *Node } + +// AsItemSumVariance returns the view when v is a Item_sum_variance. +func AsItemSumVariance(v Value) (ItemSumVariance, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_sum_variance" { + return ItemSumVariance{}, false + } + return ItemSumVariance{n}, true +} + +func (x ItemSumVariance) ItemPar() Value { return x.N.Arg("item_par") } +func (x ItemSumVariance) Sample() Value { return x.N.Arg("sample") } +func (x ItemSumVariance) W() Value { return x.N.Arg("w") } + +// ItemSumSum views a Item_sum_sum node. +type ItemSumSum struct{ N *Node } + +// AsItemSumSum returns the view when v is a Item_sum_sum. +func AsItemSumSum(v Value) (ItemSumSum, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_sum_sum" { + return ItemSumSum{}, false + } + return ItemSumSum{n}, true +} + +func (x ItemSumSum) ItemPar() Value { return x.N.Arg("item_par") } +func (x ItemSumSum) Distinct() Value { return x.N.Arg("distinct") } +func (x ItemSumSum) Window() Value { return x.N.Arg("window") } + +// ItemFuncGroupConcat views a Item_func_group_concat node. +type ItemFuncGroupConcat struct{ N *Node } + +// AsItemFuncGroupConcat returns the view when v is a Item_func_group_concat. +func AsItemFuncGroupConcat(v Value) (ItemFuncGroupConcat, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_group_concat" { + return ItemFuncGroupConcat{}, false + } + return ItemFuncGroupConcat{n}, true +} + +func (x ItemFuncGroupConcat) IsDistinct() Value { return x.N.Arg("is_distinct") } +func (x ItemFuncGroupConcat) SelectList() Value { return x.N.Arg("select_list") } +func (x ItemFuncGroupConcat) OptOrderList() Value { return x.N.Arg("opt_order_list") } +func (x ItemFuncGroupConcat) Separator() Value { return x.N.Arg("separator") } +func (x ItemFuncGroupConcat) W() Value { return x.N.Arg("w") } + +// PTIUserVariable views a PTI_user_variable node. +type PTIUserVariable struct{ N *Node } + +// AsPTIUserVariable returns the view when v is a PTI_user_variable. +func AsPTIUserVariable(v Value) (PTIUserVariable, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_user_variable" { + return PTIUserVariable{}, false + } + return PTIUserVariable{n}, true +} + +func (x PTIUserVariable) Var() Value { return x.N.Arg("var") } + +// PTTablesample views a PT_tablesample node. +type PTTablesample struct{ N *Node } + +// AsPTTablesample returns the view when v is a PT_tablesample. +func AsPTTablesample(v Value) (PTTablesample, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_tablesample" { + return PTTablesample{}, false + } + return PTTablesample{n}, true +} + +func (x PTTablesample) TablesampleType() Value { return x.N.Arg("tablesample_type") } +func (x PTTablesample) SamplePercentage() Value { return x.N.Arg("sample_percentage") } + +// ItemRowNumber views a Item_row_number node. +type ItemRowNumber struct{ N *Node } + +// AsItemRowNumber returns the view when v is a Item_row_number. +func AsItemRowNumber(v Value) (ItemRowNumber, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_row_number" { + return ItemRowNumber{}, false + } + return ItemRowNumber{n}, true +} + +func (x ItemRowNumber) W() Value { return x.N.Arg("w") } + +// ItemRank views a Item_rank node. +type ItemRank struct{ N *Node } + +// AsItemRank returns the view when v is a Item_rank. +func AsItemRank(v Value) (ItemRank, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_rank" { + return ItemRank{}, false + } + return ItemRank{n}, true +} + +func (x ItemRank) Dense() Value { return x.N.Arg("dense") } +func (x ItemRank) W() Value { return x.N.Arg("w") } + +// ItemCumeDist views a Item_cume_dist node. +type ItemCumeDist struct{ N *Node } + +// AsItemCumeDist returns the view when v is a Item_cume_dist. +func AsItemCumeDist(v Value) (ItemCumeDist, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_cume_dist" { + return ItemCumeDist{}, false + } + return ItemCumeDist{n}, true +} + +func (x ItemCumeDist) W() Value { return x.N.Arg("w") } + +// ItemPercentRank views a Item_percent_rank node. +type ItemPercentRank struct{ N *Node } + +// AsItemPercentRank returns the view when v is a Item_percent_rank. +func AsItemPercentRank(v Value) (ItemPercentRank, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_percent_rank" { + return ItemPercentRank{}, false + } + return ItemPercentRank{n}, true +} + +func (x ItemPercentRank) W() Value { return x.N.Arg("w") } + +// ItemNtile views a Item_ntile node. +type ItemNtile struct{ N *Node } + +// AsItemNtile returns the view when v is a Item_ntile. +func AsItemNtile(v Value) (ItemNtile, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_ntile" { + return ItemNtile{}, false + } + return ItemNtile{n}, true +} + +func (x ItemNtile) A() Value { return x.N.Arg("a") } +func (x ItemNtile) W() Value { return x.N.Arg("w") } + +// ItemFirstLastValue views a Item_first_last_value node. +type ItemFirstLastValue struct{ N *Node } + +// AsItemFirstLastValue returns the view when v is a Item_first_last_value. +func AsItemFirstLastValue(v Value) (ItemFirstLastValue, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_first_last_value" { + return ItemFirstLastValue{}, false + } + return ItemFirstLastValue{n}, true +} + +func (x ItemFirstLastValue) First() Value { return x.N.Arg("first") } +func (x ItemFirstLastValue) A() Value { return x.N.Arg("a") } +func (x ItemFirstLastValue) NullTreatment() Value { return x.N.Arg("null_treatment") } +func (x ItemFirstLastValue) W() Value { return x.N.Arg("w") } + +// ItemLeadLag views a Item_lead_lag node. +type ItemLeadLag struct{ N *Node } + +// AsItemLeadLag returns the view when v is a Item_lead_lag. +func AsItemLeadLag(v Value) (ItemLeadLag, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_lead_lag" { + return ItemLeadLag{}, false + } + return ItemLeadLag{n}, true +} + +// IsLead is true for LEAD, false for LAG. +func (x ItemLeadLag) IsLead() Value { return x.N.Arg("is_lead") } + +// Args is [expr], [expr, offset] or [expr, offset, default_value]: hooks_dml.go's leadLag +// only appends opt_lead_lag_info's offset / default_value when the statement gave them. +func (x ItemLeadLag) Args() Value { return x.N.Arg("args") } +func (x ItemLeadLag) NullTreatment() Value { return x.N.Arg("null_treatment") } +func (x ItemLeadLag) W() Value { return x.N.Arg("w") } + +// ItemNthValue views a Item_nth_value node. +type ItemNthValue struct{ N *Node } + +// AsItemNthValue returns the view when v is a Item_nth_value. +func AsItemNthValue(v Value) (ItemNthValue, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_nth_value" { + return ItemNthValue{}, false + } + return ItemNthValue{n}, true +} + +func (x ItemNthValue) Expr() Value { return x.N.Arg("expr") } +func (x ItemNthValue) N_() Value { return x.N.Arg("n") } +func (x ItemNthValue) FromLast() Value { return x.N.Arg("from_last") } +func (x ItemNthValue) NullTreatment() Value { return x.N.Arg("null_treatment") } +func (x ItemNthValue) W() Value { return x.N.Arg("w") } + +// PTIIntSplocal views a PTI_int_splocal node. +type PTIIntSplocal struct{ N *Node } + +// AsPTIIntSplocal returns the view when v is a PTI_int_splocal. +func AsPTIIntSplocal(v Value) (PTIIntSplocal, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_int_splocal" { + return PTIIntSplocal{}, false + } + return PTIIntSplocal{n}, true +} + +func (x PTIIntSplocal) Name() Value { return x.N.Arg("name") } + +// PTWindow views a PT_window node. +type PTWindow struct{ N *Node } + +// AsPTWindow returns the view when v is a PT_window. +func AsPTWindow(v Value) (PTWindow, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_window" { + return PTWindow{}, false + } + return PTWindow{n}, true +} + +func (x PTWindow) Name() Value { return x.N.Arg("name") } + +// ItemString views a Item_string node. +type ItemString struct{ N *Node } + +// AsItemString returns the view when v is a Item_string. +func AsItemString(v Value) (ItemString, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_string" { + return ItemString{}, false + } + return ItemString{n}, true +} + +func (x ItemString) Str() Value { return x.N.Arg("str") } +func (x ItemString) Length() Value { return x.N.Arg("length") } +func (x ItemString) Cs() Value { return x.N.Arg("cs") } + +// PTFrame views a PT_frame node. +type PTFrame struct{ N *Node } + +// AsPTFrame returns the view when v is a PT_frame. +func AsPTFrame(v Value) (PTFrame, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_frame" { + return PTFrame{}, false + } + return PTFrame{n}, true +} + +func (x PTFrame) Unit() Value { return x.N.Arg("unit") } +func (x PTFrame) FromTo() Value { return x.N.Arg("from_to") } +func (x PTFrame) Exclusion() Value { return x.N.Arg("exclusion") } + +// PTBorder views a PT_border node. +type PTBorder struct{ N *Node } + +// AsPTBorder returns the view when v is a PT_border. +func AsPTBorder(v Value) (PTBorder, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_border" { + return PTBorder{}, false + } + return PTBorder{n}, true +} + +func (x PTBorder) Type() Value { return x.N.Arg("type") } +func (x PTBorder) Value() Value { return x.N.Arg("value") } +func (x PTBorder) IntType() Value { return x.N.Arg("int_type") } + +// PTBorders views a PT_borders node. +type PTBorders struct{ N *Node } + +// AsPTBorders returns the view when v is a PT_borders. +func AsPTBorders(v Value) (PTBorders, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_borders" { + return PTBorders{}, false + } + return PTBorders{n}, true +} + +func (x PTBorders) Start() Value { return x.N.Arg("start") } +func (x PTBorders) End() Value { return x.N.Arg("end") } + +// PTExclusion views a PT_exclusion node. +type PTExclusion struct{ N *Node } + +// AsPTExclusion returns the view when v is a PT_exclusion. +func AsPTExclusion(v Value) (PTExclusion, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_exclusion" { + return PTExclusion{}, false + } + return PTExclusion{n}, true +} + +func (x PTExclusion) E() Value { return x.N.Arg("e") } + +// ItemFuncGrouping views a Item_func_grouping node. +type ItemFuncGrouping struct{ N *Node } + +// AsItemFuncGrouping returns the view when v is a Item_func_grouping. +func AsItemFuncGrouping(v Value) (ItemFuncGrouping, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_grouping" { + return ItemFuncGrouping{}, false + } + return ItemFuncGrouping{n}, true +} + +func (x ItemFuncGrouping) A() Value { return x.N.Arg("a") } + +// PTIVariableAuxSetVar views a PTI_variable_aux_set_var node. +type PTIVariableAuxSetVar struct{ N *Node } + +// AsPTIVariableAuxSetVar returns the view when v is a PTI_variable_aux_set_var. +func AsPTIVariableAuxSetVar(v Value) (PTIVariableAuxSetVar, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_variable_aux_set_var" { + return PTIVariableAuxSetVar{}, false + } + return PTIVariableAuxSetVar{n}, true +} + +func (x PTIVariableAuxSetVar) Var() Value { return x.N.Arg("var") } +func (x PTIVariableAuxSetVar) Expr() Value { return x.N.Arg("expr") } + +// PTIGetSystemVariable views a PTI_get_system_variable node. +type PTIGetSystemVariable struct{ N *Node } + +// AsPTIGetSystemVariable returns the view when v is a PTI_get_system_variable. +func AsPTIGetSystemVariable(v Value) (PTIGetSystemVariable, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_get_system_variable" { + return PTIGetSystemVariable{}, false + } + return PTIGetSystemVariable{n}, true +} + +func (x PTIGetSystemVariable) Scope() Value { return x.N.Arg("scope") } +func (x PTIGetSystemVariable) OptPrefix() Value { return x.N.Arg("opt_prefix") } +func (x PTIGetSystemVariable) Name() Value { return x.N.Arg("name") } + +// PTIInSumExpr views a PTI_in_sum_expr node. +type PTIInSumExpr struct{ N *Node } + +// AsPTIInSumExpr returns the view when v is a PTI_in_sum_expr. +func AsPTIInSumExpr(v Value) (PTIInSumExpr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_in_sum_expr" { + return PTIInSumExpr{}, false + } + return PTIInSumExpr{n}, true +} + +func (x PTIInSumExpr) Expr() Value { return x.N.Arg("expr") } + +// PTJoinedTableOn views a PT_joined_table_on node. +type PTJoinedTableOn struct{ N *Node } + +// AsPTJoinedTableOn returns the view when v is a PT_joined_table_on. +func AsPTJoinedTableOn(v Value) (PTJoinedTableOn, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_joined_table_on" { + return PTJoinedTableOn{}, false + } + return PTJoinedTableOn{n}, true +} + +func (x PTJoinedTableOn) Tab1Node() Value { return x.N.Arg("tab1_node") } +func (x PTJoinedTableOn) Type() Value { return x.N.Arg("type") } +func (x PTJoinedTableOn) Tab2Node() Value { return x.N.Arg("tab2_node") } +func (x PTJoinedTableOn) On() Value { return x.N.Arg("on") } + +// PTJoinedTableUsing views a PT_joined_table_using node. +type PTJoinedTableUsing struct{ N *Node } + +// AsPTJoinedTableUsing returns the view when v is a PT_joined_table_using. +func AsPTJoinedTableUsing(v Value) (PTJoinedTableUsing, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_joined_table_using" { + return PTJoinedTableUsing{}, false + } + return PTJoinedTableUsing{n}, true +} + +func (x PTJoinedTableUsing) Tab1Node() Value { return x.N.Arg("tab1_node") } +func (x PTJoinedTableUsing) Type() Value { return x.N.Arg("type") } +func (x PTJoinedTableUsing) Tab2Node() Value { return x.N.Arg("tab2_node") } +func (x PTJoinedTableUsing) UsingFields() Value { return x.N.Arg("using_fields") } + +// PTTableFactorJoinedTable views a PT_table_factor_joined_table node. +type PTTableFactorJoinedTable struct{ N *Node } + +// AsPTTableFactorJoinedTable returns the view when v is a PT_table_factor_joined_table. +func AsPTTableFactorJoinedTable(v Value) (PTTableFactorJoinedTable, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_table_factor_joined_table" { + return PTTableFactorJoinedTable{}, false + } + return PTTableFactorJoinedTable{n}, true +} + +func (x PTTableFactorJoinedTable) JoinedTable() Value { return x.N.Arg("joined_table") } + +// PTTableReferenceListParens views a PT_table_reference_list_parens node. +type PTTableReferenceListParens struct{ N *Node } + +// AsPTTableReferenceListParens returns the view when v is a PT_table_reference_list_parens. +func AsPTTableReferenceListParens(v Value) (PTTableReferenceListParens, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_table_reference_list_parens" { + return PTTableReferenceListParens{}, false + } + return PTTableReferenceListParens{n}, true +} + +func (x PTTableReferenceListParens) TableList() Value { return x.N.Arg("table_list") } + +// PTTableFactorTableIdent views a PT_table_factor_table_ident node. +type PTTableFactorTableIdent struct{ N *Node } + +// AsPTTableFactorTableIdent returns the view when v is a PT_table_factor_table_ident. +func AsPTTableFactorTableIdent(v Value) (PTTableFactorTableIdent, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_table_factor_table_ident" { + return PTTableFactorTableIdent{}, false + } + return PTTableFactorTableIdent{n}, true +} + +func (x PTTableFactorTableIdent) TableIdent() Value { return x.N.Arg("table_ident") } +func (x PTTableFactorTableIdent) OptUsePartition() Value { return x.N.Arg("opt_use_partition") } +func (x PTTableFactorTableIdent) OptTableAlias() Value { return x.N.Arg("opt_table_alias") } +func (x PTTableFactorTableIdent) OptKeyDefinition() Value { return x.N.Arg("opt_key_definition") } +func (x PTTableFactorTableIdent) OptTablesample() Value { return x.N.Arg("opt_tablesample") } + +// PTDerivedTable views a PT_derived_table node. +type PTDerivedTable struct{ N *Node } + +// AsPTDerivedTable returns the view when v is a PT_derived_table. +func AsPTDerivedTable(v Value) (PTDerivedTable, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_derived_table" { + return PTDerivedTable{}, false + } + return PTDerivedTable{n}, true +} + +func (x PTDerivedTable) Lateral() Value { return x.N.Arg("lateral") } +func (x PTDerivedTable) Subquery() Value { return x.N.Arg("subquery") } +func (x PTDerivedTable) TableAlias() Value { return x.N.Arg("table_alias") } +func (x PTDerivedTable) ColumnNames() Value { return x.N.Arg("column_names") } + +// PTTableFactorFunction views a PT_table_factor_function node. +type PTTableFactorFunction struct{ N *Node } + +// AsPTTableFactorFunction returns the view when v is a PT_table_factor_function. +func AsPTTableFactorFunction(v Value) (PTTableFactorFunction, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_table_factor_function" { + return PTTableFactorFunction{}, false + } + return PTTableFactorFunction{n}, true +} + +func (x PTTableFactorFunction) Expr() Value { return x.N.Arg("expr") } +func (x PTTableFactorFunction) Path() Value { return x.N.Arg("path") } +func (x PTTableFactorFunction) NestedCols() Value { return x.N.Arg("nested_cols") } +func (x PTTableFactorFunction) TableAlias() Value { return x.N.Arg("table_alias") } + +// PTJsonTableColumnForOrdinality views a PT_json_table_column_for_ordinality node. +type PTJsonTableColumnForOrdinality struct{ N *Node } + +// AsPTJsonTableColumnForOrdinality returns the view when v is a PT_json_table_column_for_ordinality. +func AsPTJsonTableColumnForOrdinality(v Value) (PTJsonTableColumnForOrdinality, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_json_table_column_for_ordinality" { + return PTJsonTableColumnForOrdinality{}, false + } + return PTJsonTableColumnForOrdinality{n}, true +} + +func (x PTJsonTableColumnForOrdinality) Name() Value { return x.N.Arg("name") } + +// PTJsonTableColumnWithNestedPath views a PT_json_table_column_with_nested_path node. +type PTJsonTableColumnWithNestedPath struct{ N *Node } + +// AsPTJsonTableColumnWithNestedPath returns the view when v is a PT_json_table_column_with_nested_path. +func AsPTJsonTableColumnWithNestedPath(v Value) (PTJsonTableColumnWithNestedPath, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_json_table_column_with_nested_path" { + return PTJsonTableColumnWithNestedPath{}, false + } + return PTJsonTableColumnWithNestedPath{n}, true +} + +func (x PTJsonTableColumnWithNestedPath) Path() Value { return x.N.Arg("path") } +func (x PTJsonTableColumnWithNestedPath) NestedCols() Value { return x.N.Arg("nested_cols") } + +// IndexHint views a Index_hint node. +type IndexHint struct{ N *Node } + +// AsIndexHint returns the view when v is a Index_hint. +func AsIndexHint(v Value) (IndexHint, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Index_hint" { + return IndexHint{}, false + } + return IndexHint{n}, true +} + +func (x IndexHint) Str() Value { return x.N.Arg("str") } +func (x IndexHint) Length() Value { return x.N.Arg("length") } + +// PTIWhere views a PTI_where node. +type PTIWhere struct{ N *Node } + +// AsPTIWhere returns the view when v is a PTI_where. +func AsPTIWhere(v Value) (PTIWhere, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_where" { + return PTIWhere{}, false + } + return PTIWhere{n}, true +} + +func (x PTIWhere) Expr() Value { return x.N.Arg("expr") } + +// PTIHaving views a PTI_having node. +type PTIHaving struct{ N *Node } + +// AsPTIHaving returns the view when v is a PTI_having. +func AsPTIHaving(v Value) (PTIHaving, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_having" { + return PTIHaving{}, false + } + return PTIHaving{n}, true +} + +func (x PTIHaving) Expr() Value { return x.N.Arg("expr") } + +// PTIQualify views a PTI_qualify node. +type PTIQualify struct{ N *Node } + +// AsPTIQualify returns the view when v is a PTI_qualify. +func AsPTIQualify(v Value) (PTIQualify, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_qualify" { + return PTIQualify{}, false + } + return PTIQualify{n}, true +} + +func (x PTIQualify) Expr() Value { return x.N.Arg("expr") } + +// PTWithClause views a PT_with_clause node. +type PTWithClause struct{ N *Node } + +// AsPTWithClause returns the view when v is a PT_with_clause. +func AsPTWithClause(v Value) (PTWithClause, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_with_clause" { + return PTWithClause{}, false + } + return PTWithClause{n}, true +} + +func (x PTWithClause) L() Value { return x.N.Arg("l") } +func (x PTWithClause) R() Value { return x.N.Arg("r") } + +// PTGroup views a PT_group node. +type PTGroup struct{ N *Node } + +// AsPTGroup returns the view when v is a PT_group. +func AsPTGroup(v Value) (PTGroup, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_group" { + return PTGroup{}, false + } + return PTGroup{n}, true +} + +func (x PTGroup) GroupList() Value { return x.N.Arg("group_list") } +func (x PTGroup) Olap() Value { return x.N.Arg("olap") } + +// PTOrderExpr views a PT_order_expr node. +type PTOrderExpr struct{ N *Node } + +// AsPTOrderExpr returns the view when v is a PT_order_expr. +func AsPTOrderExpr(v Value) (PTOrderExpr, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_order_expr" { + return PTOrderExpr{}, false + } + return PTOrderExpr{n}, true +} + +func (x PTOrderExpr) Item() Value { return x.N.Arg("item") } +func (x PTOrderExpr) Dir() Value { return x.N.Arg("dir") } + +// PTOrder views a PT_order node. +type PTOrder struct{ N *Node } + +// AsPTOrder returns the view when v is a PT_order. +func AsPTOrder(v Value) (PTOrder, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_order" { + return PTOrder{}, false + } + return PTOrder{n}, true +} + +func (x PTOrder) OrderList() Value { return x.N.Arg("order_list") } + +// PTLimitClause views a PT_limit_clause node. +type PTLimitClause struct{ N *Node } + +// AsPTLimitClause returns the view when v is a PT_limit_clause. +func AsPTLimitClause(v Value) (PTLimitClause, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_limit_clause" { + return PTLimitClause{}, false + } + return PTLimitClause{n}, true +} + +func (x PTLimitClause) LimitOptions() Value { return x.N.Arg("limit_options") } + +// PTILimitOptionIdent views a PTI_limit_option_ident node. +type PTILimitOptionIdent struct{ N *Node } + +// AsPTILimitOptionIdent returns the view when v is a PTI_limit_option_ident. +func AsPTILimitOptionIdent(v Value) (PTILimitOptionIdent, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_limit_option_ident" { + return PTILimitOptionIdent{}, false + } + return PTILimitOptionIdent{n}, true +} + +func (x PTILimitOptionIdent) Name() Value { return x.N.Arg("name") } + +// PTILimitOptionParamMarker views a PTI_limit_option_param_marker node. +type PTILimitOptionParamMarker struct{ N *Node } + +// AsPTILimitOptionParamMarker returns the view when v is a PTI_limit_option_param_marker. +func AsPTILimitOptionParamMarker(v Value) (PTILimitOptionParamMarker, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_limit_option_param_marker" { + return PTILimitOptionParamMarker{}, false + } + return PTILimitOptionParamMarker{n}, true +} + +func (x PTILimitOptionParamMarker) ParamMarker() Value { return x.N.Arg("param_marker") } + +// ItemUint views a Item_uint node. +type ItemUint struct{ N *Node } + +// AsItemUint returns the view when v is a Item_uint. +func AsItemUint(v Value) (ItemUint, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_uint" { + return ItemUint{}, false + } + return ItemUint{n}, true +} + +func (x ItemUint) Str() Value { return x.N.Arg("str") } +func (x ItemUint) Length() Value { return x.N.Arg("length") } + +// PTSelectVar views a PT_select_var node. +type PTSelectVar struct{ N *Node } + +// AsPTSelectVar returns the view when v is a PT_select_var. +func AsPTSelectVar(v Value) (PTSelectVar, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_select_var" { + return PTSelectVar{}, false + } + return PTSelectVar{n}, true +} + +func (x PTSelectVar) Name() Value { return x.N.Arg("name") } + +// PTSelectSpVar views a PT_select_sp_var node. +type PTSelectSpVar struct{ N *Node } + +// AsPTSelectSpVar returns the view when v is a PT_select_sp_var. +func AsPTSelectSpVar(v Value) (PTSelectSpVar, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_select_sp_var" { + return PTSelectSpVar{}, false + } + return PTSelectSpVar{n}, true +} + +func (x PTSelectSpVar) Name() Value { return x.N.Arg("name") } + +// PTIntoDestinationOutfile views a PT_into_destination_outfile node. +type PTIntoDestinationOutfile struct{ N *Node } + +// AsPTIntoDestinationOutfile returns the view when v is a PT_into_destination_outfile. +func AsPTIntoDestinationOutfile(v Value) (PTIntoDestinationOutfile, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_into_destination_outfile" { + return PTIntoDestinationOutfile{}, false + } + return PTIntoDestinationOutfile{n}, true +} + +func (x PTIntoDestinationOutfile) FileName() Value { return x.N.Arg("file_name") } +func (x PTIntoDestinationOutfile) Charset() Value { return x.N.Arg("charset") } +func (x PTIntoDestinationOutfile) FieldTerm() Value { return x.N.Arg("field_term") } +func (x PTIntoDestinationOutfile) LineTerm() Value { return x.N.Arg("line_term") } + +// PTIntoDestinationDumpfile views a PT_into_destination_dumpfile node. +type PTIntoDestinationDumpfile struct{ N *Node } + +// AsPTIntoDestinationDumpfile returns the view when v is a PT_into_destination_dumpfile. +func AsPTIntoDestinationDumpfile(v Value) (PTIntoDestinationDumpfile, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_into_destination_dumpfile" { + return PTIntoDestinationDumpfile{}, false + } + return PTIntoDestinationDumpfile{n}, true +} + +func (x PTIntoDestinationDumpfile) FileName() Value { return x.N.Arg("file_name") } + +// PTDropIndexStmt views a PT_drop_index_stmt node. +type PTDropIndexStmt struct{ N *Node } + +// AsPTDropIndexStmt returns the view when v is a PT_drop_index_stmt. +func AsPTDropIndexStmt(v Value) (PTDropIndexStmt, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_drop_index_stmt" { + return PTDropIndexStmt{}, false + } + return PTDropIndexStmt{n}, true +} + +func (x PTDropIndexStmt) IndexName() Value { return x.N.Arg("index_name") } +func (x PTDropIndexStmt) Table() Value { return x.N.Arg("table") } +func (x PTDropIndexStmt) Algo() Value { return x.N.Arg("algo") } +func (x PTDropIndexStmt) Lock() Value { return x.N.Arg("lock") } + +// PTDropResourceGroup views a PT_drop_resource_group node. +type PTDropResourceGroup struct{ N *Node } + +// AsPTDropResourceGroup returns the view when v is a PT_drop_resource_group. +func AsPTDropResourceGroup(v Value) (PTDropResourceGroup, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_drop_resource_group" { + return PTDropResourceGroup{}, false + } + return PTDropResourceGroup{n}, true +} + +func (x PTDropResourceGroup) ResourceGroupName() Value { return x.N.Arg("resource_group_name") } +func (x PTDropResourceGroup) Force() Value { return x.N.Arg("force") } + +// PTDropSrs views a PT_drop_srs node. +type PTDropSrs struct{ N *Node } + +// AsPTDropSrs returns the view when v is a PT_drop_srs. +func AsPTDropSrs(v Value) (PTDropSrs, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_drop_srs" { + return PTDropSrs{}, false + } + return PTDropSrs{n}, true +} + +func (x PTDropSrs) Srid() Value { return x.N.Arg("srid") } +func (x PTDropSrs) IfExists() Value { return x.N.Arg("if_exists") } + +// PTDropRole views a PT_drop_role node. +type PTDropRole struct{ N *Node } + +// AsPTDropRole returns the view when v is a PT_drop_role. +func AsPTDropRole(v Value) (PTDropRole, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_drop_role" { + return PTDropRole{}, false + } + return PTDropRole{n}, true +} + +func (x PTDropRole) IgnoreErrors() Value { return x.N.Arg("ignore_errors") } +func (x PTDropRole) Roles() Value { return x.N.Arg("roles") } + +// PTInsert views a PT_insert node. +type PTInsert struct{ N *Node } + +// AsPTInsert returns the view when v is a PT_insert. +func AsPTInsert(v Value) (PTInsert, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_insert" { + return PTInsert{}, false + } + return PTInsert{n}, true +} + +func (x PTInsert) IsReplace() Value { return x.N.Arg("is_replace") } +func (x PTInsert) OptHints() Value { return x.N.Arg("opt_hints") } +func (x PTInsert) LockOption() Value { return x.N.Arg("lock_option") } +func (x PTInsert) Ignore() Value { return x.N.Arg("ignore") } +func (x PTInsert) TableIdent() Value { return x.N.Arg("table_ident") } +func (x PTInsert) OptUsePartition() Value { return x.N.Arg("opt_use_partition") } +func (x PTInsert) ColumnList() Value { return x.N.Arg("column_list") } +func (x PTInsert) RowValueList() Value { return x.N.Arg("row_value_list") } +func (x PTInsert) InsertQueryExpression() Value { return x.N.Arg("insert_query_expression") } +func (x PTInsert) OptValuesTableAlias() Value { return x.N.Arg("opt_values_table_alias") } +func (x PTInsert) OptValuesColumnList() Value { return x.N.Arg("opt_values_column_list") } +func (x PTInsert) OptOnDuplicateColumnList() Value { return x.N.Arg("opt_on_duplicate_column_list") } +func (x PTInsert) OptOnDuplicateValueList() Value { return x.N.Arg("opt_on_duplicate_value_list") } + +// PTLocking views a PT_locking node. +type PTLocking struct{ N *Node } + +// AsPTLocking returns the view when v is a PT_locking. +func AsPTLocking(v Value) (PTLocking, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_locking" { + return PTLocking{}, false + } + return PTLocking{n}, true +} + +func (x PTLocking) Qe() Value { return x.N.Arg("qe") } +func (x PTLocking) LockingClauses() Value { return x.N.Arg("locking_clauses") } + +// PTUpdate views a PT_update node. +type PTUpdate struct{ N *Node } + +// AsPTUpdate returns the view when v is a PT_update. +func AsPTUpdate(v Value) (PTUpdate, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_update" { + return PTUpdate{}, false + } + return PTUpdate{n}, true +} + +func (x PTUpdate) WithClause() Value { return x.N.Arg("with_clause") } +func (x PTUpdate) OptHints() Value { return x.N.Arg("opt_hints") } +func (x PTUpdate) OptLowPriority() Value { return x.N.Arg("opt_low_priority") } +func (x PTUpdate) OptIgnore() Value { return x.N.Arg("opt_ignore") } +func (x PTUpdate) JoinTableList() Value { return x.N.Arg("join_table_list") } +func (x PTUpdate) ColumnList() Value { return x.N.Arg("column_list") } +func (x PTUpdate) ValueList() Value { return x.N.Arg("value_list") } +func (x PTUpdate) OptWhereClause() Value { return x.N.Arg("opt_where_clause") } +func (x PTUpdate) OptOrderClause() Value { return x.N.Arg("opt_order_clause") } +func (x PTUpdate) OptLimitClause() Value { return x.N.Arg("opt_limit_clause") } + +// PTDelete views a PT_delete node. +type PTDelete struct{ N *Node } + +// AsPTDelete returns the view when v is a PT_delete. +func AsPTDelete(v Value) (PTDelete, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_delete" { + return PTDelete{}, false + } + return PTDelete{n}, true +} + +func (x PTDelete) WithClause() Value { return x.N.Arg("with_clause") } +func (x PTDelete) OptHints() Value { return x.N.Arg("opt_hints") } +func (x PTDelete) OptDeleteOptions() Value { return x.N.Arg("opt_delete_options") } +func (x PTDelete) TableIdent() Value { return x.N.Arg("table_ident") } +func (x PTDelete) OptTableAlias() Value { return x.N.Arg("opt_table_alias") } +func (x PTDelete) OptUsePartition() Value { return x.N.Arg("opt_use_partition") } +func (x PTDelete) OptWhereClause() Value { return x.N.Arg("opt_where_clause") } +func (x PTDelete) OptOrderClause() Value { return x.N.Arg("opt_order_clause") } +func (x PTDelete) OptDeleteLimitClause() Value { return x.N.Arg("opt_delete_limit_clause") } +func (x PTDelete) TableList() Value { return x.N.Arg("table_list") } +func (x PTDelete) JoinTableList() Value { return x.N.Arg("join_table_list") } + +// PTTruncateTableStmt views a PT_truncate_table_stmt node. +type PTTruncateTableStmt struct{ N *Node } + +// AsPTTruncateTableStmt returns the view when v is a PT_truncate_table_stmt. +func AsPTTruncateTableStmt(v Value) (PTTruncateTableStmt, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_truncate_table_stmt" { + return PTTruncateTableStmt{}, false + } + return PTTruncateTableStmt{n}, true +} + +func (x PTTruncateTableStmt) Table() Value { return x.N.Arg("table") } + +// PTShowDatabases views a PT_show_databases node. +type PTShowDatabases struct{ N *Node } + +// AsPTShowDatabases returns the view when v is a PT_show_databases. +func AsPTShowDatabases(v Value) (PTShowDatabases, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_databases" { + return PTShowDatabases{}, false + } + return PTShowDatabases{n}, true +} + +func (x PTShowDatabases) Wild() Value { return x.N.Arg("wild") } +func (x PTShowDatabases) Where() Value { return x.N.Arg("where") } + +// PTShowTables views a PT_show_tables node. +type PTShowTables struct{ N *Node } + +// AsPTShowTables returns the view when v is a PT_show_tables. +func AsPTShowTables(v Value) (PTShowTables, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_tables" { + return PTShowTables{}, false + } + return PTShowTables{n}, true +} + +func (x PTShowTables) ShowCmdType() Value { return x.N.Arg("show_cmd_type") } +func (x PTShowTables) OptDb() Value { return x.N.Arg("opt_db") } +func (x PTShowTables) Wild() Value { return x.N.Arg("wild") } +func (x PTShowTables) Where() Value { return x.N.Arg("where") } + +// PTShowTriggers views a PT_show_triggers node. +type PTShowTriggers struct{ N *Node } + +// AsPTShowTriggers returns the view when v is a PT_show_triggers. +func AsPTShowTriggers(v Value) (PTShowTriggers, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_triggers" { + return PTShowTriggers{}, false + } + return PTShowTriggers{n}, true +} + +func (x PTShowTriggers) Full() Value { return x.N.Arg("full") } +func (x PTShowTriggers) OptDb() Value { return x.N.Arg("opt_db") } +func (x PTShowTriggers) Wild() Value { return x.N.Arg("wild") } +func (x PTShowTriggers) Where() Value { return x.N.Arg("where") } + +// PTShowEvents views a PT_show_events node. +type PTShowEvents struct{ N *Node } + +// AsPTShowEvents returns the view when v is a PT_show_events. +func AsPTShowEvents(v Value) (PTShowEvents, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_events" { + return PTShowEvents{}, false + } + return PTShowEvents{n}, true +} + +func (x PTShowEvents) OptDb() Value { return x.N.Arg("opt_db") } +func (x PTShowEvents) Wild() Value { return x.N.Arg("wild") } +func (x PTShowEvents) Where() Value { return x.N.Arg("where") } + +// PTShowTableStatus views a PT_show_table_status node. +type PTShowTableStatus struct{ N *Node } + +// AsPTShowTableStatus returns the view when v is a PT_show_table_status. +func AsPTShowTableStatus(v Value) (PTShowTableStatus, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_table_status" { + return PTShowTableStatus{}, false + } + return PTShowTableStatus{n}, true +} + +func (x PTShowTableStatus) OptDb() Value { return x.N.Arg("opt_db") } +func (x PTShowTableStatus) Wild() Value { return x.N.Arg("wild") } +func (x PTShowTableStatus) Where() Value { return x.N.Arg("where") } + +// PTShowOpenTables views a PT_show_open_tables node. +type PTShowOpenTables struct{ N *Node } + +// AsPTShowOpenTables returns the view when v is a PT_show_open_tables. +func AsPTShowOpenTables(v Value) (PTShowOpenTables, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_open_tables" { + return PTShowOpenTables{}, false + } + return PTShowOpenTables{n}, true +} + +func (x PTShowOpenTables) OptDb() Value { return x.N.Arg("opt_db") } +func (x PTShowOpenTables) Wild() Value { return x.N.Arg("wild") } +func (x PTShowOpenTables) Where() Value { return x.N.Arg("where") } + +// PTShowPlugins views a PT_show_plugins node. +type PTShowPlugins struct{ N *Node } + +// AsPTShowPlugins returns the view when v is a PT_show_plugins. +func AsPTShowPlugins(v Value) (PTShowPlugins, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_plugins" { + return PTShowPlugins{}, false + } + return PTShowPlugins{n}, true +} + +// PTShowEngineLogs views a PT_show_engine_logs node. +type PTShowEngineLogs struct{ N *Node } + +// AsPTShowEngineLogs returns the view when v is a PT_show_engine_logs. +func AsPTShowEngineLogs(v Value) (PTShowEngineLogs, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_engine_logs" { + return PTShowEngineLogs{}, false + } + return PTShowEngineLogs{n}, true +} + +func (x PTShowEngineLogs) OptEngine() Value { return x.N.Arg("opt_engine") } + +// PTShowEngineMutex views a PT_show_engine_mutex node. +type PTShowEngineMutex struct{ N *Node } + +// AsPTShowEngineMutex returns the view when v is a PT_show_engine_mutex. +func AsPTShowEngineMutex(v Value) (PTShowEngineMutex, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_engine_mutex" { + return PTShowEngineMutex{}, false + } + return PTShowEngineMutex{n}, true +} + +func (x PTShowEngineMutex) OptEngine() Value { return x.N.Arg("opt_engine") } + +// PTShowEngineStatus views a PT_show_engine_status node. +type PTShowEngineStatus struct{ N *Node } + +// AsPTShowEngineStatus returns the view when v is a PT_show_engine_status. +func AsPTShowEngineStatus(v Value) (PTShowEngineStatus, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_engine_status" { + return PTShowEngineStatus{}, false + } + return PTShowEngineStatus{n}, true +} + +func (x PTShowEngineStatus) OptEngine() Value { return x.N.Arg("opt_engine") } + +// PTShowBinlogs views a PT_show_binlogs node. +type PTShowBinlogs struct{ N *Node } + +// AsPTShowBinlogs returns the view when v is a PT_show_binlogs. +func AsPTShowBinlogs(v Value) (PTShowBinlogs, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_binlogs" { + return PTShowBinlogs{}, false + } + return PTShowBinlogs{n}, true +} + +// PTShowReplicas views a PT_show_replicas node. +type PTShowReplicas struct{ N *Node } + +// AsPTShowReplicas returns the view when v is a PT_show_replicas. +func AsPTShowReplicas(v Value) (PTShowReplicas, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_replicas" { + return PTShowReplicas{}, false + } + return PTShowReplicas{n}, true +} + +// PTShowBinlogEvents views a PT_show_binlog_events node. +type PTShowBinlogEvents struct{ N *Node } + +// AsPTShowBinlogEvents returns the view when v is a PT_show_binlog_events. +func AsPTShowBinlogEvents(v Value) (PTShowBinlogEvents, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_binlog_events" { + return PTShowBinlogEvents{}, false + } + return PTShowBinlogEvents{n}, true +} + +func (x PTShowBinlogEvents) OptLogFileName() Value { return x.N.Arg("opt_log_file_name") } +func (x PTShowBinlogEvents) OptLimitClause() Value { return x.N.Arg("opt_limit_clause") } + +// PTShowRelaylogEvents views a PT_show_relaylog_events node. +type PTShowRelaylogEvents struct{ N *Node } + +// AsPTShowRelaylogEvents returns the view when v is a PT_show_relaylog_events. +func AsPTShowRelaylogEvents(v Value) (PTShowRelaylogEvents, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_relaylog_events" { + return PTShowRelaylogEvents{}, false + } + return PTShowRelaylogEvents{n}, true +} + +func (x PTShowRelaylogEvents) OptLogFileName() Value { return x.N.Arg("opt_log_file_name") } +func (x PTShowRelaylogEvents) OptLimitClause() Value { return x.N.Arg("opt_limit_clause") } +func (x PTShowRelaylogEvents) OptChannelName() Value { return x.N.Arg("opt_channel_name") } + +// PTShowEngines views a PT_show_engines node. +type PTShowEngines struct{ N *Node } + +// AsPTShowEngines returns the view when v is a PT_show_engines. +func AsPTShowEngines(v Value) (PTShowEngines, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_engines" { + return PTShowEngines{}, false + } + return PTShowEngines{n}, true +} + +// PTShowCountWarnings views a PT_show_count_warnings node. +type PTShowCountWarnings struct{ N *Node } + +// AsPTShowCountWarnings returns the view when v is a PT_show_count_warnings. +func AsPTShowCountWarnings(v Value) (PTShowCountWarnings, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_count_warnings" { + return PTShowCountWarnings{}, false + } + return PTShowCountWarnings{n}, true +} + +// PTShowCountErrors views a PT_show_count_errors node. +type PTShowCountErrors struct{ N *Node } + +// AsPTShowCountErrors returns the view when v is a PT_show_count_errors. +func AsPTShowCountErrors(v Value) (PTShowCountErrors, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_count_errors" { + return PTShowCountErrors{}, false + } + return PTShowCountErrors{n}, true +} + +// PTShowWarnings views a PT_show_warnings node. +type PTShowWarnings struct{ N *Node } + +// AsPTShowWarnings returns the view when v is a PT_show_warnings. +func AsPTShowWarnings(v Value) (PTShowWarnings, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_warnings" { + return PTShowWarnings{}, false + } + return PTShowWarnings{n}, true +} + +func (x PTShowWarnings) OptLimitClause() Value { return x.N.Arg("opt_limit_clause") } + +// PTShowErrors views a PT_show_errors node. +type PTShowErrors struct{ N *Node } + +// AsPTShowErrors returns the view when v is a PT_show_errors. +func AsPTShowErrors(v Value) (PTShowErrors, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_errors" { + return PTShowErrors{}, false + } + return PTShowErrors{n}, true +} + +func (x PTShowErrors) OptLimitClause() Value { return x.N.Arg("opt_limit_clause") } + +// PTShowProfiles views a PT_show_profiles node. +type PTShowProfiles struct{ N *Node } + +// AsPTShowProfiles returns the view when v is a PT_show_profiles. +func AsPTShowProfiles(v Value) (PTShowProfiles, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_profiles" { + return PTShowProfiles{}, false + } + return PTShowProfiles{n}, true +} + +// PTShowProfile views a PT_show_profile node. +type PTShowProfile struct{ N *Node } + +// AsPTShowProfile returns the view when v is a PT_show_profile. +func AsPTShowProfile(v Value) (PTShowProfile, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_profile" { + return PTShowProfile{}, false + } + return PTShowProfile{n}, true +} + +func (x PTShowProfile) OptProfileOptions() Value { return x.N.Arg("opt_profile_options") } +func (x PTShowProfile) OptQueryId() Value { return x.N.Arg("opt_query_id") } +func (x PTShowProfile) OptLimitClause() Value { return x.N.Arg("opt_limit_clause") } + +// PTShowStatus views a PT_show_status node. +type PTShowStatus struct{ N *Node } + +// AsPTShowStatus returns the view when v is a PT_show_status. +func AsPTShowStatus(v Value) (PTShowStatus, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_status" { + return PTShowStatus{}, false + } + return PTShowStatus{n}, true +} + +func (x PTShowStatus) VarType() Value { return x.N.Arg("var_type") } +func (x PTShowStatus) Wild() Value { return x.N.Arg("wild") } +func (x PTShowStatus) Where() Value { return x.N.Arg("where") } + +// PTShowProcesslist views a PT_show_processlist node. +type PTShowProcesslist struct{ N *Node } + +// AsPTShowProcesslist returns the view when v is a PT_show_processlist. +func AsPTShowProcesslist(v Value) (PTShowProcesslist, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_processlist" { + return PTShowProcesslist{}, false + } + return PTShowProcesslist{n}, true +} + +func (x PTShowProcesslist) Verbose() Value { return x.N.Arg("verbose") } + +// PTShowVariables views a PT_show_variables node. +type PTShowVariables struct{ N *Node } + +// AsPTShowVariables returns the view when v is a PT_show_variables. +func AsPTShowVariables(v Value) (PTShowVariables, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_variables" { + return PTShowVariables{}, false + } + return PTShowVariables{n}, true +} + +func (x PTShowVariables) VarType() Value { return x.N.Arg("var_type") } +func (x PTShowVariables) Wild() Value { return x.N.Arg("wild") } +func (x PTShowVariables) Where() Value { return x.N.Arg("where") } + +// PTShowCharsets views a PT_show_charsets node. +type PTShowCharsets struct{ N *Node } + +// AsPTShowCharsets returns the view when v is a PT_show_charsets. +func AsPTShowCharsets(v Value) (PTShowCharsets, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_charsets" { + return PTShowCharsets{}, false + } + return PTShowCharsets{n}, true +} + +func (x PTShowCharsets) Wild() Value { return x.N.Arg("wild") } +func (x PTShowCharsets) Where() Value { return x.N.Arg("where") } + +// PTShowCollations views a PT_show_collations node. +type PTShowCollations struct{ N *Node } + +// AsPTShowCollations returns the view when v is a PT_show_collations. +func AsPTShowCollations(v Value) (PTShowCollations, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_collations" { + return PTShowCollations{}, false + } + return PTShowCollations{n}, true +} + +func (x PTShowCollations) Wild() Value { return x.N.Arg("wild") } +func (x PTShowCollations) Where() Value { return x.N.Arg("where") } + +// PTShowPrivileges views a PT_show_privileges node. +type PTShowPrivileges struct{ N *Node } + +// AsPTShowPrivileges returns the view when v is a PT_show_privileges. +func AsPTShowPrivileges(v Value) (PTShowPrivileges, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_privileges" { + return PTShowPrivileges{}, false + } + return PTShowPrivileges{n}, true +} + +// PTShowGrants views a PT_show_grants node. +type PTShowGrants struct{ N *Node } + +// AsPTShowGrants returns the view when v is a PT_show_grants. +func AsPTShowGrants(v Value) (PTShowGrants, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_grants" { + return PTShowGrants{}, false + } + return PTShowGrants{n}, true +} + +func (x PTShowGrants) OptForUser() Value { return x.N.Arg("opt_for_user") } +func (x PTShowGrants) OptUsingUsers() Value { return x.N.Arg("opt_using_users") } + +// PTShowCreateDatabase views a PT_show_create_database node. +type PTShowCreateDatabase struct{ N *Node } + +// AsPTShowCreateDatabase returns the view when v is a PT_show_create_database. +func AsPTShowCreateDatabase(v Value) (PTShowCreateDatabase, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_create_database" { + return PTShowCreateDatabase{}, false + } + return PTShowCreateDatabase{n}, true +} + +func (x PTShowCreateDatabase) IfNotExists() Value { return x.N.Arg("if_not_exists") } +func (x PTShowCreateDatabase) Name() Value { return x.N.Arg("name") } + +// PTShowCreateTable views a PT_show_create_table node. +type PTShowCreateTable struct{ N *Node } + +// AsPTShowCreateTable returns the view when v is a PT_show_create_table. +func AsPTShowCreateTable(v Value) (PTShowCreateTable, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_create_table" { + return PTShowCreateTable{}, false + } + return PTShowCreateTable{n}, true +} + +func (x PTShowCreateTable) TableIdent() Value { return x.N.Arg("table_ident") } + +// PTShowCreateView views a PT_show_create_view node. +type PTShowCreateView struct{ N *Node } + +// AsPTShowCreateView returns the view when v is a PT_show_create_view. +func AsPTShowCreateView(v Value) (PTShowCreateView, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_create_view" { + return PTShowCreateView{}, false + } + return PTShowCreateView{n}, true +} + +func (x PTShowCreateView) TableIdent() Value { return x.N.Arg("table_ident") } + +// PTShowBinaryLogStatus views a PT_show_binary_log_status node. +type PTShowBinaryLogStatus struct{ N *Node } + +// AsPTShowBinaryLogStatus returns the view when v is a PT_show_binary_log_status. +func AsPTShowBinaryLogStatus(v Value) (PTShowBinaryLogStatus, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_binary_log_status" { + return PTShowBinaryLogStatus{}, false + } + return PTShowBinaryLogStatus{n}, true +} + +// PTShowReplicaStatus views a PT_show_replica_status node. +type PTShowReplicaStatus struct{ N *Node } + +// AsPTShowReplicaStatus returns the view when v is a PT_show_replica_status. +func AsPTShowReplicaStatus(v Value) (PTShowReplicaStatus, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_replica_status" { + return PTShowReplicaStatus{}, false + } + return PTShowReplicaStatus{n}, true +} + +func (x PTShowReplicaStatus) OptChannelName() Value { return x.N.Arg("opt_channel_name") } + +// PTShowCreateProcedure views a PT_show_create_procedure node. +type PTShowCreateProcedure struct{ N *Node } + +// AsPTShowCreateProcedure returns the view when v is a PT_show_create_procedure. +func AsPTShowCreateProcedure(v Value) (PTShowCreateProcedure, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_create_procedure" { + return PTShowCreateProcedure{}, false + } + return PTShowCreateProcedure{n}, true +} + +func (x PTShowCreateProcedure) ProcedureName() Value { return x.N.Arg("procedure_name") } + +// PTShowCreateFunction views a PT_show_create_function node. +type PTShowCreateFunction struct{ N *Node } + +// AsPTShowCreateFunction returns the view when v is a PT_show_create_function. +func AsPTShowCreateFunction(v Value) (PTShowCreateFunction, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_create_function" { + return PTShowCreateFunction{}, false + } + return PTShowCreateFunction{n}, true +} + +func (x PTShowCreateFunction) FunctionName() Value { return x.N.Arg("function_name") } + +// PTShowCreateTrigger views a PT_show_create_trigger node. +type PTShowCreateTrigger struct{ N *Node } + +// AsPTShowCreateTrigger returns the view when v is a PT_show_create_trigger. +func AsPTShowCreateTrigger(v Value) (PTShowCreateTrigger, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_create_trigger" { + return PTShowCreateTrigger{}, false + } + return PTShowCreateTrigger{n}, true +} + +func (x PTShowCreateTrigger) TriggerName() Value { return x.N.Arg("trigger_name") } + +// PTShowStatusProc views a PT_show_status_proc node. +type PTShowStatusProc struct{ N *Node } + +// AsPTShowStatusProc returns the view when v is a PT_show_status_proc. +func AsPTShowStatusProc(v Value) (PTShowStatusProc, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_status_proc" { + return PTShowStatusProc{}, false + } + return PTShowStatusProc{n}, true +} + +func (x PTShowStatusProc) Wild() Value { return x.N.Arg("wild") } +func (x PTShowStatusProc) Where() Value { return x.N.Arg("where") } + +// PTShowStatusFunc views a PT_show_status_func node. +type PTShowStatusFunc struct{ N *Node } + +// AsPTShowStatusFunc returns the view when v is a PT_show_status_func. +func AsPTShowStatusFunc(v Value) (PTShowStatusFunc, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_status_func" { + return PTShowStatusFunc{}, false + } + return PTShowStatusFunc{n}, true +} + +func (x PTShowStatusFunc) Wild() Value { return x.N.Arg("wild") } +func (x PTShowStatusFunc) Where() Value { return x.N.Arg("where") } + +// PTShowProcedureCode views a PT_show_procedure_code node. +type PTShowProcedureCode struct{ N *Node } + +// AsPTShowProcedureCode returns the view when v is a PT_show_procedure_code. +func AsPTShowProcedureCode(v Value) (PTShowProcedureCode, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_procedure_code" { + return PTShowProcedureCode{}, false + } + return PTShowProcedureCode{n}, true +} + +func (x PTShowProcedureCode) ProcedureName() Value { return x.N.Arg("procedure_name") } + +// PTShowFunctionCode views a PT_show_function_code node. +type PTShowFunctionCode struct{ N *Node } + +// AsPTShowFunctionCode returns the view when v is a PT_show_function_code. +func AsPTShowFunctionCode(v Value) (PTShowFunctionCode, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_function_code" { + return PTShowFunctionCode{}, false + } + return PTShowFunctionCode{n}, true +} + +func (x PTShowFunctionCode) FunctionName() Value { return x.N.Arg("function_name") } + +// PTShowCreateEvent views a PT_show_create_event node. +type PTShowCreateEvent struct{ N *Node } + +// AsPTShowCreateEvent returns the view when v is a PT_show_create_event. +func AsPTShowCreateEvent(v Value) (PTShowCreateEvent, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_create_event" { + return PTShowCreateEvent{}, false + } + return PTShowCreateEvent{n}, true +} + +func (x PTShowCreateEvent) EventName() Value { return x.N.Arg("event_name") } + +// PTShowCreateUser views a PT_show_create_user node. +type PTShowCreateUser struct{ N *Node } + +// AsPTShowCreateUser returns the view when v is a PT_show_create_user. +func AsPTShowCreateUser(v Value) (PTShowCreateUser, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_create_user" { + return PTShowCreateUser{}, false + } + return PTShowCreateUser{n}, true +} + +func (x PTShowCreateUser) User() Value { return x.N.Arg("user") } + +// PTShowFields views a PT_show_fields node. +type PTShowFields struct{ N *Node } + +// AsPTShowFields returns the view when v is a PT_show_fields. +func AsPTShowFields(v Value) (PTShowFields, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_show_fields" { + return PTShowFields{}, false + } + return PTShowFields{n}, true +} + +func (x PTShowFields) ShowCmdType() Value { return x.N.Arg("show_cmd_type") } +func (x PTShowFields) Table() Value { return x.N.Arg("table") } +func (x PTShowFields) OptWild() Value { return x.N.Arg("opt_wild") } + +// PTExplain views a PT_explain node. +type PTExplain struct{ N *Node } + +// AsPTExplain returns the view when v is a PT_explain. +func AsPTExplain(v Value) (PTExplain, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_explain" { + return PTExplain{}, false + } + return PTExplain{n}, true +} + +func (x PTExplain) Format() Value { return x.N.Arg("format") } +func (x PTExplain) IsAnalyze() Value { return x.N.Arg("is_analyze") } +func (x PTExplain) IsExplicitFormat() Value { return x.N.Arg("is_explicit_format") } +func (x PTExplain) ExplainableStmt() Value { return x.N.Arg("explainable_stmt") } +func (x PTExplain) ExplainIntoVariableName() Value { return x.N.Arg("explain_into_variable_name") } +func (x PTExplain) SchemaNameForExplain() Value { return x.N.Arg("schema_name_for_explain") } + +// PTLoadTable views a PT_load_table node. +type PTLoadTable struct{ N *Node } + +// AsPTLoadTable returns the view when v is a PT_load_table. +func AsPTLoadTable(v Value) (PTLoadTable, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_load_table" { + return PTLoadTable{}, false + } + return PTLoadTable{n}, true +} + +func (x PTLoadTable) Filetype() Value { return x.N.Arg("filetype") } +func (x PTLoadTable) LockType() Value { return x.N.Arg("lock_type") } +func (x PTLoadTable) IsLocalFile() Value { return x.N.Arg("is_local_file") } +func (x PTLoadTable) SourceType() Value { return x.N.Arg("source_type") } +func (x PTLoadTable) Filename() Value { return x.N.Arg("filename") } +func (x PTLoadTable) FileCount() Value { return x.N.Arg("file_count") } +func (x PTLoadTable) InKeyOrder() Value { return x.N.Arg("in_key_order") } +func (x PTLoadTable) OnDuplicate() Value { return x.N.Arg("on_duplicate") } +func (x PTLoadTable) Table() Value { return x.N.Arg("table") } +func (x PTLoadTable) OptPartitions() Value { return x.N.Arg("opt_partitions") } +func (x PTLoadTable) OptCharset() Value { return x.N.Arg("opt_charset") } +func (x PTLoadTable) CompressionAlgorithm() Value { return x.N.Arg("compression_algorithm") } +func (x PTLoadTable) OptXmlRowsIdentifiedBy() Value { return x.N.Arg("opt_xml_rows_identified_by") } +func (x PTLoadTable) OptFieldSeparators() Value { return x.N.Arg("opt_field_separators") } +func (x PTLoadTable) OptLineSeparators() Value { return x.N.Arg("opt_line_separators") } +func (x PTLoadTable) OptIgnoreLines() Value { return x.N.Arg("opt_ignore_lines") } +func (x PTLoadTable) OptFieldsOrVars() Value { return x.N.Arg("opt_fields_or_vars") } +func (x PTLoadTable) OptSetFields() Value { return x.N.Arg("opt_set_fields") } +func (x PTLoadTable) OptSetExprs() Value { return x.N.Arg("opt_set_exprs") } +func (x PTLoadTable) OptSetExprStrings() Value { return x.N.Arg("opt_set_expr_strings") } +func (x PTLoadTable) Parallel() Value { return x.N.Arg("parallel") } +func (x PTLoadTable) MemorySize() Value { return x.N.Arg("memory_size") } +func (x PTLoadTable) IsBulkOperation() Value { return x.N.Arg("is_bulk_operation") } + +// ItemUserVarAsOutParam views a Item_user_var_as_out_param node. +type ItemUserVarAsOutParam struct{ N *Node } + +// AsItemUserVarAsOutParam returns the view when v is a Item_user_var_as_out_param. +func AsItemUserVarAsOutParam(v Value) (ItemUserVarAsOutParam, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_user_var_as_out_param" { + return ItemUserVarAsOutParam{}, false + } + return ItemUserVarAsOutParam{n}, true +} + +func (x ItemUserVarAsOutParam) A() Value { return x.N.Arg("a") } + +// PTITextLiteralTextString views a PTI_text_literal_text_string node. +type PTITextLiteralTextString struct{ N *Node } + +// AsPTITextLiteralTextString returns the view when v is a PTI_text_literal_text_string. +func AsPTITextLiteralTextString(v Value) (PTITextLiteralTextString, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_text_literal_text_string" { + return PTITextLiteralTextString{}, false + } + return PTITextLiteralTextString{n}, true +} + +func (x PTITextLiteralTextString) Is7bit() Value { return x.N.Arg("is_7bit") } +func (x PTITextLiteralTextString) Literal() Value { return x.N.Arg("literal") } + +// PTITextLiteralNcharString views a PTI_text_literal_nchar_string node. +type PTITextLiteralNcharString struct{ N *Node } + +// AsPTITextLiteralNcharString returns the view when v is a PTI_text_literal_nchar_string. +func AsPTITextLiteralNcharString(v Value) (PTITextLiteralNcharString, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_text_literal_nchar_string" { + return PTITextLiteralNcharString{}, false + } + return PTITextLiteralNcharString{n}, true +} + +func (x PTITextLiteralNcharString) Is7bit() Value { return x.N.Arg("is_7bit") } +func (x PTITextLiteralNcharString) Literal() Value { return x.N.Arg("literal") } + +// PTITextLiteralUnderscoreCharset views a PTI_text_literal_underscore_charset node. +type PTITextLiteralUnderscoreCharset struct{ N *Node } + +// AsPTITextLiteralUnderscoreCharset returns the view when v is a PTI_text_literal_underscore_charset. +func AsPTITextLiteralUnderscoreCharset(v Value) (PTITextLiteralUnderscoreCharset, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_text_literal_underscore_charset" { + return PTITextLiteralUnderscoreCharset{}, false + } + return PTITextLiteralUnderscoreCharset{n}, true +} + +func (x PTITextLiteralUnderscoreCharset) Is7bit() Value { return x.N.Arg("is_7bit") } +func (x PTITextLiteralUnderscoreCharset) Cs() Value { return x.N.Arg("cs") } +func (x PTITextLiteralUnderscoreCharset) Literal() Value { return x.N.Arg("literal") } + +// PTITextLiteralConcat views a PTI_text_literal_concat node. +type PTITextLiteralConcat struct{ N *Node } + +// AsPTITextLiteralConcat returns the view when v is a PTI_text_literal_concat. +func AsPTITextLiteralConcat(v Value) (PTITextLiteralConcat, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_text_literal_concat" { + return PTITextLiteralConcat{}, false + } + return PTITextLiteralConcat{n}, true +} + +func (x PTITextLiteralConcat) Is7bit() Value { return x.N.Arg("is_7bit") } +func (x PTITextLiteralConcat) Head() Value { return x.N.Arg("head") } +func (x PTITextLiteralConcat) Tail() Value { return x.N.Arg("tail") } + +// String views a String node. +type String struct{ N *Node } + +// AsString returns the view when v is a String. +func AsString(v Value) (String, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "String" { + return String{}, false + } + return String{n}, true +} + +func (x String) Str() Value { return x.N.Arg("str") } +func (x String) Len() Value { return x.N.Arg("len") } +func (x String) Cs() Value { return x.N.Arg("cs") } + +// ItemNull views a Item_null node. +type ItemNull struct{ N *Node } + +// AsItemNull returns the view when v is a Item_null. +func AsItemNull(v Value) (ItemNull, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_null" { + return ItemNull{}, false + } + return ItemNull{n}, true +} + +// ItemFuncFalse views a Item_func_false node. +type ItemFuncFalse struct{ N *Node } + +// AsItemFuncFalse returns the view when v is a Item_func_false. +func AsItemFuncFalse(v Value) (ItemFuncFalse, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_false" { + return ItemFuncFalse{}, false + } + return ItemFuncFalse{n}, true +} + +// ItemFuncTrue views a Item_func_true node. +type ItemFuncTrue struct{ N *Node } + +// AsItemFuncTrue returns the view when v is a Item_func_true. +func AsItemFuncTrue(v Value) (ItemFuncTrue, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_func_true" { + return ItemFuncTrue{}, false + } + return ItemFuncTrue{n}, true +} + +// ItemHexString views a Item_hex_string node. +type ItemHexString struct{ N *Node } + +// AsItemHexString returns the view when v is a Item_hex_string. +func AsItemHexString(v Value) (ItemHexString, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_hex_string" { + return ItemHexString{}, false + } + return ItemHexString{n}, true +} + +func (x ItemHexString) Literal() Value { return x.N.Arg("literal") } + +// ItemBinString views a Item_bin_string node. +type ItemBinString struct{ N *Node } + +// AsItemBinString returns the view when v is a Item_bin_string. +func AsItemBinString(v Value) (ItemBinString, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_bin_string" { + return ItemBinString{}, false + } + return ItemBinString{n}, true +} + +func (x ItemBinString) Literal() Value { return x.N.Arg("literal") } + +// PTILiteralUnderscoreCharsetHexNum views a PTI_literal_underscore_charset_hex_num node. +type PTILiteralUnderscoreCharsetHexNum struct{ N *Node } + +// AsPTILiteralUnderscoreCharsetHexNum returns the view when v is a PTI_literal_underscore_charset_hex_num. +func AsPTILiteralUnderscoreCharsetHexNum(v Value) (PTILiteralUnderscoreCharsetHexNum, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_literal_underscore_charset_hex_num" { + return PTILiteralUnderscoreCharsetHexNum{}, false + } + return PTILiteralUnderscoreCharsetHexNum{n}, true +} + +func (x PTILiteralUnderscoreCharsetHexNum) Charset() Value { return x.N.Arg("charset") } +func (x PTILiteralUnderscoreCharsetHexNum) Literal() Value { return x.N.Arg("literal") } + +// PTILiteralUnderscoreCharsetBinNum views a PTI_literal_underscore_charset_bin_num node. +type PTILiteralUnderscoreCharsetBinNum struct{ N *Node } + +// AsPTILiteralUnderscoreCharsetBinNum returns the view when v is a PTI_literal_underscore_charset_bin_num. +func AsPTILiteralUnderscoreCharsetBinNum(v Value) (PTILiteralUnderscoreCharsetBinNum, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_literal_underscore_charset_bin_num" { + return PTILiteralUnderscoreCharsetBinNum{}, false + } + return PTILiteralUnderscoreCharsetBinNum{n}, true +} + +func (x PTILiteralUnderscoreCharsetBinNum) Charset() Value { return x.N.Arg("charset") } +func (x PTILiteralUnderscoreCharsetBinNum) Literal() Value { return x.N.Arg("literal") } + +// ItemDecimal views a Item_decimal node. +type ItemDecimal struct{ N *Node } + +// AsItemDecimal returns the view when v is a Item_decimal. +func AsItemDecimal(v Value) (ItemDecimal, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_decimal" { + return ItemDecimal{}, false + } + return ItemDecimal{n}, true +} + +func (x ItemDecimal) Str() Value { return x.N.Arg("str") } +func (x ItemDecimal) Length() Value { return x.N.Arg("length") } +func (x ItemDecimal) Charset() Value { return x.N.Arg("charset") } + +// ItemFloat views a Item_float node. +type ItemFloat struct{ N *Node } + +// AsItemFloat returns the view when v is a Item_float. +func AsItemFloat(v Value) (ItemFloat, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_float" { + return ItemFloat{}, false + } + return ItemFloat{n}, true +} + +func (x ItemFloat) Str() Value { return x.N.Arg("str") } +func (x ItemFloat) Length() Value { return x.N.Arg("length") } + +// ItemInt views a Item_int node. +type ItemInt struct{ N *Node } + +// AsItemInt returns the view when v is a Item_int. +func AsItemInt(v Value) (ItemInt, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_int" { + return ItemInt{}, false + } + return ItemInt{n}, true +} + +func (x ItemInt) I() Value { return x.N.Arg("i") } + +// PTITemporalLiteral views a PTI_temporal_literal node. +type PTITemporalLiteral struct{ N *Node } + +// AsPTITemporalLiteral returns the view when v is a PTI_temporal_literal. +func AsPTITemporalLiteral(v Value) (PTITemporalLiteral, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_temporal_literal" { + return PTITemporalLiteral{}, false + } + return PTITemporalLiteral{n}, true +} + +func (x PTITemporalLiteral) Literal() Value { return x.N.Arg("literal") } +func (x PTITemporalLiteral) FieldType() Value { return x.N.Arg("field_type") } +func (x PTITemporalLiteral) Cs() Value { return x.N.Arg("cs") } + +// ItemAsterisk views a Item_asterisk node. +type ItemAsterisk struct{ N *Node } + +// AsItemAsterisk returns the view when v is a Item_asterisk. +func AsItemAsterisk(v Value) (ItemAsterisk, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Item_asterisk" { + return ItemAsterisk{}, false + } + return ItemAsterisk{n}, true +} + +func (x ItemAsterisk) OptSchemaName() Value { return x.N.Arg("opt_schema_name") } +func (x ItemAsterisk) OptTableName() Value { return x.N.Arg("opt_table_name") } + +// PTISimpleIdentIdent views a PTI_simple_ident_ident node. +type PTISimpleIdentIdent struct{ N *Node } + +// AsPTISimpleIdentIdent returns the view when v is a PTI_simple_ident_ident. +func AsPTISimpleIdentIdent(v Value) (PTISimpleIdentIdent, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_simple_ident_ident" { + return PTISimpleIdentIdent{}, false + } + return PTISimpleIdentIdent{n}, true +} + +func (x PTISimpleIdentIdent) Ident() Value { return x.N.Arg("ident") } + +// PTISimpleIdentNospvarIdent views a PTI_simple_ident_nospvar_ident node. +type PTISimpleIdentNospvarIdent struct{ N *Node } + +// AsPTISimpleIdentNospvarIdent returns the view when v is a PTI_simple_ident_nospvar_ident. +func AsPTISimpleIdentNospvarIdent(v Value) (PTISimpleIdentNospvarIdent, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_simple_ident_nospvar_ident" { + return PTISimpleIdentNospvarIdent{}, false + } + return PTISimpleIdentNospvarIdent{n}, true +} + +func (x PTISimpleIdentNospvarIdent) Ident() Value { return x.N.Arg("ident") } + +// PTISimpleIdentQ2d views a PTI_simple_ident_q_2d node. +type PTISimpleIdentQ2d struct{ N *Node } + +// AsPTISimpleIdentQ2d returns the view when v is a PTI_simple_ident_q_2d. +func AsPTISimpleIdentQ2d(v Value) (PTISimpleIdentQ2d, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_simple_ident_q_2d" { + return PTISimpleIdentQ2d{}, false + } + return PTISimpleIdentQ2d{n}, true +} + +func (x PTISimpleIdentQ2d) Table() Value { return x.N.Arg("table") } +func (x PTISimpleIdentQ2d) Field() Value { return x.N.Arg("field") } + +// PTISimpleIdentQ3d views a PTI_simple_ident_q_3d node. +type PTISimpleIdentQ3d struct{ N *Node } + +// AsPTISimpleIdentQ3d returns the view when v is a PTI_simple_ident_q_3d. +func AsPTISimpleIdentQ3d(v Value) (PTISimpleIdentQ3d, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PTI_simple_ident_q_3d" { + return PTISimpleIdentQ3d{}, false + } + return PTISimpleIdentQ3d{n}, true +} + +func (x PTISimpleIdentQ3d) Db() Value { return x.N.Arg("db") } +func (x PTISimpleIdentQ3d) Table() Value { return x.N.Arg("table") } +func (x PTISimpleIdentQ3d) Field() Value { return x.N.Arg("field") } + +// TableIdent views a Table_ident node. +type TableIdent struct{ N *Node } + +// AsTableIdent returns the view when v is a Table_ident. +func AsTableIdent(v Value) (TableIdent, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Table_ident" { + return TableIdent{}, false + } + return TableIdent{n}, true +} + +func (x TableIdent) Table() Value { return x.N.Arg("table") } +func (x TableIdent) Protocol() Value { return x.N.Arg("protocol") } +func (x TableIdent) Db() Value { return x.N.Arg("db") } +func (x TableIdent) Force() Value { return x.N.Arg("force") } + +// ToLexString views a to_lex_string node. +type ToLexString struct{ N *Node } + +// AsToLexString returns the view when v is a to_lex_string. +func AsToLexString(v Value) (ToLexString, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "to_lex_string" { + return ToLexString{}, false + } + return ToLexString{n}, true +} + +func (x ToLexString) S() Value { return x.N.Arg("s") } + +// PTSet views a PT_set node. +type PTSet struct{ N *Node } + +// AsPTSet returns the view when v is a PT_set. +func AsPTSet(v Value) (PTSet, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_set" { + return PTSet{}, false + } + return PTSet{n}, true +} + +func (x PTSet) List() Value { return x.N.Arg("list") } + +// PTStartOptionValueListNoType views a PT_start_option_value_list_no_type node. +type PTStartOptionValueListNoType struct{ N *Node } + +// AsPTStartOptionValueListNoType returns the view when v is a PT_start_option_value_list_no_type. +func AsPTStartOptionValueListNoType(v Value) (PTStartOptionValueListNoType, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_start_option_value_list_no_type" { + return PTStartOptionValueListNoType{}, false + } + return PTStartOptionValueListNoType{n}, true +} + +func (x PTStartOptionValueListNoType) Head() Value { return x.N.Arg("head") } +func (x PTStartOptionValueListNoType) Tail() Value { return x.N.Arg("tail") } + +// PTStartOptionValueListTransaction views a PT_start_option_value_list_transaction node. +type PTStartOptionValueListTransaction struct{ N *Node } + +// AsPTStartOptionValueListTransaction returns the view when v is a PT_start_option_value_list_transaction. +func AsPTStartOptionValueListTransaction(v Value) (PTStartOptionValueListTransaction, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_start_option_value_list_transaction" { + return PTStartOptionValueListTransaction{}, false + } + return PTStartOptionValueListTransaction{n}, true +} + +func (x PTStartOptionValueListTransaction) Characteristics() Value { return x.N.Arg("characteristics") } + +// PTStartOptionValueListType views a PT_start_option_value_list_type node. +type PTStartOptionValueListType struct{ N *Node } + +// AsPTStartOptionValueListType returns the view when v is a PT_start_option_value_list_type. +func AsPTStartOptionValueListType(v Value) (PTStartOptionValueListType, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_start_option_value_list_type" { + return PTStartOptionValueListType{}, false + } + return PTStartOptionValueListType{n}, true +} + +func (x PTStartOptionValueListType) Type() Value { return x.N.Arg("type") } +func (x PTStartOptionValueListType) List() Value { return x.N.Arg("list") } + +// PTOptionValueNoOptionTypePassword views a PT_option_value_no_option_type_password node. +type PTOptionValueNoOptionTypePassword struct{ N *Node } + +// AsPTOptionValueNoOptionTypePassword returns the view when v is a PT_option_value_no_option_type_password. +func AsPTOptionValueNoOptionTypePassword(v Value) (PTOptionValueNoOptionTypePassword, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_option_value_no_option_type_password" { + return PTOptionValueNoOptionTypePassword{}, false + } + return PTOptionValueNoOptionTypePassword{n}, true +} + +func (x PTOptionValueNoOptionTypePassword) Password() Value { return x.N.Arg("password") } +func (x PTOptionValueNoOptionTypePassword) CurrentPassword() Value { + return x.N.Arg("current_password") +} +func (x PTOptionValueNoOptionTypePassword) RetainCurrent() Value { return x.N.Arg("retain_current") } +func (x PTOptionValueNoOptionTypePassword) RandomPassword() Value { return x.N.Arg("random_password") } + +// PTOptionValueNoOptionTypePasswordFor views a PT_option_value_no_option_type_password_for node. +type PTOptionValueNoOptionTypePasswordFor struct{ N *Node } + +// AsPTOptionValueNoOptionTypePasswordFor returns the view when v is a PT_option_value_no_option_type_password_for. +func AsPTOptionValueNoOptionTypePasswordFor(v Value) (PTOptionValueNoOptionTypePasswordFor, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_option_value_no_option_type_password_for" { + return PTOptionValueNoOptionTypePasswordFor{}, false + } + return PTOptionValueNoOptionTypePasswordFor{n}, true +} + +func (x PTOptionValueNoOptionTypePasswordFor) User() Value { return x.N.Arg("user") } +func (x PTOptionValueNoOptionTypePasswordFor) Password() Value { return x.N.Arg("password") } +func (x PTOptionValueNoOptionTypePasswordFor) CurrentPassword() Value { + return x.N.Arg("current_password") +} +func (x PTOptionValueNoOptionTypePasswordFor) RetainCurrent() Value { return x.N.Arg("retain_current") } +func (x PTOptionValueNoOptionTypePasswordFor) RandomPass() Value { return x.N.Arg("random_pass") } + +// PTSetRole views a PT_set_role node. +type PTSetRole struct{ N *Node } + +// AsPTSetRole returns the view when v is a PT_set_role. +func AsPTSetRole(v Value) (PTSetRole, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_set_role" { + return PTSetRole{}, false + } + return PTSetRole{n}, true +} + +func (x PTSetRole) Roles() Value { return x.N.Arg("roles") } + +// PTAlterUserDefaultRole views a PT_alter_user_default_role node. +type PTAlterUserDefaultRole struct{ N *Node } + +// AsPTAlterUserDefaultRole returns the view when v is a PT_alter_user_default_role. +func AsPTAlterUserDefaultRole(v Value) (PTAlterUserDefaultRole, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_user_default_role" { + return PTAlterUserDefaultRole{}, false + } + return PTAlterUserDefaultRole{n}, true +} + +func (x PTAlterUserDefaultRole) IfExists() Value { return x.N.Arg("if_exists") } +func (x PTAlterUserDefaultRole) Users() Value { return x.N.Arg("users") } +func (x PTAlterUserDefaultRole) Roles() Value { return x.N.Arg("roles") } +func (x PTAlterUserDefaultRole) RoleType() Value { return x.N.Arg("role_type") } + +// PTSetResourceGroup views a PT_set_resource_group node. +type PTSetResourceGroup struct{ N *Node } + +// AsPTSetResourceGroup returns the view when v is a PT_set_resource_group. +func AsPTSetResourceGroup(v Value) (PTSetResourceGroup, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_set_resource_group" { + return PTSetResourceGroup{}, false + } + return PTSetResourceGroup{n}, true +} + +func (x PTSetResourceGroup) Name() Value { return x.N.Arg("name") } +func (x PTSetResourceGroup) ThreadIdList() Value { return x.N.Arg("thread_id_list") } + +// PTStartOptionValueListFollowingOptionTypeEq views a PT_start_option_value_list_following_option_type_eq node. +type PTStartOptionValueListFollowingOptionTypeEq struct{ N *Node } + +// AsPTStartOptionValueListFollowingOptionTypeEq returns the view when v is a PT_start_option_value_list_following_option_type_eq. +func AsPTStartOptionValueListFollowingOptionTypeEq(v Value) (PTStartOptionValueListFollowingOptionTypeEq, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_start_option_value_list_following_option_type_eq" { + return PTStartOptionValueListFollowingOptionTypeEq{}, false + } + return PTStartOptionValueListFollowingOptionTypeEq{n}, true +} + +func (x PTStartOptionValueListFollowingOptionTypeEq) Head() Value { return x.N.Arg("head") } +func (x PTStartOptionValueListFollowingOptionTypeEq) OptTail() Value { return x.N.Arg("opt_tail") } + +// PTStartOptionValueListFollowingOptionTypeTransaction views a PT_start_option_value_list_following_option_type_transaction node. +type PTStartOptionValueListFollowingOptionTypeTransaction struct{ N *Node } + +// AsPTStartOptionValueListFollowingOptionTypeTransaction returns the view when v is a PT_start_option_value_list_following_option_type_transaction. +func AsPTStartOptionValueListFollowingOptionTypeTransaction(v Value) (PTStartOptionValueListFollowingOptionTypeTransaction, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_start_option_value_list_following_option_type_transaction" { + return PTStartOptionValueListFollowingOptionTypeTransaction{}, false + } + return PTStartOptionValueListFollowingOptionTypeTransaction{n}, true +} + +func (x PTStartOptionValueListFollowingOptionTypeTransaction) Characteristics() Value { + return x.N.Arg("characteristics") +} + +// PTOptionValueListHead views a PT_option_value_list_head node. +type PTOptionValueListHead struct{ N *Node } + +// AsPTOptionValueListHead returns the view when v is a PT_option_value_list_head. +func AsPTOptionValueListHead(v Value) (PTOptionValueListHead, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_option_value_list_head" { + return PTOptionValueListHead{}, false + } + return PTOptionValueListHead{n}, true +} + +func (x PTOptionValueListHead) Value() Value { return x.N.Arg("value") } + +// PTOptionValueList views a PT_option_value_list node. +type PTOptionValueList struct{ N *Node } + +// AsPTOptionValueList returns the view when v is a PT_option_value_list. +func AsPTOptionValueList(v Value) (PTOptionValueList, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_option_value_list" { + return PTOptionValueList{}, false + } + return PTOptionValueList{n}, true +} + +func (x PTOptionValueList) Head() Value { return x.N.Arg("head") } +func (x PTOptionValueList) Tail() Value { return x.N.Arg("tail") } + +// PTOptionValueType views a PT_option_value_type node. +type PTOptionValueType struct{ N *Node } + +// AsPTOptionValueType returns the view when v is a PT_option_value_type. +func AsPTOptionValueType(v Value) (PTOptionValueType, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_option_value_type" { + return PTOptionValueType{}, false + } + return PTOptionValueType{n}, true +} + +func (x PTOptionValueType) Type() Value { return x.N.Arg("type") } +func (x PTOptionValueType) Value() Value { return x.N.Arg("value") } + +// PTSetScopedSystemVariable views a PT_set_scoped_system_variable node. +type PTSetScopedSystemVariable struct{ N *Node } + +// AsPTSetScopedSystemVariable returns the view when v is a PT_set_scoped_system_variable. +func AsPTSetScopedSystemVariable(v Value) (PTSetScopedSystemVariable, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_set_scoped_system_variable" { + return PTSetScopedSystemVariable{}, false + } + return PTSetScopedSystemVariable{n}, true +} + +func (x PTSetScopedSystemVariable) OptPrefix() Value { return x.N.Arg("opt_prefix") } +func (x PTSetScopedSystemVariable) Name() Value { return x.N.Arg("name") } +func (x PTSetScopedSystemVariable) OptExpr() Value { return x.N.Arg("opt_expr") } + +// PTSetVariable views a PT_set_variable node. +type PTSetVariable struct{ N *Node } + +// AsPTSetVariable returns the view when v is a PT_set_variable. +func AsPTSetVariable(v Value) (PTSetVariable, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_set_variable" { + return PTSetVariable{}, false + } + return PTSetVariable{n}, true +} + +func (x PTSetVariable) OptPrefix() Value { return x.N.Arg("opt_prefix") } +func (x PTSetVariable) Name() Value { return x.N.Arg("name") } +func (x PTSetVariable) OptExpr() Value { return x.N.Arg("opt_expr") } + +// PTOptionValueNoOptionTypeUserVar views a PT_option_value_no_option_type_user_var node. +type PTOptionValueNoOptionTypeUserVar struct{ N *Node } + +// AsPTOptionValueNoOptionTypeUserVar returns the view when v is a PT_option_value_no_option_type_user_var. +func AsPTOptionValueNoOptionTypeUserVar(v Value) (PTOptionValueNoOptionTypeUserVar, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_option_value_no_option_type_user_var" { + return PTOptionValueNoOptionTypeUserVar{}, false + } + return PTOptionValueNoOptionTypeUserVar{n}, true +} + +func (x PTOptionValueNoOptionTypeUserVar) Name() Value { return x.N.Arg("name") } +func (x PTOptionValueNoOptionTypeUserVar) Expr() Value { return x.N.Arg("expr") } + +// PTSetSystemVariable views a PT_set_system_variable node. +type PTSetSystemVariable struct{ N *Node } + +// AsPTSetSystemVariable returns the view when v is a PT_set_system_variable. +func AsPTSetSystemVariable(v Value) (PTSetSystemVariable, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_set_system_variable" { + return PTSetSystemVariable{}, false + } + return PTSetSystemVariable{n}, true +} + +func (x PTSetSystemVariable) Scope() Value { return x.N.Arg("scope") } +func (x PTSetSystemVariable) OptPrefix() Value { return x.N.Arg("opt_prefix") } +func (x PTSetSystemVariable) Name() Value { return x.N.Arg("name") } +func (x PTSetSystemVariable) OptExpr() Value { return x.N.Arg("opt_expr") } + +// PTOptionValueNoOptionTypeCharset views a PT_option_value_no_option_type_charset node. +type PTOptionValueNoOptionTypeCharset struct{ N *Node } + +// AsPTOptionValueNoOptionTypeCharset returns the view when v is a PT_option_value_no_option_type_charset. +func AsPTOptionValueNoOptionTypeCharset(v Value) (PTOptionValueNoOptionTypeCharset, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_option_value_no_option_type_charset" { + return PTOptionValueNoOptionTypeCharset{}, false + } + return PTOptionValueNoOptionTypeCharset{n}, true +} + +func (x PTOptionValueNoOptionTypeCharset) OptCharset() Value { return x.N.Arg("opt_charset") } + +// PTOptionValueNoOptionTypeNames views a PT_option_value_no_option_type_names node. +type PTOptionValueNoOptionTypeNames struct{ N *Node } + +// AsPTOptionValueNoOptionTypeNames returns the view when v is a PT_option_value_no_option_type_names. +func AsPTOptionValueNoOptionTypeNames(v Value) (PTOptionValueNoOptionTypeNames, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_option_value_no_option_type_names" { + return PTOptionValueNoOptionTypeNames{}, false + } + return PTOptionValueNoOptionTypeNames{n}, true +} + +// PTSetNames views a PT_set_names node. +type PTSetNames struct{ N *Node } + +// AsPTSetNames returns the view when v is a PT_set_names. +func AsPTSetNames(v Value) (PTSetNames, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_set_names" { + return PTSetNames{}, false + } + return PTSetNames{n}, true +} + +func (x PTSetNames) OptCharset() Value { return x.N.Arg("opt_charset") } +func (x PTSetNames) OptCollation() Value { return x.N.Arg("opt_collation") } + +// PTTransactionCharacteristics views a PT_transaction_characteristics node. +type PTTransactionCharacteristics struct{ N *Node } + +// AsPTTransactionCharacteristics returns the view when v is a PT_transaction_characteristics. +func AsPTTransactionCharacteristics(v Value) (PTTransactionCharacteristics, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_transaction_characteristics" { + return PTTransactionCharacteristics{}, false + } + return PTTransactionCharacteristics{n}, true +} + +func (x PTTransactionCharacteristics) Head() Value { return x.N.Arg("head") } +func (x PTTransactionCharacteristics) OptTail() Value { return x.N.Arg("opt_tail") } + +// PTTransactionAccessMode views a PT_transaction_access_mode node. +type PTTransactionAccessMode struct{ N *Node } + +// AsPTTransactionAccessMode returns the view when v is a PT_transaction_access_mode. +func AsPTTransactionAccessMode(v Value) (PTTransactionAccessMode, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_transaction_access_mode" { + return PTTransactionAccessMode{}, false + } + return PTTransactionAccessMode{n}, true +} + +func (x PTTransactionAccessMode) IsReadOnly() Value { return x.N.Arg("is_read_only") } + +// PTIsolationLevel views a PT_isolation_level node. +type PTIsolationLevel struct{ N *Node } + +// AsPTIsolationLevel returns the view when v is a PT_isolation_level. +func AsPTIsolationLevel(v Value) (PTIsolationLevel, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_isolation_level" { + return PTIsolationLevel{}, false + } + return PTIsolationLevel{n}, true +} + +func (x PTIsolationLevel) Level() Value { return x.N.Arg("level") } + +// PTAlterInstance views a PT_alter_instance node. +type PTAlterInstance struct{ N *Node } + +// AsPTAlterInstance returns the view when v is a PT_alter_instance. +func AsPTAlterInstance(v Value) (PTAlterInstance, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_alter_instance" { + return PTAlterInstance{}, false + } + return PTAlterInstance{n}, true +} + +func (x PTAlterInstance) AlterInstanceAction() Value { return x.N.Arg("alter_instance_action") } +func (x PTAlterInstance) Channel() Value { return x.N.Arg("channel") } + +// PTHandlerOpen views a PT_handler_open node. +type PTHandlerOpen struct{ N *Node } + +// AsPTHandlerOpen returns the view when v is a PT_handler_open. +func AsPTHandlerOpen(v Value) (PTHandlerOpen, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_handler_open" { + return PTHandlerOpen{}, false + } + return PTHandlerOpen{n}, true +} + +func (x PTHandlerOpen) Table() Value { return x.N.Arg("table") } +func (x PTHandlerOpen) OptTableAlias() Value { return x.N.Arg("opt_table_alias") } + +// PTHandlerClose views a PT_handler_close node. +type PTHandlerClose struct{ N *Node } + +// AsPTHandlerClose returns the view when v is a PT_handler_close. +func AsPTHandlerClose(v Value) (PTHandlerClose, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_handler_close" { + return PTHandlerClose{}, false + } + return PTHandlerClose{n}, true +} + +func (x PTHandlerClose) Table() Value { return x.N.Arg("table") } + +// PTHandlerTableScan views a PT_handler_table_scan node. +type PTHandlerTableScan struct{ N *Node } + +// AsPTHandlerTableScan returns the view when v is a PT_handler_table_scan. +func AsPTHandlerTableScan(v Value) (PTHandlerTableScan, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_handler_table_scan" { + return PTHandlerTableScan{}, false + } + return PTHandlerTableScan{n}, true +} + +func (x PTHandlerTableScan) Table() Value { return x.N.Arg("table") } +func (x PTHandlerTableScan) Direction() Value { return x.N.Arg("direction") } +func (x PTHandlerTableScan) OptWhereClause() Value { return x.N.Arg("opt_where_clause") } +func (x PTHandlerTableScan) OptLimitClause() Value { return x.N.Arg("opt_limit_clause") } + +// PTHandlerIndexScan views a PT_handler_index_scan node. +type PTHandlerIndexScan struct{ N *Node } + +// AsPTHandlerIndexScan returns the view when v is a PT_handler_index_scan. +func AsPTHandlerIndexScan(v Value) (PTHandlerIndexScan, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_handler_index_scan" { + return PTHandlerIndexScan{}, false + } + return PTHandlerIndexScan{n}, true +} + +func (x PTHandlerIndexScan) Table() Value { return x.N.Arg("table") } +func (x PTHandlerIndexScan) Index() Value { return x.N.Arg("index") } +func (x PTHandlerIndexScan) Direction() Value { return x.N.Arg("direction") } +func (x PTHandlerIndexScan) OptWhereClause() Value { return x.N.Arg("opt_where_clause") } +func (x PTHandlerIndexScan) OptLimitClause() Value { return x.N.Arg("opt_limit_clause") } + +// PTHandlerIndexRangeScan views a PT_handler_index_range_scan node. +type PTHandlerIndexRangeScan struct{ N *Node } + +// AsPTHandlerIndexRangeScan returns the view when v is a PT_handler_index_range_scan. +func AsPTHandlerIndexRangeScan(v Value) (PTHandlerIndexRangeScan, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_handler_index_range_scan" { + return PTHandlerIndexRangeScan{}, false + } + return PTHandlerIndexRangeScan{n}, true +} + +func (x PTHandlerIndexRangeScan) Table() Value { return x.N.Arg("table") } +func (x PTHandlerIndexRangeScan) Index() Value { return x.N.Arg("index") } +func (x PTHandlerIndexRangeScan) KeyFunction() Value { return x.N.Arg("key_function") } +func (x PTHandlerIndexRangeScan) KeypartValues() Value { return x.N.Arg("keypart_values") } +func (x PTHandlerIndexRangeScan) OptWhereClause() Value { return x.N.Arg("opt_where_clause") } +func (x PTHandlerIndexRangeScan) OptLimitClause() Value { return x.N.Arg("opt_limit_clause") } + +// PTRoleAtHost views a PT_role_at_host node. +type PTRoleAtHost struct{ N *Node } + +// AsPTRoleAtHost returns the view when v is a PT_role_at_host. +func AsPTRoleAtHost(v Value) (PTRoleAtHost, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_role_at_host" { + return PTRoleAtHost{}, false + } + return PTRoleAtHost{n}, true +} + +func (x PTRoleAtHost) Role() Value { return x.N.Arg("role") } +func (x PTRoleAtHost) Host() Value { return x.N.Arg("host") } + +// PTStaticPrivilege views a PT_static_privilege node. +type PTStaticPrivilege struct{ N *Node } + +// AsPTStaticPrivilege returns the view when v is a PT_static_privilege. +func AsPTStaticPrivilege(v Value) (PTStaticPrivilege, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_static_privilege" { + return PTStaticPrivilege{}, false + } + return PTStaticPrivilege{n}, true +} + +func (x PTStaticPrivilege) Grant() Value { return x.N.Arg("grant") } +func (x PTStaticPrivilege) Columns() Value { return x.N.Arg("columns") } + +// PTSubquery views a PT_subquery node. +type PTSubquery struct{ N *Node } + +// AsPTSubquery returns the view when v is a PT_subquery. +func AsPTSubquery(v Value) (PTSubquery, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_subquery" { + return PTSubquery{}, false + } + return PTSubquery{n}, true +} + +func (x PTSubquery) QueryExpression() Value { return x.N.Arg("query_expression") } + +// PTInstallComponent views a PT_install_component node. +type PTInstallComponent struct{ N *Node } + +// AsPTInstallComponent returns the view when v is a PT_install_component. +func AsPTInstallComponent(v Value) (PTInstallComponent, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "PT_install_component" { + return PTInstallComponent{}, false + } + return PTInstallComponent{n}, true +} + +func (x PTInstallComponent) Urns() Value { return x.N.Arg("urns") } +func (x PTInstallComponent) SetElements() Value { return x.N.Arg("set_elements") } + +// MemRootArray views a Mem_root_array node. +type MemRootArray struct{ N *Node } + +// AsMemRootArray returns the view when v is a Mem_root_array. +func AsMemRootArray(v Value) (MemRootArray, bool) { + n, ok := v.(*Node) + if !ok || n.Class != "Mem_root_array" { + return MemRootArray{}, false + } + return MemRootArray{n}, true +} diff --git a/check/mysql/internal/mysqlparse/kinds.go b/check/mysql/internal/mysqlparse/kinds.go new file mode 100644 index 0000000..efd2545 --- /dev/null +++ b/check/mysql/internal/mysqlparse/kinds.go @@ -0,0 +1,1730 @@ +// Code generated by parsegen from MySQL 8.4.6 sql_yacc.yy. DO NOT EDIT. + +package mysqlparse + +// firstTerminal is the first kind that is a token rather than a rule. +const firstTerminal Kind = 945 + +var kindNames = [...]string{ + "start_entry", + "sql_statement", + "opt_end_of_input", + "simple_statement_or_begin", + "simple_statement", + "deallocate", + "deallocate_or_drop", + "prepare", + "prepare_src", + "execute", + "execute_using", + "execute_var_list", + "execute_var_ident", + "help", + "change_replication_stmt", + "filter_defs", + "filter_def", + "opt_filter_db_list", + "filter_db_list", + "filter_db_ident", + "opt_filter_db_pair_list", + "filter_db_pair_list", + "opt_filter_table_list", + "filter_table_list", + "filter_table_ident", + "opt_filter_string_list", + "filter_string_list", + "filter_string", + "source_defs", + "source_def", + "ignore_server_id_list", + "ignore_server_id", + "privilege_check_def", + "table_primary_key_check_def", + "assign_gtids_to_anonymous_transactions_def", + "source_tls_ciphersuites_def", + "source_file_def", + "opt_channel", + "create_table_stmt", + "create_role_stmt", + "create_resource_group_stmt", + "create", + "create_srs_stmt", + "srs_attributes", + "default_role_clause", + "create_index_stmt", + "server_options_list", + "server_option", + "event_tail", + "ev_schedule_time", + "opt_ev_status", + "ev_starts", + "ev_ends", + "opt_ev_on_completion", + "ev_on_completion", + "opt_ev_comment", + "ev_sql_stmt", + "ev_sql_stmt_inner", + "sp_name", + "sp_a_chistics", + "sp_c_chistics", + "sp_chistic", + "sp_c_chistic", + "sp_suid", + "call_stmt", + "opt_paren_expr_list", + "sp_fdparam_list", + "sp_fdparams", + "sp_fdparam", + "sp_pdparam_list", + "sp_pdparams", + "sp_pdparam", + "sp_opt_inout", + "sp_proc_stmts", + "sp_proc_stmts1", + "sp_decls", + "sp_decl", + "sp_handler_type", + "sp_hcond_list", + "sp_hcond_element", + "sp_cond", + "sqlstate", + "opt_value", + "sp_hcond", + "signal_stmt", + "signal_value", + "opt_signal_value", + "opt_set_signal_information", + "signal_information_item_list", + "signal_allowed_expr", + "signal_condition_information_item_name", + "resignal_stmt", + "get_diagnostics", + "which_area", + "diagnostics_information", + "statement_information", + "statement_information_item", + "simple_target_specification", + "statement_information_item_name", + "condition_number", + "condition_information", + "condition_information_item", + "condition_information_item_name", + "sp_decl_idents", + "sp_opt_default", + "sp_proc_stmt", + "sp_proc_stmt_if", + "sp_proc_stmt_statement", + "sp_proc_stmt_return", + "sp_proc_stmt_unlabeled", + "sp_proc_stmt_leave", + "sp_proc_stmt_iterate", + "sp_proc_stmt_open", + "sp_proc_stmt_fetch", + "sp_proc_stmt_close", + "sp_opt_fetch_noise", + "sp_fetch_list", + "sp_if", + "sp_elseifs", + "case_stmt_specification", + "simple_case_stmt", + "searched_case_stmt", + "simple_when_clause_list", + "searched_when_clause_list", + "simple_when_clause", + "searched_when_clause", + "else_clause_opt", + "sp_labeled_control", + "sp_opt_label", + "sp_labeled_block", + "sp_unlabeled_block", + "sp_block_content", + "sp_unlabeled_control", + "trg_action_time", + "trg_event", + "opt_ts_datafile_name", + "opt_logfile_group_name", + "opt_tablespace_options", + "tablespace_option_list", + "tablespace_option", + "opt_alter_tablespace_options", + "alter_tablespace_option_list", + "alter_tablespace_option", + "opt_undo_tablespace_options", + "undo_tablespace_option_list", + "undo_tablespace_option", + "opt_logfile_group_options", + "logfile_group_option_list", + "logfile_group_option", + "opt_alter_logfile_group_options", + "alter_logfile_group_option_list", + "alter_logfile_group_option", + "ts_datafile", + "undo_tablespace_state", + "lg_undofile", + "ts_option_initial_size", + "ts_option_autoextend_size", + "option_autoextend_size", + "ts_option_max_size", + "ts_option_extent_size", + "ts_option_undo_buffer_size", + "ts_option_redo_buffer_size", + "ts_option_nodegroup", + "ts_option_comment", + "ts_option_engine", + "ts_option_file_block_size", + "ts_option_wait", + "ts_option_encryption", + "ts_option_engine_attribute", + "size_number", + "opt_create_table_options_etc", + "opt_create_partitioning_etc", + "opt_duplicate_as_qe", + "as_create_query_expression", + "partition_clause", + "part_type_def", + "opt_linear", + "opt_key_algo", + "opt_num_parts", + "opt_sub_part", + "opt_name_list", + "name_list", + "opt_num_subparts", + "opt_part_defs", + "part_def_list", + "part_definition", + "opt_part_values", + "part_func_max", + "part_values_in", + "part_value_list", + "part_value_item_list_paren", + "part_value_item_list", + "part_value_item", + "opt_sub_partition", + "sub_part_list", + "sub_part_definition", + "opt_part_options", + "part_option_list", + "part_option", + "alter_database_options", + "alter_database_option", + "opt_create_database_options", + "create_database_options", + "create_database_option", + "opt_if_not_exists", + "create_table_options_space_separated", + "create_table_options", + "opt_comma", + "create_table_option", + "ternary_option", + "default_charset", + "default_collation", + "default_encryption", + "row_types", + "merge_insert_types", + "udf_type", + "table_element_list", + "table_element", + "column_def", + "opt_references", + "table_constraint_def", + "check_constraint", + "opt_constraint_name", + "opt_not", + "opt_constraint_enforcement", + "constraint_enforcement", + "field_def", + "opt_generated_always", + "opt_stored_attribute", + "type", + "spatial_type", + "nchar", + "varchar", + "nvarchar", + "int_type", + "real_type", + "opt_PRECISION", + "numeric_type", + "standard_float_options", + "float_options", + "precision", + "type_datetime_precision", + "func_datetime_precision", + "field_options", + "field_opt_list", + "field_option", + "field_length", + "opt_field_length", + "opt_precision", + "opt_column_attribute_list", + "column_attribute_list", + "column_attribute", + "column_format", + "storage_media", + "now", + "now_or_signed_literal", + "character_set", + "charset_name", + "opt_load_data_charset", + "old_or_new_charset_name", + "old_or_new_charset_name_or_default", + "collation_name", + "opt_collate", + "opt_default", + "ascii", + "unicode", + "opt_charset_with_opt_binary", + "opt_bin_mod", + "ws_num_codepoints", + "opt_primary", + "references", + "opt_ref_list", + "reference_list", + "opt_match_clause", + "opt_on_update_delete", + "delete_option", + "constraint_key_type", + "key_or_index", + "opt_key_or_index", + "keys_or_index", + "opt_unique", + "opt_fulltext_index_options", + "fulltext_index_options", + "fulltext_index_option", + "opt_spatial_index_options", + "spatial_index_options", + "spatial_index_option", + "opt_index_options", + "index_options", + "index_option", + "common_index_option", + "opt_index_name_and_type", + "opt_index_type_clause", + "index_type_clause", + "visibility", + "index_type", + "key_list", + "key_part", + "key_list_with_expression", + "key_part_with_expression", + "opt_ident", + "string_list", + "alter_table_stmt", + "alter_database_stmt", + "alter_procedure_stmt", + "alter_function_stmt", + "alter_view_stmt", + "alter_event_stmt", + "alter_logfile_stmt", + "alter_tablespace_stmt", + "alter_undo_tablespace_stmt", + "alter_server_stmt", + "alter_user_stmt", + "opt_replace_password", + "alter_resource_group_stmt", + "alter_user_command", + "opt_user_attribute", + "opt_account_lock_password_expire_options", + "opt_account_lock_password_expire_option_list", + "opt_account_lock_password_expire_option", + "connect_options", + "connect_option_list", + "connect_option", + "user_func", + "ev_alter_on_schedule_completion", + "opt_ev_rename_to", + "opt_ev_sql_stmt", + "ident_or_empty", + "opt_alter_table_actions", + "standalone_alter_table_action", + "alter_table_partition_options", + "opt_alter_command_list", + "standalone_alter_commands", + "opt_with_validation", + "with_validation", + "all_or_alt_part_name_list", + "alter_list", + "alter_commands_modifier_list", + "alter_list_item", + "alter_commands_modifier", + "opt_index_lock_and_algorithm", + "alter_algorithm_option", + "alter_algorithm_option_value", + "alter_lock_option", + "alter_lock_option_value", + "opt_column", + "opt_ignore", + "opt_restrict", + "opt_place", + "opt_to", + "group_replication", + "group_replication_start", + "opt_group_replication_start_options", + "group_replication_start_options", + "group_replication_start_option", + "group_replication_user", + "group_replication_password", + "group_replication_plugin_auth", + "stop_replica_stmt", + "start_replica_stmt", + "start", + "opt_start_transaction_option_list", + "start_transaction_option_list", + "start_transaction_option", + "opt_user_option", + "opt_password_option", + "opt_default_auth_option", + "opt_plugin_dir_option", + "opt_replica_thread_option_list", + "replica_thread_option_list", + "replica_thread_option", + "opt_replica_until", + "replica_until", + "checksum", + "opt_checksum_type", + "repair_table_stmt", + "opt_mi_repair_types", + "mi_repair_types", + "mi_repair_type", + "analyze_table_stmt", + "opt_histogram_auto_update", + "opt_histogram_num_buckets", + "opt_histogram_update_param", + "opt_histogram", + "binlog_base64_event", + "check_table_stmt", + "opt_mi_check_types", + "mi_check_types", + "mi_check_type", + "optimize_table_stmt", + "opt_no_write_to_binlog", + "rename", + "rename_list", + "table_to_table_list", + "table_to_table", + "keycache_stmt", + "keycache_list", + "assign_to_keycache", + "key_cache_name", + "preload_stmt", + "preload_list", + "preload_keys", + "adm_partition", + "opt_cache_key_list", + "opt_ignore_leaves", + "select_stmt", + "select_stmt_with_into", + "query_expression", + "query_expression_body", + "query_expression_parens", + "query_primary", + "query_specification", + "opt_from_clause", + "from_clause", + "from_tables", + "table_reference_list", + "table_value_constructor", + "explicit_table", + "select_options", + "select_option_list", + "select_option", + "locking_clause_list", + "locking_clause", + "lock_strength", + "table_locking_list", + "opt_locked_row_action", + "locked_row_action", + "select_item_list", + "select_item", + "select_alias", + "optional_braces", + "expr", + "bool_pri", + "predicate", + "opt_of", + "bit_expr", + "or", + "and", + "not", + "not2", + "comp_op", + "all_or_any", + "simple_expr", + "opt_array_cast", + "function_call_keyword", + "function_call_nonkeyword", + "opt_returning_type", + "function_call_conflict", + "geometry_function", + "function_call_generic", + "fulltext_options", + "opt_natural_language_mode", + "opt_query_expansion", + "opt_udf_expr_list", + "udf_expr_list", + "udf_expr", + "set_function_specification", + "sum_expr", + "sampling_method", + "sampling_percentage", + "opt_tablesample_clause", + "window_func_call", + "opt_lead_lag_info", + "stable_integer", + "param_or_var", + "opt_ll_default", + "opt_null_treatment", + "opt_from_first_last", + "opt_windowing_clause", + "windowing_clause", + "window_name_or_spec", + "window_name", + "window_spec", + "window_spec_details", + "opt_existing_window_name", + "opt_partition_clause", + "opt_window_order_by_clause", + "opt_window_frame_clause", + "window_frame_extent", + "window_frame_start", + "window_frame_between", + "window_frame_bound", + "opt_window_frame_exclusion", + "window_frame_units", + "grouping_operation", + "in_expression_user_variable_assignment", + "rvalue_system_or_user_variable", + "opt_distinct", + "opt_gconcat_separator", + "opt_gorder_clause", + "gorder_list", + "in_sum_expr", + "cast_type", + "opt_expr_list", + "expr_list", + "ident_list_arg", + "ident_list", + "opt_expr", + "opt_else", + "when_list", + "table_reference", + "esc_table_reference", + "joined_table", + "natural_join_type", + "inner_join_type", + "outer_join_type", + "opt_inner", + "opt_outer", + "opt_use_partition", + "use_partition", + "table_factor", + "table_reference_list_parens", + "single_table_parens", + "single_table", + "joined_table_parens", + "derived_table", + "table_function", + "columns_clause", + "columns_list", + "jt_column", + "jt_column_type", + "opt_on_empty_or_error", + "opt_on_empty_or_error_json_table", + "on_empty", + "on_error", + "json_on_response", + "index_hint_clause", + "index_hint_type", + "index_hint_definition", + "index_hints_list", + "opt_index_hints_list", + "opt_key_definition", + "opt_key_usage_list", + "key_usage_element", + "key_usage_list", + "using_list", + "ident_string_list", + "interval", + "interval_time_stamp", + "date_time_type", + "opt_as", + "opt_table_alias", + "opt_all", + "opt_where_clause", + "where_clause", + "opt_having_clause", + "opt_qualify_clause", + "with_clause", + "with_list", + "common_table_expr", + "opt_derived_column_list", + "simple_ident_list", + "opt_window_clause", + "window_definition_list", + "window_definition", + "opt_group_clause", + "group_list", + "olap_opt", + "alter_order_list", + "alter_order_item", + "opt_order_clause", + "order_clause", + "order_list", + "opt_ordering_direction", + "ordering_direction", + "opt_limit_clause", + "limit_clause", + "limit_options", + "limit_option", + "opt_simple_limit", + "ulong_num", + "real_ulong_num", + "ulonglong_num", + "real_ulonglong_num", + "dec_num_error", + "dec_num", + "select_var_list", + "select_var_ident", + "into_clause", + "into_destination", + "do_stmt", + "drop_table_stmt", + "drop_index_stmt", + "drop_database_stmt", + "drop_function_stmt", + "drop_resource_group_stmt", + "drop_procedure_stmt", + "drop_user_stmt", + "drop_view_stmt", + "drop_event_stmt", + "drop_trigger_stmt", + "drop_tablespace_stmt", + "drop_undo_tablespace_stmt", + "drop_logfile_stmt", + "drop_server_stmt", + "drop_srs_stmt", + "drop_role_stmt", + "table_list", + "table_alias_ref_list", + "if_exists", + "opt_ignore_unknown_user", + "opt_temporary", + "opt_drop_ts_options", + "drop_ts_option_list", + "drop_ts_option", + "insert_stmt", + "replace_stmt", + "insert_lock_option", + "replace_lock_option", + "opt_INTO", + "insert_from_constructor", + "insert_query_expression", + "insert_columns", + "insert_values", + "query_expression_with_opt_locking_clauses", + "value_or_values", + "values_list", + "values_row_list", + "equal", + "opt_equal", + "row_value", + "row_value_explicit", + "opt_values", + "values", + "expr_or_default", + "opt_values_reference", + "opt_insert_update_list", + "update_stmt", + "opt_with_clause", + "update_list", + "update_elem", + "opt_low_priority", + "delete_stmt", + "opt_wild", + "opt_delete_options", + "opt_delete_option", + "truncate_stmt", + "opt_table", + "opt_profile_defs", + "profile_defs", + "profile_def", + "opt_for_query", + "show_databases_stmt", + "show_tables_stmt", + "show_triggers_stmt", + "show_events_stmt", + "show_table_status_stmt", + "show_open_tables_stmt", + "show_plugins_stmt", + "show_engine_logs_stmt", + "show_engine_mutex_stmt", + "show_engine_status_stmt", + "show_columns_stmt", + "show_binary_logs_stmt", + "show_replicas_stmt", + "show_binlog_events_stmt", + "show_relaylog_events_stmt", + "show_keys_stmt", + "show_engines_stmt", + "show_count_warnings_stmt", + "show_count_errors_stmt", + "show_warnings_stmt", + "show_errors_stmt", + "show_profiles_stmt", + "show_profile_stmt", + "show_status_stmt", + "show_processlist_stmt", + "show_variables_stmt", + "show_character_set_stmt", + "show_collation_stmt", + "show_privileges_stmt", + "show_grants_stmt", + "show_create_database_stmt", + "show_create_table_stmt", + "show_create_view_stmt", + "show_binary_log_status_stmt", + "show_replica_status_stmt", + "show_create_procedure_stmt", + "show_create_function_stmt", + "show_create_trigger_stmt", + "show_procedure_status_stmt", + "show_function_status_stmt", + "show_procedure_code_stmt", + "show_function_code_stmt", + "show_create_event_stmt", + "show_create_user_stmt", + "show_parse_tree_stmt", + "engine_or_all", + "opt_storage", + "opt_db", + "opt_full", + "opt_extended", + "opt_show_cmd_type", + "from_or_in", + "opt_binlog_in", + "binlog_from", + "opt_wild_or_where", + "describe_stmt", + "explain_stmt", + "explainable_stmt", + "describe_command", + "opt_explain_format", + "opt_explain_options", + "opt_explain_into", + "opt_explain_for_schema", + "opt_describe_column", + "flush", + "flush_options", + "opt_flush_lock", + "flush_options_list", + "flush_option", + "opt_table_list", + "reset", + "reset_options", + "opt_if_exists_ident", + "persisted_variable_ident", + "reset_option", + "opt_replica_reset_options", + "source_reset_options", + "purge", + "purge_options", + "purge_option", + "kill", + "kill_option", + "use", + "load_stmt", + "data_or_xml", + "opt_local", + "opt_from_keyword", + "load_data_lock", + "load_source_type", + "opt_source_count", + "opt_source_order", + "opt_duplicate", + "duplicate", + "opt_field_term", + "field_term_list", + "field_term", + "opt_line_term", + "line_term_list", + "line_term", + "opt_xml_rows_identified_by", + "opt_ignore_lines", + "lines_or_rows", + "opt_field_or_var_spec", + "fields_or_vars", + "field_or_var", + "opt_load_data_set_spec", + "load_data_set_list", + "load_data_set_elem", + "opt_load_algorithm", + "opt_compression_algorithm", + "opt_load_parallel", + "opt_load_memory", + "text_literal", + "text_string", + "param_marker", + "signed_literal", + "signed_literal_or_null", + "null_as_literal", + "literal", + "literal_or_null", + "NUM_literal", + "int64_literal", + "temporal_literal", + "opt_interval", + "insert_column", + "table_wild", + "order_expr", + "grouping_expr", + "simple_ident", + "simple_ident_nospvar", + "simple_ident_q", + "table_ident", + "table_ident_opt_wild", + "IDENT_sys", + "TEXT_STRING_sys_nonewline", + "filter_wild_db_table_string", + "TEXT_STRING_sys", + "TEXT_STRING_literal", + "TEXT_STRING_filesystem", + "TEXT_STRING_password", + "TEXT_STRING_hash", + "TEXT_STRING_validated", + "ident", + "role_ident", + "label_ident", + "lvalue_ident", + "ident_or_text", + "role_ident_or_text", + "user_ident_or_text", + "user", + "role", + "schema", + "ident_keyword", + "ident_keywords_ambiguous_1_roles_and_labels", + "ident_keywords_ambiguous_2_labels", + "label_keyword", + "ident_keywords_ambiguous_3_roles", + "ident_keywords_unambiguous", + "role_keyword", + "lvalue_keyword", + "ident_keywords_ambiguous_4_system_variables", + "set", + "start_option_value_list", + "set_role_stmt", + "opt_except_role_list", + "set_resource_group_stmt", + "thread_id_list", + "thread_id_list_options", + "start_option_value_list_following_option_type", + "option_value_list_continued", + "option_value_list", + "option_value", + "option_type", + "opt_var_type", + "opt_rvalue_system_variable_type", + "opt_set_var_ident_type", + "option_value_following_option_type", + "option_value_no_option_type", + "lvalue_variable", + "rvalue_system_variable", + "transaction_characteristics", + "transaction_access_mode", + "opt_transaction_access_mode", + "isolation_level", + "opt_isolation_level", + "transaction_access_mode_types", + "isolation_types", + "set_expr_or_default", + "lock", + "table_or_tables", + "table_lock_list", + "table_lock", + "lock_option", + "unlock", + "shutdown_stmt", + "restart_server_stmt", + "alter_instance_stmt", + "alter_instance_action", + "handler_stmt", + "handler_scan_function", + "handler_rkey_function", + "handler_rkey_mode", + "revoke", + "grant", + "opt_acl_type", + "opt_privileges", + "role_or_privilege_list", + "role_or_privilege", + "opt_with_admin_option", + "opt_and", + "require_list", + "require_list_element", + "grant_ident", + "user_list", + "role_list", + "opt_retain_current_password", + "opt_discard_old_password", + "opt_user_registration", + "create_user", + "opt_create_user_with_mfa", + "identification", + "identified_by_password", + "identified_by_random_password", + "identified_with_plugin", + "identified_with_plugin_as_auth", + "identified_with_plugin_by_password", + "identified_with_plugin_by_random_password", + "opt_initial_auth", + "alter_user", + "factor", + "create_user_list", + "alter_user_list", + "opt_column_list", + "column_list", + "require_clause", + "grant_options", + "opt_grant_option", + "opt_with_roles", + "opt_grant_as", + "begin_stmt", + "opt_work", + "opt_chain", + "opt_release", + "opt_savepoint", + "commit", + "rollback", + "savepoint", + "release", + "union_option", + "row_subquery", + "table_subquery", + "subquery", + "query_spec_option", + "init_lex_create_info", + "view_or_trigger_or_sp_or_event", + "definer_tail", + "no_definer_tail", + "definer_opt", + "no_definer", + "definer", + "view_replace_or_algorithm", + "view_replace", + "view_algorithm", + "view_suid", + "view_tail", + "view_query_block", + "view_check_option", + "trigger_action_order", + "trigger_follows_precedes_clause", + "trigger_tail", + "udf_tail", + "sf_tail", + "routine_string", + "stored_routine_body", + "sp_tail", + "xa", + "opt_convert_xid", + "xid", + "begin_or_start", + "opt_join_or_resume", + "opt_one_phase", + "opt_suspend", + "install_option_type", + "install_set_rvalue", + "install_set_value", + "install_set_value_list", + "opt_install_set_value_list", + "install_stmt", + "uninstall", + "TEXT_STRING_sys_list", + "import_stmt", + "clone_stmt", + "opt_datadir_ssl", + "opt_ssl", + "resource_group_types", + "opt_resource_group_vcpu_list", + "vcpu_range_spec_list", + "vcpu_num_or_range", + "signed_num", + "opt_resource_group_priority", + "opt_resource_group_enable_disable", + "opt_force", + "json_attribute", + "GRAMMAR_SELECTOR_EXPR", + "END_OF_INPUT", + "GRAMMAR_SELECTOR_PART", + "GRAMMAR_SELECTOR_GCOL", + "'('", + "')'", + "GRAMMAR_SELECTOR_CTE", + "GRAMMAR_SELECTOR_DERIVED_EXPR", + "';'", + "PREPARE_SYM", + "DEALLOCATE_SYM", + "DROP", + "FROM", + "'@'", + "EXECUTE_SYM", + "USING", + "','", + "HELP_SYM", + "CHANGE", + "REPLICATION", + "SOURCE_SYM", + "TO_SYM", + "FILTER_SYM", + "REPLICATE_DO_DB", + "EQ", + "REPLICATE_IGNORE_DB", + "REPLICATE_DO_TABLE", + "REPLICATE_IGNORE_TABLE", + "REPLICATE_WILD_DO_TABLE", + "REPLICATE_WILD_IGNORE_TABLE", + "REPLICATE_REWRITE_DB", + "'.'", + "SOURCE_HOST_SYM", + "NETWORK_NAMESPACE_SYM", + "SOURCE_BIND_SYM", + "SOURCE_USER_SYM", + "SOURCE_PASSWORD_SYM", + "SOURCE_PORT_SYM", + "SOURCE_CONNECT_RETRY_SYM", + "SOURCE_RETRY_COUNT_SYM", + "SOURCE_DELAY_SYM", + "SOURCE_SSL_SYM", + "SOURCE_SSL_CA_SYM", + "SOURCE_SSL_CAPATH_SYM", + "SOURCE_TLS_VERSION_SYM", + "SOURCE_TLS_CIPHERSUITES_SYM", + "SOURCE_SSL_CERT_SYM", + "SOURCE_SSL_CIPHER_SYM", + "SOURCE_SSL_KEY_SYM", + "SOURCE_SSL_VERIFY_SERVER_CERT_SYM", + "SOURCE_SSL_CRL_SYM", + "SOURCE_SSL_CRLPATH_SYM", + "SOURCE_PUBLIC_KEY_PATH_SYM", + "GET_SOURCE_PUBLIC_KEY_SYM", + "SOURCE_HEARTBEAT_PERIOD_SYM", + "IGNORE_SERVER_IDS_SYM", + "SOURCE_COMPRESSION_ALGORITHM_SYM", + "SOURCE_ZSTD_COMPRESSION_LEVEL_SYM", + "SOURCE_AUTO_POSITION_SYM", + "PRIVILEGE_CHECKS_USER_SYM", + "REQUIRE_ROW_FORMAT_SYM", + "REQUIRE_TABLE_PRIMARY_KEY_CHECK_SYM", + "SOURCE_CONNECTION_AUTO_FAILOVER_SYM", + "ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_SYM", + "GTID_ONLY_SYM", + "NULL_SYM", + "STREAM_SYM", + "ON_SYM", + "OFF_SYM", + "GENERATE_SYM", + "LOCAL_SYM", + "TEXT_STRING", + "SOURCE_LOG_FILE_SYM", + "SOURCE_LOG_POS_SYM", + "RELAY_LOG_FILE_SYM", + "RELAY_LOG_POS_SYM", + "FOR_SYM", + "CHANNEL_SYM", + "CREATE", + "TABLE_SYM", + "LIKE", + "ROLE_SYM", + "RESOURCE_SYM", + "GROUP_SYM", + "TYPE_SYM", + "DATABASE", + "USER", + "LOGFILE_SYM", + "ADD", + "TABLESPACE_SYM", + "UNDO_SYM", + "SERVER_SYM", + "FOREIGN", + "DATA_SYM", + "WRAPPER_SYM", + "OPTIONS_SYM", + "OR_SYM", + "REPLACE_SYM", + "SPATIAL_SYM", + "REFERENCE_SYM", + "SYSTEM_SYM", + "NAME_SYM", + "DEFINITION_SYM", + "ORGANIZATION_SYM", + "IDENTIFIED_SYM", + "BY", + "DESCRIPTION_SYM", + "DEFAULT_SYM", + "INDEX_SYM", + "FULLTEXT_SYM", + "HOST_SYM", + "OWNER_SYM", + "PASSWORD", + "SOCKET_SYM", + "PORT_SYM", + "EVENT_SYM", + "SCHEDULE_SYM", + "DO_SYM", + "EVERY_SYM", + "AT_SYM", + "ENABLE_SYM", + "DISABLE_SYM", + "SLAVE", + "REPLICA_SYM", + "STARTS_SYM", + "ENDS_SYM", + "COMPLETION_SYM", + "PRESERVE_SYM", + "NOT_SYM", + "COMMENT_SYM", + "LANGUAGE_SYM", + "SQL_SYM", + "NO_SYM", + "CONTAINS_SYM", + "READS_SYM", + "MODIFIES_SYM", + "DETERMINISTIC_SYM", + "SECURITY_SYM", + "DEFINER_SYM", + "INVOKER_SYM", + "CALL_SYM", + "IN_SYM", + "OUT_SYM", + "INOUT_SYM", + "DECLARE_SYM", + "CONDITION_SYM", + "HANDLER_SYM", + "CURSOR_SYM", + "EXIT_SYM", + "CONTINUE_SYM", + "SQLSTATE_SYM", + "VALUE_SYM", + "SQLWARNING_SYM", + "FOUND_SYM", + "SQLEXCEPTION_SYM", + "SIGNAL_SYM", + "SET_SYM", + "CLASS_ORIGIN_SYM", + "SUBCLASS_ORIGIN_SYM", + "CONSTRAINT_CATALOG_SYM", + "CONSTRAINT_SCHEMA_SYM", + "CONSTRAINT_NAME_SYM", + "CATALOG_NAME_SYM", + "SCHEMA_NAME_SYM", + "TABLE_NAME_SYM", + "COLUMN_NAME_SYM", + "CURSOR_NAME_SYM", + "MESSAGE_TEXT_SYM", + "MYSQL_ERRNO_SYM", + "RESIGNAL_SYM", + "GET_SYM", + "DIAGNOSTICS_SYM", + "CURRENT_SYM", + "STACKED_SYM", + "NUMBER_SYM", + "ROW_COUNT_SYM", + "RETURNED_SQLSTATE_SYM", + "IF", + "END", + "RETURN_SYM", + "LEAVE_SYM", + "ITERATE_SYM", + "OPEN_SYM", + "FETCH_SYM", + "INTO", + "CLOSE_SYM", + "NEXT_SYM", + "THEN_SYM", + "ELSEIF_SYM", + "ELSE", + "CASE_SYM", + "WHEN_SYM", + "':'", + "BEGIN_SYM", + "LOOP_SYM", + "WHILE_SYM", + "REPEAT_SYM", + "UNTIL_SYM", + "BEFORE_SYM", + "AFTER_SYM", + "INSERT_SYM", + "UPDATE_SYM", + "DELETE_SYM", + "USE_SYM", + "DATAFILE_SYM", + "ACTIVE_SYM", + "INACTIVE_SYM", + "UNDOFILE_SYM", + "INITIAL_SIZE_SYM", + "AUTOEXTEND_SIZE_SYM", + "MAX_SIZE_SYM", + "EXTENT_SIZE_SYM", + "UNDO_BUFFER_SIZE_SYM", + "REDO_BUFFER_SIZE_SYM", + "NODEGROUP_SYM", + "ENGINE_SYM", + "FILE_BLOCK_SIZE_SYM", + "WAIT_SYM", + "NO_WAIT_SYM", + "ENCRYPTION_SYM", + "ENGINE_ATTRIBUTE_SYM", + "AS", + "PARTITION_SYM", + "KEY_SYM", + "HASH_SYM", + "RANGE_SYM", + "COLUMNS", + "LIST_SYM", + "LINEAR_SYM", + "ALGORITHM_SYM", + "PARTITIONS_SYM", + "SUBPARTITION_SYM", + "SUBPARTITIONS_SYM", + "VALUES", + "LESS_SYM", + "THAN_SYM", + "MAX_VALUE_SYM", + "MAX_ROWS", + "MIN_ROWS", + "DIRECTORY_SYM", + "READ_SYM", + "ONLY_SYM", + "EXISTS", + "SECONDARY_ENGINE_SYM", + "AVG_ROW_LENGTH", + "COMPRESSION_SYM", + "AUTO_INC", + "PACK_KEYS_SYM", + "STATS_AUTO_RECALC_SYM", + "STATS_PERSISTENT_SYM", + "STATS_SAMPLE_PAGES_SYM", + "CHECKSUM_SYM", + "TABLE_CHECKSUM_SYM", + "DELAY_KEY_WRITE_SYM", + "ROW_FORMAT_SYM", + "UNION_SYM", + "INSERT_METHOD", + "STORAGE_SYM", + "DISK_SYM", + "MEMORY_SYM", + "CONNECTION_SYM", + "KEY_BLOCK_SIZE", + "START_SYM", + "TRANSACTION_SYM", + "SECONDARY_ENGINE_ATTRIBUTE_SYM", + "COLLATE_SYM", + "FIXED_SYM", + "DYNAMIC_SYM", + "COMPRESSED_SYM", + "REDUNDANT_SYM", + "COMPACT_SYM", + "FIRST_SYM", + "LAST_SYM", + "STRING_SYM", + "REAL_SYM", + "DECIMAL_SYM", + "INT_SYM", + "CHECK_SYM", + "CONSTRAINT", + "ENFORCED_SYM", + "GENERATED", + "ALWAYS_SYM", + "VIRTUAL_SYM", + "STORED_SYM", + "BIT_SYM", + "BOOL_SYM", + "BOOLEAN_SYM", + "CHAR_SYM", + "BINARY_SYM", + "VARBINARY_SYM", + "YEAR_SYM", + "DATE_SYM", + "TIME_SYM", + "TIMESTAMP_SYM", + "DATETIME_SYM", + "TINYBLOB_SYM", + "BLOB_SYM", + "MEDIUMBLOB_SYM", + "LONGBLOB_SYM", + "LONG_SYM", + "TINYTEXT_SYN", + "TEXT_SYM", + "MEDIUMTEXT_SYM", + "LONGTEXT_SYM", + "ENUM_SYM", + "SERIAL_SYM", + "JSON_SYM", + "GEOMETRY_SYM", + "GEOMETRYCOLLECTION_SYM", + "POINT_SYM", + "MULTIPOINT_SYM", + "LINESTRING_SYM", + "MULTILINESTRING_SYM", + "POLYGON_SYM", + "MULTIPOLYGON_SYM", + "NCHAR_SYM", + "NATIONAL_SYM", + "VARYING", + "VARCHAR_SYM", + "NVARCHAR_SYM", + "TINYINT_SYM", + "SMALLINT_SYM", + "MEDIUMINT_SYM", + "BIGINT_SYM", + "DOUBLE_SYM", + "PRECISION", + "FLOAT_SYM", + "NUMERIC_SYM", + "NUM", + "SIGNED_SYM", + "UNSIGNED_SYM", + "ZEROFILL_SYM", + "LONG_NUM", + "ULONGLONG_NUM", + "DECIMAL_NUM", + "SECONDARY_SYM", + "UNIQUE_SYM", + "COLUMN_FORMAT_SYM", + "SRID_SYM", + "NOW_SYM", + "CHARSET", + "ASCII_SYM", + "UNICODE_SYM", + "BYTE_SYM", + "PRIMARY_SYM", + "REFERENCES", + "MATCH", + "FULL", + "PARTIAL", + "SIMPLE_SYM", + "RESTRICT", + "CASCADE", + "ACTION", + "KEYS", + "INDEXES", + "WITH", + "PARSER_SYM", + "VISIBLE_SYM", + "INVISIBLE_SYM", + "BTREE_SYM", + "RTREE_SYM", + "ALTER", + "PROCEDURE_SYM", + "FUNCTION_SYM", + "RENAME", + "DISCARD_SYM", + "OLD_SYM", + "ALL", + "NONE_SYM", + "ATTRIBUTE_SYM", + "ACCOUNT_SYM", + "UNLOCK_SYM", + "LOCK_SYM", + "EXPIRE_SYM", + "INTERVAL_SYM", + "DAY_SYM", + "NEVER_SYM", + "HISTORY_SYM", + "REUSE_SYM", + "REQUIRE_SYM", + "OPTIONAL_SYM", + "FAILED_LOGIN_ATTEMPTS_SYM", + "PASSWORD_LOCK_TIME_SYM", + "UNBOUNDED_SYM", + "MAX_QUERIES_PER_HOUR", + "MAX_UPDATES_PER_HOUR", + "MAX_CONNECTIONS_PER_HOUR", + "MAX_USER_CONNECTIONS_SYM", + "REMOVE_SYM", + "PARTITIONING_SYM", + "IMPORT", + "REBUILD_SYM", + "OPTIMIZE", + "ANALYZE_SYM", + "REPAIR", + "COALESCE", + "TRUNCATE_SYM", + "REORGANIZE_SYM", + "EXCHANGE_SYM", + "SECONDARY_LOAD_SYM", + "SECONDARY_UNLOAD_SYM", + "VALIDATION_SYM", + "WITHOUT_SYM", + "MODIFY_SYM", + "COLUMN_SYM", + "CONVERT_SYM", + "FORCE_SYM", + "ORDER_SYM", + "IGNORE_SYM", + "STOP_SYM", + "GROUP_REPLICATION", + "DEFAULT_AUTH_SYM", + "CONSISTENT_SYM", + "SNAPSHOT_SYM", + "WRITE_SYM", + "PLUGIN_DIR_SYM", + "SQL_THREAD", + "RELAY_THREAD", + "SQL_BEFORE_GTIDS", + "SQL_AFTER_GTIDS", + "SQL_AFTER_MTS_GAPS", + "QUICK", + "EXTENDED_SYM", + "USE_FRM", + "MANUAL_SYM", + "AUTO_SYM", + "BUCKETS_SYM", + "HISTOGRAM_SYM", + "BINLOG_SYM", + "FAST_SYM", + "MEDIUM_SYM", + "CHANGED", + "UPGRADE_SYM", + "NO_WRITE_TO_BINLOG", + "CACHE_SYM", + "LOAD", + "LEAVES", + "EXCEPT_SYM", + "INTERSECT_SYM", + "SELECT_SYM", + "DUAL_SYM", + "SQL_NO_CACHE_SYM", + "SHARE_SYM", + "MODE_SYM", + "OF_SYM", + "SKIP_SYM", + "LOCKED_SYM", + "NOWAIT_SYM", + "'*'", + "XOR", + "IS", + "TRUE_SYM", + "FALSE_SYM", + "UNKNOWN_SYM", + "MEMBER_SYM", + "BETWEEN_SYM", + "AND_SYM", + "SOUNDS_SYM", + "ESCAPE_SYM", + "REGEXP", + "'|'", + "'&'", + "SHIFT_LEFT", + "SHIFT_RIGHT", + "'+'", + "'-'", + "'/'", + "'%'", + "DIV_SYM", + "MOD_SYM", + "'^'", + "OR2_SYM", + "AND_AND_SYM", + "NOT2_SYM", + "'!'", + "EQUAL_SYM", + "GE", + "GT_SYM", + "LE", + "LT", + "NE", + "ANY_SYM", + "OR_OR_SYM", + "'~'", + "ROW_SYM", + "'{'", + "'}'", + "AGAINST", + "CAST_SYM", + "ZONE_SYM", + "JSON_SEPARATOR_SYM", + "JSON_UNQUOTED_SEPARATOR_SYM", + "ARRAY_SYM", + "CURRENT_USER", + "HOUR_SYM", + "JSON_VALUE_SYM", + "LEFT", + "MINUTE_SYM", + "MONTH_SYM", + "RIGHT", + "SECOND_SYM", + "TRIM", + "LEADING", + "TRAILING", + "BOTH", + "ADDDATE_SYM", + "CURDATE", + "CURTIME", + "DATE_ADD_INTERVAL", + "DATE_SUB_INTERVAL", + "EXTRACT_SYM", + "GET_FORMAT", + "LOG_SYM", + "POSITION_SYM", + "SUBDATE_SYM", + "SUBSTRING", + "SYSDATE", + "TIMESTAMP_ADD", + "TIMESTAMP_DIFF", + "UTC_DATE_SYM", + "UTC_TIME_SYM", + "UTC_TIMESTAMP_SYM", + "RETURNING_SYM", + "COLLATION_SYM", + "FORMAT_SYM", + "MICROSECOND_SYM", + "QUARTER_SYM", + "REVERSE_SYM", + "WEEK_SYM", + "WEIGHT_STRING_SYM", + "NATURAL", + "QUERY_SYM", + "EXPANSION_SYM", + "AVG_SYM", + "DISTINCT", + "BIT_AND_SYM", + "BIT_OR_SYM", + "JSON_ARRAYAGG", + "JSON_OBJECTAGG", + "ST_COLLECT_SYM", + "BIT_XOR_SYM", + "COUNT_SYM", + "MIN_SYM", + "MAX_SYM", + "STD_SYM", + "VARIANCE_SYM", + "STDDEV_SAMP_SYM", + "VAR_SAMP_SYM", + "SUM_SYM", + "GROUP_CONCAT_SYM", + "BERNOULLI_SYM", + "TABLESAMPLE_SYM", + "ROW_NUMBER_SYM", + "RANK_SYM", + "DENSE_RANK_SYM", + "CUME_DIST_SYM", + "PERCENT_RANK_SYM", + "NTILE_SYM", + "LEAD_SYM", + "LAG_SYM", + "FIRST_VALUE_SYM", + "LAST_VALUE_SYM", + "NTH_VALUE_SYM", + "RESPECT_SYM", + "NULLS_SYM", + "OVER_SYM", + "PRECEDING_SYM", + "FOLLOWING_SYM", + "EXCLUDE_SYM", + "TIES_SYM", + "OTHERS_SYM", + "ROWS_SYM", + "GROUPS_SYM", + "GROUPING_SYM", + "SET_VAR", + "SEPARATOR_SYM", + "OJ_SYM", + "JOIN_SYM", + "INNER_SYM", + "CROSS", + "STRAIGHT_JOIN", + "OUTER_SYM", + "LATERAL_SYM", + "JSON_TABLE_SYM", + "ORDINALITY_SYM", + "PATH_SYM", + "NESTED_SYM", + "EMPTY_SYM", + "ERROR_SYM", + "DAY_HOUR_SYM", + "DAY_MICROSECOND_SYM", + "DAY_MINUTE_SYM", + "DAY_SECOND_SYM", + "HOUR_MICROSECOND_SYM", + "HOUR_MINUTE_SYM", + "HOUR_SECOND_SYM", + "MINUTE_MICROSECOND_SYM", + "MINUTE_SECOND_SYM", + "SECOND_MICROSECOND_SYM", + "YEAR_MONTH_SYM", + "WHERE", + "HAVING", + "QUALIFY_SYM", + "RECURSIVE_SYM", + "WINDOW_SYM", + "ROLLUP_SYM", + "CUBE_SYM", + "WITH_ROLLUP_SYM", + "ASC", + "DESC", + "LIMIT", + "OFFSET_SYM", + "HEX_NUM", + "FLOAT_NUM", + "OUTFILE", + "DUMPFILE", + "VIEW_SYM", + "TRIGGER_SYM", + "TEMPORARY", + "LOW_PRIORITY", + "DELAYED_SYM", + "HIGH_PRIORITY", + "DUPLICATE_SYM", + "CPU_SYM", + "BLOCK_SYM", + "IO_SYM", + "CONTEXT_SYM", + "SWITCHES_SYM", + "PAGE_SYM", + "FAULTS_SYM", + "IPC_SYM", + "SWAPS_SYM", + "SHOW", + "DATABASES", + "TABLES", + "TRIGGERS_SYM", + "EVENTS_SYM", + "STATUS_SYM", + "PLUGINS_SYM", + "LOGS_SYM", + "MUTEX_SYM", + "REPLICAS_SYM", + "RELAYLOG_SYM", + "ENGINES_SYM", + "WARNINGS", + "ERRORS", + "PROFILES_SYM", + "PROFILE_SYM", + "PROCESSLIST_SYM", + "VARIABLES", + "PRIVILEGES", + "GRANTS", + "CODE_SYM", + "PARSE_TREE_SYM", + "DESCRIBE", + "FLUSH_SYM", + "EXPORT_SYM", + "GENERAL", + "SLOW", + "RELAY", + "RESOURCES", + "OPTIMIZER_COSTS_SYM", + "RESET_SYM", + "PERSIST_SYM", + "GTIDS_SYM", + "PURGE", + "KILL_SYM", + "XML_SYM", + "CONCURRENT", + "INFILE_SYM", + "URL_SYM", + "S3_SYM", + "TERMINATED", + "OPTIONALLY", + "ENCLOSED", + "ESCAPED", + "LINES", + "STARTING", + "BULK_SYM", + "PARALLEL_SYM", + "NCHAR_STRING", + "UNDERSCORE_CHARSET", + "BIN_NUM", + "PARAM_MARKER", + "IDENT", + "IDENT_QUOTED", + "LEX_HOSTNAME", + "RESTART_SYM", + "SHUTDOWN", + "CLONE_SYM", + "COMMIT_SYM", + "FOLLOWS_SYM", + "INSTALL_SYM", + "PRECEDES_SYM", + "ROLLBACK_SYM", + "SAVEPOINT_SYM", + "UNINSTALL_SYM", + "XA_SYM", + "FILE_SYM", + "PROCESS", + "PROXY_SYM", + "RELOAD", + "SUPER_SYM", + "ADMIN_SYM", + "AGGREGATE_SYM", + "AUTHENTICATION_SYM", + "BACKUP_SYM", + "CASCADED", + "CHAIN_SYM", + "CHALLENGE_RESPONSE_SYM", + "CIPHER_SYM", + "CLIENT_SYM", + "COMMITTED_SYM", + "COMPONENT_SYM", + "FACTOR_SYM", + "FINISH_SYM", + "HOSTS_SYM", + "INITIAL_SYM", + "INITIATE_SYM", + "INSTANCE_SYM", + "ISOLATION", + "ISSUER_SYM", + "KEYRING_SYM", + "LEVEL_SYM", + "LOCKS_SYM", + "MASTER_SYM", + "MERGE_SYM", + "MIGRATE_SYM", + "NAMES_SYM", + "NDBCLUSTER_SYM", + "NEW_SYM", + "ONE_SYM", + "PHASE_SYM", + "PLUGIN_SYM", + "PREV_SYM", + "RANDOM_SYM", + "READ_ONLY_SYM", + "RECOVER_SYM", + "REGISTRATION_SYM", + "REPEATABLE_SYM", + "RESTORE_SYM", + "RESUME_SYM", + "RETAIN_SYM", + "RETURNS_SYM", + "ROTATE_SYM", + "ROUTINE_SYM", + "SERIALIZABLE_SYM", + "SONAME_SYM", + "SQL_BUFFER_RESULT", + "SUBJECT_SYM", + "SUSPEND_SYM", + "TEMPTABLE_SYM", + "THREAD_PRIORITY_SYM", + "TLS_SYM", + "TYPES_SYM", + "UNCOMMITTED_SYM", + "UNDEFINED_SYM", + "UNREGISTER_SYM", + "VCPU_SYM", + "WORK_SYM", + "X509_SYM", + "XID_SYM", + "GLOBAL_SYM", + "PERSIST_ONLY_SYM", + "SESSION_SYM", + "REVOKE", + "GRANT", + "OPTION", + "USAGE", + "SSL_SYM", + "RELEASE_SYM", + "SQL_SMALL_RESULT", + "SQL_BIG_RESULT", + "SQL_CALC_FOUND_ROWS", + "EACH_SYM", + "DOLLAR_QUOTED_STRING_SYM", +} diff --git a/check/mysql/internal/mysqlparse/mysqlparse.go b/check/mysql/internal/mysqlparse/mysqlparse.go new file mode 100644 index 0000000..baaae19 --- /dev/null +++ b/check/mysql/internal/mysqlparse/mysqlparse.go @@ -0,0 +1,379 @@ +// Package mysqlparse is the MySQL parser: the server's own grammar and lexer, cut out of +// the server source by parsegen, compiled to WebAssembly and run by wazero. +// +// The grammar is sql_yacc.yy with its semantic actions stripped, so what comes back is a +// concrete syntax tree: one node per grammar rule reduced, one leaf per token, each leaf +// carrying its byte span in the input. Node kinds are the grammar's own rule and token +// names (kinds.go, generated with the module). Turning that into sqlshape's view of a +// statement is the job of the packages above this one; this package only parses. +// +// A wasm instance is not safe for concurrent use; instances are pooled and one is taken +// per call. The module is compiled once per process on first use, through wazero's +// on-disk cache when the user has a cache directory. +package mysqlparse + +import ( + "context" + _ "embed" + "encoding/binary" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/imports/emscripten" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" +) + +// Built by parsegen from the MySQL 8.4 server source. +// +//go:embed wasm/mysqlparse_8.4.wasm +var wasm84 []byte + +// Mode carries the sql_mode bits the parser reads, with MySQL's own values (the low 32 +// bits of x/sqlmode.Mode: every bit the lexer or the AST reads is below 2^32). The lexer +// reads ANSI_QUOTES, IGNORE_SPACE, NO_BACKSLASH_ESCAPES, HIGH_NOT_PRECEDENCE and +// PIPES_AS_CONCAT (`||` is OR_OR_SYM or OR2_SYM); mysqlast reads REAL_AS_FLOAT. +type Mode uint32 + +const ( + RealAsFloat Mode = 1 + PipesAsConcat Mode = 2 + ANSIQuotes Mode = 4 + IgnoreSpace Mode = 8 + NoBackslashEscapes Mode = 0x40000 * 4 + HighNotPrecedence Mode = 1 << 29 +) + +// Kind is a CST node kind: a grammar rule for inner nodes, a token for leaves. +type Kind uint16 + +// String is the grammar's name for the kind: a rule name such as "select_stmt", a token +// name such as "IDENT" or "SELECT_SYM", or a quoted character such as "'('". +func (k Kind) String() string { + if int(k) < len(kindNames) { + return kindNames[k] + } + return fmt.Sprintf("Kind(%d)", uint16(k)) +} + +// IsTerminal reports whether the kind is a token. +func (k Kind) IsTerminal() bool { return k >= firstTerminal } + +// KindOf returns the kind named s, or false. +func KindOf(s string) (Kind, bool) { + kindIndexOnce.Do(func() { + kindIndex = make(map[string]Kind, len(kindNames)) + for i, n := range kindNames { + kindIndex[n] = Kind(i) + } + }) + k, ok := kindIndex[s] + return k, ok +} + +var ( + kindIndex map[string]Kind + kindIndexOnce sync.Once +) + +// Node is one CST node. Leaves have Start/End (byte offsets into the parsed text) and no +// Children; inner nodes have Children and their span is that of their leaves. +type Node struct { + Kind Kind + // Alt is which alternative of the rule was reduced, 0-based in grammar order; it selects + // the node's Shape. Leaves have Alt 0. + Alt int + Start int + End int + // Value is the lexer's value for identifier and literal leaves: the identifier without + // its quotes, the string literal with escapes resolved, the charset name of an + // introducer. HasValue tells it apart from an empty value. + Value string + HasValue bool + Children []*Node +} + +// IsLeaf reports whether the node is a token. +func (n *Node) IsLeaf() bool { return n.Kind.IsTerminal() } + +// Text is the node's span of sql, the text it was parsed from. +func (n *Node) Text(sql string) string { return sql[n.Start:n.End] } + +// Error is what the parser reports: bison's message and the byte offset of the token it +// stopped at. +type Error struct { + Message string + Offset int +} + +func (e *Error) Error() string { return fmt.Sprintf("%s at byte %d", e.Message, e.Offset) } + +// Parse parses one statement (no trailing ';' is needed; one is accepted) and returns its +// CST. The root is the grammar's start rule. A syntax error is returned as *Error. +func Parse(sql string, mode Mode) (*Node, error) { + var out *Node + err := mod84.with(func(in *instance) error { + p, err := in.put(sql) + if err != nil { + return err + } + defer in.call("free", uint64(p)) + r, err := in.call("mysqlparse_parse", uint64(p), uint64(len(sql)), uint64(mode)) + if err != nil { + return err + } + defer in.call("mysqlparse_free", r) + if e, err := in.call("mysqlparse_error", r); err != nil { + return err + } else if e != 0 { + cur, err := in.call("mysqlparse_cursor", r) + if err != nil { + return err + } + return &Error{Message: in.cstr(uint32(e)), Offset: int(cur)} + } + data, err := in.call("mysqlparse_data", r) + if err != nil { + return err + } + n, err := in.call("mysqlparse_len", r) + if err != nil { + return err + } + b, err := in.bytesAt(uint32(data), uint32(n)) + if err != nil { + return err + } + out, err = decode(b) + return err + }) + if err != nil { + return nil, err + } + return out, nil +} + +// decode reads the wire format parse.h describes: preorder, u16 kind with bit 15 on leaves, +// then u32 start / u32 end for a leaf or u16 alternative + u16 child count for a rule. +func decode(b []byte) (*Node, error) { + pos := 0 + var rec func() (*Node, error) + rec = func() (*Node, error) { + if pos+2 > len(b) { + return nil, fmt.Errorf("mysqlparse: truncated tree at %d", pos) + } + k := binary.LittleEndian.Uint16(b[pos:]) + pos += 2 + n := &Node{Kind: Kind(k & 0x3fff)} + if k&0x8000 != 0 { + if pos+8 > len(b) { + return nil, fmt.Errorf("mysqlparse: truncated leaf at %d", pos) + } + n.Start = int(binary.LittleEndian.Uint32(b[pos:])) + n.End = int(binary.LittleEndian.Uint32(b[pos+4:])) + pos += 8 + if k&0x4000 != 0 { // the lexer's value follows + if pos+4 > len(b) { + return nil, fmt.Errorf("mysqlparse: truncated leaf value at %d", pos) + } + vlen := int(binary.LittleEndian.Uint32(b[pos:])) + pos += 4 + if pos+vlen > len(b) { + return nil, fmt.Errorf("mysqlparse: truncated leaf value at %d", pos) + } + n.Value = string(b[pos : pos+vlen]) + n.HasValue = true + pos += vlen + } + return n, nil + } + if pos+4 > len(b) { + return nil, fmt.Errorf("mysqlparse: truncated node at %d", pos) + } + n.Alt = int(binary.LittleEndian.Uint16(b[pos:])) + count := int(binary.LittleEndian.Uint16(b[pos+2:])) + pos += 4 + if count > 0 { + n.Children = make([]*Node, count) + first := true + for i := range n.Children { + c, err := rec() + if err != nil { + return nil, err + } + n.Children[i] = c + // an empty rule has no span of its own; take the span of the children that have one + if c.End > c.Start || c.IsLeaf() { + if first || c.Start < n.Start { + n.Start = c.Start + } + if first || c.End > n.End { + n.End = c.End + } + first = false + } + } + } + return n, nil + } + root, err := rec() + if err != nil { + return nil, err + } + if pos != len(b) { + return nil, fmt.Errorf("mysqlparse: %d trailing bytes after the tree", len(b)-pos) + } + return root, nil +} + +// --- wazero plumbing, the shape of internal/pgparse --------------------------------- + +type module struct { + wasm []byte + once sync.Once + runtime wazero.Runtime + compiled wazero.CompiledModule + err error + pool sync.Pool // *instance +} + +var mod84 = &module{wasm: wasm84} + +func (m *module) compile() error { + m.once.Do(func() { + ctx := context.Background() + m.runtime = wazero.NewRuntimeWithConfig(ctx, runtimeConfig()) + if _, err := wasi_snapshot_preview1.Instantiate(ctx, m.runtime); err != nil { + m.err = err + return + } + m.compiled, m.err = m.runtime.CompileModule(ctx, m.wasm) + if m.err != nil { + return + } + if _, err := emscripten.InstantiateForModule(ctx, m.runtime, m.compiled); err != nil { + m.err = err + } + }) + return m.err +} + +// runtimeConfig compiles through the same on-disk cache as pgparse; the module is large +// (the whole collation library rides along), so the cache matters more here. +func runtimeConfig() wazero.RuntimeConfig { + cfg := wazero.NewRuntimeConfig() + dir, err := os.UserCacheDir() + if err != nil { + return cfg + } + cache, err := wazero.NewCompilationCacheWithDir(filepath.Join(dir, "sqlshape", "wazero")) + if err != nil { + return cfg + } + return cfg.WithCompilationCache(cache) +} + +type instance struct { + mod api.Module + fns map[string]api.Function +} + +func (m *module) acquire() (*instance, error) { + if err := m.compile(); err != nil { + return nil, err + } + if v := m.pool.Get(); v != nil { + return v.(*instance), nil + } + ctx := context.Background() + mod, err := m.runtime.InstantiateModule(ctx, m.compiled, wazero.NewModuleConfig().WithName("")) + if err != nil { + return nil, err + } + in := &instance{mod: mod, fns: map[string]api.Function{}} + if rc, err := in.call("mysqlparse_init"); err != nil || rc != 0 { + mod.Close(ctx) + if err == nil { + err = fmt.Errorf("mysqlparse: the charset registry did not come up") + } + return nil, err + } + return in, nil +} + +func (m *module) release(in *instance, trapped bool) { + if trapped { + in.mod.Close(context.Background()) + return + } + m.pool.Put(in) +} + +func (m *module) with(f func(in *instance) error) error { + in, err := m.acquire() + if err != nil { + return err + } + err = f(in) + _, parserErr := err.(*Error) + m.release(in, err != nil && !parserErr) + return err +} + +func (in *instance) call(name string, args ...uint64) (uint64, error) { + fn := in.fns[name] + if fn == nil { + fn = in.mod.ExportedFunction(name) + if fn == nil { + return 0, fmt.Errorf("mysqlparse: wasm export %q missing", name) + } + in.fns[name] = fn + } + res, err := fn.Call(context.Background(), args...) + if err != nil { + return 0, fmt.Errorf("mysqlparse: %s: %w", name, err) + } + if len(res) == 0 { + return 0, nil + } + return res[0], nil +} + +func (in *instance) put(s string) (uint32, error) { + p, err := in.call("malloc", uint64(len(s)+1)) + if err != nil { + return 0, err + } + if !in.mod.Memory().Write(uint32(p), append([]byte(s), 0)) { + return 0, fmt.Errorf("mysqlparse: memory write of %d bytes failed", len(s)+1) + } + return uint32(p), nil +} + +func (in *instance) cstr(p uint32) string { + mem := in.mod.Memory() + var sb strings.Builder + for i := p; ; i++ { + b, ok := mem.ReadByte(i) + if !ok || b == 0 { + break + } + sb.WriteByte(b) + } + return sb.String() +} + +func (in *instance) bytesAt(p, n uint32) ([]byte, error) { + if n == 0 { + return nil, nil + } + b, ok := in.mod.Memory().Read(p, n) + if !ok { + return nil, fmt.Errorf("mysqlparse: memory read of %d bytes at %d failed", n, p) + } + return append([]byte(nil), b...), nil +} diff --git a/check/mysql/internal/mysqlparse/mysqlparse_test.go b/check/mysql/internal/mysqlparse/mysqlparse_test.go new file mode 100644 index 0000000..f3e154b --- /dev/null +++ b/check/mysql/internal/mysqlparse/mysqlparse_test.go @@ -0,0 +1,412 @@ +package mysqlparse + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/parsegen" +) + +func TestParseTree(t *testing.T) { + sql := "SELECT a, `b` FROM t WHERE c = ? AND d IN (SELECT e FROM u) ORDER BY a LIMIT 10" + root, err := Parse(sql, 0) + if err != nil { + t.Fatal(err) + } + if root.Kind.String() != "start_entry" { + t.Errorf("root kind %s", root.Kind) + } + if root.Text(sql) != sql { + t.Errorf("root span %q", root.Text(sql)) + } + var leaves []string + var kinds []string + var walk func(n *Node) + walk = func(n *Node) { + if n.IsLeaf() { + leaves = append(leaves, n.Kind.String()+"="+n.Text(sql)) + return + } + kinds = append(kinds, n.Kind.String()) + for _, c := range n.Children { + walk(c) + } + } + walk(root) + got := strings.Join(leaves, " ") + // unquoted identifiers come back as IDENT_QUOTED: the lexer marks every identifier so under a + // multi-byte connection charset (utf8mb4), as the server does + for _, want := range []string{"SELECT_SYM=SELECT", "IDENT_QUOTED=a", "','=,", "IDENT_QUOTED=`b`", "PARAM_MARKER=?", "IN_SYM=IN", "'('=(", "NUM=10", "END_OF_INPUT="} { + if !strings.Contains(got, want) { + t.Errorf("leaves lack %q:\n%s", want, got) + } + } + joined := strings.Join(kinds, " ") + for _, want := range []string{"select_stmt", "query_expression", "where_clause", "order_clause", "limit_clause", "table_subquery"} { + if !strings.Contains(joined, want) { + t.Errorf("rules lack %q", want) + } + } +} + +func TestLeafValues(t *testing.T) { + sql := "SELECT `a``b`, 'it''s\\n', _utf8mb4'x', 0x1F, 12 FROM t" + root, err := Parse(sql, 0) + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + var walk func(n *Node) + walk = func(n *Node) { + if n.IsLeaf() && n.HasValue { + got[n.Kind.String()+":"+n.Text(sql)] = n.Value + } + for _, c := range n.Children { + walk(c) + } + } + walk(root) + for k, want := range map[string]string{ + "IDENT_QUOTED:`a``b`": "a`b", + "TEXT_STRING:'it''s\\n'": "it's\n", + "UNDERSCORE_CHARSET:_utf8mb4": "utf8mb4", + "HEX_NUM:0x1F": "1F", + "NUM:12": "12", + } { + if got[k] != want { + t.Errorf("%s: value %q, want %q (all: %v)", k, got[k], want, got) + } + } +} + +func TestParseError(t *testing.T) { + _, err := Parse("SELECT 1 +", 0) + e, ok := err.(*Error) + if !ok { + t.Fatalf("want *Error, got %v", err) + } + if e.Message != "syntax error" || e.Offset != 10 { + t.Errorf("got %+v", *e) + } +} + +func TestMode(t *testing.T) { + // with ANSI_QUOTES a double-quoted string is an identifier + root, err := Parse(`SELECT "a" FROM t`, ANSIQuotes) + if err != nil { + t.Fatal(err) + } + if !hasLeaf(root, "IDENT_QUOTED") || hasLeaf(root, "TEXT_STRING") { + t.Error("ANSI_QUOTES did not turn the string into an identifier") + } + root, err = Parse(`SELECT "a" FROM t`, 0) + if err != nil { + t.Fatal(err) + } + if !hasLeaf(root, "TEXT_STRING") { + t.Error(`"a" should be a string without ANSI_QUOTES`) + } +} + +func hasLeaf(n *Node, kind string) bool { + if n.IsLeaf() { + return n.Kind.String() == kind + } + for _, c := range n.Children { + if hasLeaf(c, kind) { + return true + } + } + return false +} + +func TestKindOf(t *testing.T) { + k, ok := KindOf("select_stmt") + if !ok || k.IsTerminal() || k.String() != "select_stmt" { + t.Error("select_stmt") + } + k, ok = KindOf("SELECT_SYM") + if !ok || !k.IsTerminal() { + t.Error("SELECT_SYM") + } + if _, ok := KindOf("no_such_rule"); ok { + t.Error("no_such_rule") + } +} + +func TestVersionComment(t *testing.T) { + root, err := Parse("SELECT /*!80000 SQL_NO_CACHE */ 1 /* c */", 0) + if err != nil { + t.Fatal(err) + } + if !hasLeaf(root, "SQL_NO_CACHE_SYM") { + t.Error("the version comment for a supported version should be parsed") + } +} + +// TestCorpus parses mysql-test/t when the MySQL source is checked out, and holds the +// acceptance the spike measured. +func TestCorpus(t *testing.T) { + home, _ := os.UserHomeDir() + dir := filepath.Join(home, ".cache", "sqlshape", "mysql-server", "mysql-test", "t") + if _, err := os.Stat(dir); err != nil { + t.Skip("no MySQL source at", dir) + } + stmts, _, err := parsegen.SplitTestDir(dir) + if err != nil { + t.Fatal(err) + } + var ok, fail, expectedRejected, expectedAccepted int + for _, s := range stmts { + _, err := Parse(s.SQL, 0) + switch { + case s.Expect == "ER_PARSE_ERROR" || s.Expect == "1064": + if err != nil { + expectedRejected++ + } else { + expectedAccepted++ + } + case s.Expect != "": + case err == nil: + ok++ + default: + fail++ + if _, isParse := err.(*Error); !isParse { + t.Fatalf("%s:%d: %v", s.File, s.Line, err) + } + } + } + rate := float64(ok) / float64(ok+fail) + t.Logf("accepted %d of %d statements expecting no error (%.2f%%); ER_PARSE_ERROR: %d rejected, %d accepted", ok, ok+fail, rate*100, expectedRejected, expectedAccepted) + if rate < 0.998 { + t.Errorf("acceptance fell to %.2f%%", rate*100) + } +} + +func TestShape(t *testing.T) { + sql := "SELECT a FROM t WHERE b = 1" + root, err := Parse(sql, 0) + if err != nil { + t.Fatal(err) + } + var classes []string + var walk func(n *Node) + walk = func(n *Node) { + if n.IsLeaf() { + return + } + if s := n.Shape(); s.Kind == ActNew { + classes = append(classes, s.Class) + } + for _, c := range n.Children { + walk(c) + } + } + walk(root) + joined := strings.Join(classes, " ") + for _, want := range []string{"PT_select_stmt", "PT_query_specification", "PT_table_factor_table_ident", "PTI_comp_op", "PTI_where", "Item_int"} { + if !strings.Contains(joined, want) { + t.Errorf("shapes lack %s in %s", want, joined) + } + } + // the alternative index selects the shape: `where_clause` is the second alternative of + // opt_where_clause and has no action (bison's default $$ = $1) + k, _ := KindOf("opt_where_clause") + var where *Node + var find func(n *Node) + find = func(n *Node) { + if n.Kind == k { + where = n + } + for _, c := range n.Children { + find(c) + } + } + find(root) + if where == nil || where.Alt != 1 || where.Shape().Kind != ActDefault { + t.Errorf("opt_where_clause: %+v shape %+v", where, where.Shape()) + } +} + +func TestSplit(t *testing.T) { + script := "-- header\nCREATE TABLE t (a INT); # c\n/* block ; */ INSERT INTO t VALUES ('a;b', \"c;d\", `e;f`);\n\n-- sqlshape: unfiltered t\nSELECT 1\n" + got := Split(script) + // leading comments stay with their statement: `-- sqlshape:` directives live there + want := []string{"-- header\nCREATE TABLE t (a INT)", "# c\n/* block ; */ INSERT INTO t VALUES ('a;b', \"c;d\", `e;f`)", "-- sqlshape: unfiltered t\nSELECT 1"} + if len(got) != len(want) { + t.Fatalf("got %d statements: %+v", len(got), got) + } + for i := range want { + if got[i].SQL != want[i] || script[got[i].Offset:got[i].Offset+len(got[i].SQL)] != got[i].SQL { + t.Errorf("%d: %q at %d", i, got[i].SQL, got[i].Offset) + } + } +} + +func checkOffsets(t *testing.T, script string, stmts []Statement) { + t.Helper() + for i, s := range stmts { + if s.Offset < 0 || s.Offset+len(s.SQL) > len(script) || script[s.Offset:s.Offset+len(s.SQL)] != s.SQL { + t.Errorf("%d: offset %d does not point at %q in script", i, s.Offset, s.SQL) + } + } +} + +// TestSplitCompoundTrigger checks that a CREATE TRIGGER body with internal ';' (BEGIN ... +// END, IF ... END IF) is cut as one statement, without a DELIMITER command. +func TestSplitCompoundTrigger(t *testing.T) { + script := "CREATE TABLE t (a INT);\n" + + "CREATE TRIGGER trg BEFORE INSERT ON t FOR EACH ROW\n" + + "BEGIN\n" + + " DECLARE v INT DEFAULT 0;\n" + + " IF NEW.a > 0 THEN\n" + + " SET v = 1;\n" + + " END IF;\n" + + "END;\n" + + "SELECT 1;\n" + got := SplitMode(script, 0) + checkOffsets(t, script, got) + if len(got) != 3 { + t.Fatalf("got %d statements: %+v", len(got), got) + } + if !strings.HasPrefix(got[1].SQL, "CREATE TRIGGER") || !strings.HasSuffix(got[1].SQL, "END") { + t.Errorf("trigger statement: %q", got[1].SQL) + } + if got[2].SQL != "SELECT 1" { + t.Errorf("trailing statement: %q", got[2].SQL) + } +} + +// TestSplitDelimiter checks that a `DELIMITER` command switches the cut point and is itself +// dropped, and that `DELIMITER ;` restores the default. +func TestSplitDelimiter(t *testing.T) { + script := "DELIMITER $$\n" + + "CREATE FUNCTION f() RETURNS INT\n" + + "BEGIN\n" + + " RETURN 1;\n" + + "END$$\n" + + "DELIMITER ;\n" + + "SELECT 2;\n" + got := SplitMode(script, 0) + checkOffsets(t, script, got) + if len(got) != 2 { + t.Fatalf("got %d statements: %+v", len(got), got) + } + if !strings.HasPrefix(got[0].SQL, "CREATE FUNCTION") || !strings.HasSuffix(got[0].SQL, "END") { + t.Errorf("function statement: %q", got[0].SQL) + } + if got[1].SQL != "SELECT 2" { + t.Errorf("trailing statement: %q", got[1].SQL) + } +} + +// TestSplitCompoundNoBeginEnd checks that a simple (non-BEGIN/END) trigger body, which ends +// at its first ';', does not need the retry. +func TestSplitCompoundNoBeginEnd(t *testing.T) { + script := "CREATE TRIGGER trg BEFORE INSERT ON t FOR EACH ROW SET NEW.a = 1;\nSELECT 1;\n" + got := SplitMode(script, 0) + checkOffsets(t, script, got) + want := []string{"CREATE TRIGGER trg BEFORE INSERT ON t FOR EACH ROW SET NEW.a = 1", "SELECT 1"} + for i := range want { + if got[i].SQL != want[i] { + t.Errorf("%d: got %q want %q", i, got[i].SQL, want[i]) + } + } +} + +// TestSplitCompoundTruncatedNoSemicolon: a compound CREATE whose body never reaches a ';' +// at all (truncated mid-statement) keeps the whole remainder as one statement -- SplitMode's +// own "ran off the end of the script" branch, the found-false side of its retry loop. +func TestSplitCompoundTruncatedNoSemicolon(t *testing.T) { + script := "CREATE TRIGGER trg BEFORE INSERT ON t FOR EACH ROW\nBEGIN\n SET v = 1\n" + got := SplitMode(script, 0) + checkOffsets(t, script, got) + if len(got) != 1 { + t.Fatalf("got %d statements: %+v", len(got), got) + } + if !strings.HasPrefix(got[0].SQL, "CREATE TRIGGER") { + t.Errorf("got %q", got[0].SQL) + } +} + +// TestSplitCompoundOneSemicolonNoEnd: a compound CREATE whose body has exactly one ';' (an +// incomplete BEGIN with no END, and no further ';' to retry with) exercises the "extending +// further also finds nothing" branch (as opposed to +// TestSplitCompoundTruncatedNoSemicolon's own immediate found-false). +func TestSplitCompoundOneSemicolonNoEnd(t *testing.T) { + script := "CREATE TRIGGER trg BEFORE INSERT ON t FOR EACH ROW\nBEGIN\n SET v = 1;\n" + got := SplitMode(script, 0) + checkOffsets(t, script, got) + if len(got) != 1 { + t.Fatalf("got %d statements: %+v", len(got), got) + } + if !strings.HasPrefix(got[0].SQL, "CREATE TRIGGER") { + t.Errorf("got %q", got[0].SQL) + } +} + +// TestFindDelimiter_EscapedQuote covers findDelimiter's own backslash-escape handling +// inside a quoted string (a ';' right after the escaped quote must stay inside the string). +func TestFindDelimiter_EscapedQuote(t *testing.T) { + script := `SELECT 'a\'; b';SELECT 2;` + end, next, found := findDelimiter(script, 0, ";") + if !found { + t.Fatal("want found") + } + if script[:end] != `SELECT 'a\'; b'` { + t.Errorf("got %q", script[:end]) + } + if script[next:] != "SELECT 2;" { + t.Errorf("got %q", script[next:]) + } +} + +// TestFindDelimiter_UnterminatedBlockComment covers findDelimiter's own unterminated `/*` +// handling: the comment runs to the end of the script, so no ';' after it is ever found. +func TestFindDelimiter_UnterminatedBlockComment(t *testing.T) { + script := "SELECT 1; /* unterminated" + end, next, found := findDelimiter(script, 10, ";") + if found { + t.Fatalf("want not found, got end=%d next=%d", end, next) + } + if end != len(script) || next != len(script) { + t.Errorf("got end=%d next=%d, want both %d", end, next, len(script)) + } +} + +// TestMatchDelimiterLine_NoSpaceAfterKeyword: "DELIMITER" with nothing (or no space) +// following is not a DELIMITER command. +func TestMatchDelimiterLine_NoSpaceAfterKeyword(t *testing.T) { + if _, _, ok := matchDelimiterLine("DELIMITER", 0); ok { + t.Error("want not ok (keyword alone, no token)") + } + if _, _, ok := matchDelimiterLine("DELIMITERX $$\n", 0); ok { + t.Error("want not ok (no space after the keyword)") + } +} + +// TestMatchDelimiterLine_EmptyToken: "DELIMITER" followed only by trailing space (no token) +// is not a DELIMITER command either. +func TestMatchDelimiterLine_EmptyToken(t *testing.T) { + if _, _, ok := matchDelimiterLine("DELIMITER \n", 0); ok { + t.Error("want not ok (no token after the spaces)") + } +} + +// TestStripLeadingComments_LineCommentNoNewline: a `-- ` comment that runs to the end of +// the text with no trailing newline strips to empty. +func TestStripLeadingComments_LineCommentNoNewline(t *testing.T) { + if got := stripLeadingComments("-- just a comment, no newline"); got != "" { + t.Errorf("got %q, want empty", got) + } +} + +// TestStripLeadingComments_UnterminatedBlockComment: an unterminated `/*` also strips to +// empty (nothing follows it that could be the statement proper). +func TestStripLeadingComments_UnterminatedBlockComment(t *testing.T) { + if got := stripLeadingComments("/* unterminated"); got != "" { + t.Errorf("got %q, want empty", got) + } +} diff --git a/check/mysql/internal/mysqlparse/shape.go b/check/mysql/internal/mysqlparse/shape.go new file mode 100644 index 0000000..d4fafbf --- /dev/null +++ b/check/mysql/internal/mysqlparse/shape.go @@ -0,0 +1,74 @@ +package mysqlparse + +// Shape is what the server's semantic action did for one alternative of a rule: which +// parse-tree class it built and which children fed which constructor argument. parsegen +// reads it out of sql_yacc.yy (shapes.go); it is the map from the CST to an AST. +type Shape struct { + // Syms is the alternative's right-hand side, space-separated: the key that ties a + // hand-written hook to an alternative across grammar versions. + Syms string + Kind ActKind + Class string // ActNew, ActListNew: the server's parse-tree class + Const string // ActConst: the constant as written (an enum value, a literal) + Args []Arg // ActNew: constructor arguments; ActPass: the child; ActListAppend: list, element; ActFlags: the two operands; ActNumber: the token + Params []string // ActNew: the constructor's parameter names, aligned with Args (nil when unknown) + Fields []Field // ActStruct: field assignments +} + +// ActKind is the form of a semantic action. +type ActKind uint8 + +const ( + ActUnknown ActKind = iota // not read: the hand-written scope + ActDefault // no action; the value is the first child (keyword lists) + ActEmpty // no value + ActPass // the value is one child (or a field of it) + ActConst // a constant + ActNew // a parse-tree class built from the arguments + ActListNew // a list holding the given child + ActListAppend // a list (first arg) with a child (second arg) appended + ActFlags // two option sets or'ed + ActNumber // a numeric token read as a number + ActStruct // a by-value struct with the named fields +) + +func (k ActKind) String() string { + return [...]string{"unknown", "default", "empty", "pass", "const", "new", "list-new", "list-append", "flags", "number", "struct"}[k] +} + +// Arg is one argument as the action wrote it: a child by 1-based RHS position, possibly a +// field of it (".str", ".column_list"), or a constant / expression as text. +type Arg struct { + Child int + Field string + Text string +} + +// Field is one assignment of an ActStruct action. +type Field struct { + Name string + Arg Arg +} + +// Shape returns the action shape of a rule node; leaves have no shape. +func (n *Node) Shape() Shape { + if n.IsLeaf() || int(n.Kind) >= len(shapes) || n.Alt >= len(shapes[n.Kind]) { + return Shape{} + } + return shapes[n.Kind][n.Alt] +} + +// Alternative returns the index of rule's alternative whose right-hand side is syms +// (space-separated symbol names), or false. +func Alternative(rule, syms string) (int, bool) { + k, ok := KindOf(rule) + if !ok || int(k) >= len(shapes) { + return 0, false + } + for i, s := range shapes[k] { + if s.Syms == syms { + return i, true + } + } + return 0, false +} diff --git a/check/mysql/internal/mysqlparse/shapes.go b/check/mysql/internal/mysqlparse/shapes.go new file mode 100644 index 0000000..d03a3c8 --- /dev/null +++ b/check/mysql/internal/mysqlparse/shapes.go @@ -0,0 +1,5022 @@ +// Code generated by parsegen from MySQL 8.4.6 sql_yacc.yy. DO NOT EDIT. + +package mysqlparse + +// shapes[rule kind][alternative] is what the server's semantic action built. +var shapes = [...][]Shape{ + 0: { // start_entry + {Syms: "sql_statement", Kind: ActDefault}, + {Syms: "GRAMMAR_SELECTOR_EXPR bit_expr END_OF_INPUT", Kind: ActUnknown}, + {Syms: "GRAMMAR_SELECTOR_PART partition_clause END_OF_INPUT", Kind: ActUnknown}, + {Syms: "GRAMMAR_SELECTOR_GCOL IDENT_sys '(' expr ')' END_OF_INPUT", Kind: ActUnknown}, + {Syms: "GRAMMAR_SELECTOR_CTE table_subquery END_OF_INPUT", Kind: ActUnknown}, + {Syms: "GRAMMAR_SELECTOR_DERIVED_EXPR expr END_OF_INPUT", Kind: ActUnknown}, + }, + 1: { // sql_statement + {Syms: "END_OF_INPUT", Kind: ActDefault}, + {Syms: "simple_statement_or_begin ';' opt_end_of_input", Kind: ActDefault}, + {Syms: "simple_statement_or_begin END_OF_INPUT", Kind: ActDefault}, + }, + 2: { // opt_end_of_input + {Syms: "", Kind: ActDefault}, + {Syms: "END_OF_INPUT", Kind: ActDefault}, + }, + 3: { // simple_statement_or_begin + {Syms: "simple_statement", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "begin_stmt", Kind: ActDefault}, + }, + 4: { // simple_statement + {Syms: "alter_database_stmt", Kind: ActEmpty}, + {Syms: "alter_event_stmt", Kind: ActEmpty}, + {Syms: "alter_function_stmt", Kind: ActEmpty}, + {Syms: "alter_instance_stmt", Kind: ActDefault}, + {Syms: "alter_logfile_stmt", Kind: ActEmpty}, + {Syms: "alter_procedure_stmt", Kind: ActEmpty}, + {Syms: "alter_resource_group_stmt", Kind: ActDefault}, + {Syms: "alter_server_stmt", Kind: ActEmpty}, + {Syms: "alter_tablespace_stmt", Kind: ActEmpty}, + {Syms: "alter_undo_tablespace_stmt", Kind: ActEmpty}, + {Syms: "alter_table_stmt", Kind: ActDefault}, + {Syms: "alter_user_stmt", Kind: ActEmpty}, + {Syms: "alter_view_stmt", Kind: ActEmpty}, + {Syms: "analyze_table_stmt", Kind: ActDefault}, + {Syms: "binlog_base64_event", Kind: ActEmpty}, + {Syms: "call_stmt", Kind: ActDefault}, + {Syms: "change_replication_stmt", Kind: ActEmpty}, + {Syms: "check_table_stmt", Kind: ActDefault}, + {Syms: "checksum", Kind: ActEmpty}, + {Syms: "clone_stmt", Kind: ActEmpty}, + {Syms: "commit", Kind: ActEmpty}, + {Syms: "create", Kind: ActEmpty}, + {Syms: "create_index_stmt", Kind: ActDefault}, + {Syms: "create_resource_group_stmt", Kind: ActDefault}, + {Syms: "create_role_stmt", Kind: ActDefault}, + {Syms: "create_srs_stmt", Kind: ActDefault}, + {Syms: "create_table_stmt", Kind: ActDefault}, + {Syms: "deallocate", Kind: ActEmpty}, + {Syms: "delete_stmt", Kind: ActDefault}, + {Syms: "describe_stmt", Kind: ActDefault}, + {Syms: "do_stmt", Kind: ActDefault}, + {Syms: "drop_database_stmt", Kind: ActEmpty}, + {Syms: "drop_event_stmt", Kind: ActEmpty}, + {Syms: "drop_function_stmt", Kind: ActEmpty}, + {Syms: "drop_index_stmt", Kind: ActDefault}, + {Syms: "drop_logfile_stmt", Kind: ActEmpty}, + {Syms: "drop_procedure_stmt", Kind: ActEmpty}, + {Syms: "drop_resource_group_stmt", Kind: ActDefault}, + {Syms: "drop_role_stmt", Kind: ActDefault}, + {Syms: "drop_server_stmt", Kind: ActEmpty}, + {Syms: "drop_srs_stmt", Kind: ActDefault}, + {Syms: "drop_tablespace_stmt", Kind: ActEmpty}, + {Syms: "drop_undo_tablespace_stmt", Kind: ActEmpty}, + {Syms: "drop_table_stmt", Kind: ActEmpty}, + {Syms: "drop_trigger_stmt", Kind: ActEmpty}, + {Syms: "drop_user_stmt", Kind: ActEmpty}, + {Syms: "drop_view_stmt", Kind: ActEmpty}, + {Syms: "execute", Kind: ActEmpty}, + {Syms: "explain_stmt", Kind: ActDefault}, + {Syms: "flush", Kind: ActEmpty}, + {Syms: "get_diagnostics", Kind: ActEmpty}, + {Syms: "group_replication", Kind: ActEmpty}, + {Syms: "grant", Kind: ActEmpty}, + {Syms: "handler_stmt", Kind: ActDefault}, + {Syms: "help", Kind: ActEmpty}, + {Syms: "import_stmt", Kind: ActEmpty}, + {Syms: "insert_stmt", Kind: ActDefault}, + {Syms: "install_stmt", Kind: ActDefault}, + {Syms: "kill", Kind: ActEmpty}, + {Syms: "load_stmt", Kind: ActDefault}, + {Syms: "lock", Kind: ActEmpty}, + {Syms: "optimize_table_stmt", Kind: ActDefault}, + {Syms: "keycache_stmt", Kind: ActDefault}, + {Syms: "preload_stmt", Kind: ActDefault}, + {Syms: "prepare", Kind: ActEmpty}, + {Syms: "purge", Kind: ActEmpty}, + {Syms: "release", Kind: ActEmpty}, + {Syms: "rename", Kind: ActEmpty}, + {Syms: "repair_table_stmt", Kind: ActDefault}, + {Syms: "replace_stmt", Kind: ActDefault}, + {Syms: "reset", Kind: ActEmpty}, + {Syms: "resignal_stmt", Kind: ActEmpty}, + {Syms: "restart_server_stmt", Kind: ActDefault}, + {Syms: "revoke", Kind: ActEmpty}, + {Syms: "rollback", Kind: ActEmpty}, + {Syms: "savepoint", Kind: ActEmpty}, + {Syms: "select_stmt", Kind: ActDefault}, + {Syms: "set", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "set_resource_group_stmt", Kind: ActDefault}, + {Syms: "set_role_stmt", Kind: ActDefault}, + {Syms: "show_binary_log_status_stmt", Kind: ActDefault}, + {Syms: "show_binary_logs_stmt", Kind: ActDefault}, + {Syms: "show_binlog_events_stmt", Kind: ActDefault}, + {Syms: "show_character_set_stmt", Kind: ActDefault}, + {Syms: "show_collation_stmt", Kind: ActDefault}, + {Syms: "show_columns_stmt", Kind: ActDefault}, + {Syms: "show_count_errors_stmt", Kind: ActDefault}, + {Syms: "show_count_warnings_stmt", Kind: ActDefault}, + {Syms: "show_create_database_stmt", Kind: ActDefault}, + {Syms: "show_create_event_stmt", Kind: ActDefault}, + {Syms: "show_create_function_stmt", Kind: ActDefault}, + {Syms: "show_create_procedure_stmt", Kind: ActDefault}, + {Syms: "show_create_table_stmt", Kind: ActDefault}, + {Syms: "show_create_trigger_stmt", Kind: ActDefault}, + {Syms: "show_create_user_stmt", Kind: ActDefault}, + {Syms: "show_create_view_stmt", Kind: ActDefault}, + {Syms: "show_databases_stmt", Kind: ActDefault}, + {Syms: "show_engine_logs_stmt", Kind: ActDefault}, + {Syms: "show_engine_mutex_stmt", Kind: ActDefault}, + {Syms: "show_engine_status_stmt", Kind: ActDefault}, + {Syms: "show_engines_stmt", Kind: ActDefault}, + {Syms: "show_errors_stmt", Kind: ActDefault}, + {Syms: "show_events_stmt", Kind: ActDefault}, + {Syms: "show_function_code_stmt", Kind: ActDefault}, + {Syms: "show_function_status_stmt", Kind: ActDefault}, + {Syms: "show_grants_stmt", Kind: ActDefault}, + {Syms: "show_keys_stmt", Kind: ActDefault}, + {Syms: "show_open_tables_stmt", Kind: ActDefault}, + {Syms: "show_parse_tree_stmt", Kind: ActDefault}, + {Syms: "show_plugins_stmt", Kind: ActDefault}, + {Syms: "show_privileges_stmt", Kind: ActDefault}, + {Syms: "show_procedure_code_stmt", Kind: ActDefault}, + {Syms: "show_procedure_status_stmt", Kind: ActDefault}, + {Syms: "show_processlist_stmt", Kind: ActDefault}, + {Syms: "show_profile_stmt", Kind: ActDefault}, + {Syms: "show_profiles_stmt", Kind: ActDefault}, + {Syms: "show_relaylog_events_stmt", Kind: ActDefault}, + {Syms: "show_replica_status_stmt", Kind: ActDefault}, + {Syms: "show_replicas_stmt", Kind: ActDefault}, + {Syms: "show_status_stmt", Kind: ActDefault}, + {Syms: "show_table_status_stmt", Kind: ActDefault}, + {Syms: "show_tables_stmt", Kind: ActDefault}, + {Syms: "show_triggers_stmt", Kind: ActDefault}, + {Syms: "show_variables_stmt", Kind: ActDefault}, + {Syms: "show_warnings_stmt", Kind: ActDefault}, + {Syms: "shutdown_stmt", Kind: ActDefault}, + {Syms: "signal_stmt", Kind: ActEmpty}, + {Syms: "start", Kind: ActEmpty}, + {Syms: "start_replica_stmt", Kind: ActEmpty}, + {Syms: "stop_replica_stmt", Kind: ActEmpty}, + {Syms: "truncate_stmt", Kind: ActDefault}, + {Syms: "uninstall", Kind: ActEmpty}, + {Syms: "unlock", Kind: ActEmpty}, + {Syms: "update_stmt", Kind: ActDefault}, + {Syms: "use", Kind: ActEmpty}, + {Syms: "xa", Kind: ActEmpty}, + }, + 5: { // deallocate + {Syms: "deallocate_or_drop PREPARE_SYM ident", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_DEALLOCATE_PREPARE"}}, {Name: "prepared_stmt_name", Arg: Arg{Child: 3}}}}, + }, + 6: { // deallocate_or_drop + {Syms: "DEALLOCATE_SYM", Kind: ActDefault}, + {Syms: "DROP", Kind: ActDefault}, + }, + 7: { // prepare + {Syms: "PREPARE_SYM ident FROM prepare_src", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_PREPARE"}}, {Name: "prepared_stmt_name", Arg: Arg{Child: 2}}, {Name: "contains_plaintext_password", Arg: Arg{Text: "true"}}}}, + }, + 8: { // prepare_src + {Syms: "TEXT_STRING_sys", Kind: ActStruct, Fields: []Field{{Name: "prepared_stmt_code", Arg: Arg{Child: 1}}, {Name: "prepared_stmt_code_is_varref", Arg: Arg{Text: "false"}}}}, + {Syms: "'@' ident_or_text", Kind: ActStruct, Fields: []Field{{Name: "prepared_stmt_code", Arg: Arg{Child: 2}}, {Name: "prepared_stmt_code_is_varref", Arg: Arg{Text: "true"}}}}, + }, + 9: { // execute + {Syms: "EXECUTE_SYM ident execute_using", Kind: ActDefault}, + }, + 10: { // execute_using + {Syms: "", Kind: ActDefault}, + {Syms: "USING execute_var_list", Kind: ActDefault}, + }, + 11: { // execute_var_list + {Syms: "execute_var_list ',' execute_var_ident", Kind: ActDefault}, + {Syms: "execute_var_ident", Kind: ActDefault}, + }, + 12: { // execute_var_ident + {Syms: "'@' ident_or_text", Kind: ActUnknown}, + }, + 13: { // help + {Syms: "HELP_SYM ident_or_text", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_HELP"}}, {Name: "help_arg", Arg: Arg{Child: 2, Field: ".str"}}}}, + }, + 14: { // change_replication_stmt + {Syms: "CHANGE REPLICATION SOURCE_SYM TO_SYM source_defs opt_channel", Kind: ActDefault}, + {Syms: "CHANGE REPLICATION FILTER_SYM filter_defs opt_channel", Kind: ActDefault}, + }, + 15: { // filter_defs + {Syms: "filter_def", Kind: ActDefault}, + {Syms: "filter_defs ',' filter_def", Kind: ActDefault}, + }, + 16: { // filter_def + {Syms: "REPLICATE_DO_DB EQ opt_filter_db_list", Kind: ActUnknown}, + {Syms: "REPLICATE_IGNORE_DB EQ opt_filter_db_list", Kind: ActUnknown}, + {Syms: "REPLICATE_DO_TABLE EQ opt_filter_table_list", Kind: ActUnknown}, + {Syms: "REPLICATE_IGNORE_TABLE EQ opt_filter_table_list", Kind: ActUnknown}, + {Syms: "REPLICATE_WILD_DO_TABLE EQ opt_filter_string_list", Kind: ActUnknown}, + {Syms: "REPLICATE_WILD_IGNORE_TABLE EQ opt_filter_string_list", Kind: ActUnknown}, + {Syms: "REPLICATE_REWRITE_DB EQ opt_filter_db_pair_list", Kind: ActUnknown}, + }, + 17: { // opt_filter_db_list + {Syms: "'(' ')'", Kind: ActNew, Class: "mem_root_deque", Params: []string{"mem_root"}, Args: []Arg{{Text: "YYMEM_ROOT"}}}, + {Syms: "'(' filter_db_list ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 18: { // filter_db_list + {Syms: "filter_db_ident", Kind: ActListNew, Args: []Arg{{Child: 1}}}, + {Syms: "filter_db_list ',' filter_db_ident", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 19: { // filter_db_ident + {Syms: "ident", Kind: ActUnknown}, + }, + 20: { // opt_filter_db_pair_list + {Syms: "'(' ')'", Kind: ActNew, Class: "mem_root_deque", Params: []string{"mem_root"}, Args: []Arg{{Text: "YYMEM_ROOT"}}}, + {Syms: "'(' filter_db_pair_list ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 21: { // filter_db_pair_list + {Syms: "'(' filter_db_ident ',' filter_db_ident ')'", Kind: ActListNew, Args: []Arg{{Child: 2}, {Child: 4}}}, + {Syms: "filter_db_pair_list ',' '(' filter_db_ident ',' filter_db_ident ')'", Kind: ActUnknown}, + }, + 22: { // opt_filter_table_list + {Syms: "'(' ')'", Kind: ActNew, Class: "mem_root_deque", Params: []string{"mem_root"}, Args: []Arg{{Text: "YYMEM_ROOT"}}}, + {Syms: "'(' filter_table_list ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 23: { // filter_table_list + {Syms: "filter_table_ident", Kind: ActListNew, Args: []Arg{{Child: 1}}}, + {Syms: "filter_table_list ',' filter_table_ident", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 24: { // filter_table_ident + {Syms: "schema '.' ident", Kind: ActUnknown}, + }, + 25: { // opt_filter_string_list + {Syms: "'(' ')'", Kind: ActNew, Class: "mem_root_deque", Params: []string{"mem_root"}, Args: []Arg{{Text: "YYMEM_ROOT"}}}, + {Syms: "'(' filter_string_list ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 26: { // filter_string_list + {Syms: "filter_string", Kind: ActListNew, Args: []Arg{{Child: 1}}}, + {Syms: "filter_string_list ',' filter_string", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 27: { // filter_string + {Syms: "filter_wild_db_table_string", Kind: ActUnknown}, + }, + 28: { // source_defs + {Syms: "source_def", Kind: ActDefault}, + {Syms: "source_defs ',' source_def", Kind: ActDefault}, + }, + 29: { // source_def + {Syms: "SOURCE_HOST_SYM EQ TEXT_STRING_sys_nonewline", Kind: ActStruct, Fields: []Field{{Name: "mi.host", Arg: Arg{Child: 3, Field: ".str"}}}}, + {Syms: "NETWORK_NAMESPACE_SYM EQ TEXT_STRING_sys_nonewline", Kind: ActStruct, Fields: []Field{{Name: "mi.network_namespace", Arg: Arg{Child: 3, Field: ".str"}}}}, + {Syms: "SOURCE_BIND_SYM EQ TEXT_STRING_sys_nonewline", Kind: ActStruct, Fields: []Field{{Name: "mi.bind_addr", Arg: Arg{Child: 3, Field: ".str"}}}}, + {Syms: "SOURCE_USER_SYM EQ TEXT_STRING_sys_nonewline", Kind: ActStruct, Fields: []Field{{Name: "mi.user", Arg: Arg{Child: 3, Field: ".str"}}}}, + {Syms: "SOURCE_PASSWORD_SYM EQ TEXT_STRING_sys_nonewline", Kind: ActStruct, Fields: []Field{{Name: "mi.password", Arg: Arg{Child: 3, Field: ".str"}}, {Name: "contains_plaintext_password", Arg: Arg{Text: "true"}}}}, + {Syms: "SOURCE_PORT_SYM EQ ulong_num", Kind: ActStruct, Fields: []Field{{Name: "mi.port", Arg: Arg{Child: 3}}}}, + {Syms: "SOURCE_CONNECT_RETRY_SYM EQ ulong_num", Kind: ActStruct, Fields: []Field{{Name: "mi.connect_retry", Arg: Arg{Child: 3}}}}, + {Syms: "SOURCE_RETRY_COUNT_SYM EQ ulong_num", Kind: ActStruct, Fields: []Field{{Name: "mi.retry_count", Arg: Arg{Child: 3}}, {Name: "mi.retry_count_opt", Arg: Arg{Text: "LEX_SOURCE_INFO::LEX_MI_ENABLE"}}}}, + {Syms: "SOURCE_DELAY_SYM EQ ulong_num", Kind: ActUnknown}, + {Syms: "SOURCE_SSL_SYM EQ ulong_num", Kind: ActStruct, Fields: []Field{{Name: "mi.ssl", Arg: Arg{Text: "$3?LEX_SOURCE_INFO::LEX_MI_ENABLE:LEX_SOURCE_INFO::LEX_MI_DISABLE"}}}}, + {Syms: "SOURCE_SSL_CA_SYM EQ TEXT_STRING_sys_nonewline", Kind: ActStruct, Fields: []Field{{Name: "mi.ssl_ca", Arg: Arg{Child: 3, Field: ".str"}}}}, + {Syms: "SOURCE_SSL_CAPATH_SYM EQ TEXT_STRING_sys_nonewline", Kind: ActStruct, Fields: []Field{{Name: "mi.ssl_capath", Arg: Arg{Child: 3, Field: ".str"}}}}, + {Syms: "SOURCE_TLS_VERSION_SYM EQ TEXT_STRING_sys_nonewline", Kind: ActStruct, Fields: []Field{{Name: "mi.tls_version", Arg: Arg{Child: 3, Field: ".str"}}}}, + {Syms: "SOURCE_TLS_CIPHERSUITES_SYM EQ source_tls_ciphersuites_def", Kind: ActDefault}, + {Syms: "SOURCE_SSL_CERT_SYM EQ TEXT_STRING_sys_nonewline", Kind: ActStruct, Fields: []Field{{Name: "mi.ssl_cert", Arg: Arg{Child: 3, Field: ".str"}}}}, + {Syms: "SOURCE_SSL_CIPHER_SYM EQ TEXT_STRING_sys_nonewline", Kind: ActStruct, Fields: []Field{{Name: "mi.ssl_cipher", Arg: Arg{Child: 3, Field: ".str"}}}}, + {Syms: "SOURCE_SSL_KEY_SYM EQ TEXT_STRING_sys_nonewline", Kind: ActStruct, Fields: []Field{{Name: "mi.ssl_key", Arg: Arg{Child: 3, Field: ".str"}}}}, + {Syms: "SOURCE_SSL_VERIFY_SERVER_CERT_SYM EQ ulong_num", Kind: ActStruct, Fields: []Field{{Name: "mi.ssl_verify_server_cert", Arg: Arg{Text: "$3?LEX_SOURCE_INFO::LEX_MI_ENABLE:LEX_SOURCE_INFO::LEX_MI_DISABLE"}}}}, + {Syms: "SOURCE_SSL_CRL_SYM EQ TEXT_STRING_sys_nonewline", Kind: ActStruct, Fields: []Field{{Name: "mi.ssl_crl", Arg: Arg{Child: 3, Field: ".str"}}}}, + {Syms: "SOURCE_SSL_CRLPATH_SYM EQ TEXT_STRING_sys_nonewline", Kind: ActStruct, Fields: []Field{{Name: "mi.ssl_crlpath", Arg: Arg{Child: 3, Field: ".str"}}}}, + {Syms: "SOURCE_PUBLIC_KEY_PATH_SYM EQ TEXT_STRING_sys_nonewline", Kind: ActStruct, Fields: []Field{{Name: "mi.public_key_path", Arg: Arg{Child: 3, Field: ".str"}}}}, + {Syms: "GET_SOURCE_PUBLIC_KEY_SYM EQ ulong_num", Kind: ActStruct, Fields: []Field{{Name: "mi.get_public_key", Arg: Arg{Text: "$3?LEX_SOURCE_INFO::LEX_MI_ENABLE:LEX_SOURCE_INFO::LEX_MI_DISABLE"}}}}, + {Syms: "SOURCE_HEARTBEAT_PERIOD_SYM EQ NUM_literal", Kind: ActUnknown}, + {Syms: "IGNORE_SERVER_IDS_SYM EQ '(' ignore_server_id_list ')'", Kind: ActDefault}, + {Syms: "SOURCE_COMPRESSION_ALGORITHM_SYM EQ TEXT_STRING_sys", Kind: ActStruct, Fields: []Field{{Name: "mi.compression_algorithm", Arg: Arg{Child: 3, Field: ".str"}}}}, + {Syms: "SOURCE_ZSTD_COMPRESSION_LEVEL_SYM EQ ulong_num", Kind: ActStruct, Fields: []Field{{Name: "mi.zstd_compression_level", Arg: Arg{Child: 3}}}}, + {Syms: "SOURCE_AUTO_POSITION_SYM EQ ulong_num", Kind: ActStruct, Fields: []Field{{Name: "mi.auto_position", Arg: Arg{Text: "$3?LEX_SOURCE_INFO::LEX_MI_ENABLE:LEX_SOURCE_INFO::LEX_MI_DISABLE"}}}}, + {Syms: "PRIVILEGE_CHECKS_USER_SYM EQ privilege_check_def", Kind: ActDefault}, + {Syms: "REQUIRE_ROW_FORMAT_SYM EQ ulong_num", Kind: ActUnknown}, + {Syms: "REQUIRE_TABLE_PRIMARY_KEY_CHECK_SYM EQ table_primary_key_check_def", Kind: ActDefault}, + {Syms: "SOURCE_CONNECTION_AUTO_FAILOVER_SYM EQ real_ulong_num", Kind: ActUnknown}, + {Syms: "ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_SYM EQ assign_gtids_to_anonymous_transactions_def", Kind: ActDefault}, + {Syms: "GTID_ONLY_SYM EQ real_ulong_num", Kind: ActUnknown}, + {Syms: "source_file_def", Kind: ActDefault}, + }, + 30: { // ignore_server_id_list + {Syms: "", Kind: ActDefault}, + {Syms: "ignore_server_id", Kind: ActDefault}, + {Syms: "ignore_server_id_list ',' ignore_server_id", Kind: ActDefault}, + }, + 31: { // ignore_server_id + {Syms: "ulong_num", Kind: ActUnknown}, + }, + 32: { // privilege_check_def + {Syms: "user_ident_or_text", Kind: ActStruct, Fields: []Field{{Name: "mi.privilege_checks_none", Arg: Arg{Text: "false"}}, {Name: "mi.privilege_checks_username", Arg: Arg{Child: 1, Field: "->user.str"}}, {Name: "mi.privilege_checks_hostname", Arg: Arg{Child: 1, Field: "->host.str"}}}}, + {Syms: "NULL_SYM", Kind: ActDefault}, + }, + 33: { // table_primary_key_check_def + {Syms: "STREAM_SYM", Kind: ActDefault}, + {Syms: "ON_SYM", Kind: ActDefault}, + {Syms: "OFF_SYM", Kind: ActDefault}, + {Syms: "GENERATE_SYM", Kind: ActDefault}, + }, + 34: { // assign_gtids_to_anonymous_transactions_def + {Syms: "OFF_SYM", Kind: ActDefault}, + {Syms: "LOCAL_SYM", Kind: ActDefault}, + {Syms: "TEXT_STRING", Kind: ActStruct, Fields: []Field{{Name: "mi.assign_gtids_to_anonymous_transactions_type", Arg: Arg{Text: "LEX_SOURCE_INFO::LEX_MI_ANONYMOUS_TO_GTID_UUID"}}, {Name: "mi.assign_gtids_to_anonymous_transactions_manual_uuid", Arg: Arg{Child: 1, Field: ".str"}}}}, + }, + 35: { // source_tls_ciphersuites_def + {Syms: "TEXT_STRING_sys_nonewline", Kind: ActStruct, Fields: []Field{{Name: "mi.tls_ciphersuites", Arg: Arg{Text: "LEX_SOURCE_INFO::SPECIFIED_STRING"}}, {Name: "mi.tls_ciphersuites_string", Arg: Arg{Child: 1, Field: ".str"}}}}, + {Syms: "NULL_SYM", Kind: ActDefault}, + }, + 36: { // source_file_def + {Syms: "SOURCE_LOG_FILE_SYM EQ TEXT_STRING_sys_nonewline", Kind: ActStruct, Fields: []Field{{Name: "mi.log_file_name", Arg: Arg{Child: 3, Field: ".str"}}}}, + {Syms: "SOURCE_LOG_POS_SYM EQ ulonglong_num", Kind: ActStruct, Fields: []Field{{Name: "mi.pos", Arg: Arg{Child: 3}}, {Name: "mi.pos", Arg: Arg{Text: "max(BIN_LOG_HEADER_SIZE,Lex->mi.pos)"}}}}, + {Syms: "RELAY_LOG_FILE_SYM EQ TEXT_STRING_sys_nonewline", Kind: ActStruct, Fields: []Field{{Name: "mi.relay_log_name", Arg: Arg{Child: 3, Field: ".str"}}}}, + {Syms: "RELAY_LOG_POS_SYM EQ ulong_num", Kind: ActStruct, Fields: []Field{{Name: "mi.relay_log_pos", Arg: Arg{Child: 3}}, {Name: "mi.relay_log_pos", Arg: Arg{Text: "max(BIN_LOG_HEADER_SIZE,Lex->mi.relay_log_pos)"}}}}, + }, + 37: { // opt_channel + {Syms: "", Kind: ActEmpty}, + {Syms: "FOR_SYM CHANNEL_SYM TEXT_STRING_sys_nonewline", Kind: ActPass, Args: []Arg{{Child: 3}}}, + }, + 38: { // create_table_stmt + {Syms: "CREATE opt_temporary TABLE_SYM opt_if_not_exists table_ident '(' table_element_list ')' opt_create_table_options_etc", Kind: ActNew, Class: "PT_create_table_stmt", Params: []string{"pos", "mem_root", "is_temporary", "only_if_not_exists", "table_name", "opt_table_element_list", "opt_create_table_options", "opt_partitioning", "on_duplicate", "opt_query_expression"}, Args: []Arg{{Text: "@$"}, {Text: "YYMEM_ROOT"}, {Child: 2}, {Child: 4}, {Child: 5}, {Child: 7}, {Child: 9, Field: ".opt_create_table_options"}, {Child: 9, Field: ".opt_partitioning"}, {Child: 9, Field: ".on_duplicate"}, {Child: 9, Field: ".opt_query_expression"}}}, + {Syms: "CREATE opt_temporary TABLE_SYM opt_if_not_exists table_ident opt_create_table_options_etc", Kind: ActNew, Class: "PT_create_table_stmt", Params: []string{"pos", "mem_root", "is_temporary", "only_if_not_exists", "table_name", "opt_table_element_list", "opt_create_table_options", "opt_partitioning", "on_duplicate", "opt_query_expression"}, Args: []Arg{{Text: "@$"}, {Text: "YYMEM_ROOT"}, {Child: 2}, {Child: 4}, {Child: 5}, {Text: "nullptr"}, {Child: 6, Field: ".opt_create_table_options"}, {Child: 6, Field: ".opt_partitioning"}, {Child: 6, Field: ".on_duplicate"}, {Child: 6, Field: ".opt_query_expression"}}}, + {Syms: "CREATE opt_temporary TABLE_SYM opt_if_not_exists table_ident LIKE table_ident", Kind: ActNew, Class: "PT_create_table_stmt", Params: []string{"pos", "mem_root", "is_temporary", "only_if_not_exists", "table_name", "opt_like_clause"}, Args: []Arg{{Text: "@$"}, {Text: "YYMEM_ROOT"}, {Child: 2}, {Child: 4}, {Child: 5}, {Child: 7}}}, + {Syms: "CREATE opt_temporary TABLE_SYM opt_if_not_exists table_ident '(' LIKE table_ident ')'", Kind: ActNew, Class: "PT_create_table_stmt", Params: []string{"pos", "mem_root", "is_temporary", "only_if_not_exists", "table_name", "opt_like_clause"}, Args: []Arg{{Text: "@$"}, {Text: "YYMEM_ROOT"}, {Child: 2}, {Child: 4}, {Child: 5}, {Child: 8}}}, + }, + 39: { // create_role_stmt + {Syms: "CREATE ROLE_SYM opt_if_not_exists role_list", Kind: ActNew, Class: "PT_create_role", Params: []string{"pos", "if_not_exists", "roles"}, Args: []Arg{{Text: "@$"}, {Text: "!!$3"}, {Child: 4}}}, + }, + 40: { // create_resource_group_stmt + {Syms: "CREATE RESOURCE_SYM GROUP_SYM ident TYPE_SYM opt_equal resource_group_types opt_resource_group_vcpu_list opt_resource_group_priority opt_resource_group_enable_disable", Kind: ActNew, Class: "PT_create_resource_group", Params: []string{"pos", "name", "type", "cpu_list", "opt_priority", "enabled"}, Args: []Arg{{Text: "@$"}, {Child: 4}, {Child: 7}, {Child: 8}, {Child: 9}, {Text: "$10.is_default?true:$10.value"}}}, + }, + 41: { // create + {Syms: "CREATE DATABASE opt_if_not_exists ident opt_create_database_options", Kind: ActUnknown}, + {Syms: "CREATE view_or_trigger_or_sp_or_event", Kind: ActDefault}, + {Syms: "CREATE USER opt_if_not_exists create_user_list default_role_clause require_clause connect_options opt_account_lock_password_expire_options opt_user_attribute", Kind: ActUnknown}, + {Syms: "CREATE LOGFILE_SYM GROUP_SYM ident ADD lg_undofile opt_logfile_group_options", Kind: ActUnknown}, + {Syms: "CREATE TABLESPACE_SYM ident opt_ts_datafile_name opt_logfile_group_name opt_tablespace_options", Kind: ActUnknown}, + {Syms: "CREATE UNDO_SYM TABLESPACE_SYM ident ADD ts_datafile opt_undo_tablespace_options", Kind: ActUnknown}, + {Syms: "CREATE SERVER_SYM ident_or_text FOREIGN DATA_SYM WRAPPER_SYM ident_or_text OPTIONS_SYM '(' server_options_list ')'", Kind: ActUnknown}, + }, + 42: { // create_srs_stmt + {Syms: "CREATE OR_SYM REPLACE_SYM SPATIAL_SYM REFERENCE_SYM SYSTEM_SYM real_ulonglong_num srs_attributes", Kind: ActNew, Class: "PT_create_srs", Params: []string{"pos", "srid", "attributes", "or_replace", "if_not_exists"}, Args: []Arg{{Text: "@$"}, {Child: 7}, {Text: "*$8"}, {Text: "true"}, {Text: "false"}}}, + {Syms: "CREATE SPATIAL_SYM REFERENCE_SYM SYSTEM_SYM opt_if_not_exists real_ulonglong_num srs_attributes", Kind: ActNew, Class: "PT_create_srs", Params: []string{"pos", "srid", "attributes", "or_replace", "if_not_exists"}, Args: []Arg{{Text: "@$"}, {Child: 6}, {Text: "*$7"}, {Text: "false"}, {Child: 5}}}, + }, + 43: { // srs_attributes + {Syms: "", Kind: ActUnknown}, + {Syms: "srs_attributes NAME_SYM TEXT_STRING_sys_nonewline", Kind: ActUnknown}, + {Syms: "srs_attributes DEFINITION_SYM TEXT_STRING_sys_nonewline", Kind: ActUnknown}, + {Syms: "srs_attributes ORGANIZATION_SYM TEXT_STRING_sys_nonewline IDENTIFIED_SYM BY real_ulonglong_num", Kind: ActUnknown}, + {Syms: "srs_attributes DESCRIPTION_SYM TEXT_STRING_sys_nonewline", Kind: ActUnknown}, + }, + 44: { // default_role_clause + {Syms: "", Kind: ActEmpty}, + {Syms: "DEFAULT_SYM ROLE_SYM role_list", Kind: ActPass, Args: []Arg{{Child: 3}}}, + }, + 45: { // create_index_stmt + {Syms: "CREATE opt_unique INDEX_SYM ident opt_index_type_clause ON_SYM table_ident '(' key_list_with_expression ')' opt_index_options opt_index_lock_and_algorithm", Kind: ActNew, Class: "PT_create_index_stmt", Params: []string{"pos", "mem_root", "type_par", "name", "type", "table_ident", "cols", "options", "algo", "lock"}, Args: []Arg{{Text: "@$"}, {Text: "YYMEM_ROOT"}, {Child: 2}, {Child: 4}, {Child: 5}, {Child: 7}, {Child: 9}, {Child: 11}, {Child: 12, Field: ".algo"}, {Child: 12, Field: ".lock"}}}, + {Syms: "CREATE FULLTEXT_SYM INDEX_SYM ident ON_SYM table_ident '(' key_list_with_expression ')' opt_fulltext_index_options opt_index_lock_and_algorithm", Kind: ActNew, Class: "PT_create_index_stmt", Params: []string{"pos", "mem_root", "type_par", "name", "type", "table_ident", "cols", "options", "algo", "lock"}, Args: []Arg{{Text: "@$"}, {Text: "YYMEM_ROOT"}, {Text: "KEYTYPE_FULLTEXT"}, {Child: 4}, {Text: "nullptr"}, {Child: 6}, {Child: 8}, {Child: 10}, {Child: 11, Field: ".algo"}, {Child: 11, Field: ".lock"}}}, + {Syms: "CREATE SPATIAL_SYM INDEX_SYM ident ON_SYM table_ident '(' key_list_with_expression ')' opt_spatial_index_options opt_index_lock_and_algorithm", Kind: ActNew, Class: "PT_create_index_stmt", Params: []string{"pos", "mem_root", "type_par", "name", "type", "table_ident", "cols", "options", "algo", "lock"}, Args: []Arg{{Text: "@$"}, {Text: "YYMEM_ROOT"}, {Text: "KEYTYPE_SPATIAL"}, {Child: 4}, {Text: "nullptr"}, {Child: 6}, {Child: 8}, {Child: 10}, {Child: 11, Field: ".algo"}, {Child: 11, Field: ".lock"}}}, + }, + 46: { // server_options_list + {Syms: "server_option", Kind: ActDefault}, + {Syms: "server_options_list ',' server_option", Kind: ActDefault}, + }, + 47: { // server_option + {Syms: "USER TEXT_STRING_sys", Kind: ActUnknown}, + {Syms: "HOST_SYM TEXT_STRING_sys", Kind: ActUnknown}, + {Syms: "DATABASE TEXT_STRING_sys", Kind: ActUnknown}, + {Syms: "OWNER_SYM TEXT_STRING_sys", Kind: ActUnknown}, + {Syms: "PASSWORD TEXT_STRING_sys", Kind: ActUnknown}, + {Syms: "SOCKET_SYM TEXT_STRING_sys", Kind: ActUnknown}, + {Syms: "PORT_SYM ulong_num", Kind: ActUnknown}, + }, + 48: { // event_tail + {Syms: "EVENT_SYM opt_if_not_exists sp_name ON_SYM SCHEDULE_SYM ev_schedule_time opt_ev_on_completion opt_ev_status opt_ev_comment DO_SYM ev_sql_stmt", Kind: ActDefault}, + }, + 49: { // ev_schedule_time + {Syms: "EVERY_SYM expr interval ev_starts ev_ends", Kind: ActDefault}, + {Syms: "AT_SYM expr", Kind: ActUnknown}, + }, + 50: { // opt_ev_status + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "ENABLE_SYM", Kind: ActUnknown}, + {Syms: "DISABLE_SYM ON_SYM SLAVE", Kind: ActUnknown}, + {Syms: "DISABLE_SYM ON_SYM REPLICA_SYM", Kind: ActUnknown}, + {Syms: "DISABLE_SYM", Kind: ActUnknown}, + }, + 51: { // ev_starts + {Syms: "", Kind: ActDefault}, + {Syms: "STARTS_SYM expr", Kind: ActUnknown}, + }, + 52: { // ev_ends + {Syms: "", Kind: ActDefault}, + {Syms: "ENDS_SYM expr", Kind: ActUnknown}, + }, + 53: { // opt_ev_on_completion + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "ev_on_completion", Kind: ActDefault}, + }, + 54: { // ev_on_completion + {Syms: "ON_SYM COMPLETION_SYM PRESERVE_SYM", Kind: ActUnknown}, + {Syms: "ON_SYM COMPLETION_SYM NOT_SYM PRESERVE_SYM", Kind: ActUnknown}, + }, + 55: { // opt_ev_comment + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "COMMENT_SYM TEXT_STRING_sys", Kind: ActUnknown}, + }, + 56: { // ev_sql_stmt + {Syms: "ev_sql_stmt_inner", Kind: ActDefault}, + }, + 57: { // ev_sql_stmt_inner + {Syms: "sp_proc_stmt_statement", Kind: ActDefault}, + {Syms: "sp_proc_stmt_return", Kind: ActDefault}, + {Syms: "sp_proc_stmt_if", Kind: ActDefault}, + {Syms: "case_stmt_specification", Kind: ActDefault}, + {Syms: "sp_labeled_block", Kind: ActDefault}, + {Syms: "sp_unlabeled_block", Kind: ActDefault}, + {Syms: "sp_labeled_control", Kind: ActDefault}, + {Syms: "sp_proc_stmt_unlabeled", Kind: ActDefault}, + {Syms: "sp_proc_stmt_leave", Kind: ActDefault}, + {Syms: "sp_proc_stmt_iterate", Kind: ActDefault}, + {Syms: "sp_proc_stmt_open", Kind: ActDefault}, + {Syms: "sp_proc_stmt_fetch", Kind: ActDefault}, + {Syms: "sp_proc_stmt_close", Kind: ActDefault}, + }, + 58: { // sp_name + {Syms: "ident '.' ident", Kind: ActUnknown}, + {Syms: "ident", Kind: ActUnknown}, + }, + 59: { // sp_a_chistics + {Syms: "", Kind: ActDefault}, + {Syms: "sp_a_chistics sp_chistic", Kind: ActDefault}, + }, + 60: { // sp_c_chistics + {Syms: "", Kind: ActDefault}, + {Syms: "sp_c_chistics sp_c_chistic", Kind: ActDefault}, + }, + 61: { // sp_chistic + {Syms: "COMMENT_SYM TEXT_STRING_sys", Kind: ActStruct, Fields: []Field{{Name: "sp_chistics.comment", Arg: Arg{Child: 2}}}}, + {Syms: "LANGUAGE_SYM SQL_SYM", Kind: ActDefault}, + {Syms: "LANGUAGE_SYM ident", Kind: ActStruct, Fields: []Field{{Name: "sp_chistics.language", Arg: Arg{Child: 2}}}}, + {Syms: "NO_SYM SQL_SYM", Kind: ActDefault}, + {Syms: "CONTAINS_SYM SQL_SYM", Kind: ActDefault}, + {Syms: "READS_SYM SQL_SYM DATA_SYM", Kind: ActDefault}, + {Syms: "MODIFIES_SYM SQL_SYM DATA_SYM", Kind: ActDefault}, + {Syms: "sp_suid", Kind: ActDefault}, + }, + 62: { // sp_c_chistic + {Syms: "sp_chistic", Kind: ActDefault}, + {Syms: "DETERMINISTIC_SYM", Kind: ActDefault}, + {Syms: "not DETERMINISTIC_SYM", Kind: ActDefault}, + }, + 63: { // sp_suid + {Syms: "SQL_SYM SECURITY_SYM DEFINER_SYM", Kind: ActDefault}, + {Syms: "SQL_SYM SECURITY_SYM INVOKER_SYM", Kind: ActDefault}, + }, + 64: { // call_stmt + {Syms: "CALL_SYM sp_name opt_paren_expr_list", Kind: ActNew, Class: "PT_call", Params: []string{"pos", "proc_name", "opt_expr_list"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Child: 3}}}, + }, + 65: { // opt_paren_expr_list + {Syms: "", Kind: ActEmpty}, + {Syms: "'(' opt_expr_list ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 66: { // sp_fdparam_list + {Syms: "", Kind: ActDefault}, + {Syms: "sp_fdparams", Kind: ActDefault}, + }, + 67: { // sp_fdparams + {Syms: "sp_fdparams ',' sp_fdparam", Kind: ActDefault}, + {Syms: "sp_fdparam", Kind: ActDefault}, + }, + 68: { // sp_fdparam + {Syms: "ident type opt_collate", Kind: ActUnknown}, + }, + 69: { // sp_pdparam_list + {Syms: "", Kind: ActDefault}, + {Syms: "sp_pdparams", Kind: ActDefault}, + }, + 70: { // sp_pdparams + {Syms: "sp_pdparams ',' sp_pdparam", Kind: ActDefault}, + {Syms: "sp_pdparam", Kind: ActDefault}, + }, + 71: { // sp_pdparam + {Syms: "sp_opt_inout ident type opt_collate", Kind: ActUnknown}, + }, + 72: { // sp_opt_inout + {Syms: "", Kind: ActConst, Const: "sp_variable::MODE_IN"}, + {Syms: "IN_SYM", Kind: ActConst, Const: "sp_variable::MODE_IN"}, + {Syms: "OUT_SYM", Kind: ActConst, Const: "sp_variable::MODE_OUT"}, + {Syms: "INOUT_SYM", Kind: ActConst, Const: "sp_variable::MODE_INOUT"}, + }, + 73: { // sp_proc_stmts + {Syms: "", Kind: ActDefault}, + {Syms: "sp_proc_stmts sp_proc_stmt ';'", Kind: ActDefault}, + }, + 74: { // sp_proc_stmts1 + {Syms: "sp_proc_stmt ';'", Kind: ActDefault}, + {Syms: "sp_proc_stmts1 sp_proc_stmt ';'", Kind: ActDefault}, + }, + 75: { // sp_decls + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "vars", Arg: Arg{Text: "$$.conds=$$.hndlrs=$$.curs=0"}}}}, + {Syms: "sp_decls sp_decl ';'", Kind: ActStruct, Fields: []Field{{Name: "vars", Arg: Arg{Text: "$1.vars+$2.vars"}}, {Name: "conds", Arg: Arg{Text: "$1.conds+$2.conds"}}, {Name: "hndlrs", Arg: Arg{Text: "$1.hndlrs+$2.hndlrs"}}, {Name: "curs", Arg: Arg{Text: "$1.curs+$2.curs"}}}}, + }, + 76: { // sp_decl + {Syms: "DECLARE_SYM sp_decl_idents type opt_collate sp_opt_default", Kind: ActUnknown}, + {Syms: "DECLARE_SYM ident CONDITION_SYM FOR_SYM sp_cond", Kind: ActUnknown}, + {Syms: "DECLARE_SYM sp_handler_type HANDLER_SYM FOR_SYM sp_hcond_list sp_proc_stmt", Kind: ActUnknown}, + {Syms: "DECLARE_SYM ident CURSOR_SYM FOR_SYM select_stmt", Kind: ActUnknown}, + }, + 77: { // sp_handler_type + {Syms: "EXIT_SYM", Kind: ActConst, Const: "sp_handler::EXIT"}, + {Syms: "CONTINUE_SYM", Kind: ActConst, Const: "sp_handler::CONTINUE"}, + }, + 78: { // sp_hcond_list + {Syms: "sp_hcond_element", Kind: ActConst, Const: "1"}, + {Syms: "sp_hcond_list ',' sp_hcond_element", Kind: ActUnknown}, + }, + 79: { // sp_hcond_element + {Syms: "sp_hcond", Kind: ActUnknown}, + }, + 80: { // sp_cond + {Syms: "ulong_num", Kind: ActNew, Class: "sp_condition_value", Params: []string{"_mysqlerr"}, Args: []Arg{{Child: 1}}}, + {Syms: "sqlstate", Kind: ActDefault}, + }, + 81: { // sqlstate + {Syms: "SQLSTATE_SYM opt_value TEXT_STRING_literal", Kind: ActNew, Class: "sp_condition_value", Params: []string{"_mysqlerr"}, Args: []Arg{{Child: 3, Field: ".str"}}}, + }, + 82: { // opt_value + {Syms: "", Kind: ActDefault}, + {Syms: "VALUE_SYM", Kind: ActDefault}, + }, + 83: { // sp_hcond + {Syms: "sp_cond", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "ident", Kind: ActUnknown}, + {Syms: "SQLWARNING_SYM", Kind: ActNew, Class: "sp_condition_value", Params: []string{"_mysqlerr"}, Args: []Arg{{Text: "sp_condition_value::WARNING"}}}, + {Syms: "not FOUND_SYM", Kind: ActNew, Class: "sp_condition_value", Params: []string{"_mysqlerr"}, Args: []Arg{{Text: "sp_condition_value::NOT_FOUND"}}}, + {Syms: "SQLEXCEPTION_SYM", Kind: ActNew, Class: "sp_condition_value", Params: []string{"_mysqlerr"}, Args: []Arg{{Text: "sp_condition_value::EXCEPTION"}}}, + }, + 84: { // signal_stmt + {Syms: "SIGNAL_SYM signal_value opt_set_signal_information", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_SIGNAL"}}, {Name: "m_sql_cmd", Arg: Arg{Text: "NEW_PTNSql_cmd_signal($2,$3)"}}}}, + }, + 85: { // signal_value + {Syms: "ident", Kind: ActUnknown}, + {Syms: "sqlstate", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 86: { // opt_signal_value + {Syms: "", Kind: ActEmpty}, + {Syms: "signal_value", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 87: { // opt_set_signal_information + {Syms: "", Kind: ActNew, Class: "Set_signal_information"}, + {Syms: "SET_SYM signal_information_item_list", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 88: { // signal_information_item_list + {Syms: "signal_condition_information_item_name EQ signal_allowed_expr", Kind: ActUnknown}, + {Syms: "signal_information_item_list ',' signal_condition_information_item_name EQ signal_allowed_expr", Kind: ActUnknown}, + }, + 89: { // signal_allowed_expr + {Syms: "literal_or_null", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "rvalue_system_or_user_variable", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "simple_ident", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 90: { // signal_condition_information_item_name + {Syms: "CLASS_ORIGIN_SYM", Kind: ActConst, Const: "CIN_CLASS_ORIGIN"}, + {Syms: "SUBCLASS_ORIGIN_SYM", Kind: ActConst, Const: "CIN_SUBCLASS_ORIGIN"}, + {Syms: "CONSTRAINT_CATALOG_SYM", Kind: ActConst, Const: "CIN_CONSTRAINT_CATALOG"}, + {Syms: "CONSTRAINT_SCHEMA_SYM", Kind: ActConst, Const: "CIN_CONSTRAINT_SCHEMA"}, + {Syms: "CONSTRAINT_NAME_SYM", Kind: ActConst, Const: "CIN_CONSTRAINT_NAME"}, + {Syms: "CATALOG_NAME_SYM", Kind: ActConst, Const: "CIN_CATALOG_NAME"}, + {Syms: "SCHEMA_NAME_SYM", Kind: ActConst, Const: "CIN_SCHEMA_NAME"}, + {Syms: "TABLE_NAME_SYM", Kind: ActConst, Const: "CIN_TABLE_NAME"}, + {Syms: "COLUMN_NAME_SYM", Kind: ActConst, Const: "CIN_COLUMN_NAME"}, + {Syms: "CURSOR_NAME_SYM", Kind: ActConst, Const: "CIN_CURSOR_NAME"}, + {Syms: "MESSAGE_TEXT_SYM", Kind: ActConst, Const: "CIN_MESSAGE_TEXT"}, + {Syms: "MYSQL_ERRNO_SYM", Kind: ActConst, Const: "CIN_MYSQL_ERRNO"}, + }, + 91: { // resignal_stmt + {Syms: "RESIGNAL_SYM opt_signal_value opt_set_signal_information", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_RESIGNAL"}}, {Name: "keep_diagnostics", Arg: Arg{Text: "DA_KEEP_DIAGNOSTICS"}}, {Name: "m_sql_cmd", Arg: Arg{Text: "NEW_PTNSql_cmd_resignal($2,$3)"}}}}, + }, + 92: { // get_diagnostics + {Syms: "GET_SYM which_area DIAGNOSTICS_SYM diagnostics_information", Kind: ActUnknown}, + }, + 93: { // which_area + {Syms: "", Kind: ActConst, Const: "Diagnostics_information::CURRENT_AREA"}, + {Syms: "CURRENT_SYM", Kind: ActConst, Const: "Diagnostics_information::CURRENT_AREA"}, + {Syms: "STACKED_SYM", Kind: ActConst, Const: "Diagnostics_information::STACKED_AREA"}, + }, + 94: { // diagnostics_information + {Syms: "statement_information", Kind: ActNew, Class: "Statement_information", Params: []string{"items"}, Args: []Arg{{Child: 1}}}, + {Syms: "CONDITION_SYM condition_number condition_information", Kind: ActNew, Class: "Condition_information", Params: []string{"cond_number_expr", "items"}, Args: []Arg{{Child: 2}, {Child: 3}}}, + }, + 95: { // statement_information + {Syms: "statement_information_item", Kind: ActListNew, Args: []Arg{{Child: 1}}}, + {Syms: "statement_information ',' statement_information_item", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 96: { // statement_information_item + {Syms: "simple_target_specification EQ statement_information_item_name", Kind: ActNew, Class: "Statement_information_item", Params: []string{"name", "target"}, Args: []Arg{{Child: 3}, {Child: 1}}}, + }, + 97: { // simple_target_specification + {Syms: "ident", Kind: ActUnknown}, + {Syms: "'@' ident_or_text", Kind: ActUnknown}, + }, + 98: { // statement_information_item_name + {Syms: "NUMBER_SYM", Kind: ActConst, Const: "Statement_information_item::NUMBER"}, + {Syms: "ROW_COUNT_SYM", Kind: ActConst, Const: "Statement_information_item::ROW_COUNT"}, + }, + 99: { // condition_number + {Syms: "signal_allowed_expr", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 100: { // condition_information + {Syms: "condition_information_item", Kind: ActListNew, Args: []Arg{{Child: 1}}}, + {Syms: "condition_information ',' condition_information_item", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 101: { // condition_information_item + {Syms: "simple_target_specification EQ condition_information_item_name", Kind: ActNew, Class: "Condition_information_item", Params: []string{"name", "target"}, Args: []Arg{{Child: 3}, {Child: 1}}}, + }, + 102: { // condition_information_item_name + {Syms: "CLASS_ORIGIN_SYM", Kind: ActConst, Const: "Condition_information_item::CLASS_ORIGIN"}, + {Syms: "SUBCLASS_ORIGIN_SYM", Kind: ActConst, Const: "Condition_information_item::SUBCLASS_ORIGIN"}, + {Syms: "CONSTRAINT_CATALOG_SYM", Kind: ActConst, Const: "Condition_information_item::CONSTRAINT_CATALOG"}, + {Syms: "CONSTRAINT_SCHEMA_SYM", Kind: ActConst, Const: "Condition_information_item::CONSTRAINT_SCHEMA"}, + {Syms: "CONSTRAINT_NAME_SYM", Kind: ActConst, Const: "Condition_information_item::CONSTRAINT_NAME"}, + {Syms: "CATALOG_NAME_SYM", Kind: ActConst, Const: "Condition_information_item::CATALOG_NAME"}, + {Syms: "SCHEMA_NAME_SYM", Kind: ActConst, Const: "Condition_information_item::SCHEMA_NAME"}, + {Syms: "TABLE_NAME_SYM", Kind: ActConst, Const: "Condition_information_item::TABLE_NAME"}, + {Syms: "COLUMN_NAME_SYM", Kind: ActConst, Const: "Condition_information_item::COLUMN_NAME"}, + {Syms: "CURSOR_NAME_SYM", Kind: ActConst, Const: "Condition_information_item::CURSOR_NAME"}, + {Syms: "MESSAGE_TEXT_SYM", Kind: ActConst, Const: "Condition_information_item::MESSAGE_TEXT"}, + {Syms: "MYSQL_ERRNO_SYM", Kind: ActConst, Const: "Condition_information_item::MYSQL_ERRNO"}, + {Syms: "RETURNED_SQLSTATE_SYM", Kind: ActConst, Const: "Condition_information_item::RETURNED_SQLSTATE"}, + }, + 103: { // sp_decl_idents + {Syms: "ident", Kind: ActUnknown}, + {Syms: "sp_decl_idents ',' ident", Kind: ActUnknown}, + }, + 104: { // sp_opt_default + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "expr_start", Arg: Arg{Text: "nullptr"}}, {Name: "expr", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "DEFAULT_SYM expr", Kind: ActStruct, Fields: []Field{{Name: "expr_start", Arg: Arg{Text: "@1.raw.end"}}, {Name: "expr", Arg: Arg{Child: 2}}}}, + }, + 105: { // sp_proc_stmt + {Syms: "sp_proc_stmt_statement", Kind: ActDefault}, + {Syms: "sp_proc_stmt_return", Kind: ActDefault}, + {Syms: "sp_proc_stmt_if", Kind: ActDefault}, + {Syms: "case_stmt_specification", Kind: ActDefault}, + {Syms: "sp_labeled_block", Kind: ActDefault}, + {Syms: "sp_unlabeled_block", Kind: ActDefault}, + {Syms: "sp_labeled_control", Kind: ActDefault}, + {Syms: "sp_proc_stmt_unlabeled", Kind: ActDefault}, + {Syms: "sp_proc_stmt_leave", Kind: ActDefault}, + {Syms: "sp_proc_stmt_iterate", Kind: ActDefault}, + {Syms: "sp_proc_stmt_open", Kind: ActDefault}, + {Syms: "sp_proc_stmt_fetch", Kind: ActDefault}, + {Syms: "sp_proc_stmt_close", Kind: ActDefault}, + }, + 106: { // sp_proc_stmt_if + {Syms: "IF sp_if END IF", Kind: ActDefault}, + }, + 107: { // sp_proc_stmt_statement + {Syms: "simple_statement", Kind: ActUnknown}, + }, + 108: { // sp_proc_stmt_return + {Syms: "RETURN_SYM expr", Kind: ActUnknown}, + }, + 109: { // sp_proc_stmt_unlabeled + {Syms: "sp_unlabeled_control", Kind: ActDefault}, + }, + 110: { // sp_proc_stmt_leave + {Syms: "LEAVE_SYM label_ident", Kind: ActUnknown}, + }, + 111: { // sp_proc_stmt_iterate + {Syms: "ITERATE_SYM label_ident", Kind: ActUnknown}, + }, + 112: { // sp_proc_stmt_open + {Syms: "OPEN_SYM ident", Kind: ActDefault}, + }, + 113: { // sp_proc_stmt_fetch + {Syms: "FETCH_SYM sp_opt_fetch_noise ident INTO sp_fetch_list", Kind: ActDefault}, + }, + 114: { // sp_proc_stmt_close + {Syms: "CLOSE_SYM ident", Kind: ActDefault}, + }, + 115: { // sp_opt_fetch_noise + {Syms: "", Kind: ActDefault}, + {Syms: "NEXT_SYM FROM", Kind: ActDefault}, + {Syms: "FROM", Kind: ActDefault}, + }, + 116: { // sp_fetch_list + {Syms: "ident", Kind: ActDefault}, + {Syms: "sp_fetch_list ',' ident", Kind: ActDefault}, + }, + 117: { // sp_if + {Syms: "expr THEN_SYM sp_proc_stmts1 sp_elseifs", Kind: ActDefault}, + }, + 118: { // sp_elseifs + {Syms: "", Kind: ActDefault}, + {Syms: "ELSEIF_SYM sp_if", Kind: ActDefault}, + {Syms: "ELSE sp_proc_stmts1", Kind: ActDefault}, + }, + 119: { // case_stmt_specification + {Syms: "simple_case_stmt", Kind: ActDefault}, + {Syms: "searched_case_stmt", Kind: ActDefault}, + }, + 120: { // simple_case_stmt + {Syms: "CASE_SYM expr simple_when_clause_list else_clause_opt END CASE_SYM", Kind: ActDefault}, + }, + 121: { // searched_case_stmt + {Syms: "CASE_SYM searched_when_clause_list else_clause_opt END CASE_SYM", Kind: ActDefault}, + }, + 122: { // simple_when_clause_list + {Syms: "simple_when_clause", Kind: ActDefault}, + {Syms: "simple_when_clause_list simple_when_clause", Kind: ActDefault}, + }, + 123: { // searched_when_clause_list + {Syms: "searched_when_clause", Kind: ActDefault}, + {Syms: "searched_when_clause_list searched_when_clause", Kind: ActDefault}, + }, + 124: { // simple_when_clause + {Syms: "WHEN_SYM expr THEN_SYM sp_proc_stmts1", Kind: ActDefault}, + }, + 125: { // searched_when_clause + {Syms: "WHEN_SYM expr THEN_SYM sp_proc_stmts1", Kind: ActDefault}, + }, + 126: { // else_clause_opt + {Syms: "", Kind: ActDefault}, + {Syms: "ELSE sp_proc_stmts1", Kind: ActDefault}, + }, + 127: { // sp_labeled_control + {Syms: "label_ident ':' sp_unlabeled_control sp_opt_label", Kind: ActUnknown}, + }, + 128: { // sp_opt_label + {Syms: "", Kind: ActEmpty}, + {Syms: "label_ident", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 129: { // sp_labeled_block + {Syms: "label_ident ':' sp_block_content sp_opt_label", Kind: ActUnknown}, + }, + 130: { // sp_unlabeled_block + {Syms: "sp_block_content", Kind: ActDefault}, + }, + 131: { // sp_block_content + {Syms: "BEGIN_SYM sp_decls sp_proc_stmts END", Kind: ActDefault}, + }, + 132: { // sp_unlabeled_control + {Syms: "LOOP_SYM sp_proc_stmts1 END LOOP_SYM", Kind: ActDefault}, + {Syms: "WHILE_SYM expr DO_SYM sp_proc_stmts1 END WHILE_SYM", Kind: ActDefault}, + {Syms: "REPEAT_SYM sp_proc_stmts1 UNTIL_SYM expr END REPEAT_SYM", Kind: ActDefault}, + }, + 133: { // trg_action_time + {Syms: "BEFORE_SYM", Kind: ActConst, Const: "TRG_ACTION_BEFORE"}, + {Syms: "AFTER_SYM", Kind: ActConst, Const: "TRG_ACTION_AFTER"}, + }, + 134: { // trg_event + {Syms: "INSERT_SYM", Kind: ActConst, Const: "TRG_EVENT_INSERT"}, + {Syms: "UPDATE_SYM", Kind: ActConst, Const: "TRG_EVENT_UPDATE"}, + {Syms: "DELETE_SYM", Kind: ActConst, Const: "TRG_EVENT_DELETE"}, + }, + 135: { // opt_ts_datafile_name + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "0", Arg: Arg{Text: "nullptr"}}, {Name: "1", Arg: Arg{Text: "0"}}}}, + {Syms: "ADD ts_datafile", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 136: { // opt_logfile_group_name + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "0", Arg: Arg{Text: "nullptr"}}, {Name: "1", Arg: Arg{Text: "0"}}}}, + {Syms: "USE_SYM LOGFILE_SYM GROUP_SYM ident", Kind: ActPass, Args: []Arg{{Child: 4}}}, + }, + 137: { // opt_tablespace_options + {Syms: "", Kind: ActEmpty}, + {Syms: "tablespace_option_list", Kind: ActDefault}, + }, + 138: { // tablespace_option_list + {Syms: "tablespace_option", Kind: ActListNew, Class: "Mem_root_array", Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 1}}}, + {Syms: "tablespace_option_list opt_comma tablespace_option", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 139: { // tablespace_option + {Syms: "ts_option_initial_size", Kind: ActDefault}, + {Syms: "ts_option_autoextend_size", Kind: ActDefault}, + {Syms: "ts_option_max_size", Kind: ActDefault}, + {Syms: "ts_option_extent_size", Kind: ActDefault}, + {Syms: "ts_option_nodegroup", Kind: ActDefault}, + {Syms: "ts_option_engine", Kind: ActDefault}, + {Syms: "ts_option_wait", Kind: ActDefault}, + {Syms: "ts_option_comment", Kind: ActDefault}, + {Syms: "ts_option_file_block_size", Kind: ActDefault}, + {Syms: "ts_option_encryption", Kind: ActDefault}, + {Syms: "ts_option_engine_attribute", Kind: ActDefault}, + }, + 140: { // opt_alter_tablespace_options + {Syms: "", Kind: ActEmpty}, + {Syms: "alter_tablespace_option_list", Kind: ActDefault}, + }, + 141: { // alter_tablespace_option_list + {Syms: "alter_tablespace_option", Kind: ActListNew, Class: "Mem_root_array", Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 1}}}, + {Syms: "alter_tablespace_option_list opt_comma alter_tablespace_option", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 142: { // alter_tablespace_option + {Syms: "ts_option_initial_size", Kind: ActDefault}, + {Syms: "ts_option_autoextend_size", Kind: ActDefault}, + {Syms: "ts_option_max_size", Kind: ActDefault}, + {Syms: "ts_option_engine", Kind: ActDefault}, + {Syms: "ts_option_wait", Kind: ActDefault}, + {Syms: "ts_option_encryption", Kind: ActDefault}, + {Syms: "ts_option_engine_attribute", Kind: ActDefault}, + }, + 143: { // opt_undo_tablespace_options + {Syms: "", Kind: ActEmpty}, + {Syms: "undo_tablespace_option_list", Kind: ActDefault}, + }, + 144: { // undo_tablespace_option_list + {Syms: "undo_tablespace_option", Kind: ActListNew, Class: "Mem_root_array", Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 1}}}, + {Syms: "undo_tablespace_option_list opt_comma undo_tablespace_option", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 145: { // undo_tablespace_option + {Syms: "ts_option_engine", Kind: ActDefault}, + }, + 146: { // opt_logfile_group_options + {Syms: "", Kind: ActEmpty}, + {Syms: "logfile_group_option_list", Kind: ActDefault}, + }, + 147: { // logfile_group_option_list + {Syms: "logfile_group_option", Kind: ActListNew, Class: "Mem_root_array", Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 1}}}, + {Syms: "logfile_group_option_list opt_comma logfile_group_option", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 148: { // logfile_group_option + {Syms: "ts_option_initial_size", Kind: ActDefault}, + {Syms: "ts_option_undo_buffer_size", Kind: ActDefault}, + {Syms: "ts_option_redo_buffer_size", Kind: ActDefault}, + {Syms: "ts_option_nodegroup", Kind: ActDefault}, + {Syms: "ts_option_engine", Kind: ActDefault}, + {Syms: "ts_option_wait", Kind: ActDefault}, + {Syms: "ts_option_comment", Kind: ActDefault}, + }, + 149: { // opt_alter_logfile_group_options + {Syms: "", Kind: ActEmpty}, + {Syms: "alter_logfile_group_option_list", Kind: ActDefault}, + }, + 150: { // alter_logfile_group_option_list + {Syms: "alter_logfile_group_option", Kind: ActListNew, Class: "Mem_root_array", Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 1}}}, + {Syms: "alter_logfile_group_option_list opt_comma alter_logfile_group_option", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 151: { // alter_logfile_group_option + {Syms: "ts_option_initial_size", Kind: ActDefault}, + {Syms: "ts_option_engine", Kind: ActDefault}, + {Syms: "ts_option_wait", Kind: ActDefault}, + }, + 152: { // ts_datafile + {Syms: "DATAFILE_SYM TEXT_STRING_sys", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 153: { // undo_tablespace_state + {Syms: "ACTIVE_SYM", Kind: ActConst, Const: "ALTER_UNDO_TABLESPACE_SET_ACTIVE"}, + {Syms: "INACTIVE_SYM", Kind: ActConst, Const: "ALTER_UNDO_TABLESPACE_SET_INACTIVE"}, + }, + 154: { // lg_undofile + {Syms: "UNDOFILE_SYM TEXT_STRING_sys", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 155: { // ts_option_initial_size + {Syms: "INITIAL_SIZE_SYM opt_equal size_number", Kind: ActNew, Class: "PT_alter_tablespace_option_initial_size", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 156: { // ts_option_autoextend_size + {Syms: "option_autoextend_size", Kind: ActNew, Class: "PT_alter_tablespace_option_autoextend_size", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + }, + 157: { // option_autoextend_size + {Syms: "AUTOEXTEND_SIZE_SYM opt_equal size_number", Kind: ActPass, Args: []Arg{{Child: 3}}}, + }, + 158: { // ts_option_max_size + {Syms: "MAX_SIZE_SYM opt_equal size_number", Kind: ActNew, Class: "PT_alter_tablespace_option_max_size", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 159: { // ts_option_extent_size + {Syms: "EXTENT_SIZE_SYM opt_equal size_number", Kind: ActNew, Class: "PT_alter_tablespace_option_extent_size", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 160: { // ts_option_undo_buffer_size + {Syms: "UNDO_BUFFER_SIZE_SYM opt_equal size_number", Kind: ActNew, Class: "PT_alter_tablespace_option_undo_buffer_size", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 161: { // ts_option_redo_buffer_size + {Syms: "REDO_BUFFER_SIZE_SYM opt_equal size_number", Kind: ActNew, Class: "PT_alter_tablespace_option_redo_buffer_size", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 162: { // ts_option_nodegroup + {Syms: "NODEGROUP_SYM opt_equal real_ulong_num", Kind: ActNew, Class: "PT_alter_tablespace_option_nodegroup", Params: []string{"pos", "nodegroup_id"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 163: { // ts_option_comment + {Syms: "COMMENT_SYM opt_equal TEXT_STRING_sys", Kind: ActNew, Class: "PT_alter_tablespace_option_comment", Params: []string{"pos", "comment"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 164: { // ts_option_engine + {Syms: "opt_storage ENGINE_SYM opt_equal ident_or_text", Kind: ActNew, Class: "PT_alter_tablespace_option_engine", Params: []string{"pos", "engine_name"}, Args: []Arg{{Text: "@$"}, {Child: 4}}}, + }, + 165: { // ts_option_file_block_size + {Syms: "FILE_BLOCK_SIZE_SYM opt_equal size_number", Kind: ActNew, Class: "PT_alter_tablespace_option_file_block_size", Params: []string{"pos", "file_block_size"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 166: { // ts_option_wait + {Syms: "WAIT_SYM", Kind: ActNew, Class: "PT_alter_tablespace_option_wait_until_completed", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Text: "true"}}}, + {Syms: "NO_WAIT_SYM", Kind: ActNew, Class: "PT_alter_tablespace_option_wait_until_completed", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Text: "false"}}}, + }, + 167: { // ts_option_encryption + {Syms: "ENCRYPTION_SYM opt_equal TEXT_STRING_sys", Kind: ActNew, Class: "PT_alter_tablespace_option_encryption", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 168: { // ts_option_engine_attribute + {Syms: "ENGINE_ATTRIBUTE_SYM opt_equal json_attribute", Kind: ActNew, Class: "make_tablespace_engine_attribute", Params: []string{"", ""}, Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 3}}}, + }, + 169: { // size_number + {Syms: "real_ulonglong_num", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "IDENT_sys", Kind: ActUnknown}, + }, + 170: { // opt_create_table_options_etc + {Syms: "create_table_options opt_create_partitioning_etc", Kind: ActStruct, Fields: []Field{{Name: "$base", Arg: Arg{Child: 2}}, {Name: "opt_create_table_options", Arg: Arg{Child: 1}}}}, + {Syms: "opt_create_partitioning_etc", Kind: ActDefault}, + }, + 171: { // opt_create_partitioning_etc + {Syms: "partition_clause opt_duplicate_as_qe", Kind: ActStruct, Fields: []Field{{Name: "$base", Arg: Arg{Child: 2}}, {Name: "opt_partitioning", Arg: Arg{Child: 1}}}}, + {Syms: "opt_duplicate_as_qe", Kind: ActDefault}, + }, + 172: { // opt_duplicate_as_qe + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "opt_create_table_options", Arg: Arg{Text: "nullptr"}}, {Name: "opt_partitioning", Arg: Arg{Text: "nullptr"}}, {Name: "on_duplicate", Arg: Arg{Text: "On_duplicate::ERROR"}}, {Name: "opt_query_expression", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "duplicate as_create_query_expression", Kind: ActStruct, Fields: []Field{{Name: "opt_create_table_options", Arg: Arg{Text: "nullptr"}}, {Name: "opt_partitioning", Arg: Arg{Text: "nullptr"}}, {Name: "on_duplicate", Arg: Arg{Child: 1}}, {Name: "opt_query_expression", Arg: Arg{Child: 2}}}}, + {Syms: "as_create_query_expression", Kind: ActStruct, Fields: []Field{{Name: "opt_create_table_options", Arg: Arg{Text: "nullptr"}}, {Name: "opt_partitioning", Arg: Arg{Text: "nullptr"}}, {Name: "on_duplicate", Arg: Arg{Text: "On_duplicate::ERROR"}}, {Name: "opt_query_expression", Arg: Arg{Child: 1}}}}, + }, + 173: { // as_create_query_expression + {Syms: "AS query_expression_with_opt_locking_clauses", Kind: ActPass, Args: []Arg{{Child: 2}}}, + {Syms: "query_expression_with_opt_locking_clauses", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 174: { // partition_clause + {Syms: "PARTITION_SYM BY part_type_def opt_num_parts opt_sub_part opt_part_defs", Kind: ActNew, Class: "PT_partition", Params: []string{"pos", "part_type_def", "opt_num_parts", "opt_sub_part", "part_defs_pos", "part_defs"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 4}, {Child: 5}, {Text: "@6"}, {Child: 6}}}, + }, + 175: { // part_type_def + {Syms: "opt_linear KEY_SYM opt_key_algo '(' opt_name_list ')'", Kind: ActNew, Class: "PT_part_type_def_key", Params: []string{"pos", "is_linear", "key_algo", "opt_columns"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}, {Child: 5}}}, + {Syms: "opt_linear HASH_SYM '(' bit_expr ')'", Kind: ActNew, Class: "PT_part_type_def_hash", Params: []string{"pos", "is_linear", "expr_pos", "expr"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Text: "@4"}, {Child: 4}}}, + {Syms: "RANGE_SYM '(' bit_expr ')'", Kind: ActNew, Class: "PT_part_type_def_range_expr", Params: []string{"pos", "expr_pos", "expr"}, Args: []Arg{{Text: "@$"}, {Text: "@3"}, {Child: 3}}}, + {Syms: "RANGE_SYM COLUMNS '(' name_list ')'", Kind: ActNew, Class: "PT_part_type_def_range_columns", Params: []string{"pos", "columns"}, Args: []Arg{{Text: "@$"}, {Child: 4}}}, + {Syms: "LIST_SYM '(' bit_expr ')'", Kind: ActNew, Class: "PT_part_type_def_list_expr", Params: []string{"pos", "expr_pos", "expr"}, Args: []Arg{{Text: "@$"}, {Text: "@3"}, {Child: 3}}}, + {Syms: "LIST_SYM COLUMNS '(' name_list ')'", Kind: ActNew, Class: "PT_part_type_def_list_columns", Params: []string{"pos", "columns"}, Args: []Arg{{Text: "@$"}, {Child: 4}}}, + }, + 176: { // opt_linear + {Syms: "", Kind: ActConst, Const: "false"}, + {Syms: "LINEAR_SYM", Kind: ActConst, Const: "true"}, + }, + 177: { // opt_key_algo + {Syms: "", Kind: ActConst, Const: "enum_key_algorithm::KEY_ALGORITHM_NONE"}, + {Syms: "ALGORITHM_SYM EQ real_ulong_num", Kind: ActUnknown}, + }, + 178: { // opt_num_parts + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "PARTITIONS_SYM real_ulong_num", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 179: { // opt_sub_part + {Syms: "", Kind: ActEmpty}, + {Syms: "SUBPARTITION_SYM BY opt_linear HASH_SYM '(' bit_expr ')' opt_num_subparts", Kind: ActNew, Class: "PT_sub_partition_by_hash", Params: []string{"pos", "is_linear", "hash_pos", "hash", "opt_num_subparts"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "@6"}, {Child: 6}, {Child: 8}}}, + {Syms: "SUBPARTITION_SYM BY opt_linear KEY_SYM opt_key_algo '(' name_list ')' opt_num_subparts", Kind: ActNew, Class: "PT_sub_partition_by_key", Params: []string{"pos", "is_linear", "key_algo", "field_names", "opt_num_subparts"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}, {Child: 7}, {Child: 9}}}, + }, + 180: { // opt_name_list + {Syms: "", Kind: ActEmpty}, + {Syms: "name_list", Kind: ActDefault}, + }, + 181: { // name_list + {Syms: "ident", Kind: ActListNew, Args: []Arg{{Child: 1, Field: ".str"}}}, + {Syms: "name_list ',' ident", Kind: ActUnknown}, + }, + 182: { // opt_num_subparts + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "SUBPARTITIONS_SYM real_ulong_num", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 183: { // opt_part_defs + {Syms: "", Kind: ActEmpty}, + {Syms: "'(' part_def_list ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 184: { // part_def_list + {Syms: "part_definition", Kind: ActListNew, Class: "Mem_root_array", Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 1}}}, + {Syms: "part_def_list ',' part_definition", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 185: { // part_definition + {Syms: "PARTITION_SYM ident opt_part_values opt_part_options opt_sub_partition", Kind: ActNew, Class: "PT_part_definition", Params: []string{"pos", "errpos", "name", "type", "opt_part_values", "values_pos", "opt_part_options", "opt_sub_partitions", "sub_partitions_pos"}, Args: []Arg{{Text: "@$"}, {Text: "@0"}, {Child: 2}, {Child: 3, Field: ".type"}, {Child: 3, Field: ".values"}, {Text: "@3"}, {Child: 4}, {Child: 5}, {Text: "@5"}}}, + }, + 186: { // opt_part_values + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "type", Arg: Arg{Text: "partition_type::HASH"}}}}, + {Syms: "VALUES LESS_SYM THAN_SYM part_func_max", Kind: ActStruct, Fields: []Field{{Name: "type", Arg: Arg{Text: "partition_type::RANGE"}}, {Name: "values", Arg: Arg{Child: 4}}}}, + {Syms: "VALUES IN_SYM part_values_in", Kind: ActStruct, Fields: []Field{{Name: "type", Arg: Arg{Text: "partition_type::LIST"}}, {Name: "values", Arg: Arg{Child: 3}}}}, + }, + 187: { // part_func_max + {Syms: "MAX_VALUE_SYM", Kind: ActEmpty}, + {Syms: "part_value_item_list_paren", Kind: ActDefault}, + }, + 188: { // part_values_in + {Syms: "part_value_item_list_paren", Kind: ActNew, Class: "PT_part_values_in_item", Params: []string{"pos", "errpos", "item"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Child: 1}}}, + {Syms: "'(' part_value_list ')'", Kind: ActNew, Class: "PT_part_values_in_list", Params: []string{"pos", "errpos", "list"}, Args: []Arg{{Text: "@$"}, {Text: "@3"}, {Child: 2}}}, + }, + 189: { // part_value_list + {Syms: "part_value_item_list_paren", Kind: ActListNew, Class: "Mem_root_array", Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 1}}}, + {Syms: "part_value_list ',' part_value_item_list_paren", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 190: { // part_value_item_list_paren + {Syms: "'(' part_value_item_list ')'", Kind: ActNew, Class: "PT_part_value_item_list_paren", Params: []string{"pos", "values", "paren_pos"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Text: "@4"}}}, + }, + 191: { // part_value_item_list + {Syms: "part_value_item", Kind: ActListNew, Class: "Mem_root_array", Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 1}}}, + {Syms: "part_value_item_list ',' part_value_item", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 192: { // part_value_item + {Syms: "MAX_VALUE_SYM", Kind: ActNew, Class: "PT_part_value_item_max", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "bit_expr", Kind: ActNew, Class: "PT_part_value_item_expr", Params: []string{"pos", "expr"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + }, + 193: { // opt_sub_partition + {Syms: "", Kind: ActEmpty}, + {Syms: "'(' sub_part_list ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 194: { // sub_part_list + {Syms: "sub_part_definition", Kind: ActListNew, Class: "Mem_root_array", Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 1}}}, + {Syms: "sub_part_list ',' sub_part_definition", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 195: { // sub_part_definition + {Syms: "SUBPARTITION_SYM ident_or_text opt_part_options", Kind: ActNew, Class: "PT_subpartition", Params: []string{"pos", "errpos", "name", "options"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Child: 2, Field: ".str"}, {Child: 3}}}, + }, + 196: { // opt_part_options + {Syms: "", Kind: ActEmpty}, + {Syms: "part_option_list", Kind: ActDefault}, + }, + 197: { // part_option_list + {Syms: "part_option_list part_option", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 2}}}, + {Syms: "part_option", Kind: ActListNew, Class: "Mem_root_array", Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 1}}}, + }, + 198: { // part_option + {Syms: "TABLESPACE_SYM opt_equal ident", Kind: ActNew, Class: "PT_partition_tablespace", Params: []string{"pos", "tablespace"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".str"}}}, + {Syms: "opt_storage ENGINE_SYM opt_equal ident_or_text", Kind: ActNew, Class: "PT_partition_engine", Params: []string{"pos", "name"}, Args: []Arg{{Text: "@$"}, {Child: 4}}}, + {Syms: "NODEGROUP_SYM opt_equal real_ulong_num", Kind: ActNew, Class: "PT_partition_nodegroup", Params: []string{"pos", "nodegroup"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "MAX_ROWS opt_equal real_ulonglong_num", Kind: ActNew, Class: "PT_partition_max_rows", Params: []string{"pos", "max_rows"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "MIN_ROWS opt_equal real_ulonglong_num", Kind: ActNew, Class: "PT_partition_min_rows", Params: []string{"pos", "min_rows"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "DATA_SYM DIRECTORY_SYM opt_equal TEXT_STRING_sys", Kind: ActNew, Class: "PT_partition_data_directory", Params: []string{"pos", "data_directory"}, Args: []Arg{{Text: "@$"}, {Child: 4, Field: ".str"}}}, + {Syms: "INDEX_SYM DIRECTORY_SYM opt_equal TEXT_STRING_sys", Kind: ActNew, Class: "PT_partition_index_directory", Params: []string{"pos", "index_directory"}, Args: []Arg{{Text: "@$"}, {Child: 4, Field: ".str"}}}, + {Syms: "COMMENT_SYM opt_equal TEXT_STRING_sys", Kind: ActNew, Class: "PT_partition_comment", Params: []string{"pos", "comment"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".str"}}}, + }, + 199: { // alter_database_options + {Syms: "alter_database_option", Kind: ActDefault}, + {Syms: "alter_database_options alter_database_option", Kind: ActDefault}, + }, + 200: { // alter_database_option + {Syms: "create_database_option", Kind: ActDefault}, + {Syms: "READ_SYM ONLY_SYM opt_equal ternary_option", Kind: ActUnknown}, + }, + 201: { // opt_create_database_options + {Syms: "", Kind: ActDefault}, + {Syms: "create_database_options", Kind: ActDefault}, + }, + 202: { // create_database_options + {Syms: "create_database_option", Kind: ActDefault}, + {Syms: "create_database_options create_database_option", Kind: ActDefault}, + }, + 203: { // create_database_option + {Syms: "default_collation", Kind: ActDefault}, + {Syms: "default_charset", Kind: ActDefault}, + {Syms: "default_encryption", Kind: ActUnknown}, + }, + 204: { // opt_if_not_exists + {Syms: "", Kind: ActConst, Const: "false"}, + {Syms: "IF not EXISTS", Kind: ActConst, Const: "true"}, + }, + 205: { // create_table_options_space_separated + {Syms: "create_table_option", Kind: ActListNew, Class: "Mem_root_array", Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 1}}}, + {Syms: "create_table_options_space_separated create_table_option", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 2}}}, + }, + 206: { // create_table_options + {Syms: "create_table_option", Kind: ActListNew, Class: "Mem_root_array", Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 1}}}, + {Syms: "create_table_options opt_comma create_table_option", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 207: { // opt_comma + {Syms: "", Kind: ActDefault}, + {Syms: "','", Kind: ActDefault}, + }, + 208: { // create_table_option + {Syms: "ENGINE_SYM opt_equal ident_or_text", Kind: ActNew, Class: "PT_create_table_engine_option", Params: []string{"pos", "engine"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "SECONDARY_ENGINE_SYM opt_equal NULL_SYM", Kind: ActNew, Class: "PT_create_table_secondary_engine_option", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "SECONDARY_ENGINE_SYM opt_equal ident_or_text", Kind: ActNew, Class: "PT_create_table_secondary_engine_option", Params: []string{"pos", "secondary_engine"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "MAX_ROWS opt_equal ulonglong_num", Kind: ActNew, Class: "PT_create_max_rows_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "MIN_ROWS opt_equal ulonglong_num", Kind: ActNew, Class: "PT_create_min_rows_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "AVG_ROW_LENGTH opt_equal ulonglong_num", Kind: ActNew, Class: "PT_create_avg_row_length_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "PASSWORD opt_equal TEXT_STRING_sys", Kind: ActNew, Class: "PT_create_password_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".str"}}}, + {Syms: "COMMENT_SYM opt_equal TEXT_STRING_sys", Kind: ActNew, Class: "PT_create_commen_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "COMPRESSION_SYM opt_equal TEXT_STRING_sys", Kind: ActNew, Class: "PT_create_compress_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "ENCRYPTION_SYM opt_equal TEXT_STRING_sys", Kind: ActNew, Class: "PT_create_encryption_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "AUTO_INC opt_equal ulonglong_num", Kind: ActNew, Class: "PT_create_auto_increment_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "PACK_KEYS_SYM opt_equal ternary_option", Kind: ActNew, Class: "PT_create_pack_keys_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "STATS_AUTO_RECALC_SYM opt_equal ternary_option", Kind: ActNew, Class: "PT_create_stats_auto_recalc_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "STATS_PERSISTENT_SYM opt_equal ternary_option", Kind: ActNew, Class: "PT_create_stats_persistent_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "STATS_SAMPLE_PAGES_SYM opt_equal ulong_num", Kind: ActNew, Class: "PT_create_stats_stable_pages", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "STATS_SAMPLE_PAGES_SYM opt_equal DEFAULT_SYM", Kind: ActNew, Class: "PT_create_stats_stable_pages", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "CHECKSUM_SYM opt_equal ulong_num", Kind: ActNew, Class: "PT_create_checksum_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "TABLE_CHECKSUM_SYM opt_equal ulong_num", Kind: ActNew, Class: "PT_create_checksum_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "DELAY_KEY_WRITE_SYM opt_equal ulong_num", Kind: ActNew, Class: "PT_create_delay_key_write_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "ROW_FORMAT_SYM opt_equal row_types", Kind: ActNew, Class: "PT_create_row_format_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "UNION_SYM opt_equal '(' opt_table_list ')'", Kind: ActNew, Class: "PT_create_union_option", Params: []string{"pos", "tables"}, Args: []Arg{{Text: "@$"}, {Child: 4}}}, + {Syms: "default_charset", Kind: ActNew, Class: "PT_create_table_default_charset", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "default_collation", Kind: ActNew, Class: "PT_create_table_default_collation", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "INSERT_METHOD opt_equal merge_insert_types", Kind: ActNew, Class: "PT_create_insert_method_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "DATA_SYM DIRECTORY_SYM opt_equal TEXT_STRING_sys", Kind: ActNew, Class: "PT_create_data_directory_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 4, Field: ".str"}}}, + {Syms: "INDEX_SYM DIRECTORY_SYM opt_equal TEXT_STRING_sys", Kind: ActNew, Class: "PT_create_index_directory_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 4, Field: ".str"}}}, + {Syms: "TABLESPACE_SYM opt_equal ident", Kind: ActNew, Class: "PT_create_tablespace_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".str"}}}, + {Syms: "STORAGE_SYM DISK_SYM", Kind: ActNew, Class: "PT_create_storage_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Text: "HA_SM_DISK"}}}, + {Syms: "STORAGE_SYM MEMORY_SYM", Kind: ActNew, Class: "PT_create_storage_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Text: "HA_SM_MEMORY"}}}, + {Syms: "CONNECTION_SYM opt_equal TEXT_STRING_sys", Kind: ActNew, Class: "PT_create_connection_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "KEY_BLOCK_SIZE opt_equal ulonglong_num", Kind: ActNew, Class: "PT_create_key_block_size_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "START_SYM TRANSACTION_SYM", Kind: ActNew, Class: "PT_create_start_transaction_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Text: "true"}}}, + {Syms: "ENGINE_ATTRIBUTE_SYM opt_equal json_attribute", Kind: ActNew, Class: "make_table_engine_attribute", Params: []string{"", ""}, Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 3}}}, + {Syms: "SECONDARY_ENGINE_ATTRIBUTE_SYM opt_equal json_attribute", Kind: ActNew, Class: "make_table_secondary_engine_attribute", Params: []string{"", ""}, Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 3}}}, + {Syms: "option_autoextend_size", Kind: ActNew, Class: "PT_create_ts_autoextend_size_option", Params: []string{"pos", "value"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + }, + 209: { // ternary_option + {Syms: "ulong_num", Kind: ActUnknown}, + {Syms: "DEFAULT_SYM", Kind: ActConst, Const: "Ternary_option::DEFAULT"}, + }, + 210: { // default_charset + {Syms: "opt_default character_set opt_equal charset_name", Kind: ActPass, Args: []Arg{{Child: 4}}}, + }, + 211: { // default_collation + {Syms: "opt_default COLLATE_SYM opt_equal collation_name", Kind: ActPass, Args: []Arg{{Child: 4}}}, + }, + 212: { // default_encryption + {Syms: "opt_default ENCRYPTION_SYM opt_equal TEXT_STRING_sys", Kind: ActPass, Args: []Arg{{Child: 4}}}, + }, + 213: { // row_types + {Syms: "DEFAULT_SYM", Kind: ActConst, Const: "ROW_TYPE_DEFAULT"}, + {Syms: "FIXED_SYM", Kind: ActConst, Const: "ROW_TYPE_FIXED"}, + {Syms: "DYNAMIC_SYM", Kind: ActConst, Const: "ROW_TYPE_DYNAMIC"}, + {Syms: "COMPRESSED_SYM", Kind: ActConst, Const: "ROW_TYPE_COMPRESSED"}, + {Syms: "REDUNDANT_SYM", Kind: ActConst, Const: "ROW_TYPE_REDUNDANT"}, + {Syms: "COMPACT_SYM", Kind: ActConst, Const: "ROW_TYPE_COMPACT"}, + }, + 214: { // merge_insert_types + {Syms: "NO_SYM", Kind: ActConst, Const: "MERGE_INSERT_DISABLED"}, + {Syms: "FIRST_SYM", Kind: ActConst, Const: "MERGE_INSERT_TO_FIRST"}, + {Syms: "LAST_SYM", Kind: ActConst, Const: "MERGE_INSERT_TO_LAST"}, + }, + 215: { // udf_type + {Syms: "STRING_SYM", Kind: ActUnknown}, + {Syms: "REAL_SYM", Kind: ActUnknown}, + {Syms: "DECIMAL_SYM", Kind: ActUnknown}, + {Syms: "INT_SYM", Kind: ActUnknown}, + }, + 216: { // table_element_list + {Syms: "table_element", Kind: ActListNew, Class: "Mem_root_array", Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 1}}}, + {Syms: "table_element_list ',' table_element", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 217: { // table_element + {Syms: "column_def", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "table_constraint_def", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 218: { // column_def + {Syms: "ident field_def opt_references", Kind: ActNew, Class: "PT_column_def", Params: []string{"pos", "field_ident", "field_def", "opt_column_constraint"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}, {Child: 3}}}, + }, + 219: { // opt_references + {Syms: "", Kind: ActEmpty}, + {Syms: "references", Kind: ActEmpty}, + }, + 220: { // table_constraint_def + {Syms: "key_or_index opt_index_name_and_type '(' key_list_with_expression ')' opt_index_options", Kind: ActNew, Class: "PT_inline_index_definition", Params: []string{"pos", "type_par", "name", "type", "cols", "options"}, Args: []Arg{{Text: "@$"}, {Text: "KEYTYPE_MULTIPLE"}, {Child: 2, Field: ".name"}, {Child: 2, Field: ".type"}, {Child: 4}, {Child: 6}}}, + {Syms: "FULLTEXT_SYM opt_key_or_index opt_ident '(' key_list_with_expression ')' opt_fulltext_index_options", Kind: ActNew, Class: "PT_inline_index_definition", Params: []string{"pos", "type_par", "name", "type", "cols", "options"}, Args: []Arg{{Text: "@$"}, {Text: "KEYTYPE_FULLTEXT"}, {Child: 3}, {Text: "nullptr"}, {Child: 5}, {Child: 7}}}, + {Syms: "SPATIAL_SYM opt_key_or_index opt_ident '(' key_list_with_expression ')' opt_spatial_index_options", Kind: ActNew, Class: "PT_inline_index_definition", Params: []string{"pos", "type_par", "name", "type", "cols", "options"}, Args: []Arg{{Text: "@$"}, {Text: "KEYTYPE_SPATIAL"}, {Child: 3}, {Text: "nullptr"}, {Child: 5}, {Child: 7}}}, + {Syms: "opt_constraint_name constraint_key_type opt_index_name_and_type '(' key_list_with_expression ')' opt_index_options", Kind: ActUnknown}, + {Syms: "opt_constraint_name FOREIGN KEY_SYM opt_ident '(' key_list ')' references", Kind: ActNew, Class: "PT_foreign_key_definition", Params: []string{"pos", "constraint_name", "key_name", "columns", "referenced_table", "ref_list", "fk_match_option", "fk_update_opt", "fk_delete_opt"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 4}, {Child: 6}, {Child: 8, Field: ".table_name"}, {Child: 8, Field: ".reference_list"}, {Child: 8, Field: ".fk_match_option"}, {Child: 8, Field: ".fk_update_opt"}, {Child: 8, Field: ".fk_delete_opt"}}}, + {Syms: "opt_constraint_name check_constraint opt_constraint_enforcement", Kind: ActNew, Class: "PT_check_constraint", Params: []string{"pos", "name", "expr", "is_enforced"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}, {Child: 3}}}, + }, + 221: { // check_constraint + {Syms: "CHECK_SYM '(' expr ')'", Kind: ActPass, Args: []Arg{{Child: 3}}}, + }, + 222: { // opt_constraint_name + {Syms: "", Kind: ActEmpty}, + {Syms: "CONSTRAINT opt_ident", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 223: { // opt_not + {Syms: "", Kind: ActConst, Const: "false"}, + {Syms: "NOT_SYM", Kind: ActConst, Const: "true"}, + }, + 224: { // opt_constraint_enforcement + {Syms: "", Kind: ActConst, Const: "true"}, + {Syms: "constraint_enforcement", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 225: { // constraint_enforcement + {Syms: "opt_not ENFORCED_SYM", Kind: ActUnknown}, + }, + 226: { // field_def + {Syms: "type opt_column_attribute_list", Kind: ActNew, Class: "PT_field_def", Params: []string{"pos", "type_node", "opt_attrs"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}}}, + {Syms: "type opt_collate opt_generated_always AS '(' expr ')' opt_stored_attribute opt_column_attribute_list", Kind: ActUnknown}, + }, + 227: { // opt_generated_always + {Syms: "", Kind: ActDefault}, + {Syms: "GENERATED ALWAYS_SYM", Kind: ActDefault}, + }, + 228: { // opt_stored_attribute + {Syms: "", Kind: ActConst, Const: "Virtual_or_stored::VIRTUAL"}, + {Syms: "VIRTUAL_SYM", Kind: ActConst, Const: "Virtual_or_stored::VIRTUAL"}, + {Syms: "STORED_SYM", Kind: ActConst, Const: "Virtual_or_stored::STORED"}, + }, + 229: { // type + {Syms: "int_type opt_field_length field_options", Kind: ActNew, Class: "PT_numeric_type", Params: []string{"pos", "thd", "type", "length", "options"}, Args: []Arg{{Text: "@$"}, {Text: "YYTHD"}, {Child: 1}, {Child: 2}, {Child: 3}}}, + {Syms: "real_type opt_precision field_options", Kind: ActNew, Class: "PT_numeric_type", Params: []string{"pos", "thd", "type", "length", "dec", "options"}, Args: []Arg{{Text: "@$"}, {Text: "YYTHD"}, {Child: 1}, {Child: 2, Field: ".length"}, {Child: 2, Field: ".dec"}, {Child: 3}}}, + {Syms: "numeric_type float_options field_options", Kind: ActNew, Class: "PT_numeric_type", Params: []string{"pos", "thd", "type", "length", "dec", "options"}, Args: []Arg{{Text: "@$"}, {Text: "YYTHD"}, {Child: 1}, {Child: 2, Field: ".length"}, {Child: 2, Field: ".dec"}, {Child: 3}}}, + {Syms: "BIT_SYM", Kind: ActNew, Class: "PT_bit_type", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "BIT_SYM field_length", Kind: ActNew, Class: "PT_bit_type", Params: []string{"pos", "length"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "BOOL_SYM", Kind: ActNew, Class: "PT_boolean_type", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "BOOLEAN_SYM", Kind: ActNew, Class: "PT_boolean_type", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "CHAR_SYM field_length opt_charset_with_opt_binary", Kind: ActNew, Class: "PT_char_type", Params: []string{"pos", "char_type", "length", "charset", "force_binary"}, Args: []Arg{{Text: "@$"}, {Text: "Char_type::CHAR"}, {Child: 2}, {Child: 3, Field: ".charset"}, {Child: 3, Field: ".force_binary"}}}, + {Syms: "CHAR_SYM opt_charset_with_opt_binary", Kind: ActNew, Class: "PT_char_type", Params: []string{"pos", "char_type", "charset", "force_binary"}, Args: []Arg{{Text: "@$"}, {Text: "Char_type::CHAR"}, {Child: 2, Field: ".charset"}, {Child: 2, Field: ".force_binary"}}}, + {Syms: "nchar field_length opt_bin_mod", Kind: ActUnknown}, + {Syms: "nchar opt_bin_mod", Kind: ActUnknown}, + {Syms: "BINARY_SYM field_length", Kind: ActNew, Class: "PT_char_type", Params: []string{"pos", "char_type", "charset", "force_binary"}, Args: []Arg{{Text: "@$"}, {Text: "Char_type::CHAR"}, {Child: 2}, {Text: "my_charset_bin"}}}, + {Syms: "BINARY_SYM", Kind: ActNew, Class: "PT_char_type", Params: []string{"pos", "char_type", "charset"}, Args: []Arg{{Text: "@$"}, {Text: "Char_type::CHAR"}, {Text: "my_charset_bin"}}}, + {Syms: "varchar field_length opt_charset_with_opt_binary", Kind: ActNew, Class: "PT_char_type", Params: []string{"pos", "char_type", "length", "charset", "force_binary"}, Args: []Arg{{Text: "@$"}, {Text: "Char_type::VARCHAR"}, {Child: 2}, {Child: 3, Field: ".charset"}, {Child: 3, Field: ".force_binary"}}}, + {Syms: "nvarchar field_length opt_bin_mod", Kind: ActUnknown}, + {Syms: "VARBINARY_SYM field_length", Kind: ActNew, Class: "PT_char_type", Params: []string{"pos", "char_type", "charset", "force_binary"}, Args: []Arg{{Text: "@$"}, {Text: "Char_type::VARCHAR"}, {Child: 2}, {Text: "my_charset_bin"}}}, + {Syms: "YEAR_SYM opt_field_length field_options", Kind: ActUnknown}, + {Syms: "DATE_SYM", Kind: ActNew, Class: "PT_date_type", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "TIME_SYM type_datetime_precision", Kind: ActNew, Class: "PT_time_type", Params: []string{"pos", "time_type", "dec"}, Args: []Arg{{Text: "@$"}, {Text: "Time_type::TIME"}, {Child: 2}}}, + {Syms: "TIMESTAMP_SYM type_datetime_precision", Kind: ActNew, Class: "PT_timestamp_type", Params: []string{"pos", "dec"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "DATETIME_SYM type_datetime_precision", Kind: ActNew, Class: "PT_time_type", Params: []string{"pos", "time_type", "dec"}, Args: []Arg{{Text: "@$"}, {Text: "Time_type::DATETIME"}, {Child: 2}}}, + {Syms: "TINYBLOB_SYM", Kind: ActNew, Class: "PT_blob_type", Params: []string{"pos", "blob_type", "charset"}, Args: []Arg{{Text: "@$"}, {Text: "Blob_type::TINY"}, {Text: "my_charset_bin"}}}, + {Syms: "BLOB_SYM opt_field_length", Kind: ActNew, Class: "PT_blob_type", Params: []string{"pos", "length"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "spatial_type", Kind: ActDefault}, + {Syms: "MEDIUMBLOB_SYM", Kind: ActNew, Class: "PT_blob_type", Params: []string{"pos", "blob_type", "charset"}, Args: []Arg{{Text: "@$"}, {Text: "Blob_type::MEDIUM"}, {Text: "my_charset_bin"}}}, + {Syms: "LONGBLOB_SYM", Kind: ActNew, Class: "PT_blob_type", Params: []string{"pos", "blob_type", "charset"}, Args: []Arg{{Text: "@$"}, {Text: "Blob_type::LONG"}, {Text: "my_charset_bin"}}}, + {Syms: "LONG_SYM VARBINARY_SYM", Kind: ActNew, Class: "PT_blob_type", Params: []string{"pos", "blob_type", "charset"}, Args: []Arg{{Text: "@$"}, {Text: "Blob_type::MEDIUM"}, {Text: "my_charset_bin"}}}, + {Syms: "LONG_SYM varchar opt_charset_with_opt_binary", Kind: ActNew, Class: "PT_blob_type", Params: []string{"pos", "blob_type", "charset", "force_binary"}, Args: []Arg{{Text: "@$"}, {Text: "Blob_type::MEDIUM"}, {Child: 3, Field: ".charset"}, {Child: 3, Field: ".force_binary"}}}, + {Syms: "TINYTEXT_SYN opt_charset_with_opt_binary", Kind: ActNew, Class: "PT_blob_type", Params: []string{"pos", "blob_type", "charset", "force_binary"}, Args: []Arg{{Text: "@$"}, {Text: "Blob_type::TINY"}, {Child: 2, Field: ".charset"}, {Child: 2, Field: ".force_binary"}}}, + {Syms: "TEXT_SYM opt_field_length opt_charset_with_opt_binary", Kind: ActNew, Class: "PT_char_type", Params: []string{"pos", "char_type", "length", "charset", "force_binary"}, Args: []Arg{{Text: "@$"}, {Text: "Char_type::TEXT"}, {Child: 2}, {Child: 3, Field: ".charset"}, {Child: 3, Field: ".force_binary"}}}, + {Syms: "MEDIUMTEXT_SYM opt_charset_with_opt_binary", Kind: ActNew, Class: "PT_blob_type", Params: []string{"pos", "blob_type", "charset", "force_binary"}, Args: []Arg{{Text: "@$"}, {Text: "Blob_type::MEDIUM"}, {Child: 2, Field: ".charset"}, {Child: 2, Field: ".force_binary"}}}, + {Syms: "LONGTEXT_SYM opt_charset_with_opt_binary", Kind: ActNew, Class: "PT_blob_type", Params: []string{"pos", "blob_type", "charset", "force_binary"}, Args: []Arg{{Text: "@$"}, {Text: "Blob_type::LONG"}, {Child: 2, Field: ".charset"}, {Child: 2, Field: ".force_binary"}}}, + {Syms: "ENUM_SYM '(' string_list ')' opt_charset_with_opt_binary", Kind: ActNew, Class: "PT_enum_type", Params: []string{"pos", "interval_list", "charset", "force_binary"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5, Field: ".charset"}, {Child: 5, Field: ".force_binary"}}}, + {Syms: "SET_SYM '(' string_list ')' opt_charset_with_opt_binary", Kind: ActNew, Class: "PT_set_type", Params: []string{"pos", "interval_list", "charset", "force_binary"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5, Field: ".charset"}, {Child: 5, Field: ".force_binary"}}}, + {Syms: "LONG_SYM opt_charset_with_opt_binary", Kind: ActNew, Class: "PT_blob_type", Params: []string{"pos", "blob_type", "charset", "force_binary"}, Args: []Arg{{Text: "@$"}, {Text: "Blob_type::MEDIUM"}, {Child: 2, Field: ".charset"}, {Child: 2, Field: ".force_binary"}}}, + {Syms: "SERIAL_SYM", Kind: ActNew, Class: "PT_serial_type", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "JSON_SYM", Kind: ActNew, Class: "PT_json_type", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + }, + 230: { // spatial_type + {Syms: "GEOMETRY_SYM", Kind: ActNew, Class: "PT_spacial_type", Params: []string{"pos", "geo_type"}, Args: []Arg{{Text: "@$"}, {Text: "Field::GEOM_GEOMETRY"}}}, + {Syms: "GEOMETRYCOLLECTION_SYM", Kind: ActNew, Class: "PT_spacial_type", Params: []string{"pos", "geo_type"}, Args: []Arg{{Text: "@$"}, {Text: "Field::GEOM_GEOMETRYCOLLECTION"}}}, + {Syms: "POINT_SYM", Kind: ActNew, Class: "PT_spacial_type", Params: []string{"pos", "geo_type"}, Args: []Arg{{Text: "@$"}, {Text: "Field::GEOM_POINT"}}}, + {Syms: "MULTIPOINT_SYM", Kind: ActNew, Class: "PT_spacial_type", Params: []string{"pos", "geo_type"}, Args: []Arg{{Text: "@$"}, {Text: "Field::GEOM_MULTIPOINT"}}}, + {Syms: "LINESTRING_SYM", Kind: ActNew, Class: "PT_spacial_type", Params: []string{"pos", "geo_type"}, Args: []Arg{{Text: "@$"}, {Text: "Field::GEOM_LINESTRING"}}}, + {Syms: "MULTILINESTRING_SYM", Kind: ActNew, Class: "PT_spacial_type", Params: []string{"pos", "geo_type"}, Args: []Arg{{Text: "@$"}, {Text: "Field::GEOM_MULTILINESTRING"}}}, + {Syms: "POLYGON_SYM", Kind: ActNew, Class: "PT_spacial_type", Params: []string{"pos", "geo_type"}, Args: []Arg{{Text: "@$"}, {Text: "Field::GEOM_POLYGON"}}}, + {Syms: "MULTIPOLYGON_SYM", Kind: ActNew, Class: "PT_spacial_type", Params: []string{"pos", "geo_type"}, Args: []Arg{{Text: "@$"}, {Text: "Field::GEOM_MULTIPOLYGON"}}}, + }, + 231: { // nchar + {Syms: "NCHAR_SYM", Kind: ActDefault}, + {Syms: "NATIONAL_SYM CHAR_SYM", Kind: ActDefault}, + }, + 232: { // varchar + {Syms: "CHAR_SYM VARYING", Kind: ActDefault}, + {Syms: "VARCHAR_SYM", Kind: ActDefault}, + }, + 233: { // nvarchar + {Syms: "NATIONAL_SYM VARCHAR_SYM", Kind: ActDefault}, + {Syms: "NVARCHAR_SYM", Kind: ActDefault}, + {Syms: "NCHAR_SYM VARCHAR_SYM", Kind: ActDefault}, + {Syms: "NATIONAL_SYM CHAR_SYM VARYING", Kind: ActDefault}, + {Syms: "NCHAR_SYM VARYING", Kind: ActDefault}, + }, + 234: { // int_type + {Syms: "INT_SYM", Kind: ActConst, Const: "Int_type::INT"}, + {Syms: "TINYINT_SYM", Kind: ActConst, Const: "Int_type::TINYINT"}, + {Syms: "SMALLINT_SYM", Kind: ActConst, Const: "Int_type::SMALLINT"}, + {Syms: "MEDIUMINT_SYM", Kind: ActConst, Const: "Int_type::MEDIUMINT"}, + {Syms: "BIGINT_SYM", Kind: ActConst, Const: "Int_type::BIGINT"}, + }, + 235: { // real_type + {Syms: "REAL_SYM", Kind: ActUnknown}, + {Syms: "DOUBLE_SYM opt_PRECISION", Kind: ActConst, Const: "Numeric_type::DOUBLE"}, + }, + 236: { // opt_PRECISION + {Syms: "", Kind: ActDefault}, + {Syms: "PRECISION", Kind: ActDefault}, + }, + 237: { // numeric_type + {Syms: "FLOAT_SYM", Kind: ActConst, Const: "Numeric_type::FLOAT"}, + {Syms: "DECIMAL_SYM", Kind: ActConst, Const: "Numeric_type::DECIMAL"}, + {Syms: "NUMERIC_SYM", Kind: ActConst, Const: "Numeric_type::DECIMAL"}, + {Syms: "FIXED_SYM", Kind: ActConst, Const: "Numeric_type::DECIMAL"}, + }, + 238: { // standard_float_options + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "length", Arg: Arg{Text: "nullptr"}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "field_length", Kind: ActStruct, Fields: []Field{{Name: "length", Arg: Arg{Child: 1}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + }, + 239: { // float_options + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "length", Arg: Arg{Text: "nullptr"}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "field_length", Kind: ActStruct, Fields: []Field{{Name: "length", Arg: Arg{Child: 1}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "precision", Kind: ActDefault}, + }, + 240: { // precision + {Syms: "'(' NUM ',' NUM ')'", Kind: ActStruct, Fields: []Field{{Name: "length", Arg: Arg{Child: 2, Field: ".str"}}, {Name: "dec", Arg: Arg{Child: 4, Field: ".str"}}}}, + }, + 241: { // type_datetime_precision + {Syms: "", Kind: ActEmpty}, + {Syms: "'(' NUM ')'", Kind: ActPass, Args: []Arg{{Child: 2, Field: ".str"}}}, + }, + 242: { // func_datetime_precision + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "'(' ')'", Kind: ActConst, Const: "0"}, + {Syms: "'(' NUM ')'", Kind: ActNumber, Args: []Arg{{Child: 2}}}, + }, + 243: { // field_options + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "field_opt_list", Kind: ActDefault}, + }, + 244: { // field_opt_list + {Syms: "field_opt_list field_option", Kind: ActFlags, Args: []Arg{{Child: 1}, {Child: 2}}}, + {Syms: "field_option", Kind: ActDefault}, + }, + 245: { // field_option + {Syms: "SIGNED_SYM", Kind: ActConst, Const: "0"}, + {Syms: "UNSIGNED_SYM", Kind: ActConst, Const: "UNSIGNED_FLAG"}, + {Syms: "ZEROFILL_SYM", Kind: ActConst, Const: "ZEROFILL_FLAG"}, + }, + 246: { // field_length + {Syms: "'(' LONG_NUM ')'", Kind: ActPass, Args: []Arg{{Child: 2, Field: ".str"}}}, + {Syms: "'(' ULONGLONG_NUM ')'", Kind: ActPass, Args: []Arg{{Child: 2, Field: ".str"}}}, + {Syms: "'(' DECIMAL_NUM ')'", Kind: ActPass, Args: []Arg{{Child: 2, Field: ".str"}}}, + {Syms: "'(' NUM ')'", Kind: ActPass, Args: []Arg{{Child: 2, Field: ".str"}}}, + }, + 247: { // opt_field_length + {Syms: "", Kind: ActEmpty}, + {Syms: "field_length", Kind: ActDefault}, + }, + 248: { // opt_precision + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "length", Arg: Arg{Text: "nullptr"}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "precision", Kind: ActDefault}, + }, + 249: { // opt_column_attribute_list + {Syms: "", Kind: ActEmpty}, + {Syms: "column_attribute_list", Kind: ActDefault}, + }, + 250: { // column_attribute_list + {Syms: "column_attribute_list column_attribute", Kind: ActUnknown}, + {Syms: "column_attribute", Kind: ActListNew, Class: "Mem_root_array", Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 1}}}, + }, + 251: { // column_attribute + {Syms: "NULL_SYM", Kind: ActNew, Class: "PT_null_column_attr", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "not NULL_SYM", Kind: ActNew, Class: "PT_not_null_column_attr", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "not SECONDARY_SYM", Kind: ActNew, Class: "PT_secondary_column_attr", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "DEFAULT_SYM now_or_signed_literal", Kind: ActNew, Class: "PT_default_column_attr", Params: []string{"pos", "item"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "DEFAULT_SYM '(' expr ')'", Kind: ActNew, Class: "PT_generated_default_val_column_attr", Params: []string{"pos", "expr"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "ON_SYM UPDATE_SYM now", Kind: ActNew, Class: "PT_on_update_column_attr", Params: []string{"pos", "precision"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "AUTO_INC", Kind: ActNew, Class: "PT_auto_increment_column_attr", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "SERIAL_SYM DEFAULT_SYM VALUE_SYM", Kind: ActNew, Class: "PT_serial_default_value_column_attr", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "opt_primary KEY_SYM", Kind: ActNew, Class: "PT_primary_key_column_attr", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "UNIQUE_SYM", Kind: ActNew, Class: "PT_unique_key_column_attr", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "UNIQUE_SYM KEY_SYM", Kind: ActNew, Class: "PT_unique_key_column_attr", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "COMMENT_SYM TEXT_STRING_sys", Kind: ActNew, Class: "PT_comment_column_attr", Params: []string{"pos", "comment"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "COLLATE_SYM collation_name", Kind: ActNew, Class: "PT_collate_column_attr", Params: []string{"pos", "collation"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "COLUMN_FORMAT_SYM column_format", Kind: ActNew, Class: "PT_column_format_column_attr", Params: []string{"pos", "format"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "STORAGE_SYM storage_media", Kind: ActNew, Class: "PT_storage_media_column_attr", Params: []string{"pos", "media"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "SRID_SYM real_ulonglong_num", Kind: ActNew, Class: "PT_srid_column_attr", Params: []string{"pos", "srid"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "opt_constraint_name check_constraint", Kind: ActNew, Class: "PT_check_constraint_column_attr", Params: []string{"pos", "name", "expr"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}}}, + {Syms: "constraint_enforcement", Kind: ActNew, Class: "PT_constraint_enforcement_attr", Params: []string{"pos", "enforced"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "ENGINE_ATTRIBUTE_SYM opt_equal json_attribute", Kind: ActNew, Class: "make_column_engine_attribute", Params: []string{"", ""}, Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 3}}}, + {Syms: "SECONDARY_ENGINE_ATTRIBUTE_SYM opt_equal json_attribute", Kind: ActNew, Class: "make_column_secondary_engine_attribute", Params: []string{"", ""}, Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 3}}}, + {Syms: "visibility", Kind: ActNew, Class: "PT_column_visibility_attr", Params: []string{"pos", "is_visible"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + }, + 252: { // column_format + {Syms: "DEFAULT_SYM", Kind: ActConst, Const: "COLUMN_FORMAT_TYPE_DEFAULT"}, + {Syms: "FIXED_SYM", Kind: ActConst, Const: "COLUMN_FORMAT_TYPE_FIXED"}, + {Syms: "DYNAMIC_SYM", Kind: ActConst, Const: "COLUMN_FORMAT_TYPE_DYNAMIC"}, + }, + 253: { // storage_media + {Syms: "DEFAULT_SYM", Kind: ActConst, Const: "HA_SM_DEFAULT"}, + {Syms: "DISK_SYM", Kind: ActConst, Const: "HA_SM_DISK"}, + {Syms: "MEMORY_SYM", Kind: ActConst, Const: "HA_SM_MEMORY"}, + }, + 254: { // now + {Syms: "NOW_SYM func_datetime_precision", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 255: { // now_or_signed_literal + {Syms: "now", Kind: ActNew, Class: "Item_func_now_local", Params: []string{"pos", "dec"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "signed_literal_or_null", Kind: ActDefault}, + }, + 256: { // character_set + {Syms: "CHAR_SYM SET_SYM", Kind: ActDefault}, + {Syms: "CHARSET", Kind: ActDefault}, + }, + 257: { // charset_name + {Syms: "ident_or_text", Kind: ActUnknown}, + {Syms: "BINARY_SYM", Kind: ActConst, Const: "&my_charset_bin"}, + }, + 258: { // opt_load_data_charset + {Syms: "", Kind: ActEmpty}, + {Syms: "character_set charset_name", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 259: { // old_or_new_charset_name + {Syms: "ident_or_text", Kind: ActUnknown}, + {Syms: "BINARY_SYM", Kind: ActConst, Const: "&my_charset_bin"}, + }, + 260: { // old_or_new_charset_name_or_default + {Syms: "old_or_new_charset_name", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "DEFAULT_SYM", Kind: ActEmpty}, + }, + 261: { // collation_name + {Syms: "ident_or_text", Kind: ActUnknown}, + {Syms: "BINARY_SYM", Kind: ActConst, Const: "&my_charset_bin"}, + }, + 262: { // opt_collate + {Syms: "", Kind: ActEmpty}, + {Syms: "COLLATE_SYM collation_name", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 263: { // opt_default + {Syms: "", Kind: ActDefault}, + {Syms: "DEFAULT_SYM", Kind: ActDefault}, + }, + 264: { // ascii + {Syms: "ASCII_SYM", Kind: ActConst, Const: "&my_charset_latin1"}, + {Syms: "BINARY_SYM ASCII_SYM", Kind: ActConst, Const: "&my_charset_latin1_bin"}, + {Syms: "ASCII_SYM BINARY_SYM", Kind: ActConst, Const: "&my_charset_latin1_bin"}, + }, + 265: { // unicode + {Syms: "UNICODE_SYM", Kind: ActUnknown}, + {Syms: "UNICODE_SYM BINARY_SYM", Kind: ActUnknown}, + {Syms: "BINARY_SYM UNICODE_SYM", Kind: ActDefault}, + }, + 266: { // opt_charset_with_opt_binary + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "force_binary", Arg: Arg{Text: "false"}}}}, + {Syms: "ascii", Kind: ActStruct, Fields: []Field{{Name: "charset", Arg: Arg{Child: 1}}, {Name: "force_binary", Arg: Arg{Text: "false"}}}}, + {Syms: "unicode", Kind: ActStruct, Fields: []Field{{Name: "charset", Arg: Arg{Child: 1}}, {Name: "force_binary", Arg: Arg{Text: "false"}}}}, + {Syms: "BYTE_SYM", Kind: ActStruct, Fields: []Field{{Name: "charset", Arg: Arg{Text: "my_charset_bin"}}, {Name: "force_binary", Arg: Arg{Text: "false"}}}}, + {Syms: "character_set charset_name opt_bin_mod", Kind: ActUnknown}, + {Syms: "BINARY_SYM", Kind: ActStruct, Fields: []Field{{Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "force_binary", Arg: Arg{Text: "true"}}}}, + {Syms: "BINARY_SYM character_set charset_name", Kind: ActUnknown}, + }, + 267: { // opt_bin_mod + {Syms: "", Kind: ActConst, Const: "false"}, + {Syms: "BINARY_SYM", Kind: ActConst, Const: "true"}, + }, + 268: { // ws_num_codepoints + {Syms: "'(' real_ulong_num ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 269: { // opt_primary + {Syms: "", Kind: ActDefault}, + {Syms: "PRIMARY_SYM", Kind: ActDefault}, + }, + 270: { // references + {Syms: "REFERENCES table_ident opt_ref_list opt_match_clause opt_on_update_delete", Kind: ActStruct, Fields: []Field{{Name: "table_name", Arg: Arg{Child: 2}}, {Name: "reference_list", Arg: Arg{Child: 3}}, {Name: "fk_match_option", Arg: Arg{Child: 4}}, {Name: "fk_update_opt", Arg: Arg{Child: 5, Field: ".fk_update_opt"}}, {Name: "fk_delete_opt", Arg: Arg{Child: 5, Field: ".fk_delete_opt"}}}}, + }, + 271: { // opt_ref_list + {Syms: "", Kind: ActEmpty}, + {Syms: "'(' reference_list ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 272: { // reference_list + {Syms: "reference_list ',' ident", Kind: ActUnknown}, + {Syms: "ident", Kind: ActUnknown}, + }, + 273: { // opt_match_clause + {Syms: "", Kind: ActConst, Const: "FK_MATCH_UNDEF"}, + {Syms: "MATCH FULL", Kind: ActConst, Const: "FK_MATCH_FULL"}, + {Syms: "MATCH PARTIAL", Kind: ActConst, Const: "FK_MATCH_PARTIAL"}, + {Syms: "MATCH SIMPLE_SYM", Kind: ActConst, Const: "FK_MATCH_SIMPLE"}, + }, + 274: { // opt_on_update_delete + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "fk_update_opt", Arg: Arg{Text: "FK_OPTION_UNDEF"}}, {Name: "fk_delete_opt", Arg: Arg{Text: "FK_OPTION_UNDEF"}}}}, + {Syms: "ON_SYM UPDATE_SYM delete_option", Kind: ActStruct, Fields: []Field{{Name: "fk_update_opt", Arg: Arg{Child: 3}}, {Name: "fk_delete_opt", Arg: Arg{Text: "FK_OPTION_UNDEF"}}}}, + {Syms: "ON_SYM DELETE_SYM delete_option", Kind: ActStruct, Fields: []Field{{Name: "fk_update_opt", Arg: Arg{Text: "FK_OPTION_UNDEF"}}, {Name: "fk_delete_opt", Arg: Arg{Child: 3}}}}, + {Syms: "ON_SYM UPDATE_SYM delete_option ON_SYM DELETE_SYM delete_option", Kind: ActStruct, Fields: []Field{{Name: "fk_update_opt", Arg: Arg{Child: 3}}, {Name: "fk_delete_opt", Arg: Arg{Child: 6}}}}, + {Syms: "ON_SYM DELETE_SYM delete_option ON_SYM UPDATE_SYM delete_option", Kind: ActStruct, Fields: []Field{{Name: "fk_update_opt", Arg: Arg{Child: 6}}, {Name: "fk_delete_opt", Arg: Arg{Child: 3}}}}, + }, + 275: { // delete_option + {Syms: "RESTRICT", Kind: ActConst, Const: "FK_OPTION_RESTRICT"}, + {Syms: "CASCADE", Kind: ActConst, Const: "FK_OPTION_CASCADE"}, + {Syms: "SET_SYM NULL_SYM", Kind: ActConst, Const: "FK_OPTION_SET_NULL"}, + {Syms: "NO_SYM ACTION", Kind: ActConst, Const: "FK_OPTION_NO_ACTION"}, + {Syms: "SET_SYM DEFAULT_SYM", Kind: ActConst, Const: "FK_OPTION_DEFAULT"}, + }, + 276: { // constraint_key_type + {Syms: "PRIMARY_SYM KEY_SYM", Kind: ActConst, Const: "KEYTYPE_PRIMARY"}, + {Syms: "UNIQUE_SYM opt_key_or_index", Kind: ActConst, Const: "KEYTYPE_UNIQUE"}, + }, + 277: { // key_or_index + {Syms: "KEY_SYM", Kind: ActDefault}, + {Syms: "INDEX_SYM", Kind: ActDefault}, + }, + 278: { // opt_key_or_index + {Syms: "", Kind: ActDefault}, + {Syms: "key_or_index", Kind: ActDefault}, + }, + 279: { // keys_or_index + {Syms: "KEYS", Kind: ActDefault}, + {Syms: "INDEX_SYM", Kind: ActDefault}, + {Syms: "INDEXES", Kind: ActDefault}, + }, + 280: { // opt_unique + {Syms: "", Kind: ActConst, Const: "KEYTYPE_MULTIPLE"}, + {Syms: "UNIQUE_SYM", Kind: ActConst, Const: "KEYTYPE_UNIQUE"}, + }, + 281: { // opt_fulltext_index_options + {Syms: "", Kind: ActListNew}, + {Syms: "fulltext_index_options", Kind: ActDefault}, + }, + 282: { // fulltext_index_options + {Syms: "fulltext_index_option", Kind: ActListNew, Args: []Arg{{Child: 1}}}, + {Syms: "fulltext_index_options fulltext_index_option", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 2}}}, + }, + 283: { // fulltext_index_option + {Syms: "common_index_option", Kind: ActDefault}, + {Syms: "WITH PARSER_SYM IDENT_sys", Kind: ActUnknown}, + }, + 284: { // opt_spatial_index_options + {Syms: "", Kind: ActListNew}, + {Syms: "spatial_index_options", Kind: ActDefault}, + }, + 285: { // spatial_index_options + {Syms: "spatial_index_option", Kind: ActListNew, Args: []Arg{{Child: 1}}}, + {Syms: "spatial_index_options spatial_index_option", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 2}}}, + }, + 286: { // spatial_index_option + {Syms: "common_index_option", Kind: ActDefault}, + }, + 287: { // opt_index_options + {Syms: "", Kind: ActListNew}, + {Syms: "index_options", Kind: ActDefault}, + }, + 288: { // index_options + {Syms: "index_option", Kind: ActListNew, Args: []Arg{{Child: 1}}}, + {Syms: "index_options index_option", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 2}}}, + }, + 289: { // index_option + {Syms: "common_index_option", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "index_type_clause", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 290: { // common_index_option + {Syms: "KEY_BLOCK_SIZE opt_equal ulong_num", Kind: ActNew, Class: "PT_block_size", Params: []string{"pos", "option_value"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "COMMENT_SYM TEXT_STRING_sys", Kind: ActNew, Class: "PT_index_comment", Params: []string{"pos", "option_value"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "visibility", Kind: ActNew, Class: "PT_index_visibility", Params: []string{"pos", "option_value"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "ENGINE_ATTRIBUTE_SYM opt_equal json_attribute", Kind: ActNew, Class: "make_index_engine_attribute", Params: []string{"", ""}, Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 3}}}, + {Syms: "SECONDARY_ENGINE_ATTRIBUTE_SYM opt_equal json_attribute", Kind: ActNew, Class: "make_index_secondary_engine_attribute", Params: []string{"", ""}, Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 3}}}, + }, + 291: { // opt_index_name_and_type + {Syms: "opt_ident", Kind: ActStruct, Fields: []Field{{Name: "name", Arg: Arg{Child: 1}}, {Name: "type", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "opt_ident USING index_type", Kind: ActStruct, Fields: []Field{{Name: "name", Arg: Arg{Child: 1}}, {Name: "type", Arg: Arg{Text: "NEW_PTNPT_index_type(@$,$3)"}}}}, + {Syms: "ident TYPE_SYM index_type", Kind: ActStruct, Fields: []Field{{Name: "name", Arg: Arg{Child: 1}}, {Name: "type", Arg: Arg{Text: "NEW_PTNPT_index_type(@$,$3)"}}}}, + }, + 292: { // opt_index_type_clause + {Syms: "", Kind: ActEmpty}, + {Syms: "index_type_clause", Kind: ActDefault}, + }, + 293: { // index_type_clause + {Syms: "USING index_type", Kind: ActNew, Class: "PT_index_type", Params: []string{"pos", "option_value"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "TYPE_SYM index_type", Kind: ActNew, Class: "PT_index_type", Params: []string{"pos", "option_value"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + }, + 294: { // visibility + {Syms: "VISIBLE_SYM", Kind: ActConst, Const: "true"}, + {Syms: "INVISIBLE_SYM", Kind: ActConst, Const: "false"}, + }, + 295: { // index_type + {Syms: "BTREE_SYM", Kind: ActConst, Const: "HA_KEY_ALG_BTREE"}, + {Syms: "RTREE_SYM", Kind: ActConst, Const: "HA_KEY_ALG_RTREE"}, + {Syms: "HASH_SYM", Kind: ActConst, Const: "HA_KEY_ALG_HASH"}, + }, + 296: { // key_list + {Syms: "key_list ',' key_part", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + {Syms: "key_part", Kind: ActListNew, Args: []Arg{{Child: 1}}}, + }, + 297: { // key_part + {Syms: "ident opt_ordering_direction", Kind: ActNew, Class: "PT_key_part_specification", Params: []string{"pos", "column_name", "order", "prefix_length"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}, {Text: "0"}}}, + {Syms: "ident '(' NUM ')' opt_ordering_direction", Kind: ActUnknown}, + }, + 298: { // key_list_with_expression + {Syms: "key_list_with_expression ',' key_part_with_expression", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + {Syms: "key_part_with_expression", Kind: ActListNew, Args: []Arg{{Child: 1}}}, + }, + 299: { // key_part_with_expression + {Syms: "key_part", Kind: ActDefault}, + {Syms: "'(' expr ')' opt_ordering_direction", Kind: ActNew, Class: "PT_key_part_specification", Params: []string{"pos", "expression", "order"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Child: 4}}}, + }, + 300: { // opt_ident + {Syms: "", Kind: ActEmpty}, + {Syms: "ident", Kind: ActDefault}, + }, + 301: { // string_list + {Syms: "text_string", Kind: ActListNew, Args: []Arg{{Child: 1}}}, + {Syms: "string_list ',' text_string", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 302: { // alter_table_stmt + {Syms: "ALTER TABLE_SYM table_ident opt_alter_table_actions", Kind: ActNew, Class: "PT_alter_table_stmt", Params: []string{"pos", "mem_root", "table_name", "opt_actions", "algo", "lock", "validation"}, Args: []Arg{{Text: "@$"}, {Text: "YYMEM_ROOT"}, {Child: 3}, {Child: 4, Field: ".actions"}, {Child: 4, Field: ".flags.algo"}, {Child: 4, Field: ".flags.lock"}, {Child: 4, Field: ".flags.validation"}}}, + {Syms: "ALTER TABLE_SYM table_ident standalone_alter_table_action", Kind: ActNew, Class: "PT_alter_table_standalone_stmt", Params: []string{"pos", "mem_root", "table_name", "action", "algo", "lock", "validation"}, Args: []Arg{{Text: "@$"}, {Text: "YYMEM_ROOT"}, {Child: 3}, {Child: 4, Field: ".action"}, {Child: 4, Field: ".flags.algo"}, {Child: 4, Field: ".flags.lock"}, {Child: 4, Field: ".flags.validation"}}}, + }, + 303: { // alter_database_stmt + {Syms: "ALTER DATABASE ident_or_empty alter_database_options", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_ALTER_DB"}}, {Name: "name", Arg: Arg{Child: 3}}}}, + }, + 304: { // alter_procedure_stmt + {Syms: "ALTER PROCEDURE_SYM sp_name sp_a_chistics", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_ALTER_PROCEDURE"}}, {Name: "spname", Arg: Arg{Child: 3}}}}, + }, + 305: { // alter_function_stmt + {Syms: "ALTER FUNCTION_SYM sp_name sp_a_chistics", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_ALTER_FUNCTION"}}, {Name: "spname", Arg: Arg{Child: 3}}}}, + }, + 306: { // alter_view_stmt + {Syms: "ALTER view_algorithm definer_opt view_tail", Kind: ActDefault}, + {Syms: "ALTER definer_opt view_tail", Kind: ActDefault}, + }, + 307: { // alter_event_stmt + {Syms: "ALTER definer_opt EVENT_SYM sp_name ev_alter_on_schedule_completion opt_ev_rename_to opt_ev_status opt_ev_comment opt_ev_sql_stmt", Kind: ActDefault}, + }, + 308: { // alter_logfile_stmt + {Syms: "ALTER LOGFILE_SYM GROUP_SYM ident ADD lg_undofile opt_alter_logfile_group_options", Kind: ActUnknown}, + }, + 309: { // alter_tablespace_stmt + {Syms: "ALTER TABLESPACE_SYM ident ADD ts_datafile opt_alter_tablespace_options", Kind: ActUnknown}, + {Syms: "ALTER TABLESPACE_SYM ident DROP ts_datafile opt_alter_tablespace_options", Kind: ActUnknown}, + {Syms: "ALTER TABLESPACE_SYM ident RENAME TO_SYM ident", Kind: ActStruct, Fields: []Field{{Name: "m_sql_cmd", Arg: Arg{Text: "NEW_PTNSql_cmd_alter_tablespace_rename{$3,$6}"}}, {Name: "sql_command", Arg: Arg{Text: "SQLCOM_ALTER_TABLESPACE"}}}}, + {Syms: "ALTER TABLESPACE_SYM ident alter_tablespace_option_list", Kind: ActUnknown}, + }, + 310: { // alter_undo_tablespace_stmt + {Syms: "ALTER UNDO_SYM TABLESPACE_SYM ident SET_SYM undo_tablespace_state opt_undo_tablespace_options", Kind: ActUnknown}, + }, + 311: { // alter_server_stmt + {Syms: "ALTER SERVER_SYM ident_or_text OPTIONS_SYM '(' server_options_list ')'", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_ALTER_SERVER"}}, {Name: "server_options.m_server_name", Arg: Arg{Child: 3}}, {Name: "m_sql_cmd", Arg: Arg{Text: "NEW_PTNSql_cmd_alter_server(&Lex->server_options)"}}}}, + }, + 312: { // alter_user_stmt + {Syms: "alter_user_command alter_user_list require_clause connect_options opt_account_lock_password_expire_options opt_user_attribute", Kind: ActDefault}, + {Syms: "alter_user_command user_func identified_by_random_password opt_replace_password opt_retain_current_password", Kind: ActUnknown}, + {Syms: "alter_user_command user_func identified_by_password opt_replace_password opt_retain_current_password", Kind: ActUnknown}, + {Syms: "alter_user_command user_func DISCARD_SYM OLD_SYM PASSWORD", Kind: ActUnknown}, + {Syms: "alter_user_command user DEFAULT_SYM ROLE_SYM ALL", Kind: ActUnknown}, + {Syms: "alter_user_command user DEFAULT_SYM ROLE_SYM NONE_SYM", Kind: ActUnknown}, + {Syms: "alter_user_command user DEFAULT_SYM ROLE_SYM role_list", Kind: ActUnknown}, + {Syms: "alter_user_command user opt_user_registration", Kind: ActUnknown}, + {Syms: "alter_user_command user_func opt_user_registration", Kind: ActUnknown}, + }, + 313: { // opt_replace_password + {Syms: "", Kind: ActEmpty}, + {Syms: "REPLACE_SYM TEXT_STRING_password", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 314: { // alter_resource_group_stmt + {Syms: "ALTER RESOURCE_SYM GROUP_SYM ident opt_resource_group_vcpu_list opt_resource_group_priority opt_resource_group_enable_disable opt_force", Kind: ActNew, Class: "PT_alter_resource_group", Params: []string{"pos", "name", "cpu_list", "opt_priority", "enable", "force"}, Args: []Arg{{Text: "@$"}, {Child: 4}, {Child: 5}, {Child: 6}, {Child: 7}, {Child: 8}}}, + }, + 315: { // alter_user_command + {Syms: "ALTER USER if_exists", Kind: ActUnknown}, + }, + 316: { // opt_user_attribute + {Syms: "", Kind: ActDefault}, + {Syms: "ATTRIBUTE_SYM TEXT_STRING_literal", Kind: ActStruct, Fields: []Field{{Name: "alter_user_attribute", Arg: Arg{Text: "enum_alter_user_attribute::ALTER_USER_ATTRIBUTE"}}, {Name: "alter_user_comment_text", Arg: Arg{Child: 2}}}}, + {Syms: "COMMENT_SYM TEXT_STRING_literal", Kind: ActStruct, Fields: []Field{{Name: "alter_user_attribute", Arg: Arg{Text: "enum_alter_user_attribute::ALTER_USER_COMMENT"}}, {Name: "alter_user_comment_text", Arg: Arg{Child: 2}}}}, + }, + 317: { // opt_account_lock_password_expire_options + {Syms: "", Kind: ActDefault}, + {Syms: "opt_account_lock_password_expire_option_list", Kind: ActDefault}, + }, + 318: { // opt_account_lock_password_expire_option_list + {Syms: "opt_account_lock_password_expire_option", Kind: ActDefault}, + {Syms: "opt_account_lock_password_expire_option_list opt_account_lock_password_expire_option", Kind: ActDefault}, + }, + 319: { // opt_account_lock_password_expire_option + {Syms: "ACCOUNT_SYM UNLOCK_SYM", Kind: ActDefault}, + {Syms: "ACCOUNT_SYM LOCK_SYM", Kind: ActDefault}, + {Syms: "PASSWORD EXPIRE_SYM", Kind: ActDefault}, + {Syms: "PASSWORD EXPIRE_SYM INTERVAL_SYM real_ulong_num DAY_SYM", Kind: ActStruct, Fields: []Field{{Name: "alter_password.expire_after_days", Arg: Arg{Child: 4}}, {Name: "alter_password.update_password_expired_column", Arg: Arg{Text: "false"}}, {Name: "alter_password.update_password_expired_fields", Arg: Arg{Text: "true"}}, {Name: "alter_password.use_default_password_lifetime", Arg: Arg{Text: "false"}}}}, + {Syms: "PASSWORD EXPIRE_SYM NEVER_SYM", Kind: ActDefault}, + {Syms: "PASSWORD EXPIRE_SYM DEFAULT_SYM", Kind: ActDefault}, + {Syms: "PASSWORD HISTORY_SYM real_ulong_num", Kind: ActStruct, Fields: []Field{{Name: "alter_password.password_history_length", Arg: Arg{Child: 3}}, {Name: "alter_password.update_password_history", Arg: Arg{Text: "true"}}, {Name: "alter_password.use_default_password_history", Arg: Arg{Text: "false"}}}}, + {Syms: "PASSWORD HISTORY_SYM DEFAULT_SYM", Kind: ActDefault}, + {Syms: "PASSWORD REUSE_SYM INTERVAL_SYM real_ulong_num DAY_SYM", Kind: ActStruct, Fields: []Field{{Name: "alter_password.password_reuse_interval", Arg: Arg{Child: 4}}, {Name: "alter_password.update_password_reuse_interval", Arg: Arg{Text: "true"}}, {Name: "alter_password.use_default_password_reuse_interval", Arg: Arg{Text: "false"}}}}, + {Syms: "PASSWORD REUSE_SYM INTERVAL_SYM DEFAULT_SYM", Kind: ActDefault}, + {Syms: "PASSWORD REQUIRE_SYM CURRENT_SYM", Kind: ActDefault}, + {Syms: "PASSWORD REQUIRE_SYM CURRENT_SYM DEFAULT_SYM", Kind: ActDefault}, + {Syms: "PASSWORD REQUIRE_SYM CURRENT_SYM OPTIONAL_SYM", Kind: ActDefault}, + {Syms: "FAILED_LOGIN_ATTEMPTS_SYM real_ulong_num", Kind: ActStruct, Fields: []Field{{Name: "alter_password.update_failed_login_attempts", Arg: Arg{Text: "true"}}, {Name: "alter_password.failed_login_attempts", Arg: Arg{Child: 2}}}}, + {Syms: "PASSWORD_LOCK_TIME_SYM real_ulong_num", Kind: ActStruct, Fields: []Field{{Name: "alter_password.update_password_lock_time", Arg: Arg{Text: "true"}}, {Name: "alter_password.password_lock_time", Arg: Arg{Child: 2}}}}, + {Syms: "PASSWORD_LOCK_TIME_SYM UNBOUNDED_SYM", Kind: ActDefault}, + }, + 320: { // connect_options + {Syms: "", Kind: ActDefault}, + {Syms: "WITH connect_option_list", Kind: ActDefault}, + }, + 321: { // connect_option_list + {Syms: "connect_option_list connect_option", Kind: ActDefault}, + {Syms: "connect_option", Kind: ActDefault}, + }, + 322: { // connect_option + {Syms: "MAX_QUERIES_PER_HOUR ulong_num", Kind: ActStruct, Fields: []Field{{Name: "mqh.questions", Arg: Arg{Child: 2}}, {Name: "mqh.specified_limits", Arg: Arg{Text: "USER_RESOURCES::QUERIES_PER_HOUR"}}}}, + {Syms: "MAX_UPDATES_PER_HOUR ulong_num", Kind: ActStruct, Fields: []Field{{Name: "mqh.updates", Arg: Arg{Child: 2}}, {Name: "mqh.specified_limits", Arg: Arg{Text: "USER_RESOURCES::UPDATES_PER_HOUR"}}}}, + {Syms: "MAX_CONNECTIONS_PER_HOUR ulong_num", Kind: ActStruct, Fields: []Field{{Name: "mqh.conn_per_hour", Arg: Arg{Child: 2}}, {Name: "mqh.specified_limits", Arg: Arg{Text: "USER_RESOURCES::CONNECTIONS_PER_HOUR"}}}}, + {Syms: "MAX_USER_CONNECTIONS_SYM ulong_num", Kind: ActStruct, Fields: []Field{{Name: "mqh.user_conn", Arg: Arg{Child: 2}}, {Name: "mqh.specified_limits", Arg: Arg{Text: "USER_RESOURCES::USER_CONNECTIONS"}}}}, + }, + 323: { // user_func + {Syms: "USER '(' ')'", Kind: ActUnknown}, + }, + 324: { // ev_alter_on_schedule_completion + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "ON_SYM SCHEDULE_SYM ev_schedule_time", Kind: ActConst, Const: "1"}, + {Syms: "ev_on_completion", Kind: ActConst, Const: "1"}, + {Syms: "ON_SYM SCHEDULE_SYM ev_schedule_time ev_on_completion", Kind: ActConst, Const: "1"}, + }, + 325: { // opt_ev_rename_to + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "RENAME TO_SYM sp_name", Kind: ActUnknown}, + }, + 326: { // opt_ev_sql_stmt + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "DO_SYM ev_sql_stmt", Kind: ActConst, Const: "1"}, + }, + 327: { // ident_or_empty + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "str", Arg: Arg{Text: "nullptr"}}, {Name: "length", Arg: Arg{Text: "0"}}}}, + {Syms: "ident", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 328: { // opt_alter_table_actions + {Syms: "opt_alter_command_list", Kind: ActDefault}, + {Syms: "opt_alter_command_list alter_table_partition_options", Kind: ActUnknown}, + }, + 329: { // standalone_alter_table_action + {Syms: "standalone_alter_commands", Kind: ActStruct, Fields: []Field{{Name: "action", Arg: Arg{Child: 1}}}}, + {Syms: "alter_commands_modifier_list ',' standalone_alter_commands", Kind: ActStruct, Fields: []Field{{Name: "flags", Arg: Arg{Child: 1}}, {Name: "action", Arg: Arg{Child: 3}}}}, + }, + 330: { // alter_table_partition_options + {Syms: "partition_clause", Kind: ActNew, Class: "PT_alter_table_partition_by", Params: []string{"pos", "partition"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "REMOVE_SYM PARTITIONING_SYM", Kind: ActNew, Class: "PT_alter_table_remove_partitioning", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + }, + 331: { // opt_alter_command_list + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "actions", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "alter_commands_modifier_list", Kind: ActStruct, Fields: []Field{{Name: "flags", Arg: Arg{Child: 1}}, {Name: "actions", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "alter_list", Kind: ActDefault}, + {Syms: "alter_commands_modifier_list ',' alter_list", Kind: ActUnknown}, + }, + 332: { // standalone_alter_commands + {Syms: "DISCARD_SYM TABLESPACE_SYM", Kind: ActNew, Class: "PT_alter_table_discard_tablespace", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "IMPORT TABLESPACE_SYM", Kind: ActNew, Class: "PT_alter_table_import_tablespace", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "ADD PARTITION_SYM opt_no_write_to_binlog", Kind: ActNew, Class: "PT_alter_table_add_partition", Params: []string{"pos", "no_write_to_binlog"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "ADD PARTITION_SYM opt_no_write_to_binlog '(' part_def_list ')'", Kind: ActNew, Class: "PT_alter_table_add_partition_def_list", Params: []string{"pos", "no_write_to_binlog", "def_list"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "ADD PARTITION_SYM opt_no_write_to_binlog PARTITIONS_SYM real_ulong_num", Kind: ActNew, Class: "PT_alter_table_add_partition_num", Params: []string{"pos", "no_write_to_binlog", "num_parts"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "DROP PARTITION_SYM ident_string_list", Kind: ActNew, Class: "PT_alter_table_drop_partition", Params: []string{"pos", "partitions"}, Args: []Arg{{Text: "@$"}, {Text: "*$3"}}}, + {Syms: "REBUILD_SYM PARTITION_SYM opt_no_write_to_binlog all_or_alt_part_name_list", Kind: ActNew, Class: "PT_alter_table_rebuild_partition", Params: []string{"pos", "no_write_to_binlog", "opt_partition_list"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 4}}}, + {Syms: "OPTIMIZE PARTITION_SYM opt_no_write_to_binlog all_or_alt_part_name_list", Kind: ActNew, Class: "PT_alter_table_optimize_partition", Params: []string{"pos", "no_write_to_binlog", "opt_partition_list"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 4}}}, + {Syms: "ANALYZE_SYM PARTITION_SYM opt_no_write_to_binlog all_or_alt_part_name_list", Kind: ActNew, Class: "PT_alter_table_analyze_partition", Params: []string{"pos", "no_write_to_binlog", "opt_partition_list"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 4}}}, + {Syms: "CHECK_SYM PARTITION_SYM all_or_alt_part_name_list opt_mi_check_types", Kind: ActNew, Class: "PT_alter_table_check_partition", Params: []string{"pos", "opt_partition_list", "flags", "sql_flags"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 4, Field: ".flags"}, {Child: 4, Field: ".sql_flags"}}}, + {Syms: "REPAIR PARTITION_SYM opt_no_write_to_binlog all_or_alt_part_name_list opt_mi_repair_types", Kind: ActNew, Class: "PT_alter_table_repair_partition", Params: []string{"pos", "no_write_to_binlog", "opt_partition_list", "flags", "sql_flags"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 4}, {Child: 5, Field: ".flags"}, {Child: 5, Field: ".sql_flags"}}}, + {Syms: "COALESCE PARTITION_SYM opt_no_write_to_binlog real_ulong_num", Kind: ActNew, Class: "PT_alter_table_coalesce_partition", Params: []string{"pos", "no_write_to_binlog", "num_parts"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 4}}}, + {Syms: "TRUNCATE_SYM PARTITION_SYM all_or_alt_part_name_list", Kind: ActNew, Class: "PT_alter_table_truncate_partition", Params: []string{"pos", "opt_partition_list"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "REORGANIZE_SYM PARTITION_SYM opt_no_write_to_binlog", Kind: ActNew, Class: "PT_alter_table_reorganize_partition", Params: []string{"pos", "no_write_to_binlog"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "REORGANIZE_SYM PARTITION_SYM opt_no_write_to_binlog ident_string_list INTO '(' part_def_list ')'", Kind: ActNew, Class: "PT_alter_table_reorganize_partition_into", Params: []string{"pos", "no_write_to_binlog", "partition_names", "into"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "*$4"}, {Child: 7}}}, + {Syms: "EXCHANGE_SYM PARTITION_SYM ident WITH TABLE_SYM table_ident opt_with_validation", Kind: ActNew, Class: "PT_alter_table_exchange_partition", Params: []string{"pos", "partition_name", "table_name", "validation"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 6}, {Child: 7}}}, + {Syms: "DISCARD_SYM PARTITION_SYM all_or_alt_part_name_list TABLESPACE_SYM", Kind: ActNew, Class: "PT_alter_table_discard_partition_tablespace", Params: []string{"pos", "opt_partition_list"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "IMPORT PARTITION_SYM all_or_alt_part_name_list TABLESPACE_SYM", Kind: ActNew, Class: "PT_alter_table_import_partition_tablespace", Params: []string{"pos", "opt_partition_list"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "SECONDARY_LOAD_SYM opt_use_partition", Kind: ActNew, Class: "PT_alter_table_secondary_load", Params: []string{"pos", "opt_use_partition"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "SECONDARY_UNLOAD_SYM opt_use_partition", Kind: ActNew, Class: "PT_alter_table_secondary_unload", Params: []string{"pos", "opt_use_partition"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + }, + 333: { // opt_with_validation + {Syms: "", Kind: ActConst, Const: "Alter_info::ALTER_VALIDATION_DEFAULT"}, + {Syms: "with_validation", Kind: ActDefault}, + }, + 334: { // with_validation + {Syms: "WITH VALIDATION_SYM", Kind: ActConst, Const: "Alter_info::ALTER_WITH_VALIDATION"}, + {Syms: "WITHOUT_SYM VALIDATION_SYM", Kind: ActConst, Const: "Alter_info::ALTER_WITHOUT_VALIDATION"}, + }, + 335: { // all_or_alt_part_name_list + {Syms: "ALL", Kind: ActEmpty}, + {Syms: "ident_string_list", Kind: ActDefault}, + }, + 336: { // alter_list + {Syms: "alter_list_item", Kind: ActUnknown}, + {Syms: "alter_list ',' alter_list_item", Kind: ActUnknown}, + {Syms: "alter_list ',' alter_commands_modifier", Kind: ActUnknown}, + {Syms: "create_table_options_space_separated", Kind: ActStruct, Fields: []Field{{Name: "actions", Arg: Arg{Child: 1}}}}, + {Syms: "alter_list ',' create_table_options_space_separated", Kind: ActUnknown}, + }, + 337: { // alter_commands_modifier_list + {Syms: "alter_commands_modifier", Kind: ActDefault}, + {Syms: "alter_commands_modifier_list ',' alter_commands_modifier", Kind: ActUnknown}, + }, + 338: { // alter_list_item + {Syms: "ADD opt_column ident field_def opt_references opt_place", Kind: ActNew, Class: "PT_alter_table_add_column", Params: []string{"pos", "field_ident", "field_def", "opt_column_constraint", "opt_place"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 4}, {Child: 5}, {Child: 6}}}, + {Syms: "ADD opt_column '(' table_element_list ')'", Kind: ActNew, Class: "PT_alter_table_add_columns", Params: []string{"pos", "columns"}, Args: []Arg{{Text: "@$"}, {Child: 4}}}, + {Syms: "ADD table_constraint_def", Kind: ActNew, Class: "PT_alter_table_add_constraint", Params: []string{"pos", "constraint"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "CHANGE opt_column ident ident field_def opt_place", Kind: ActNew, Class: "PT_alter_table_change_column", Params: []string{"pos", "old_name", "new_name", "field_def", "opt_place"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 4}, {Child: 5}, {Child: 6}}}, + {Syms: "MODIFY_SYM opt_column ident field_def opt_place", Kind: ActNew, Class: "PT_alter_table_change_column", Params: []string{"pos", "name", "field_def", "opt_place"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 4}, {Child: 5}}}, + {Syms: "DROP opt_column ident opt_restrict", Kind: ActNew, Class: "PT_alter_table_drop_column", Params: []string{"pos", "name"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".str"}}}, + {Syms: "DROP FOREIGN KEY_SYM ident", Kind: ActNew, Class: "PT_alter_table_drop_foreign_key", Params: []string{"pos", "name"}, Args: []Arg{{Text: "@$"}, {Child: 4, Field: ".str"}}}, + {Syms: "DROP PRIMARY_SYM KEY_SYM", Kind: ActNew, Class: "PT_alter_table_drop_key", Params: []string{"pos", "name"}, Args: []Arg{{Text: "@$"}, {Text: "primary_key_name"}}}, + {Syms: "DROP key_or_index ident", Kind: ActNew, Class: "PT_alter_table_drop_key", Params: []string{"pos", "name"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".str"}}}, + {Syms: "DROP CHECK_SYM ident", Kind: ActNew, Class: "PT_alter_table_drop_check_constraint", Params: []string{"pos", "name"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".str"}}}, + {Syms: "DROP CONSTRAINT ident", Kind: ActNew, Class: "PT_alter_table_drop_constraint", Params: []string{"pos", "name"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".str"}}}, + {Syms: "DISABLE_SYM KEYS", Kind: ActNew, Class: "PT_alter_table_enable_keys", Params: []string{"pos", "enable"}, Args: []Arg{{Text: "@$"}, {Text: "false"}}}, + {Syms: "ENABLE_SYM KEYS", Kind: ActNew, Class: "PT_alter_table_enable_keys", Params: []string{"pos", "enable"}, Args: []Arg{{Text: "@$"}, {Text: "true"}}}, + {Syms: "ALTER opt_column ident SET_SYM DEFAULT_SYM signed_literal_or_null", Kind: ActNew, Class: "PT_alter_table_set_default", Params: []string{"pos", "col_name", "opt_default_expr"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".str"}, {Child: 6}}}, + {Syms: "ALTER opt_column ident SET_SYM DEFAULT_SYM '(' expr ')'", Kind: ActNew, Class: "PT_alter_table_set_default", Params: []string{"pos", "col_name", "opt_default_expr"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".str"}, {Child: 7}}}, + {Syms: "ALTER opt_column ident DROP DEFAULT_SYM", Kind: ActNew, Class: "PT_alter_table_set_default", Params: []string{"pos", "col_name", "opt_default_expr"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".str"}, {Text: "nullptr"}}}, + {Syms: "ALTER opt_column ident SET_SYM visibility", Kind: ActNew, Class: "PT_alter_table_column_visibility", Params: []string{"pos", "col_name", "is_visible"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".str"}, {Child: 5}}}, + {Syms: "ALTER INDEX_SYM ident visibility", Kind: ActNew, Class: "PT_alter_table_index_visible", Params: []string{"pos", "name", "visible"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".str"}, {Child: 4}}}, + {Syms: "ALTER CHECK_SYM ident constraint_enforcement", Kind: ActNew, Class: "PT_alter_table_enforce_check_constraint", Params: []string{"pos", "name", "is_enforced"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".str"}, {Child: 4}}}, + {Syms: "ALTER CONSTRAINT ident constraint_enforcement", Kind: ActNew, Class: "PT_alter_table_enforce_constraint", Params: []string{"pos", "name", "is_enforced"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".str"}, {Child: 4}}}, + {Syms: "RENAME opt_to table_ident", Kind: ActNew, Class: "PT_alter_table_rename", Params: []string{"pos", "ident"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "RENAME key_or_index ident TO_SYM ident", Kind: ActNew, Class: "PT_alter_table_rename_key", Params: []string{"pos", "from", "to"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".str"}, {Child: 5, Field: ".str"}}}, + {Syms: "RENAME COLUMN_SYM ident TO_SYM ident", Kind: ActNew, Class: "PT_alter_table_rename_column", Params: []string{"pos", "from", "to"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".str"}, {Child: 5, Field: ".str"}}}, + {Syms: "CONVERT_SYM TO_SYM character_set charset_name opt_collate", Kind: ActNew, Class: "PT_alter_table_convert_to_charset", Params: []string{"pos", "charset", "opt_collation"}, Args: []Arg{{Text: "@$"}, {Child: 4}, {Child: 5}}}, + {Syms: "CONVERT_SYM TO_SYM character_set DEFAULT_SYM opt_collate", Kind: ActNew, Class: "PT_alter_table_convert_to_charset", Params: []string{"pos", "charset", "opt_collation"}, Args: []Arg{{Text: "@$"}, {Text: "YYTHD->variables.collation_database"}, {Text: "$5?$5:YYTHD->variables.collation_database"}}}, + {Syms: "FORCE_SYM", Kind: ActNew, Class: "PT_alter_table_force", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "ORDER_SYM BY alter_order_list", Kind: ActNew, Class: "PT_alter_table_order", Params: []string{"pos", "order"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 339: { // alter_commands_modifier + {Syms: "alter_algorithm_option", Kind: ActStruct, Fields: []Field{{Name: "algo", Arg: Arg{Child: 1}}}}, + {Syms: "alter_lock_option", Kind: ActStruct, Fields: []Field{{Name: "lock", Arg: Arg{Child: 1}}}}, + {Syms: "with_validation", Kind: ActStruct, Fields: []Field{{Name: "validation", Arg: Arg{Child: 1}}}}, + }, + 340: { // opt_index_lock_and_algorithm + {Syms: "", Kind: ActDefault}, + {Syms: "alter_lock_option", Kind: ActStruct, Fields: []Field{{Name: "lock", Arg: Arg{Child: 1}}}}, + {Syms: "alter_algorithm_option", Kind: ActStruct, Fields: []Field{{Name: "algo", Arg: Arg{Child: 1}}}}, + {Syms: "alter_lock_option alter_algorithm_option", Kind: ActStruct, Fields: []Field{{Name: "lock", Arg: Arg{Child: 1}}, {Name: "algo", Arg: Arg{Child: 2}}}}, + {Syms: "alter_algorithm_option alter_lock_option", Kind: ActStruct, Fields: []Field{{Name: "algo", Arg: Arg{Child: 1}}, {Name: "lock", Arg: Arg{Child: 2}}}}, + }, + 341: { // alter_algorithm_option + {Syms: "ALGORITHM_SYM opt_equal alter_algorithm_option_value", Kind: ActPass, Args: []Arg{{Child: 3}}}, + }, + 342: { // alter_algorithm_option_value + {Syms: "DEFAULT_SYM", Kind: ActConst, Const: "Alter_info::ALTER_TABLE_ALGORITHM_DEFAULT"}, + {Syms: "ident", Kind: ActUnknown}, + }, + 343: { // alter_lock_option + {Syms: "LOCK_SYM opt_equal alter_lock_option_value", Kind: ActPass, Args: []Arg{{Child: 3}}}, + }, + 344: { // alter_lock_option_value + {Syms: "DEFAULT_SYM", Kind: ActConst, Const: "Alter_info::ALTER_TABLE_LOCK_DEFAULT"}, + {Syms: "ident", Kind: ActUnknown}, + }, + 345: { // opt_column + {Syms: "", Kind: ActDefault}, + {Syms: "COLUMN_SYM", Kind: ActDefault}, + }, + 346: { // opt_ignore + {Syms: "", Kind: ActConst, Const: "false"}, + {Syms: "IGNORE_SYM", Kind: ActConst, Const: "true"}, + }, + 347: { // opt_restrict + {Syms: "", Kind: ActConst, Const: "DROP_DEFAULT"}, + {Syms: "RESTRICT", Kind: ActConst, Const: "DROP_RESTRICT"}, + {Syms: "CASCADE", Kind: ActConst, Const: "DROP_CASCADE"}, + }, + 348: { // opt_place + {Syms: "", Kind: ActEmpty}, + {Syms: "AFTER_SYM ident", Kind: ActPass, Args: []Arg{{Child: 2, Field: ".str"}}}, + {Syms: "FIRST_SYM", Kind: ActConst, Const: "first_keyword"}, + }, + 349: { // opt_to + {Syms: "", Kind: ActDefault}, + {Syms: "TO_SYM", Kind: ActDefault}, + {Syms: "EQ", Kind: ActDefault}, + {Syms: "AS", Kind: ActDefault}, + }, + 350: { // group_replication + {Syms: "group_replication_start opt_group_replication_start_options", Kind: ActDefault}, + {Syms: "STOP_SYM GROUP_REPLICATION", Kind: ActDefault}, + }, + 351: { // group_replication_start + {Syms: "START_SYM GROUP_REPLICATION", Kind: ActDefault}, + }, + 352: { // opt_group_replication_start_options + {Syms: "", Kind: ActDefault}, + {Syms: "group_replication_start_options", Kind: ActDefault}, + }, + 353: { // group_replication_start_options + {Syms: "group_replication_start_option", Kind: ActDefault}, + {Syms: "group_replication_start_options ',' group_replication_start_option", Kind: ActDefault}, + }, + 354: { // group_replication_start_option + {Syms: "group_replication_user", Kind: ActDefault}, + {Syms: "group_replication_password", Kind: ActDefault}, + {Syms: "group_replication_plugin_auth", Kind: ActDefault}, + }, + 355: { // group_replication_user + {Syms: "USER EQ TEXT_STRING_sys_nonewline", Kind: ActStruct, Fields: []Field{{Name: "replica_connection.user", Arg: Arg{Child: 3, Field: ".str"}}}}, + }, + 356: { // group_replication_password + {Syms: "PASSWORD EQ TEXT_STRING_sys_nonewline", Kind: ActStruct, Fields: []Field{{Name: "replica_connection.password", Arg: Arg{Child: 3, Field: ".str"}}, {Name: "contains_plaintext_password", Arg: Arg{Text: "true"}}}}, + }, + 357: { // group_replication_plugin_auth + {Syms: "DEFAULT_AUTH_SYM EQ TEXT_STRING_sys_nonewline", Kind: ActStruct, Fields: []Field{{Name: "replica_connection.plugin_auth", Arg: Arg{Child: 3, Field: ".str"}}}}, + }, + 358: { // stop_replica_stmt + {Syms: "STOP_SYM REPLICA_SYM opt_replica_thread_option_list opt_channel", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_REPLICA_STOP"}}, {Name: "type", Arg: Arg{Text: "0"}}, {Name: "replica_thd_opt", Arg: Arg{Child: 3}}}}, + }, + 359: { // start_replica_stmt + {Syms: "START_SYM REPLICA_SYM opt_replica_thread_option_list opt_replica_until opt_user_option opt_password_option opt_default_auth_option opt_plugin_dir_option opt_channel", Kind: ActDefault}, + }, + 360: { // start + {Syms: "START_SYM TRANSACTION_SYM opt_start_transaction_option_list", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_BEGIN"}}, {Name: "start_transaction_opt", Arg: Arg{Child: 3}}}}, + }, + 361: { // opt_start_transaction_option_list + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "start_transaction_option_list", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 362: { // start_transaction_option_list + {Syms: "start_transaction_option", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "start_transaction_option_list ',' start_transaction_option", Kind: ActFlags, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 363: { // start_transaction_option + {Syms: "WITH CONSISTENT_SYM SNAPSHOT_SYM", Kind: ActConst, Const: "MYSQL_START_TRANS_OPT_WITH_CONS_SNAPSHOT"}, + {Syms: "READ_SYM ONLY_SYM", Kind: ActConst, Const: "MYSQL_START_TRANS_OPT_READ_ONLY"}, + {Syms: "READ_SYM WRITE_SYM", Kind: ActConst, Const: "MYSQL_START_TRANS_OPT_READ_WRITE"}, + }, + 364: { // opt_user_option + {Syms: "", Kind: ActDefault}, + {Syms: "USER EQ TEXT_STRING_sys", Kind: ActStruct, Fields: []Field{{Name: "replica_connection.user", Arg: Arg{Child: 3, Field: ".str"}}}}, + }, + 365: { // opt_password_option + {Syms: "", Kind: ActDefault}, + {Syms: "PASSWORD EQ TEXT_STRING_sys", Kind: ActStruct, Fields: []Field{{Name: "replica_connection.password", Arg: Arg{Child: 3, Field: ".str"}}, {Name: "contains_plaintext_password", Arg: Arg{Text: "true"}}}}, + }, + 366: { // opt_default_auth_option + {Syms: "", Kind: ActDefault}, + {Syms: "DEFAULT_AUTH_SYM EQ TEXT_STRING_sys", Kind: ActStruct, Fields: []Field{{Name: "replica_connection.plugin_auth", Arg: Arg{Child: 3, Field: ".str"}}}}, + }, + 367: { // opt_plugin_dir_option + {Syms: "", Kind: ActDefault}, + {Syms: "PLUGIN_DIR_SYM EQ TEXT_STRING_sys", Kind: ActStruct, Fields: []Field{{Name: "replica_connection.plugin_dir", Arg: Arg{Child: 3, Field: ".str"}}}}, + }, + 368: { // opt_replica_thread_option_list + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "replica_thread_option_list", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 369: { // replica_thread_option_list + {Syms: "replica_thread_option", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "replica_thread_option_list ',' replica_thread_option", Kind: ActFlags, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 370: { // replica_thread_option + {Syms: "SQL_THREAD", Kind: ActConst, Const: "REPLICA_SQL"}, + {Syms: "RELAY_THREAD", Kind: ActConst, Const: "REPLICA_IO"}, + }, + 371: { // opt_replica_until + {Syms: "", Kind: ActDefault}, + {Syms: "UNTIL_SYM replica_until", Kind: ActDefault}, + }, + 372: { // replica_until + {Syms: "source_file_def", Kind: ActDefault}, + {Syms: "replica_until ',' source_file_def", Kind: ActDefault}, + {Syms: "SQL_BEFORE_GTIDS EQ TEXT_STRING_sys", Kind: ActStruct, Fields: []Field{{Name: "mi.gtid", Arg: Arg{Child: 3, Field: ".str"}}, {Name: "mi.gtid_until_condition", Arg: Arg{Text: "LEX_SOURCE_INFO::UNTIL_SQL_BEFORE_GTIDS"}}}}, + {Syms: "SQL_AFTER_GTIDS EQ TEXT_STRING_sys", Kind: ActStruct, Fields: []Field{{Name: "mi.gtid", Arg: Arg{Child: 3, Field: ".str"}}, {Name: "mi.gtid_until_condition", Arg: Arg{Text: "LEX_SOURCE_INFO::UNTIL_SQL_AFTER_GTIDS"}}}}, + {Syms: "SQL_AFTER_MTS_GAPS", Kind: ActDefault}, + }, + 373: { // checksum + {Syms: "CHECKSUM_SYM table_or_tables table_list opt_checksum_type", Kind: ActUnknown}, + }, + 374: { // opt_checksum_type + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "QUICK", Kind: ActConst, Const: "T_QUICK"}, + {Syms: "EXTENDED_SYM", Kind: ActConst, Const: "T_EXTEND"}, + }, + 375: { // repair_table_stmt + {Syms: "REPAIR opt_no_write_to_binlog table_or_tables table_list opt_mi_repair_types", Kind: ActNew, Class: "PT_repair_table_stmt", Params: []string{"pos", "mem_root", "no_write_to_binlog", "table_list", "flags", "sql_flags"}, Args: []Arg{{Text: "@$"}, {Text: "YYMEM_ROOT"}, {Child: 2}, {Child: 4}, {Child: 5, Field: ".flags"}, {Child: 5, Field: ".sql_flags"}}}, + }, + 376: { // opt_mi_repair_types + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "flags", Arg: Arg{Text: "T_MEDIUM"}}, {Name: "sql_flags", Arg: Arg{Text: "0"}}}}, + {Syms: "mi_repair_types", Kind: ActDefault}, + }, + 377: { // mi_repair_types + {Syms: "mi_repair_type", Kind: ActDefault}, + {Syms: "mi_repair_types mi_repair_type", Kind: ActStruct, Fields: []Field{{Name: "flags", Arg: Arg{Text: "$1.flags|$2.flags"}}, {Name: "sql_flags", Arg: Arg{Text: "$1.sql_flags|$2.sql_flags"}}}}, + }, + 378: { // mi_repair_type + {Syms: "QUICK", Kind: ActStruct, Fields: []Field{{Name: "flags", Arg: Arg{Text: "T_QUICK"}}, {Name: "sql_flags", Arg: Arg{Text: "0"}}}}, + {Syms: "EXTENDED_SYM", Kind: ActStruct, Fields: []Field{{Name: "flags", Arg: Arg{Text: "T_EXTEND"}}, {Name: "sql_flags", Arg: Arg{Text: "0"}}}}, + {Syms: "USE_FRM", Kind: ActStruct, Fields: []Field{{Name: "flags", Arg: Arg{Text: "0"}}, {Name: "sql_flags", Arg: Arg{Text: "TT_USEFRM"}}}}, + }, + 379: { // analyze_table_stmt + {Syms: "ANALYZE_SYM opt_no_write_to_binlog table_or_tables table_list opt_histogram", Kind: ActUnknown}, + }, + 380: { // opt_histogram_auto_update + {Syms: "", Kind: ActConst, Const: "false"}, + {Syms: "MANUAL_SYM UPDATE_SYM", Kind: ActConst, Const: "false"}, + {Syms: "AUTO_SYM UPDATE_SYM", Kind: ActConst, Const: "true"}, + }, + 381: { // opt_histogram_num_buckets + {Syms: "", Kind: ActConst, Const: "DEFAULT_NUMBER_OF_HISTOGRAM_BUCKETS"}, + {Syms: "WITH NUM BUCKETS_SYM", Kind: ActUnknown}, + }, + 382: { // opt_histogram_update_param + {Syms: "opt_histogram_num_buckets opt_histogram_auto_update", Kind: ActStruct, Fields: []Field{{Name: "num_buckets", Arg: Arg{Child: 1}}, {Name: "auto_update", Arg: Arg{Child: 2}}, {Name: "data", Arg: Arg{Text: "{nullptr,0}"}}}}, + {Syms: "USING DATA_SYM TEXT_STRING_literal", Kind: ActStruct, Fields: []Field{{Name: "num_buckets", Arg: Arg{Text: "0"}}, {Name: "auto_update", Arg: Arg{Text: "false"}}, {Name: "data", Arg: Arg{Child: 3}}}}, + }, + 383: { // opt_histogram + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "command", Arg: Arg{Text: "Sql_cmd_analyze_table::Histogram_command::NONE"}}, {Name: "columns", Arg: Arg{Text: "nullptr"}}, {Name: "param", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "UPDATE_SYM HISTOGRAM_SYM ON_SYM ident_string_list opt_histogram_update_param", Kind: ActUnknown}, + {Syms: "DROP HISTOGRAM_SYM ON_SYM ident_string_list", Kind: ActStruct, Fields: []Field{{Name: "command", Arg: Arg{Text: "Sql_cmd_analyze_table::Histogram_command::DROP_HISTOGRAM"}}, {Name: "columns", Arg: Arg{Child: 4}}, {Name: "param", Arg: Arg{Text: "nullptr"}}}}, + }, + 384: { // binlog_base64_event + {Syms: "BINLOG_SYM TEXT_STRING_sys", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_BINLOG_BASE64_EVENT"}}, {Name: "binlog_stmt_arg", Arg: Arg{Child: 2}}}}, + }, + 385: { // check_table_stmt + {Syms: "CHECK_SYM table_or_tables table_list opt_mi_check_types", Kind: ActNew, Class: "PT_check_table_stmt", Params: []string{"pos", "mem_root", "table_list", "flags", "sql_flags"}, Args: []Arg{{Text: "@$"}, {Text: "YYMEM_ROOT"}, {Child: 3}, {Child: 4, Field: ".flags"}, {Child: 4, Field: ".sql_flags"}}}, + }, + 386: { // opt_mi_check_types + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "flags", Arg: Arg{Text: "T_MEDIUM"}}, {Name: "sql_flags", Arg: Arg{Text: "0"}}}}, + {Syms: "mi_check_types", Kind: ActDefault}, + }, + 387: { // mi_check_types + {Syms: "mi_check_type", Kind: ActDefault}, + {Syms: "mi_check_type mi_check_types", Kind: ActStruct, Fields: []Field{{Name: "flags", Arg: Arg{Text: "$1.flags|$2.flags"}}, {Name: "sql_flags", Arg: Arg{Text: "$1.sql_flags|$2.sql_flags"}}}}, + }, + 388: { // mi_check_type + {Syms: "QUICK", Kind: ActStruct, Fields: []Field{{Name: "flags", Arg: Arg{Text: "T_QUICK"}}, {Name: "sql_flags", Arg: Arg{Text: "0"}}}}, + {Syms: "FAST_SYM", Kind: ActStruct, Fields: []Field{{Name: "flags", Arg: Arg{Text: "T_FAST"}}, {Name: "sql_flags", Arg: Arg{Text: "0"}}}}, + {Syms: "MEDIUM_SYM", Kind: ActStruct, Fields: []Field{{Name: "flags", Arg: Arg{Text: "T_MEDIUM"}}, {Name: "sql_flags", Arg: Arg{Text: "0"}}}}, + {Syms: "EXTENDED_SYM", Kind: ActStruct, Fields: []Field{{Name: "flags", Arg: Arg{Text: "T_EXTEND"}}, {Name: "sql_flags", Arg: Arg{Text: "0"}}}}, + {Syms: "CHANGED", Kind: ActStruct, Fields: []Field{{Name: "flags", Arg: Arg{Text: "T_CHECK_ONLY_CHANGED"}}, {Name: "sql_flags", Arg: Arg{Text: "0"}}}}, + {Syms: "FOR_SYM UPGRADE_SYM", Kind: ActStruct, Fields: []Field{{Name: "flags", Arg: Arg{Text: "0"}}, {Name: "sql_flags", Arg: Arg{Text: "TT_FOR_UPGRADE"}}}}, + }, + 389: { // optimize_table_stmt + {Syms: "OPTIMIZE opt_no_write_to_binlog table_or_tables table_list", Kind: ActNew, Class: "PT_optimize_table_stmt", Params: []string{"pos", "mem_root", "no_write_to_binlog", "table_list"}, Args: []Arg{{Text: "@$"}, {Text: "YYMEM_ROOT"}, {Child: 2}, {Child: 4}}}, + }, + 390: { // opt_no_write_to_binlog + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "NO_WRITE_TO_BINLOG", Kind: ActConst, Const: "1"}, + {Syms: "LOCAL_SYM", Kind: ActConst, Const: "1"}, + }, + 391: { // rename + {Syms: "RENAME table_or_tables table_to_table_list", Kind: ActDefault}, + {Syms: "RENAME USER rename_list", Kind: ActDefault}, + }, + 392: { // rename_list + {Syms: "user TO_SYM user", Kind: ActUnknown}, + {Syms: "rename_list ',' user TO_SYM user", Kind: ActUnknown}, + }, + 393: { // table_to_table_list + {Syms: "table_to_table", Kind: ActDefault}, + {Syms: "table_to_table_list ',' table_to_table", Kind: ActDefault}, + }, + 394: { // table_to_table + {Syms: "table_ident TO_SYM table_ident", Kind: ActDefault}, + }, + 395: { // keycache_stmt + {Syms: "CACHE_SYM INDEX_SYM keycache_list IN_SYM key_cache_name", Kind: ActNew, Class: "PT_cache_index_stmt", Params: []string{"pos", "mem_root", "tbl_index_lists", "key_cache_name"}, Args: []Arg{{Text: "@$"}, {Text: "YYMEM_ROOT"}, {Child: 3}, {Child: 5}}}, + {Syms: "CACHE_SYM INDEX_SYM table_ident adm_partition opt_cache_key_list IN_SYM key_cache_name", Kind: ActNew, Class: "PT_cache_index_partitions_stmt", Params: []string{"pos", "mem_root", "table", "partitions", "opt_key_usage_list", "key_cache_name"}, Args: []Arg{{Text: "@$"}, {Text: "YYMEM_ROOT"}, {Child: 3}, {Child: 4}, {Child: 5}, {Child: 7}}}, + }, + 396: { // keycache_list + {Syms: "assign_to_keycache", Kind: ActListNew, Class: "Mem_root_array", Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 1}}}, + {Syms: "keycache_list ',' assign_to_keycache", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 397: { // assign_to_keycache + {Syms: "table_ident opt_cache_key_list", Kind: ActNew, Class: "PT_assign_to_keycache", Params: []string{"pos", "table", "index_hints"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}}}, + }, + 398: { // key_cache_name + {Syms: "ident", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "DEFAULT_SYM", Kind: ActConst, Const: "default_key_cache_base"}, + }, + 399: { // preload_stmt + {Syms: "LOAD INDEX_SYM INTO CACHE_SYM table_ident adm_partition opt_cache_key_list opt_ignore_leaves", Kind: ActNew, Class: "PT_load_index_partitions_stmt", Params: []string{"pos", "mem_root", "table", "partitions", "opt_cache_key_list", "ignore_leaves"}, Args: []Arg{{Text: "@$"}, {Text: "YYMEM_ROOT"}, {Child: 5}, {Child: 6}, {Child: 7}, {Child: 8}}}, + {Syms: "LOAD INDEX_SYM INTO CACHE_SYM preload_list", Kind: ActNew, Class: "PT_load_index_stmt", Params: []string{"pos", "mem_root", "preload_list"}, Args: []Arg{{Text: "@$"}, {Text: "YYMEM_ROOT"}, {Child: 5}}}, + }, + 400: { // preload_list + {Syms: "preload_keys", Kind: ActListNew, Args: []Arg{{Child: 1}}}, + {Syms: "preload_list ',' preload_keys", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 401: { // preload_keys + {Syms: "table_ident opt_cache_key_list opt_ignore_leaves", Kind: ActNew, Class: "PT_preload_keys", Params: []string{"pos", "table", "opt_cache_key_list", "ignore_leaves"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}, {Child: 3}}}, + }, + 402: { // adm_partition + {Syms: "PARTITION_SYM '(' all_or_alt_part_name_list ')'", Kind: ActNew, Class: "PT_adm_partition", Params: []string{"pos", "opt_partitions"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 403: { // opt_cache_key_list + {Syms: "", Kind: ActEmpty}, + {Syms: "key_or_index '(' opt_key_usage_list ')'", Kind: ActUnknown}, + }, + 404: { // opt_ignore_leaves + {Syms: "", Kind: ActConst, Const: "false"}, + {Syms: "IGNORE_SYM LEAVES", Kind: ActConst, Const: "true"}, + }, + 405: { // select_stmt + {Syms: "query_expression", Kind: ActNew, Class: "PT_select_stmt", Params: []string{"pos", "qe"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "query_expression locking_clause_list", Kind: ActNew, Class: "PT_select_stmt", Params: []string{"pos", "qe", "into", "has_trailing_locking_clauses"}, Args: []Arg{{Text: "@$"}, {Text: "NEW_PTNPT_locking(@$,$1,$2)"}, {Text: "nullptr"}, {Text: "true"}}}, + {Syms: "select_stmt_with_into", Kind: ActDefault}, + }, + 406: { // select_stmt_with_into + {Syms: "'(' select_stmt_with_into ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + {Syms: "query_expression into_clause", Kind: ActNew, Class: "PT_select_stmt", Params: []string{"pos", "sql_command", "qe"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}}}, + {Syms: "query_expression into_clause locking_clause_list", Kind: ActNew, Class: "PT_select_stmt", Params: []string{"pos", "qe", "into", "has_trailing_locking_clauses"}, Args: []Arg{{Text: "@$"}, {Text: "NEW_PTNPT_locking(@$,$1,$3)"}, {Child: 2}, {Text: "true"}}}, + {Syms: "query_expression locking_clause_list into_clause", Kind: ActNew, Class: "PT_select_stmt", Params: []string{"pos", "sql_command", "qe"}, Args: []Arg{{Text: "@$"}, {Text: "NEW_PTNPT_locking(@$,$1,$2)"}, {Child: 3}}}, + }, + 407: { // query_expression + {Syms: "query_expression_body opt_order_clause opt_limit_clause", Kind: ActNew, Class: "PT_query_expression", Params: []string{"pos", "body", "order", "limit"}, Args: []Arg{{Text: "@$"}, {Child: 1, Field: ".body"}, {Child: 2}, {Child: 3}}}, + {Syms: "with_clause query_expression_body opt_order_clause opt_limit_clause", Kind: ActNew, Class: "PT_query_expression", Params: []string{"pos", "with_clause", "body", "order", "limit"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2, Field: ".body"}, {Child: 3}, {Child: 4}}}, + }, + 408: { // query_expression_body + {Syms: "query_primary", Kind: ActStruct, Fields: []Field{{Name: "body", Arg: Arg{Child: 1}}, {Name: "is_parenthesized", Arg: Arg{Text: "false"}}}}, + {Syms: "query_expression_parens", Kind: ActStruct, Fields: []Field{{Name: "body", Arg: Arg{Child: 1}}, {Name: "is_parenthesized", Arg: Arg{Text: "true"}}}}, + {Syms: "query_expression_body UNION_SYM union_option query_expression_body", Kind: ActStruct, Fields: []Field{{Name: "body", Arg: Arg{Text: "flatten_equal_set_ops(YYMEM_ROOT,@$,$1.body,$3,$4.body,$4.is_parenthesized)"}}, {Name: "2", Arg: Arg{Text: "false"}}}}, + {Syms: "query_expression_body EXCEPT_SYM union_option query_expression_body", Kind: ActStruct, Fields: []Field{{Name: "body", Arg: Arg{Text: "flatten_equal_set_ops(YYMEM_ROOT,@$,$1.body,$3,$4.body,$4.is_parenthesized)"}}, {Name: "2", Arg: Arg{Text: "false"}}}}, + {Syms: "query_expression_body INTERSECT_SYM union_option query_expression_body", Kind: ActStruct, Fields: []Field{{Name: "body", Arg: Arg{Text: "flatten_equal_set_ops(YYMEM_ROOT,@$,$1.body,$3,$4.body,$4.is_parenthesized)"}}, {Name: "2", Arg: Arg{Text: "false"}}}}, + }, + 409: { // query_expression_parens + {Syms: "'(' query_expression_parens ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + {Syms: "'(' query_expression_with_opt_locking_clauses ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 410: { // query_primary + {Syms: "query_specification", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "table_value_constructor", Kind: ActNew, Class: "PT_table_value_constructor", Params: []string{"pos", "row_value_list"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "explicit_table", Kind: ActUnknown}, + }, + 411: { // query_specification + {Syms: "SELECT_SYM select_options select_item_list into_clause opt_from_clause opt_where_clause opt_group_clause opt_having_clause opt_window_clause opt_qualify_clause", Kind: ActNew, Class: "PT_query_specification", Params: []string{"pos", "opt_hints", "options", "item_list", "opt_into1", "from_clause", "opt_where_clause", "opt_group_clause", "opt_having_clause", "opt_window_clause", "opt_qualify_clause", "implicit_from_clause"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}, {Child: 3}, {Child: 4}, {Child: 5}, {Child: 6}, {Child: 7}, {Child: 8}, {Child: 9}, {Child: 10}, {Text: "@5.raw.is_empty()"}}}, + {Syms: "SELECT_SYM select_options select_item_list opt_from_clause opt_where_clause opt_group_clause opt_having_clause opt_window_clause opt_qualify_clause", Kind: ActNew, Class: "PT_query_specification", Params: []string{"pos", "opt_hints", "options", "item_list", "opt_into1", "from_clause", "opt_where_clause", "opt_group_clause", "opt_having_clause", "opt_window_clause", "opt_qualify_clause", "implicit_from_clause"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}, {Child: 3}, {Text: "nullptr"}, {Child: 4}, {Child: 5}, {Child: 6}, {Child: 7}, {Child: 8}, {Child: 9}, {Text: "@4.raw.is_empty()"}}}, + }, + 412: { // opt_from_clause + {Syms: "", Kind: ActListNew}, + {Syms: "from_clause", Kind: ActDefault}, + }, + 413: { // from_clause + {Syms: "FROM from_tables", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 414: { // from_tables + {Syms: "DUAL_SYM", Kind: ActListNew}, + {Syms: "table_reference_list", Kind: ActDefault}, + }, + 415: { // table_reference_list + {Syms: "table_reference", Kind: ActListNew, Args: []Arg{{Child: 1}}}, + {Syms: "table_reference_list ',' table_reference", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 416: { // table_value_constructor + {Syms: "VALUES values_row_list", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 417: { // explicit_table + {Syms: "TABLE_SYM table_ident", Kind: ActUnknown}, + }, + 418: { // select_options + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "query_spec_options", Arg: Arg{Text: "0"}}}}, + {Syms: "select_option_list", Kind: ActDefault}, + }, + 419: { // select_option_list + {Syms: "select_option_list select_option", Kind: ActUnknown}, + {Syms: "select_option", Kind: ActDefault}, + }, + 420: { // select_option + {Syms: "query_spec_option", Kind: ActStruct, Fields: []Field{{Name: "query_spec_options", Arg: Arg{Child: 1}}}}, + {Syms: "SQL_NO_CACHE_SYM", Kind: ActStruct, Fields: []Field{{Name: "query_spec_options", Arg: Arg{Text: "0"}}}}, + }, + 421: { // locking_clause_list + {Syms: "locking_clause_list locking_clause", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 2}}}, + {Syms: "locking_clause", Kind: ActListNew, Class: "PT_locking_clause_list", Args: []Arg{{Text: "@$"}, {Text: "YYTHD->mem_root"}, {Child: 1}}}, + }, + 422: { // locking_clause + {Syms: "FOR_SYM lock_strength opt_locked_row_action", Kind: ActNew, Class: "PT_query_block_locking_clause", Params: []string{"pos", "strength", "action"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Child: 3}}}, + {Syms: "FOR_SYM lock_strength table_locking_list opt_locked_row_action", Kind: ActNew, Class: "PT_table_locking_clause", Params: []string{"pos", "strength", "tables", "action"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Child: 3}, {Child: 4}}}, + {Syms: "LOCK_SYM IN_SYM SHARE_SYM MODE_SYM", Kind: ActNew, Class: "PT_query_block_locking_clause", Params: []string{"pos", "strength"}, Args: []Arg{{Text: "@$"}, {Text: "Lock_strength::SHARE"}}}, + }, + 423: { // lock_strength + {Syms: "UPDATE_SYM", Kind: ActConst, Const: "Lock_strength::UPDATE"}, + {Syms: "SHARE_SYM", Kind: ActConst, Const: "Lock_strength::SHARE"}, + }, + 424: { // table_locking_list + {Syms: "OF_SYM table_alias_ref_list", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 425: { // opt_locked_row_action + {Syms: "", Kind: ActConst, Const: "Locked_row_action::WAIT"}, + {Syms: "locked_row_action", Kind: ActDefault}, + }, + 426: { // locked_row_action + {Syms: "SKIP_SYM LOCKED_SYM", Kind: ActConst, Const: "Locked_row_action::SKIP"}, + {Syms: "NOWAIT_SYM", Kind: ActConst, Const: "Locked_row_action::NOWAIT"}, + }, + 427: { // select_item_list + {Syms: "select_item_list ',' select_item", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + {Syms: "select_item", Kind: ActListNew, Class: "PT_select_item_list", Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "'*'", Kind: ActUnknown}, + }, + 428: { // select_item + {Syms: "table_wild", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "expr select_alias", Kind: ActNew, Class: "PTI_expr_with_alias", Params: []string{"pos", "expr", "expr_loc", "alias"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Text: "@1.cpp"}, {Child: 2}}}, + }, + 429: { // select_alias + {Syms: "", Kind: ActEmpty}, + {Syms: "AS ident", Kind: ActPass, Args: []Arg{{Child: 2}}}, + {Syms: "AS TEXT_STRING_validated", Kind: ActPass, Args: []Arg{{Child: 2}}}, + {Syms: "ident", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "TEXT_STRING_validated", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 430: { // optional_braces + {Syms: "", Kind: ActDefault}, + {Syms: "'(' ')'", Kind: ActDefault}, + }, + 431: { // expr + {Syms: "expr or expr", Kind: ActNew, Class: "Item_cond_or", Params: []string{"i1", "i2"}, Args: []Arg{{Child: 1}, {Child: 3}}}, + {Syms: "expr XOR expr", Kind: ActNew, Class: "Item_func_xor", Params: []string{"pos", "i1", "i2"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}}}, + {Syms: "expr and expr", Kind: ActNew, Class: "Item_cond_and", Params: []string{"i1", "i2"}, Args: []Arg{{Child: 1}, {Child: 3}}}, + {Syms: "NOT_SYM expr", Kind: ActNew, Class: "PTI_truth_transform", Params: []string{"pos", "expr", "truth_test"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Text: "Item::BOOL_NEGATED"}}}, + {Syms: "bool_pri IS TRUE_SYM", Kind: ActNew, Class: "PTI_truth_transform", Params: []string{"pos", "expr", "truth_test"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Text: "Item::BOOL_IS_TRUE"}}}, + {Syms: "bool_pri IS not TRUE_SYM", Kind: ActNew, Class: "PTI_truth_transform", Params: []string{"pos", "expr", "truth_test"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Text: "Item::BOOL_NOT_TRUE"}}}, + {Syms: "bool_pri IS FALSE_SYM", Kind: ActNew, Class: "PTI_truth_transform", Params: []string{"pos", "expr", "truth_test"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Text: "Item::BOOL_IS_FALSE"}}}, + {Syms: "bool_pri IS not FALSE_SYM", Kind: ActNew, Class: "PTI_truth_transform", Params: []string{"pos", "expr", "truth_test"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Text: "Item::BOOL_NOT_FALSE"}}}, + {Syms: "bool_pri IS UNKNOWN_SYM", Kind: ActNew, Class: "Item_func_isnull", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "bool_pri IS not UNKNOWN_SYM", Kind: ActNew, Class: "Item_func_isnotnull", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "bool_pri", Kind: ActDefault}, + }, + 432: { // bool_pri + {Syms: "bool_pri IS NULL_SYM", Kind: ActNew, Class: "Item_func_isnull", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "bool_pri IS not NULL_SYM", Kind: ActNew, Class: "Item_func_isnotnull", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "bool_pri comp_op predicate", Kind: ActNew, Class: "PTI_comp_op", Params: []string{"pos", "left", "boolfunc2creator", "right"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}, {Child: 3}}}, + {Syms: "bool_pri comp_op all_or_any table_subquery", Kind: ActUnknown}, + {Syms: "predicate", Kind: ActDefault}, + }, + 433: { // predicate + {Syms: "bit_expr IN_SYM table_subquery", Kind: ActNew, Class: "Item_in_subselect", Params: []string{"pos", "left_expr", "pt_subquery"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}}}, + {Syms: "bit_expr not IN_SYM table_subquery", Kind: ActUnknown}, + {Syms: "bit_expr IN_SYM '(' expr ')'", Kind: ActNew, Class: "PTI_handle_sql2003_note184_exception", Params: []string{"pos", "left", "is_negation", "right"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Text: "false"}, {Child: 4}}}, + {Syms: "bit_expr IN_SYM '(' expr ',' expr_list ')'", Kind: ActUnknown}, + {Syms: "bit_expr not IN_SYM '(' expr ')'", Kind: ActNew, Class: "PTI_handle_sql2003_note184_exception", Params: []string{"pos", "left", "is_negation", "right"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Text: "true"}, {Child: 5}}}, + {Syms: "bit_expr not IN_SYM '(' expr ',' expr_list ')'", Kind: ActUnknown}, + {Syms: "bit_expr MEMBER_SYM opt_of '(' simple_expr ')'", Kind: ActNew, Class: "Item_func_member_of", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 5}}}, + {Syms: "bit_expr BETWEEN_SYM bit_expr AND_SYM predicate", Kind: ActNew, Class: "Item_func_between", Params: []string{"pos", "a", "b", "c", "is_negation"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}, {Child: 5}, {Text: "false"}}}, + {Syms: "bit_expr not BETWEEN_SYM bit_expr AND_SYM predicate", Kind: ActNew, Class: "Item_func_between", Params: []string{"pos", "a", "b", "c", "is_negation"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 4}, {Child: 6}, {Text: "true"}}}, + {Syms: "bit_expr SOUNDS_SYM LIKE bit_expr", Kind: ActUnknown}, + {Syms: "bit_expr LIKE simple_expr", Kind: ActNew, Class: "Item_func_like", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}}}, + {Syms: "bit_expr LIKE simple_expr ESCAPE_SYM simple_expr", Kind: ActNew, Class: "Item_func_like", Params: []string{"pos", "a", "b", "escape"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}, {Child: 5}}}, + {Syms: "bit_expr not LIKE simple_expr", Kind: ActUnknown}, + {Syms: "bit_expr not LIKE simple_expr ESCAPE_SYM simple_expr", Kind: ActUnknown}, + {Syms: "bit_expr REGEXP bit_expr", Kind: ActUnknown}, + {Syms: "bit_expr not REGEXP bit_expr", Kind: ActUnknown}, + {Syms: "bit_expr", Kind: ActDefault}, + }, + 434: { // opt_of + {Syms: "OF_SYM", Kind: ActDefault}, + {Syms: "", Kind: ActDefault}, + }, + 435: { // bit_expr + {Syms: "bit_expr '|' bit_expr", Kind: ActNew, Class: "Item_func_bit_or", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}}}, + {Syms: "bit_expr '&' bit_expr", Kind: ActNew, Class: "Item_func_bit_and", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}}}, + {Syms: "bit_expr SHIFT_LEFT bit_expr", Kind: ActNew, Class: "Item_func_shift_left", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}}}, + {Syms: "bit_expr SHIFT_RIGHT bit_expr", Kind: ActNew, Class: "Item_func_shift_right", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}}}, + {Syms: "bit_expr '+' bit_expr", Kind: ActNew, Class: "Item_func_plus", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}}}, + {Syms: "bit_expr '-' bit_expr", Kind: ActNew, Class: "Item_func_minus", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}}}, + {Syms: "bit_expr '+' INTERVAL_SYM expr interval", Kind: ActNew, Class: "Item_date_add_interval", Params: []string{"pos", "a", "b", "type", "subtract"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 4}, {Child: 5}, {Text: "0"}}}, + {Syms: "bit_expr '-' INTERVAL_SYM expr interval", Kind: ActNew, Class: "Item_date_add_interval", Params: []string{"pos", "a", "b", "type", "subtract"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 4}, {Child: 5}, {Text: "1"}}}, + {Syms: "bit_expr '*' bit_expr", Kind: ActNew, Class: "Item_func_mul", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}}}, + {Syms: "bit_expr '/' bit_expr", Kind: ActNew, Class: "Item_func_div", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}}}, + {Syms: "bit_expr '%' bit_expr", Kind: ActNew, Class: "Item_func_mod", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}}}, + {Syms: "bit_expr DIV_SYM bit_expr", Kind: ActNew, Class: "Item_func_div_int", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}}}, + {Syms: "bit_expr MOD_SYM bit_expr", Kind: ActNew, Class: "Item_func_mod", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}}}, + {Syms: "bit_expr '^' bit_expr", Kind: ActNew, Class: "Item_func_bit_xor", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}}}, + {Syms: "simple_expr", Kind: ActDefault}, + }, + 436: { // or + {Syms: "OR_SYM", Kind: ActDefault}, + {Syms: "OR2_SYM", Kind: ActDefault}, + }, + 437: { // and + {Syms: "AND_SYM", Kind: ActDefault}, + {Syms: "AND_AND_SYM", Kind: ActDefault}, + }, + 438: { // not + {Syms: "NOT_SYM", Kind: ActDefault}, + {Syms: "NOT2_SYM", Kind: ActDefault}, + }, + 439: { // not2 + {Syms: "'!'", Kind: ActDefault}, + {Syms: "NOT2_SYM", Kind: ActDefault}, + }, + 440: { // comp_op + {Syms: "EQ", Kind: ActConst, Const: "&comp_eq_creator"}, + {Syms: "EQUAL_SYM", Kind: ActConst, Const: "&comp_equal_creator"}, + {Syms: "GE", Kind: ActConst, Const: "&comp_ge_creator"}, + {Syms: "GT_SYM", Kind: ActConst, Const: "&comp_gt_creator"}, + {Syms: "LE", Kind: ActConst, Const: "&comp_le_creator"}, + {Syms: "LT", Kind: ActConst, Const: "&comp_lt_creator"}, + {Syms: "NE", Kind: ActConst, Const: "&comp_ne_creator"}, + }, + 441: { // all_or_any + {Syms: "ALL", Kind: ActConst, Const: "1"}, + {Syms: "ANY_SYM", Kind: ActConst, Const: "0"}, + }, + 442: { // simple_expr + {Syms: "simple_ident", Kind: ActDefault}, + {Syms: "function_call_keyword", Kind: ActDefault}, + {Syms: "function_call_nonkeyword", Kind: ActDefault}, + {Syms: "function_call_generic", Kind: ActDefault}, + {Syms: "function_call_conflict", Kind: ActDefault}, + {Syms: "simple_expr COLLATE_SYM ident_or_text", Kind: ActNew, Class: "Item_func_set_collation", Params: []string{"pos", "a", "collation_string"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}}}, + {Syms: "literal_or_null", Kind: ActDefault}, + {Syms: "param_marker", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "rvalue_system_or_user_variable", Kind: ActDefault}, + {Syms: "in_expression_user_variable_assignment", Kind: ActDefault}, + {Syms: "set_function_specification", Kind: ActDefault}, + {Syms: "window_func_call", Kind: ActDefault}, + {Syms: "simple_expr OR_OR_SYM simple_expr", Kind: ActNew, Class: "Item_func_concat", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}}}, + {Syms: "'+' simple_expr", Kind: ActPass, Args: []Arg{{Child: 2}}}, + {Syms: "'-' simple_expr", Kind: ActNew, Class: "Item_func_neg", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "'~' simple_expr", Kind: ActNew, Class: "Item_func_bit_neg", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "not2 simple_expr", Kind: ActNew, Class: "PTI_truth_transform", Params: []string{"pos", "expr", "truth_test"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Text: "Item::BOOL_NEGATED"}}}, + {Syms: "row_subquery", Kind: ActNew, Class: "PTI_singlerow_subselect", Params: []string{"pos", "subselect"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "'(' expr ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + {Syms: "'(' expr ',' expr_list ')'", Kind: ActNew, Class: "Item_row", Params: []string{"pos", "head", "tail"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Child: 4, Field: "->value"}}}, + {Syms: "ROW_SYM '(' expr ',' expr_list ')'", Kind: ActNew, Class: "Item_row", Params: []string{"pos", "head", "tail"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5, Field: "->value"}}}, + {Syms: "EXISTS table_subquery", Kind: ActNew, Class: "PTI_exists_subselect", Params: []string{"pos", "subselect"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "'{' ident expr '}'", Kind: ActNew, Class: "PTI_odbc_date", Params: []string{"pos", "ident", "expr"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Child: 3}}}, + {Syms: "MATCH ident_list_arg AGAINST '(' bit_expr fulltext_options ')'", Kind: ActNew, Class: "Item_func_match", Params: []string{"pos", "a", "against", "b"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Child: 5}, {Child: 6}}}, + {Syms: "BINARY_SYM simple_expr", Kind: ActNew, Class: "create_func_cast", Params: []string{"thd", "pos", "arg", "type", "as_array"}, Args: []Arg{{Text: "YYTHD"}, {Text: "@$"}, {Child: 2}, {Text: "ITEM_CAST_CHAR"}, {Text: "my_charset_bin"}}}, + {Syms: "CAST_SYM '(' expr AS cast_type opt_array_cast ')'", Kind: ActNew, Class: "create_func_cast", Params: []string{"thd", "pos", "arg", "type", "as_array"}, Args: []Arg{{Text: "YYTHD"}, {Text: "@$"}, {Child: 3}, {Child: 5}, {Child: 6}}}, + {Syms: "CAST_SYM '(' expr AT_SYM LOCAL_SYM AS cast_type opt_array_cast ')'", Kind: ActDefault}, + {Syms: "CAST_SYM '(' expr AT_SYM TIME_SYM ZONE_SYM opt_interval TEXT_STRING_literal AS DATETIME_SYM type_datetime_precision ')'", Kind: ActUnknown}, + {Syms: "CASE_SYM opt_expr when_list opt_else END", Kind: ActNew, Class: "Item_func_case", Params: []string{"pos", "list", "first_expr", "else_expr"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 2}, {Child: 4}}}, + {Syms: "CONVERT_SYM '(' expr ',' cast_type ')'", Kind: ActNew, Class: "create_func_cast", Params: []string{"thd", "pos", "arg", "type", "as_array"}, Args: []Arg{{Text: "YYTHD"}, {Text: "@$"}, {Child: 3}, {Child: 5}, {Text: "false"}}}, + {Syms: "CONVERT_SYM '(' expr USING charset_name ')'", Kind: ActNew, Class: "Item_func_conv_charset", Params: []string{"pos", "a", "cs"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "DEFAULT_SYM '(' simple_ident ')'", Kind: ActNew, Class: "Item_default_value", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "VALUES '(' simple_ident_nospvar ')'", Kind: ActNew, Class: "Item_insert_value", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "INTERVAL_SYM expr interval '+' expr", Kind: ActNew, Class: "Item_date_add_interval", Params: []string{"pos", "a", "b", "type", "subtract"}, Args: []Arg{{Text: "@$"}, {Child: 5}, {Child: 2}, {Child: 3}, {Text: "0"}}}, + {Syms: "simple_ident JSON_SEPARATOR_SYM TEXT_STRING_literal", Kind: ActUnknown}, + {Syms: "simple_ident JSON_UNQUOTED_SEPARATOR_SYM TEXT_STRING_literal", Kind: ActUnknown}, + }, + 443: { // opt_array_cast + {Syms: "", Kind: ActConst, Const: "false"}, + {Syms: "ARRAY_SYM", Kind: ActConst, Const: "true"}, + }, + 444: { // function_call_keyword + {Syms: "CHAR_SYM '(' expr_list ')'", Kind: ActNew, Class: "Item_func_char", Params: []string{"pos", "list"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "CHAR_SYM '(' expr_list USING charset_name ')'", Kind: ActNew, Class: "Item_func_char", Params: []string{"pos", "list", "cs"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "CURRENT_USER optional_braces", Kind: ActNew, Class: "Item_func_current_user", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "DATE_SYM '(' expr ')'", Kind: ActNew, Class: "Item_typecast_date", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "DAY_SYM '(' expr ')'", Kind: ActNew, Class: "Item_func_dayofmonth", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "HOUR_SYM '(' expr ')'", Kind: ActNew, Class: "Item_func_hour", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "INSERT_SYM '(' expr ',' expr ',' expr ',' expr ')'", Kind: ActNew, Class: "Item_func_insert", Params: []string{"pos", "org", "start", "length", "new_str"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}, {Child: 7}, {Child: 9}}}, + {Syms: "INTERVAL_SYM '(' expr ',' expr ')'", Kind: ActNew, Class: "Item_func_interval", Params: []string{"pos", "mem_root", "expr1", "expr2"}, Args: []Arg{{Text: "@$"}, {Text: "YYMEM_ROOT"}, {Child: 3}, {Child: 5}}}, + {Syms: "INTERVAL_SYM '(' expr ',' expr ',' expr_list ')'", Kind: ActNew, Class: "Item_func_interval", Params: []string{"pos", "mem_root", "expr1", "expr2", "opt_expr_list"}, Args: []Arg{{Text: "@$"}, {Text: "YYMEM_ROOT"}, {Child: 3}, {Child: 5}, {Child: 7}}}, + {Syms: "JSON_VALUE_SYM '(' simple_expr ',' text_literal opt_returning_type opt_on_empty_or_error ')'", Kind: ActNew, Class: "create_func_json_value", Params: []string{"thd", "pos", "arg", "path", "type", "on_empty_type", "on_empty_default", "on_error_type", "on_error_default"}, Args: []Arg{{Text: "YYTHD"}, {Text: "@3"}, {Child: 3}, {Child: 5}, {Child: 6}, {Child: 7, Field: ".empty.type"}, {Child: 7, Field: ".empty.default_string"}, {Child: 7, Field: ".error.type"}, {Child: 7, Field: ".error.default_string"}}}, + {Syms: "LEFT '(' expr ',' expr ')'", Kind: ActNew, Class: "Item_func_left", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "MINUTE_SYM '(' expr ')'", Kind: ActNew, Class: "Item_func_minute", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "MONTH_SYM '(' expr ')'", Kind: ActNew, Class: "Item_func_month", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "RIGHT '(' expr ',' expr ')'", Kind: ActNew, Class: "Item_func_right", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "SECOND_SYM '(' expr ')'", Kind: ActNew, Class: "Item_func_second", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "TIME_SYM '(' expr ')'", Kind: ActNew, Class: "Item_typecast_time", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "TIMESTAMP_SYM '(' expr ')'", Kind: ActNew, Class: "Item_typecast_datetime", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "TIMESTAMP_SYM '(' expr ',' expr ')'", Kind: ActNew, Class: "Item_func_add_time", Params: []string{"pos", "a", "b", "datetime", "negate"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}, {Text: "1"}, {Text: "0"}}}, + {Syms: "TRIM '(' expr ')'", Kind: ActNew, Class: "Item_func_trim", Params: []string{"pos", "a", "tm"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "Item_func_trim::TRIM_BOTH_DEFAULT"}}}, + {Syms: "TRIM '(' LEADING expr FROM expr ')'", Kind: ActNew, Class: "Item_func_trim", Params: []string{"pos", "a", "b", "tm"}, Args: []Arg{{Text: "@$"}, {Child: 6}, {Child: 4}, {Text: "Item_func_trim::TRIM_LEADING"}}}, + {Syms: "TRIM '(' TRAILING expr FROM expr ')'", Kind: ActNew, Class: "Item_func_trim", Params: []string{"pos", "a", "b", "tm"}, Args: []Arg{{Text: "@$"}, {Child: 6}, {Child: 4}, {Text: "Item_func_trim::TRIM_TRAILING"}}}, + {Syms: "TRIM '(' BOTH expr FROM expr ')'", Kind: ActNew, Class: "Item_func_trim", Params: []string{"pos", "a", "b", "tm"}, Args: []Arg{{Text: "@$"}, {Child: 6}, {Child: 4}, {Text: "Item_func_trim::TRIM_BOTH"}}}, + {Syms: "TRIM '(' LEADING FROM expr ')'", Kind: ActNew, Class: "Item_func_trim", Params: []string{"pos", "a", "tm"}, Args: []Arg{{Text: "@$"}, {Child: 5}, {Text: "Item_func_trim::TRIM_LEADING"}}}, + {Syms: "TRIM '(' TRAILING FROM expr ')'", Kind: ActNew, Class: "Item_func_trim", Params: []string{"pos", "a", "tm"}, Args: []Arg{{Text: "@$"}, {Child: 5}, {Text: "Item_func_trim::TRIM_TRAILING"}}}, + {Syms: "TRIM '(' BOTH FROM expr ')'", Kind: ActNew, Class: "Item_func_trim", Params: []string{"pos", "a", "tm"}, Args: []Arg{{Text: "@$"}, {Child: 5}, {Text: "Item_func_trim::TRIM_BOTH"}}}, + {Syms: "TRIM '(' expr FROM expr ')'", Kind: ActNew, Class: "Item_func_trim", Params: []string{"pos", "a", "b", "tm"}, Args: []Arg{{Text: "@$"}, {Child: 5}, {Child: 3}, {Text: "Item_func_trim::TRIM_BOTH_DEFAULT"}}}, + {Syms: "USER '(' ')'", Kind: ActNew, Class: "Item_func_user", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "YEAR_SYM '(' expr ')'", Kind: ActNew, Class: "Item_func_year", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 445: { // function_call_nonkeyword + {Syms: "ADDDATE_SYM '(' expr ',' expr ')'", Kind: ActNew, Class: "Item_date_add_interval", Params: []string{"pos", "a", "b", "type", "subtract"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}, {Text: "INTERVAL_DAY"}, {Text: "0"}}}, + {Syms: "ADDDATE_SYM '(' expr ',' INTERVAL_SYM expr interval ')'", Kind: ActNew, Class: "Item_date_add_interval", Params: []string{"pos", "a", "b", "type", "subtract"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 6}, {Child: 7}, {Text: "0"}}}, + {Syms: "CURDATE optional_braces", Kind: ActNew, Class: "Item_func_curdate_local", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "CURTIME func_datetime_precision", Kind: ActNew, Class: "Item_func_curtime_local", Params: []string{"pos", "dec"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "DATE_ADD_INTERVAL '(' expr ',' INTERVAL_SYM expr interval ')'", Kind: ActNew, Class: "Item_date_add_interval", Params: []string{"pos", "a", "b", "type", "subtract"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 6}, {Child: 7}, {Text: "0"}}}, + {Syms: "DATE_SUB_INTERVAL '(' expr ',' INTERVAL_SYM expr interval ')'", Kind: ActNew, Class: "Item_date_add_interval", Params: []string{"pos", "a", "b", "type", "subtract"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 6}, {Child: 7}, {Text: "1"}}}, + {Syms: "EXTRACT_SYM '(' interval FROM expr ')'", Kind: ActNew, Class: "Item_extract", Params: []string{"pos", "type", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "GET_FORMAT '(' date_time_type ',' expr ')'", Kind: ActNew, Class: "Item_func_get_format", Params: []string{"pos", "type", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "LOG_SYM '(' expr ')'", Kind: ActNew, Class: "Item_func_log", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "LOG_SYM '(' expr ',' expr ')'", Kind: ActNew, Class: "Item_func_log", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "now", Kind: ActNew, Class: "PTI_function_call_nonkeyword_now", Params: []string{"pos", "dec"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "POSITION_SYM '(' bit_expr IN_SYM expr ')'", Kind: ActNew, Class: "Item_func_locate", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 5}, {Child: 3}}}, + {Syms: "SUBDATE_SYM '(' expr ',' expr ')'", Kind: ActNew, Class: "Item_date_add_interval", Params: []string{"pos", "a", "b", "type", "subtract"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}, {Text: "INTERVAL_DAY"}, {Text: "1"}}}, + {Syms: "SUBDATE_SYM '(' expr ',' INTERVAL_SYM expr interval ')'", Kind: ActNew, Class: "Item_date_add_interval", Params: []string{"pos", "a", "b", "type", "subtract"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 6}, {Child: 7}, {Text: "1"}}}, + {Syms: "SUBSTRING '(' expr ',' expr ',' expr ')'", Kind: ActNew, Class: "Item_func_substr", Params: []string{"pos", "a", "b", "c"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}, {Child: 7}}}, + {Syms: "SUBSTRING '(' expr ',' expr ')'", Kind: ActNew, Class: "Item_func_substr", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "SUBSTRING '(' expr FROM expr FOR_SYM expr ')'", Kind: ActNew, Class: "Item_func_substr", Params: []string{"pos", "a", "b", "c"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}, {Child: 7}}}, + {Syms: "SUBSTRING '(' expr FROM expr ')'", Kind: ActNew, Class: "Item_func_substr", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "SYSDATE func_datetime_precision", Kind: ActNew, Class: "PTI_function_call_nonkeyword_sysdate", Params: []string{"pos", "dec"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "TIMESTAMP_ADD '(' interval_time_stamp ',' expr ',' expr ')'", Kind: ActNew, Class: "Item_date_add_interval", Params: []string{"pos", "a", "b", "type", "subtract"}, Args: []Arg{{Text: "@$"}, {Child: 7}, {Child: 5}, {Child: 3}, {Text: "0"}}}, + {Syms: "TIMESTAMP_DIFF '(' interval_time_stamp ',' expr ',' expr ')'", Kind: ActNew, Class: "Item_func_timestamp_diff", Params: []string{"pos", "a", "b", "type"}, Args: []Arg{{Text: "@$"}, {Child: 5}, {Child: 7}, {Child: 3}}}, + {Syms: "UTC_DATE_SYM optional_braces", Kind: ActNew, Class: "Item_func_curdate_utc", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "UTC_TIME_SYM func_datetime_precision", Kind: ActNew, Class: "Item_func_curtime_utc", Params: []string{"pos", "dec"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "UTC_TIMESTAMP_SYM func_datetime_precision", Kind: ActNew, Class: "Item_func_now_utc", Params: []string{"pos", "dec"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + }, + 446: { // opt_returning_type + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "0", Arg: Arg{Text: "ITEM_CAST_CHAR"}}, {Name: "1", Arg: Arg{Text: "nullptr"}}, {Name: "2", Arg: Arg{Text: "\"512\""}}, {Name: "3", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "RETURNING_SYM cast_type", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 447: { // function_call_conflict + {Syms: "ASCII_SYM '(' expr ')'", Kind: ActNew, Class: "Item_func_ascii", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "CHARSET '(' expr ')'", Kind: ActNew, Class: "Item_func_charset", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "COALESCE '(' expr_list ')'", Kind: ActNew, Class: "Item_func_coalesce", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "COLLATION_SYM '(' expr ')'", Kind: ActNew, Class: "Item_func_collation", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "DATABASE '(' ')'", Kind: ActNew, Class: "Item_func_database", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "IF '(' expr ',' expr ',' expr ')'", Kind: ActNew, Class: "Item_func_if", Params: []string{"pos", "a", "b", "c"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}, {Child: 7}}}, + {Syms: "FORMAT_SYM '(' expr ',' expr ')'", Kind: ActNew, Class: "Item_func_format", Params: []string{"pos", "org", "dec"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "FORMAT_SYM '(' expr ',' expr ',' expr ')'", Kind: ActNew, Class: "Item_func_format", Params: []string{"pos", "org", "dec", "lang"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}, {Child: 7}}}, + {Syms: "MICROSECOND_SYM '(' expr ')'", Kind: ActNew, Class: "Item_func_microsecond", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "MOD_SYM '(' expr ',' expr ')'", Kind: ActNew, Class: "Item_func_mod", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "QUARTER_SYM '(' expr ')'", Kind: ActNew, Class: "Item_func_quarter", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "REPEAT_SYM '(' expr ',' expr ')'", Kind: ActNew, Class: "Item_func_repeat", Params: []string{"pos", "arg1", "arg2"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "REPLACE_SYM '(' expr ',' expr ',' expr ')'", Kind: ActNew, Class: "Item_func_replace", Params: []string{"pos", "org", "find", "replace"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}, {Child: 7}}}, + {Syms: "REVERSE_SYM '(' expr ')'", Kind: ActNew, Class: "Item_func_reverse", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "ROW_COUNT_SYM '(' ')'", Kind: ActNew, Class: "Item_func_row_count", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "TRUNCATE_SYM '(' expr ',' expr ')'", Kind: ActNew, Class: "Item_func_round", Params: []string{"pos", "a", "b", "trunc"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}, {Text: "1"}}}, + {Syms: "WEEK_SYM '(' expr ')'", Kind: ActNew, Class: "Item_func_week", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "nullptr"}}}, + {Syms: "WEEK_SYM '(' expr ',' expr ')'", Kind: ActNew, Class: "Item_func_week", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "WEIGHT_STRING_SYM '(' expr ')'", Kind: ActNew, Class: "Item_func_weight_string", Params: []string{"pos", "a", "result_length", "num_codepoints", "flags"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "0"}, {Text: "0"}, {Text: "0"}}}, + {Syms: "WEIGHT_STRING_SYM '(' expr AS CHAR_SYM ws_num_codepoints ')'", Kind: ActNew, Class: "Item_func_weight_string", Params: []string{"pos", "a", "result_length", "num_codepoints", "flags"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "0"}, {Child: 6}, {Text: "0"}}}, + {Syms: "WEIGHT_STRING_SYM '(' expr AS BINARY_SYM ws_num_codepoints ')'", Kind: ActNew, Class: "Item_func_weight_string", Params: []string{"pos", "a", "result_length", "num_codepoints", "flags", "as_binary"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "0"}, {Child: 6}, {Text: "0"}, {Text: "true"}}}, + {Syms: "WEIGHT_STRING_SYM '(' expr ',' ulong_num ',' ulong_num ',' ulong_num ')'", Kind: ActNew, Class: "Item_func_weight_string", Params: []string{"pos", "a", "result_length", "num_codepoints", "flags"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}, {Child: 7}, {Child: 9}}}, + {Syms: "geometry_function", Kind: ActDefault}, + }, + 448: { // geometry_function + {Syms: "GEOMETRYCOLLECTION_SYM '(' opt_expr_list ')'", Kind: ActNew, Class: "Item_func_spatial_collection", Params: []string{"pos", "list", "ct", "it"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "Geometry::wkb_geometrycollection"}, {Text: "Geometry::wkb_point"}}}, + {Syms: "LINESTRING_SYM '(' expr_list ')'", Kind: ActNew, Class: "Item_func_spatial_collection", Params: []string{"pos", "list", "ct", "it"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "Geometry::wkb_linestring"}, {Text: "Geometry::wkb_point"}}}, + {Syms: "MULTILINESTRING_SYM '(' expr_list ')'", Kind: ActNew, Class: "Item_func_spatial_collection", Params: []string{"pos", "list", "ct", "it"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "Geometry::wkb_multilinestring"}, {Text: "Geometry::wkb_linestring"}}}, + {Syms: "MULTIPOINT_SYM '(' expr_list ')'", Kind: ActNew, Class: "Item_func_spatial_collection", Params: []string{"pos", "list", "ct", "it"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "Geometry::wkb_multipoint"}, {Text: "Geometry::wkb_point"}}}, + {Syms: "MULTIPOLYGON_SYM '(' expr_list ')'", Kind: ActNew, Class: "Item_func_spatial_collection", Params: []string{"pos", "list", "ct", "it"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "Geometry::wkb_multipolygon"}, {Text: "Geometry::wkb_polygon"}}}, + {Syms: "POINT_SYM '(' expr ',' expr ')'", Kind: ActNew, Class: "Item_func_point", Params: []string{"pos", "a", "b"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "POLYGON_SYM '(' expr_list ')'", Kind: ActNew, Class: "Item_func_spatial_collection", Params: []string{"pos", "list", "ct", "it"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "Geometry::wkb_polygon"}, {Text: "Geometry::wkb_linestring"}}}, + }, + 449: { // function_call_generic + {Syms: "IDENT_sys '(' opt_udf_expr_list ')'", Kind: ActNew, Class: "PTI_function_call_generic_ident_sys", Params: []string{"pos", "ident", "opt_udf_expr_list"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}}}, + {Syms: "ident '.' ident '(' opt_expr_list ')'", Kind: ActNew, Class: "PTI_function_call_generic_2d", Params: []string{"pos", "db", "func", "opt_expr_list"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 3}, {Child: 5}}}, + }, + 450: { // fulltext_options + {Syms: "opt_natural_language_mode opt_query_expansion", Kind: ActFlags, Args: []Arg{{Child: 1}, {Child: 2}}}, + {Syms: "IN_SYM BOOLEAN_SYM MODE_SYM", Kind: ActConst, Const: "FT_BOOL"}, + }, + 451: { // opt_natural_language_mode + {Syms: "", Kind: ActConst, Const: "FT_NL"}, + {Syms: "IN_SYM NATURAL LANGUAGE_SYM MODE_SYM", Kind: ActConst, Const: "FT_NL"}, + }, + 452: { // opt_query_expansion + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "WITH QUERY_SYM EXPANSION_SYM", Kind: ActConst, Const: "FT_EXPAND"}, + }, + 453: { // opt_udf_expr_list + {Syms: "", Kind: ActEmpty}, + {Syms: "udf_expr_list", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 454: { // udf_expr_list + {Syms: "udf_expr", Kind: ActListNew, Class: "PT_item_list", Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "udf_expr_list ',' udf_expr", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 455: { // udf_expr + {Syms: "expr select_alias", Kind: ActNew, Class: "PTI_udf_expr", Params: []string{"pos", "expr", "select_alias", "expr_loc"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}, {Text: "@1.cpp"}}}, + }, + 456: { // set_function_specification + {Syms: "sum_expr", Kind: ActDefault}, + {Syms: "grouping_operation", Kind: ActDefault}, + }, + 457: { // sum_expr + {Syms: "AVG_SYM '(' in_sum_expr ')' opt_windowing_clause", Kind: ActNew, Class: "Item_sum_avg", Params: []string{"pos", "item_par", "distinct", "w"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "false"}, {Child: 5}}}, + {Syms: "AVG_SYM '(' DISTINCT in_sum_expr ')' opt_windowing_clause", Kind: ActNew, Class: "Item_sum_avg", Params: []string{"pos", "item_par", "distinct", "w"}, Args: []Arg{{Text: "@$"}, {Child: 4}, {Text: "true"}, {Child: 6}}}, + {Syms: "BIT_AND_SYM '(' in_sum_expr ')' opt_windowing_clause", Kind: ActNew, Class: "Item_sum_and", Params: []string{"pos", "item_par", "w"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "BIT_OR_SYM '(' in_sum_expr ')' opt_windowing_clause", Kind: ActNew, Class: "Item_sum_or", Params: []string{"pos", "item_par", "w"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "JSON_ARRAYAGG '(' in_sum_expr ')' opt_windowing_clause", Kind: ActUnknown}, + {Syms: "JSON_OBJECTAGG '(' in_sum_expr ',' in_sum_expr ')' opt_windowing_clause", Kind: ActUnknown}, + {Syms: "ST_COLLECT_SYM '(' in_sum_expr ')' opt_windowing_clause", Kind: ActNew, Class: "Item_sum_collect", Params: []string{"pos", "a", "w", "distinct"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}, {Text: "false"}}}, + {Syms: "ST_COLLECT_SYM '(' DISTINCT in_sum_expr ')' opt_windowing_clause", Kind: ActNew, Class: "Item_sum_collect", Params: []string{"pos", "a", "w", "distinct"}, Args: []Arg{{Text: "@$"}, {Child: 4}, {Child: 6}, {Text: "true"}}}, + {Syms: "BIT_XOR_SYM '(' in_sum_expr ')' opt_windowing_clause", Kind: ActNew, Class: "Item_sum_xor", Params: []string{"pos", "item_par", "w"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "COUNT_SYM '(' opt_all '*' ')' opt_windowing_clause", Kind: ActNew, Class: "PTI_count_sym", Params: []string{"pos", "w"}, Args: []Arg{{Text: "@$"}, {Child: 6}}}, + {Syms: "COUNT_SYM '(' in_sum_expr ')' opt_windowing_clause", Kind: ActNew, Class: "Item_sum_count", Params: []string{"pos", "item_par", "w"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "COUNT_SYM '(' DISTINCT expr_list ')' opt_windowing_clause", Kind: ActNew, Class: "Item_sum_count", Params: []string{"pos", "item_par", "w"}, Args: []Arg{{Text: "@$"}, {Child: 4}, {Child: 6}}}, + {Syms: "MIN_SYM '(' in_sum_expr ')' opt_windowing_clause", Kind: ActNew, Class: "Item_sum_min", Params: []string{"pos", "item_par", "w"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "MIN_SYM '(' DISTINCT in_sum_expr ')' opt_windowing_clause", Kind: ActNew, Class: "Item_sum_min", Params: []string{"pos", "item_par", "w"}, Args: []Arg{{Text: "@$"}, {Child: 4}, {Child: 6}}}, + {Syms: "MAX_SYM '(' in_sum_expr ')' opt_windowing_clause", Kind: ActNew, Class: "Item_sum_max", Params: []string{"pos", "item_par", "w"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "MAX_SYM '(' DISTINCT in_sum_expr ')' opt_windowing_clause", Kind: ActNew, Class: "Item_sum_max", Params: []string{"pos", "item_par", "w"}, Args: []Arg{{Text: "@$"}, {Child: 4}, {Child: 6}}}, + {Syms: "STD_SYM '(' in_sum_expr ')' opt_windowing_clause", Kind: ActNew, Class: "Item_sum_std", Params: []string{"pos", "item_par", "sample", "w"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "0"}, {Child: 5}}}, + {Syms: "VARIANCE_SYM '(' in_sum_expr ')' opt_windowing_clause", Kind: ActNew, Class: "Item_sum_variance", Params: []string{"pos", "item_par", "sample", "w"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "0"}, {Child: 5}}}, + {Syms: "STDDEV_SAMP_SYM '(' in_sum_expr ')' opt_windowing_clause", Kind: ActNew, Class: "Item_sum_std", Params: []string{"pos", "item_par", "sample", "w"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "1"}, {Child: 5}}}, + {Syms: "VAR_SAMP_SYM '(' in_sum_expr ')' opt_windowing_clause", Kind: ActNew, Class: "Item_sum_variance", Params: []string{"pos", "item_par", "sample", "w"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "1"}, {Child: 5}}}, + {Syms: "SUM_SYM '(' in_sum_expr ')' opt_windowing_clause", Kind: ActNew, Class: "Item_sum_sum", Params: []string{"pos", "item_par", "distinct", "window"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "false"}, {Child: 5}}}, + {Syms: "SUM_SYM '(' DISTINCT in_sum_expr ')' opt_windowing_clause", Kind: ActNew, Class: "Item_sum_sum", Params: []string{"pos", "item_par", "distinct", "window"}, Args: []Arg{{Text: "@$"}, {Child: 4}, {Text: "true"}, {Child: 6}}}, + {Syms: "GROUP_CONCAT_SYM '(' opt_distinct expr_list opt_gorder_clause opt_gconcat_separator ')' opt_windowing_clause", Kind: ActNew, Class: "Item_func_group_concat", Params: []string{"pos", "is_distinct", "select_list", "opt_order_list", "separator", "w"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 4}, {Child: 5}, {Child: 6}, {Child: 8}}}, + }, + 458: { // sampling_method + {Syms: "SYSTEM_SYM", Kind: ActConst, Const: "tablesample_type::SYSTEM_TABLESAMPLE_TYPE"}, + {Syms: "BERNOULLI_SYM", Kind: ActConst, Const: "tablesample_type::BERNOULLI_TABLESAMPLE_TYPE"}, + }, + 459: { // sampling_percentage + {Syms: "NUM_literal", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "'@' ident_or_text", Kind: ActNew, Class: "PTI_user_variable", Params: []string{"pos", "var"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "param_marker", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 460: { // opt_tablesample_clause + {Syms: "", Kind: ActEmpty}, + {Syms: "TABLESAMPLE_SYM sampling_method '(' sampling_percentage ')'", Kind: ActNew, Class: "PT_tablesample", Params: []string{"pos", "tablesample_type", "sample_percentage"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Child: 4}}}, + }, + 461: { // window_func_call + {Syms: "ROW_NUMBER_SYM '(' ')' windowing_clause", Kind: ActNew, Class: "Item_row_number", Params: []string{"pos", "w"}, Args: []Arg{{Text: "@$"}, {Child: 4}}}, + {Syms: "RANK_SYM '(' ')' windowing_clause", Kind: ActNew, Class: "Item_rank", Params: []string{"pos", "dense", "w"}, Args: []Arg{{Text: "@$"}, {Text: "false"}, {Child: 4}}}, + {Syms: "DENSE_RANK_SYM '(' ')' windowing_clause", Kind: ActNew, Class: "Item_rank", Params: []string{"pos", "dense", "w"}, Args: []Arg{{Text: "@$"}, {Text: "true"}, {Child: 4}}}, + {Syms: "CUME_DIST_SYM '(' ')' windowing_clause", Kind: ActNew, Class: "Item_cume_dist", Params: []string{"pos", "w"}, Args: []Arg{{Text: "@$"}, {Child: 4}}}, + {Syms: "PERCENT_RANK_SYM '(' ')' windowing_clause", Kind: ActNew, Class: "Item_percent_rank", Params: []string{"pos", "w"}, Args: []Arg{{Text: "@$"}, {Child: 4}}}, + {Syms: "NTILE_SYM '(' stable_integer ')' windowing_clause", Kind: ActNew, Class: "Item_ntile", Params: []string{"pos", "a", "w"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}}}, + {Syms: "LEAD_SYM '(' expr opt_lead_lag_info ')' opt_null_treatment windowing_clause", Kind: ActUnknown}, + {Syms: "LAG_SYM '(' expr opt_lead_lag_info ')' opt_null_treatment windowing_clause", Kind: ActUnknown}, + {Syms: "FIRST_VALUE_SYM '(' expr ')' opt_null_treatment windowing_clause", Kind: ActNew, Class: "Item_first_last_value", Params: []string{"pos", "first", "a", "null_treatment", "w"}, Args: []Arg{{Text: "@$"}, {Text: "true"}, {Child: 3}, {Child: 5}, {Child: 6}}}, + {Syms: "LAST_VALUE_SYM '(' expr ')' opt_null_treatment windowing_clause", Kind: ActNew, Class: "Item_first_last_value", Params: []string{"pos", "first", "a", "null_treatment", "w"}, Args: []Arg{{Text: "@$"}, {Text: "false"}, {Child: 3}, {Child: 5}, {Child: 6}}}, + {Syms: "NTH_VALUE_SYM '(' expr ',' simple_expr ')' opt_from_first_last opt_null_treatment windowing_clause", Kind: ActNew, Class: "Item_nth_value", Params: []string{"pos", "expr", "n", "from_last", "null_treatment", "w"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}, {Child: 7}, {Child: 8}, {Child: 9}}}, + }, + 462: { // opt_lead_lag_info + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "offset", Arg: Arg{Text: "nullptr"}}, {Name: "default_value", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "',' stable_integer opt_ll_default", Kind: ActStruct, Fields: []Field{{Name: "offset", Arg: Arg{Child: 2}}, {Name: "default_value", Arg: Arg{Child: 3}}}}, + }, + 463: { // stable_integer + {Syms: "int64_literal", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "param_or_var", Kind: ActDefault}, + }, + 464: { // param_or_var + {Syms: "param_marker", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "ident", Kind: ActNew, Class: "PTI_int_splocal", Params: []string{"pos", "name"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "'@' ident_or_text", Kind: ActNew, Class: "PTI_user_variable", Params: []string{"pos", "var"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + }, + 465: { // opt_ll_default + {Syms: "", Kind: ActEmpty}, + {Syms: "',' expr", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 466: { // opt_null_treatment + {Syms: "", Kind: ActConst, Const: "NT_NONE"}, + {Syms: "RESPECT_SYM NULLS_SYM", Kind: ActConst, Const: "NT_RESPECT_NULLS"}, + {Syms: "IGNORE_SYM NULLS_SYM", Kind: ActConst, Const: "NT_IGNORE_NULLS"}, + }, + 467: { // opt_from_first_last + {Syms: "", Kind: ActConst, Const: "NFL_NONE"}, + {Syms: "FROM FIRST_SYM", Kind: ActConst, Const: "NFL_FROM_FIRST"}, + {Syms: "FROM LAST_SYM", Kind: ActConst, Const: "NFL_FROM_LAST"}, + }, + 468: { // opt_windowing_clause + {Syms: "", Kind: ActEmpty}, + {Syms: "windowing_clause", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 469: { // windowing_clause + {Syms: "OVER_SYM window_name_or_spec", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 470: { // window_name_or_spec + {Syms: "window_name", Kind: ActNew, Class: "PT_window", Params: []string{"pos", "name"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "window_spec", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 471: { // window_name + {Syms: "ident", Kind: ActNew, Class: "Item_string", Params: []string{"str", "length", "cs"}, Args: []Arg{{Child: 1, Field: ".str"}, {Child: 1, Field: ".length"}, {Text: "YYTHD->charset()"}}}, + }, + 472: { // window_spec + {Syms: "'(' window_spec_details ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 473: { // window_spec_details + {Syms: "opt_existing_window_name opt_partition_clause opt_window_order_by_clause opt_window_frame_clause", Kind: ActUnknown}, + }, + 474: { // opt_existing_window_name + {Syms: "", Kind: ActEmpty}, + {Syms: "window_name", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 475: { // opt_partition_clause + {Syms: "", Kind: ActEmpty}, + {Syms: "PARTITION_SYM BY group_list", Kind: ActPass, Args: []Arg{{Child: 3}}}, + }, + 476: { // opt_window_order_by_clause + {Syms: "", Kind: ActEmpty}, + {Syms: "ORDER_SYM BY order_list", Kind: ActPass, Args: []Arg{{Child: 3}}}, + }, + 477: { // opt_window_frame_clause + {Syms: "", Kind: ActEmpty}, + {Syms: "window_frame_units window_frame_extent opt_window_frame_exclusion", Kind: ActNew, Class: "PT_frame", Params: []string{"pos", "unit", "from_to", "exclusion"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}, {Child: 3}}}, + }, + 478: { // window_frame_extent + {Syms: "window_frame_start", Kind: ActUnknown}, + {Syms: "window_frame_between", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 479: { // window_frame_start + {Syms: "UNBOUNDED_SYM PRECEDING_SYM", Kind: ActNew, Class: "PT_border", Params: []string{"pos", "type"}, Args: []Arg{{Text: "@$"}, {Text: "WBT_UNBOUNDED_PRECEDING"}}}, + {Syms: "NUM_literal PRECEDING_SYM", Kind: ActNew, Class: "PT_border", Params: []string{"pos", "type", "value"}, Args: []Arg{{Text: "@$"}, {Text: "WBT_VALUE_PRECEDING"}, {Child: 1}}}, + {Syms: "param_marker PRECEDING_SYM", Kind: ActNew, Class: "PT_border", Params: []string{"pos", "type", "value"}, Args: []Arg{{Text: "@$"}, {Text: "WBT_VALUE_PRECEDING"}, {Child: 1}}}, + {Syms: "INTERVAL_SYM expr interval PRECEDING_SYM", Kind: ActNew, Class: "PT_border", Params: []string{"pos", "type", "value", "int_type"}, Args: []Arg{{Text: "@$"}, {Text: "WBT_VALUE_PRECEDING"}, {Child: 2}, {Child: 3}}}, + {Syms: "CURRENT_SYM ROW_SYM", Kind: ActNew, Class: "PT_border", Params: []string{"pos", "type"}, Args: []Arg{{Text: "@$"}, {Text: "WBT_CURRENT_ROW"}}}, + }, + 480: { // window_frame_between + {Syms: "BETWEEN_SYM window_frame_bound AND_SYM window_frame_bound", Kind: ActNew, Class: "PT_borders", Params: []string{"pos", "start", "end"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Child: 4}}}, + }, + 481: { // window_frame_bound + {Syms: "window_frame_start", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "UNBOUNDED_SYM FOLLOWING_SYM", Kind: ActNew, Class: "PT_border", Params: []string{"pos", "type"}, Args: []Arg{{Text: "@$"}, {Text: "WBT_UNBOUNDED_FOLLOWING"}}}, + {Syms: "NUM_literal FOLLOWING_SYM", Kind: ActNew, Class: "PT_border", Params: []string{"pos", "type", "value"}, Args: []Arg{{Text: "@$"}, {Text: "WBT_VALUE_FOLLOWING"}, {Child: 1}}}, + {Syms: "param_marker FOLLOWING_SYM", Kind: ActNew, Class: "PT_border", Params: []string{"pos", "type", "value"}, Args: []Arg{{Text: "@$"}, {Text: "WBT_VALUE_FOLLOWING"}, {Child: 1}}}, + {Syms: "INTERVAL_SYM expr interval FOLLOWING_SYM", Kind: ActNew, Class: "PT_border", Params: []string{"pos", "type", "value", "int_type"}, Args: []Arg{{Text: "@$"}, {Text: "WBT_VALUE_FOLLOWING"}, {Child: 2}, {Child: 3}}}, + }, + 482: { // opt_window_frame_exclusion + {Syms: "", Kind: ActEmpty}, + {Syms: "EXCLUDE_SYM CURRENT_SYM ROW_SYM", Kind: ActNew, Class: "PT_exclusion", Params: []string{"pos", "e"}, Args: []Arg{{Text: "@$"}, {Text: "WFX_CURRENT_ROW"}}}, + {Syms: "EXCLUDE_SYM GROUP_SYM", Kind: ActNew, Class: "PT_exclusion", Params: []string{"pos", "e"}, Args: []Arg{{Text: "@$"}, {Text: "WFX_GROUP"}}}, + {Syms: "EXCLUDE_SYM TIES_SYM", Kind: ActNew, Class: "PT_exclusion", Params: []string{"pos", "e"}, Args: []Arg{{Text: "@$"}, {Text: "WFX_TIES"}}}, + {Syms: "EXCLUDE_SYM NO_SYM OTHERS_SYM", Kind: ActNew, Class: "PT_exclusion", Params: []string{"pos", "e"}, Args: []Arg{{Text: "@$"}, {Text: "WFX_NO_OTHERS"}}}, + }, + 483: { // window_frame_units + {Syms: "ROWS_SYM", Kind: ActConst, Const: "WFU_ROWS"}, + {Syms: "RANGE_SYM", Kind: ActConst, Const: "WFU_RANGE"}, + {Syms: "GROUPS_SYM", Kind: ActConst, Const: "WFU_GROUPS"}, + }, + 484: { // grouping_operation + {Syms: "GROUPING_SYM '(' expr_list ')'", Kind: ActNew, Class: "Item_func_grouping", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 485: { // in_expression_user_variable_assignment + {Syms: "'@' ident_or_text SET_VAR expr", Kind: ActNew, Class: "PTI_variable_aux_set_var", Params: []string{"pos", "var", "expr"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Child: 4}}}, + }, + 486: { // rvalue_system_or_user_variable + {Syms: "'@' ident_or_text", Kind: ActNew, Class: "PTI_user_variable", Params: []string{"pos", "var"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "'@' '@' opt_rvalue_system_variable_type rvalue_system_variable", Kind: ActNew, Class: "PTI_get_system_variable", Params: []string{"pos", "scope", "name_pos", "opt_prefix", "name"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "@4"}, {Child: 4, Field: ".prefix"}, {Child: 4, Field: ".name"}}}, + }, + 487: { // opt_distinct + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "DISTINCT", Kind: ActConst, Const: "1"}, + }, + 488: { // opt_gconcat_separator + {Syms: "", Kind: ActNew, Class: "String", Args: []Arg{{Text: "\""}, {Text: "\""}, {Text: "1"}, {Text: "my_charset_latin1"}}}, + {Syms: "SEPARATOR_SYM text_string", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 489: { // opt_gorder_clause + {Syms: "", Kind: ActEmpty}, + {Syms: "ORDER_SYM BY gorder_list", Kind: ActPass, Args: []Arg{{Child: 3}}}, + }, + 490: { // gorder_list + {Syms: "gorder_list ',' order_expr", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + {Syms: "order_expr", Kind: ActListNew, Class: "PT_gorder_list", Args: []Arg{{Child: 1}}}, + }, + 491: { // in_sum_expr + {Syms: "opt_all expr", Kind: ActNew, Class: "PTI_in_sum_expr", Params: []string{"pos", "expr"}, Args: []Arg{{Text: "@1"}, {Child: 2}}}, + }, + 492: { // cast_type + {Syms: "BINARY_SYM opt_field_length", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "ITEM_CAST_CHAR"}}, {Name: "charset", Arg: Arg{Text: "my_charset_bin"}}, {Name: "length", Arg: Arg{Child: 2}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "CHAR_SYM opt_field_length opt_charset_with_opt_binary", Kind: ActUnknown}, + {Syms: "nchar opt_field_length", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "ITEM_CAST_CHAR"}}, {Name: "charset", Arg: Arg{Text: "national_charset_info"}}, {Name: "length", Arg: Arg{Child: 2}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "SIGNED_SYM", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "ITEM_CAST_SIGNED_INT"}}, {Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "length", Arg: Arg{Text: "nullptr"}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "SIGNED_SYM INT_SYM", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "ITEM_CAST_SIGNED_INT"}}, {Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "length", Arg: Arg{Text: "nullptr"}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "UNSIGNED_SYM", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "ITEM_CAST_UNSIGNED_INT"}}, {Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "length", Arg: Arg{Text: "nullptr"}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "UNSIGNED_SYM INT_SYM", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "ITEM_CAST_UNSIGNED_INT"}}, {Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "length", Arg: Arg{Text: "nullptr"}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "DATE_SYM", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "ITEM_CAST_DATE"}}, {Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "length", Arg: Arg{Text: "nullptr"}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "YEAR_SYM", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "ITEM_CAST_YEAR"}}, {Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "length", Arg: Arg{Text: "nullptr"}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "TIME_SYM type_datetime_precision", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "ITEM_CAST_TIME"}}, {Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "length", Arg: Arg{Text: "nullptr"}}, {Name: "dec", Arg: Arg{Child: 2}}}}, + {Syms: "DATETIME_SYM type_datetime_precision", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "ITEM_CAST_DATETIME"}}, {Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "length", Arg: Arg{Text: "nullptr"}}, {Name: "dec", Arg: Arg{Child: 2}}}}, + {Syms: "DECIMAL_SYM float_options", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "ITEM_CAST_DECIMAL"}}, {Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "length", Arg: Arg{Child: 2, Field: ".length"}}, {Name: "dec", Arg: Arg{Child: 2, Field: ".dec"}}}}, + {Syms: "JSON_SYM", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "ITEM_CAST_JSON"}}, {Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "length", Arg: Arg{Text: "nullptr"}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "real_type", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "($1==Numeric_type::DOUBLE)?ITEM_CAST_DOUBLE:ITEM_CAST_FLOAT"}}, {Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "length", Arg: Arg{Text: "nullptr"}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "FLOAT_SYM standard_float_options", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "ITEM_CAST_FLOAT"}}, {Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "length", Arg: Arg{Child: 2, Field: ".length"}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "POINT_SYM", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "ITEM_CAST_POINT"}}, {Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "length", Arg: Arg{Text: "nullptr"}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "LINESTRING_SYM", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "ITEM_CAST_LINESTRING"}}, {Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "length", Arg: Arg{Text: "nullptr"}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "POLYGON_SYM", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "ITEM_CAST_POLYGON"}}, {Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "length", Arg: Arg{Text: "nullptr"}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "MULTIPOINT_SYM", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "ITEM_CAST_MULTIPOINT"}}, {Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "length", Arg: Arg{Text: "nullptr"}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "MULTILINESTRING_SYM", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "ITEM_CAST_MULTILINESTRING"}}, {Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "length", Arg: Arg{Text: "nullptr"}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "MULTIPOLYGON_SYM", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "ITEM_CAST_MULTIPOLYGON"}}, {Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "length", Arg: Arg{Text: "nullptr"}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "GEOMETRYCOLLECTION_SYM", Kind: ActStruct, Fields: []Field{{Name: "target", Arg: Arg{Text: "ITEM_CAST_GEOMETRYCOLLECTION"}}, {Name: "charset", Arg: Arg{Text: "nullptr"}}, {Name: "length", Arg: Arg{Text: "nullptr"}}, {Name: "dec", Arg: Arg{Text: "nullptr"}}}}, + }, + 493: { // opt_expr_list + {Syms: "", Kind: ActEmpty}, + {Syms: "expr_list", Kind: ActDefault}, + }, + 494: { // expr_list + {Syms: "expr", Kind: ActListNew, Class: "PT_item_list", Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "expr_list ',' expr", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 495: { // ident_list_arg + {Syms: "ident_list", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "'(' ident_list ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 496: { // ident_list + {Syms: "simple_ident", Kind: ActListNew, Class: "PT_item_list", Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "ident_list ',' simple_ident", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 497: { // opt_expr + {Syms: "", Kind: ActEmpty}, + {Syms: "expr", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 498: { // opt_else + {Syms: "", Kind: ActEmpty}, + {Syms: "ELSE expr", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 499: { // when_list + {Syms: "WHEN_SYM expr THEN_SYM expr", Kind: ActListNew, Args: []Arg{{Child: 2}, {Child: 4}}}, + {Syms: "when_list WHEN_SYM expr THEN_SYM expr", Kind: ActUnknown}, + }, + 500: { // table_reference + {Syms: "table_factor", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "joined_table", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "'{' OJ_SYM esc_table_reference '}'", Kind: ActPass, Args: []Arg{{Child: 3}}}, + }, + 501: { // esc_table_reference + {Syms: "table_factor", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "joined_table", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 502: { // joined_table + {Syms: "table_reference inner_join_type table_reference ON_SYM expr", Kind: ActNew, Class: "PT_joined_table_on", Params: []string{"pos", "tab1_node", "join_pos", "type", "tab2_node", "on"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Text: "@2"}, {Child: 2}, {Child: 3}, {Child: 5}}}, + {Syms: "table_reference inner_join_type table_reference USING '(' using_list ')'", Kind: ActNew, Class: "PT_joined_table_using", Params: []string{"pos", "tab1_node", "join_pos", "type", "tab2_node", "using_fields"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Text: "@2"}, {Child: 2}, {Child: 3}, {Child: 6}}}, + {Syms: "table_reference outer_join_type table_reference ON_SYM expr", Kind: ActNew, Class: "PT_joined_table_on", Params: []string{"pos", "tab1_node", "join_pos", "type", "tab2_node", "on"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Text: "@2"}, {Child: 2}, {Child: 3}, {Child: 5}}}, + {Syms: "table_reference outer_join_type table_reference USING '(' using_list ')'", Kind: ActNew, Class: "PT_joined_table_using", Params: []string{"pos", "tab1_node", "join_pos", "type", "tab2_node", "using_fields"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Text: "@2"}, {Child: 2}, {Child: 3}, {Child: 6}}}, + {Syms: "table_reference inner_join_type table_reference", Kind: ActUnknown}, + {Syms: "table_reference natural_join_type table_factor", Kind: ActNew, Class: "PT_joined_table_using", Params: []string{"pos", "tab1_node", "join_pos", "type", "tab2_node"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Text: "@2"}, {Child: 2}, {Child: 3}}}, + }, + 503: { // natural_join_type + {Syms: "NATURAL opt_inner JOIN_SYM", Kind: ActConst, Const: "JTT_NATURAL_INNER"}, + {Syms: "NATURAL RIGHT opt_outer JOIN_SYM", Kind: ActConst, Const: "JTT_NATURAL_RIGHT"}, + {Syms: "NATURAL LEFT opt_outer JOIN_SYM", Kind: ActConst, Const: "JTT_NATURAL_LEFT"}, + }, + 504: { // inner_join_type + {Syms: "JOIN_SYM", Kind: ActConst, Const: "JTT_INNER"}, + {Syms: "INNER_SYM JOIN_SYM", Kind: ActConst, Const: "JTT_INNER"}, + {Syms: "CROSS JOIN_SYM", Kind: ActConst, Const: "JTT_INNER"}, + {Syms: "STRAIGHT_JOIN", Kind: ActConst, Const: "JTT_STRAIGHT_INNER"}, + }, + 505: { // outer_join_type + {Syms: "LEFT opt_outer JOIN_SYM", Kind: ActConst, Const: "JTT_LEFT"}, + {Syms: "RIGHT opt_outer JOIN_SYM", Kind: ActConst, Const: "JTT_RIGHT"}, + }, + 506: { // opt_inner + {Syms: "", Kind: ActDefault}, + {Syms: "INNER_SYM", Kind: ActDefault}, + }, + 507: { // opt_outer + {Syms: "", Kind: ActDefault}, + {Syms: "OUTER_SYM", Kind: ActDefault}, + }, + 508: { // opt_use_partition + {Syms: "", Kind: ActEmpty}, + {Syms: "use_partition", Kind: ActDefault}, + }, + 509: { // use_partition + {Syms: "PARTITION_SYM '(' using_list ')'", Kind: ActPass, Args: []Arg{{Child: 3}}}, + }, + 510: { // table_factor + {Syms: "single_table", Kind: ActDefault}, + {Syms: "single_table_parens", Kind: ActDefault}, + {Syms: "derived_table", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "joined_table_parens", Kind: ActNew, Class: "PT_table_factor_joined_table", Params: []string{"pos", "joined_table"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "table_reference_list_parens", Kind: ActNew, Class: "PT_table_reference_list_parens", Params: []string{"pos", "table_list"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "table_function", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 511: { // table_reference_list_parens + {Syms: "'(' table_reference_list_parens ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + {Syms: "'(' table_reference_list ',' table_reference ')'", Kind: ActListAppend, Args: []Arg{{Child: 2}, {Child: 4}}}, + }, + 512: { // single_table_parens + {Syms: "'(' single_table_parens ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + {Syms: "'(' single_table ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 513: { // single_table + {Syms: "table_ident opt_use_partition opt_table_alias opt_key_definition opt_tablesample_clause", Kind: ActNew, Class: "PT_table_factor_table_ident", Params: []string{"pos", "table_ident", "opt_use_partition", "opt_table_alias", "opt_key_definition", "opt_tablesample"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}, {Child: 3}, {Child: 4}, {Child: 5}}}, + }, + 514: { // joined_table_parens + {Syms: "'(' joined_table_parens ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + {Syms: "'(' joined_table ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 515: { // derived_table + {Syms: "table_subquery opt_table_alias opt_derived_column_list", Kind: ActNew, Class: "PT_derived_table", Params: []string{"pos", "lateral", "subquery", "table_alias", "column_names"}, Args: []Arg{{Text: "@$"}, {Text: "false"}, {Child: 1}, {Child: 2}, {Child: 3}}}, + {Syms: "LATERAL_SYM table_subquery opt_table_alias opt_derived_column_list", Kind: ActNew, Class: "PT_derived_table", Params: []string{"pos", "lateral", "subquery", "table_alias", "column_names"}, Args: []Arg{{Text: "@$"}, {Text: "true"}, {Child: 2}, {Child: 3}, {Child: 4}}}, + }, + 516: { // table_function + {Syms: "JSON_TABLE_SYM '(' expr ',' text_literal columns_clause ')' opt_table_alias", Kind: ActNew, Class: "PT_table_factor_function", Params: []string{"pos", "expr", "path", "nested_cols", "table_alias"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5}, {Child: 6}, {Text: "to_lex_string($8)"}}}, + }, + 517: { // columns_clause + {Syms: "COLUMNS '(' columns_list ')'", Kind: ActPass, Args: []Arg{{Child: 3}}}, + }, + 518: { // columns_list + {Syms: "jt_column", Kind: ActListNew, Class: "Mem_root_array", Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 1}}}, + {Syms: "columns_list ',' jt_column", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 519: { // jt_column + {Syms: "ident FOR_SYM ORDINALITY_SYM", Kind: ActNew, Class: "PT_json_table_column_for_ordinality", Params: []string{"pos", "name"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "ident type opt_collate jt_column_type PATH_SYM text_literal opt_on_empty_or_error_json_table", Kind: ActUnknown}, + {Syms: "NESTED_SYM PATH_SYM text_literal columns_clause", Kind: ActNew, Class: "PT_json_table_column_with_nested_path", Params: []string{"pos", "path", "nested_cols"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 4}}}, + }, + 520: { // jt_column_type + {Syms: "", Kind: ActConst, Const: "enum_jt_column::JTC_PATH"}, + {Syms: "EXISTS", Kind: ActConst, Const: "enum_jt_column::JTC_EXISTS"}, + }, + 521: { // opt_on_empty_or_error + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "empty", Arg: Arg{Text: "{Json_on_response_type::IMPLICIT,nullptr}"}}, {Name: "error", Arg: Arg{Text: "{Json_on_response_type::IMPLICIT,nullptr}"}}}}, + {Syms: "on_empty", Kind: ActStruct, Fields: []Field{{Name: "empty", Arg: Arg{Child: 1}}, {Name: "error", Arg: Arg{Text: "{Json_on_response_type::IMPLICIT,nullptr}"}}}}, + {Syms: "on_error", Kind: ActStruct, Fields: []Field{{Name: "error", Arg: Arg{Child: 1}}, {Name: "empty", Arg: Arg{Text: "{Json_on_response_type::IMPLICIT,nullptr}"}}}}, + {Syms: "on_empty on_error", Kind: ActStruct, Fields: []Field{{Name: "empty", Arg: Arg{Child: 1}}, {Name: "error", Arg: Arg{Child: 2}}}}, + }, + 522: { // opt_on_empty_or_error_json_table + {Syms: "opt_on_empty_or_error", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "on_error on_empty", Kind: ActStruct, Fields: []Field{{Name: "error", Arg: Arg{Child: 1}}, {Name: "empty", Arg: Arg{Child: 2}}}}, + }, + 523: { // on_empty + {Syms: "json_on_response ON_SYM EMPTY_SYM", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 524: { // on_error + {Syms: "json_on_response ON_SYM ERROR_SYM", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 525: { // json_on_response + {Syms: "ERROR_SYM", Kind: ActStruct, Fields: []Field{{Name: "type", Arg: Arg{Text: "Json_on_response_type::ERROR"}}, {Name: "default_string", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "NULL_SYM", Kind: ActStruct, Fields: []Field{{Name: "type", Arg: Arg{Text: "Json_on_response_type::NULL_VALUE"}}, {Name: "default_string", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "DEFAULT_SYM signed_literal", Kind: ActStruct, Fields: []Field{{Name: "type", Arg: Arg{Text: "Json_on_response_type::DEFAULT"}}, {Name: "default_string", Arg: Arg{Child: 2}}}}, + }, + 526: { // index_hint_clause + {Syms: "", Kind: ActConst, Const: "INDEX_HINT_MASK_ALL"}, + {Syms: "FOR_SYM JOIN_SYM", Kind: ActConst, Const: "INDEX_HINT_MASK_JOIN"}, + {Syms: "FOR_SYM ORDER_SYM BY", Kind: ActConst, Const: "INDEX_HINT_MASK_ORDER"}, + {Syms: "FOR_SYM GROUP_SYM BY", Kind: ActConst, Const: "INDEX_HINT_MASK_GROUP"}, + }, + 527: { // index_hint_type + {Syms: "FORCE_SYM", Kind: ActConst, Const: "INDEX_HINT_FORCE"}, + {Syms: "IGNORE_SYM", Kind: ActConst, Const: "INDEX_HINT_IGNORE"}, + }, + 528: { // index_hint_definition + {Syms: "index_hint_type key_or_index index_hint_clause '(' key_usage_list ')'", Kind: ActUnknown}, + {Syms: "USE_SYM key_or_index index_hint_clause '(' opt_key_usage_list ')'", Kind: ActUnknown}, + }, + 529: { // index_hints_list + {Syms: "index_hint_definition", Kind: ActDefault}, + {Syms: "index_hints_list index_hint_definition", Kind: ActUnknown}, + }, + 530: { // opt_index_hints_list + {Syms: "", Kind: ActEmpty}, + {Syms: "index_hints_list", Kind: ActDefault}, + }, + 531: { // opt_key_definition + {Syms: "opt_index_hints_list", Kind: ActDefault}, + }, + 532: { // opt_key_usage_list + {Syms: "", Kind: ActUnknown}, + {Syms: "key_usage_list", Kind: ActDefault}, + }, + 533: { // key_usage_element + {Syms: "ident", Kind: ActNew, Class: "Index_hint", Params: []string{"str", "length"}, Args: []Arg{{Child: 1, Field: ".str"}, {Child: 1, Field: ".length"}}}, + {Syms: "PRIMARY_SYM", Kind: ActNew, Class: "Index_hint", Args: []Arg{{Text: "STRING_WITH_LEN(\"PRIMARY\")"}}}, + }, + 534: { // key_usage_list + {Syms: "key_usage_element", Kind: ActListNew, Args: []Arg{{Child: 1}}}, + {Syms: "key_usage_list ',' key_usage_element", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 535: { // using_list + {Syms: "ident_string_list", Kind: ActDefault}, + }, + 536: { // ident_string_list + {Syms: "ident", Kind: ActUnknown}, + {Syms: "ident_string_list ',' ident", Kind: ActUnknown}, + }, + 537: { // interval + {Syms: "interval_time_stamp", Kind: ActDefault}, + {Syms: "DAY_HOUR_SYM", Kind: ActConst, Const: "INTERVAL_DAY_HOUR"}, + {Syms: "DAY_MICROSECOND_SYM", Kind: ActConst, Const: "INTERVAL_DAY_MICROSECOND"}, + {Syms: "DAY_MINUTE_SYM", Kind: ActConst, Const: "INTERVAL_DAY_MINUTE"}, + {Syms: "DAY_SECOND_SYM", Kind: ActConst, Const: "INTERVAL_DAY_SECOND"}, + {Syms: "HOUR_MICROSECOND_SYM", Kind: ActConst, Const: "INTERVAL_HOUR_MICROSECOND"}, + {Syms: "HOUR_MINUTE_SYM", Kind: ActConst, Const: "INTERVAL_HOUR_MINUTE"}, + {Syms: "HOUR_SECOND_SYM", Kind: ActConst, Const: "INTERVAL_HOUR_SECOND"}, + {Syms: "MINUTE_MICROSECOND_SYM", Kind: ActConst, Const: "INTERVAL_MINUTE_MICROSECOND"}, + {Syms: "MINUTE_SECOND_SYM", Kind: ActConst, Const: "INTERVAL_MINUTE_SECOND"}, + {Syms: "SECOND_MICROSECOND_SYM", Kind: ActConst, Const: "INTERVAL_SECOND_MICROSECOND"}, + {Syms: "YEAR_MONTH_SYM", Kind: ActConst, Const: "INTERVAL_YEAR_MONTH"}, + }, + 538: { // interval_time_stamp + {Syms: "DAY_SYM", Kind: ActConst, Const: "INTERVAL_DAY"}, + {Syms: "WEEK_SYM", Kind: ActConst, Const: "INTERVAL_WEEK"}, + {Syms: "HOUR_SYM", Kind: ActConst, Const: "INTERVAL_HOUR"}, + {Syms: "MINUTE_SYM", Kind: ActConst, Const: "INTERVAL_MINUTE"}, + {Syms: "MONTH_SYM", Kind: ActConst, Const: "INTERVAL_MONTH"}, + {Syms: "QUARTER_SYM", Kind: ActConst, Const: "INTERVAL_QUARTER"}, + {Syms: "SECOND_SYM", Kind: ActConst, Const: "INTERVAL_SECOND"}, + {Syms: "MICROSECOND_SYM", Kind: ActConst, Const: "INTERVAL_MICROSECOND"}, + {Syms: "YEAR_SYM", Kind: ActConst, Const: "INTERVAL_YEAR"}, + }, + 539: { // date_time_type + {Syms: "DATE_SYM", Kind: ActConst, Const: "MYSQL_TIMESTAMP_DATE"}, + {Syms: "TIME_SYM", Kind: ActConst, Const: "MYSQL_TIMESTAMP_TIME"}, + {Syms: "TIMESTAMP_SYM", Kind: ActConst, Const: "MYSQL_TIMESTAMP_DATETIME"}, + {Syms: "DATETIME_SYM", Kind: ActConst, Const: "MYSQL_TIMESTAMP_DATETIME"}, + }, + 540: { // opt_as + {Syms: "", Kind: ActDefault}, + {Syms: "AS", Kind: ActDefault}, + }, + 541: { // opt_table_alias + {Syms: "", Kind: ActEmpty}, + {Syms: "opt_as ident", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 542: { // opt_all + {Syms: "", Kind: ActDefault}, + {Syms: "ALL", Kind: ActDefault}, + }, + 543: { // opt_where_clause + {Syms: "", Kind: ActEmpty}, + {Syms: "where_clause", Kind: ActDefault}, + }, + 544: { // where_clause + {Syms: "WHERE expr", Kind: ActNew, Class: "PTI_where", Params: []string{"pos", "expr"}, Args: []Arg{{Text: "@2"}, {Child: 2}}}, + }, + 545: { // opt_having_clause + {Syms: "", Kind: ActEmpty}, + {Syms: "HAVING expr", Kind: ActNew, Class: "PTI_having", Params: []string{"pos", "expr"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + }, + 546: { // opt_qualify_clause + {Syms: "", Kind: ActEmpty}, + {Syms: "QUALIFY_SYM expr", Kind: ActNew, Class: "PTI_qualify", Params: []string{"pos", "expr"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + }, + 547: { // with_clause + {Syms: "WITH with_list", Kind: ActNew, Class: "PT_with_clause", Params: []string{"pos", "l", "r"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Text: "false"}}}, + {Syms: "WITH RECURSIVE_SYM with_list", Kind: ActNew, Class: "PT_with_clause", Params: []string{"pos", "l", "r"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "true"}}}, + }, + 548: { // with_list + {Syms: "with_list ',' common_table_expr", Kind: ActUnknown}, + {Syms: "common_table_expr", Kind: ActListNew, Class: "PT_with_list", Args: []Arg{{Text: "@$"}, {Text: "YYTHD->mem_root"}, {Child: 1}}}, + }, + 549: { // common_table_expr + {Syms: "ident opt_derived_column_list AS table_subquery", Kind: ActUnknown}, + }, + 550: { // opt_derived_column_list + {Syms: "", Kind: ActListNew}, + {Syms: "'(' simple_ident_list ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 551: { // simple_ident_list + {Syms: "ident", Kind: ActListNew, Args: []Arg{{Child: 1}}}, + {Syms: "simple_ident_list ',' ident", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 552: { // opt_window_clause + {Syms: "", Kind: ActEmpty}, + {Syms: "WINDOW_SYM window_definition_list", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 553: { // window_definition_list + {Syms: "window_definition", Kind: ActListNew, Class: "PT_window_list", Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "window_definition_list ',' window_definition", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 554: { // window_definition + {Syms: "window_name AS window_spec", Kind: ActUnknown}, + }, + 555: { // opt_group_clause + {Syms: "", Kind: ActEmpty}, + {Syms: "GROUP_SYM BY group_list olap_opt", Kind: ActNew, Class: "PT_group", Params: []string{"pos", "group_list", "olap"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 4}}}, + {Syms: "GROUP_SYM BY ROLLUP_SYM '(' group_list ')'", Kind: ActNew, Class: "PT_group", Params: []string{"pos", "group_list", "olap"}, Args: []Arg{{Text: "@$"}, {Child: 5}, {Text: "ROLLUP_TYPE"}}}, + {Syms: "GROUP_SYM BY CUBE_SYM '(' group_list ')'", Kind: ActNew, Class: "PT_group", Params: []string{"pos", "group_list", "olap"}, Args: []Arg{{Text: "@$"}, {Child: 5}, {Text: "CUBE_TYPE"}}}, + }, + 556: { // group_list + {Syms: "group_list ',' grouping_expr", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + {Syms: "grouping_expr", Kind: ActListNew, Class: "PT_order_list", Args: []Arg{{Child: 1}}}, + }, + 557: { // olap_opt + {Syms: "", Kind: ActConst, Const: "UNSPECIFIED_OLAP_TYPE"}, + {Syms: "WITH_ROLLUP_SYM", Kind: ActConst, Const: "ROLLUP_TYPE"}, + }, + 558: { // alter_order_list + {Syms: "alter_order_list ',' alter_order_item", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + {Syms: "alter_order_item", Kind: ActListNew, Class: "PT_order_list", Args: []Arg{{Child: 1}}}, + }, + 559: { // alter_order_item + {Syms: "simple_ident_nospvar opt_ordering_direction", Kind: ActNew, Class: "PT_order_expr", Params: []string{"pos", "item", "dir"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}}}, + }, + 560: { // opt_order_clause + {Syms: "", Kind: ActEmpty}, + {Syms: "order_clause", Kind: ActDefault}, + }, + 561: { // order_clause + {Syms: "ORDER_SYM BY order_list", Kind: ActNew, Class: "PT_order", Params: []string{"pos", "order_list"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 562: { // order_list + {Syms: "order_list ',' order_expr", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + {Syms: "order_expr", Kind: ActListNew, Class: "PT_order_list", Args: []Arg{{Child: 1}}}, + }, + 563: { // opt_ordering_direction + {Syms: "", Kind: ActConst, Const: "ORDER_NOT_RELEVANT"}, + {Syms: "ordering_direction", Kind: ActDefault}, + }, + 564: { // ordering_direction + {Syms: "ASC", Kind: ActConst, Const: "ORDER_ASC"}, + {Syms: "DESC", Kind: ActConst, Const: "ORDER_DESC"}, + }, + 565: { // opt_limit_clause + {Syms: "", Kind: ActEmpty}, + {Syms: "limit_clause", Kind: ActDefault}, + }, + 566: { // limit_clause + {Syms: "LIMIT limit_options", Kind: ActNew, Class: "PT_limit_clause", Params: []string{"pos", "limit_options"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + }, + 567: { // limit_options + {Syms: "limit_option", Kind: ActStruct, Fields: []Field{{Name: "limit", Arg: Arg{Child: 1}}, {Name: "opt_offset", Arg: Arg{Text: "nullptr"}}, {Name: "is_offset_first", Arg: Arg{Text: "false"}}}}, + {Syms: "limit_option ',' limit_option", Kind: ActStruct, Fields: []Field{{Name: "limit", Arg: Arg{Child: 3}}, {Name: "opt_offset", Arg: Arg{Child: 1}}, {Name: "is_offset_first", Arg: Arg{Text: "true"}}}}, + {Syms: "limit_option OFFSET_SYM limit_option", Kind: ActStruct, Fields: []Field{{Name: "limit", Arg: Arg{Child: 1}}, {Name: "opt_offset", Arg: Arg{Child: 3}}, {Name: "is_offset_first", Arg: Arg{Text: "false"}}}}, + }, + 568: { // limit_option + {Syms: "ident", Kind: ActNew, Class: "PTI_limit_option_ident", Params: []string{"pos", "name"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "param_marker", Kind: ActNew, Class: "PTI_limit_option_param_marker", Params: []string{"pos", "param_marker"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "ULONGLONG_NUM", Kind: ActNew, Class: "Item_uint", Params: []string{"pos", "str", "length"}, Args: []Arg{{Text: "@$"}, {Child: 1, Field: ".str"}, {Child: 1, Field: ".length"}}}, + {Syms: "LONG_NUM", Kind: ActNew, Class: "Item_uint", Params: []string{"pos", "str", "length"}, Args: []Arg{{Text: "@$"}, {Child: 1, Field: ".str"}, {Child: 1, Field: ".length"}}}, + {Syms: "NUM", Kind: ActNew, Class: "Item_uint", Params: []string{"pos", "str", "length"}, Args: []Arg{{Text: "@$"}, {Child: 1, Field: ".str"}, {Child: 1, Field: ".length"}}}, + }, + 569: { // opt_simple_limit + {Syms: "", Kind: ActEmpty}, + {Syms: "LIMIT limit_option", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 570: { // ulong_num + {Syms: "NUM", Kind: ActNumber, Args: []Arg{{Child: 1}}}, + {Syms: "HEX_NUM", Kind: ActNumber, Const: "16", Args: []Arg{{Child: 1}}}, + {Syms: "LONG_NUM", Kind: ActNumber, Args: []Arg{{Child: 1}}}, + {Syms: "ULONGLONG_NUM", Kind: ActNumber, Args: []Arg{{Child: 1}}}, + {Syms: "DECIMAL_NUM", Kind: ActNumber, Args: []Arg{{Child: 1}}}, + {Syms: "FLOAT_NUM", Kind: ActNumber, Args: []Arg{{Child: 1}}}, + }, + 571: { // real_ulong_num + {Syms: "NUM", Kind: ActNumber, Args: []Arg{{Child: 1}}}, + {Syms: "HEX_NUM", Kind: ActNumber, Const: "16", Args: []Arg{{Child: 1}}}, + {Syms: "LONG_NUM", Kind: ActNumber, Args: []Arg{{Child: 1}}}, + {Syms: "ULONGLONG_NUM", Kind: ActNumber, Args: []Arg{{Child: 1}}}, + {Syms: "dec_num_error", Kind: ActDefault}, + }, + 572: { // ulonglong_num + {Syms: "NUM", Kind: ActNumber, Args: []Arg{{Child: 1}}}, + {Syms: "ULONGLONG_NUM", Kind: ActNumber, Args: []Arg{{Child: 1}}}, + {Syms: "LONG_NUM", Kind: ActNumber, Args: []Arg{{Child: 1}}}, + {Syms: "DECIMAL_NUM", Kind: ActNumber, Args: []Arg{{Child: 1}}}, + {Syms: "FLOAT_NUM", Kind: ActNumber, Args: []Arg{{Child: 1}}}, + }, + 573: { // real_ulonglong_num + {Syms: "NUM", Kind: ActNumber, Args: []Arg{{Child: 1}}}, + {Syms: "HEX_NUM", Kind: ActNumber, Const: "16", Args: []Arg{{Child: 1}}}, + {Syms: "ULONGLONG_NUM", Kind: ActNumber, Args: []Arg{{Child: 1}}}, + {Syms: "LONG_NUM", Kind: ActNumber, Args: []Arg{{Child: 1}}}, + {Syms: "dec_num_error", Kind: ActDefault}, + }, + 574: { // dec_num_error + {Syms: "dec_num", Kind: ActDefault}, + }, + 575: { // dec_num + {Syms: "DECIMAL_NUM", Kind: ActDefault}, + {Syms: "FLOAT_NUM", Kind: ActDefault}, + }, + 576: { // select_var_list + {Syms: "select_var_list ',' select_var_ident", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + {Syms: "select_var_ident", Kind: ActListNew, Class: "PT_select_var_list", Args: []Arg{{Text: "@$"}, {Child: 1}}}, + }, + 577: { // select_var_ident + {Syms: "'@' ident_or_text", Kind: ActNew, Class: "PT_select_var", Params: []string{"pos", "name"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "ident_or_text", Kind: ActNew, Class: "PT_select_sp_var", Params: []string{"pos", "name"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + }, + 578: { // into_clause + {Syms: "INTO into_destination", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 579: { // into_destination + {Syms: "OUTFILE TEXT_STRING_filesystem opt_load_data_charset opt_field_term opt_line_term", Kind: ActNew, Class: "PT_into_destination_outfile", Params: []string{"pos", "file_name", "charset", "field_term", "line_term"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Child: 3}, {Child: 4}, {Child: 5}}}, + {Syms: "DUMPFILE TEXT_STRING_filesystem", Kind: ActNew, Class: "PT_into_destination_dumpfile", Params: []string{"pos", "file_name"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "select_var_list", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 580: { // do_stmt + {Syms: "DO_SYM select_item_list", Kind: ActNew, Class: "PT_select_stmt", Params: []string{"pos", "sql_command", "qe"}, Args: []Arg{{Text: "@$"}, {Text: "SQLCOM_DO"}, {Text: "NEW_PTNPT_query_expression(@$,NEW_PTNPT_query_specification(@$,{},$2))"}}}, + }, + 581: { // drop_table_stmt + {Syms: "DROP opt_temporary table_or_tables if_exists table_list opt_restrict", Kind: ActUnknown}, + }, + 582: { // drop_index_stmt + {Syms: "DROP INDEX_SYM ident ON_SYM table_ident opt_index_lock_and_algorithm", Kind: ActNew, Class: "PT_drop_index_stmt", Params: []string{"pos", "mem_root", "index_name", "table", "algo", "lock"}, Args: []Arg{{Text: "@$"}, {Text: "YYMEM_ROOT"}, {Child: 3, Field: ".str"}, {Child: 5}, {Child: 6, Field: ".algo"}, {Child: 6, Field: ".lock"}}}, + }, + 583: { // drop_database_stmt + {Syms: "DROP DATABASE if_exists ident", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_DROP_DB"}}, {Name: "drop_if_exists", Arg: Arg{Child: 3}}, {Name: "name", Arg: Arg{Child: 4}}}}, + }, + 584: { // drop_function_stmt + {Syms: "DROP FUNCTION_SYM if_exists ident '.' ident", Kind: ActUnknown}, + {Syms: "DROP FUNCTION_SYM if_exists ident", Kind: ActUnknown}, + }, + 585: { // drop_resource_group_stmt + {Syms: "DROP RESOURCE_SYM GROUP_SYM ident opt_force", Kind: ActNew, Class: "PT_drop_resource_group", Params: []string{"pos", "resource_group_name", "force"}, Args: []Arg{{Text: "@$"}, {Child: 4}, {Child: 5}}}, + }, + 586: { // drop_procedure_stmt + {Syms: "DROP PROCEDURE_SYM if_exists sp_name", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_DROP_PROCEDURE"}}, {Name: "drop_if_exists", Arg: Arg{Child: 3}}, {Name: "spname", Arg: Arg{Child: 4}}}}, + }, + 587: { // drop_user_stmt + {Syms: "DROP USER if_exists user_list", Kind: ActUnknown}, + }, + 588: { // drop_view_stmt + {Syms: "DROP VIEW_SYM if_exists table_list opt_restrict", Kind: ActUnknown}, + }, + 589: { // drop_event_stmt + {Syms: "DROP EVENT_SYM if_exists sp_name", Kind: ActStruct, Fields: []Field{{Name: "drop_if_exists", Arg: Arg{Child: 3}}, {Name: "spname", Arg: Arg{Child: 4}}, {Name: "sql_command", Arg: Arg{Text: "SQLCOM_DROP_EVENT"}}}}, + }, + 590: { // drop_trigger_stmt + {Syms: "DROP TRIGGER_SYM if_exists sp_name", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_DROP_TRIGGER"}}, {Name: "drop_if_exists", Arg: Arg{Child: 3}}, {Name: "spname", Arg: Arg{Child: 4}}, {Name: "m_sql_cmd", Arg: Arg{Text: "new(YYTHD->mem_root)Sql_cmd_drop_trigger()"}}}}, + }, + 591: { // drop_tablespace_stmt + {Syms: "DROP TABLESPACE_SYM ident opt_drop_ts_options", Kind: ActUnknown}, + }, + 592: { // drop_undo_tablespace_stmt + {Syms: "DROP UNDO_SYM TABLESPACE_SYM ident opt_undo_tablespace_options", Kind: ActUnknown}, + }, + 593: { // drop_logfile_stmt + {Syms: "DROP LOGFILE_SYM GROUP_SYM ident opt_drop_ts_options", Kind: ActUnknown}, + }, + 594: { // drop_server_stmt + {Syms: "DROP SERVER_SYM if_exists ident_or_text", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_DROP_SERVER"}}, {Name: "m_sql_cmd", Arg: Arg{Text: "NEW_PTNSql_cmd_drop_server($4,$3)"}}}}, + }, + 595: { // drop_srs_stmt + {Syms: "DROP SPATIAL_SYM REFERENCE_SYM SYSTEM_SYM if_exists real_ulonglong_num", Kind: ActNew, Class: "PT_drop_srs", Params: []string{"pos", "srid", "if_exists"}, Args: []Arg{{Text: "@$"}, {Child: 6}, {Child: 5}}}, + }, + 596: { // drop_role_stmt + {Syms: "DROP ROLE_SYM if_exists role_list", Kind: ActNew, Class: "PT_drop_role", Params: []string{"pos", "ignore_errors", "roles"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 4}}}, + }, + 597: { // table_list + {Syms: "table_ident", Kind: ActListNew, Args: []Arg{{Child: 1}}}, + {Syms: "table_list ',' table_ident", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 598: { // table_alias_ref_list + {Syms: "table_ident_opt_wild", Kind: ActListNew, Args: []Arg{{Child: 1}}}, + {Syms: "table_alias_ref_list ',' table_ident_opt_wild", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 599: { // if_exists + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "IF EXISTS", Kind: ActConst, Const: "1"}, + }, + 600: { // opt_ignore_unknown_user + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "IGNORE_SYM UNKNOWN_SYM USER", Kind: ActConst, Const: "1"}, + }, + 601: { // opt_temporary + {Syms: "", Kind: ActConst, Const: "false"}, + {Syms: "TEMPORARY", Kind: ActConst, Const: "true"}, + }, + 602: { // opt_drop_ts_options + {Syms: "", Kind: ActEmpty}, + {Syms: "drop_ts_option_list", Kind: ActDefault}, + }, + 603: { // drop_ts_option_list + {Syms: "drop_ts_option", Kind: ActListNew, Class: "Mem_root_array", Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 1}}}, + {Syms: "drop_ts_option_list opt_comma drop_ts_option", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 604: { // drop_ts_option + {Syms: "ts_option_engine", Kind: ActDefault}, + {Syms: "ts_option_wait", Kind: ActDefault}, + }, + 605: { // insert_stmt + {Syms: "INSERT_SYM insert_lock_option opt_ignore opt_INTO table_ident opt_use_partition insert_from_constructor opt_values_reference opt_insert_update_list", Kind: ActNew, Class: "PT_insert", Params: []string{"pos", "is_replace", "opt_hints", "lock_option", "ignore", "table_ident", "opt_use_partition", "column_list", "row_value_list", "insert_query_expression", "opt_values_table_alias", "opt_values_column_list", "opt_on_duplicate_column_list", "opt_on_duplicate_value_list"}, Args: []Arg{{Text: "@$"}, {Text: "false"}, {Child: 1}, {Child: 2}, {Child: 3}, {Child: 5}, {Child: 6}, {Child: 7, Field: ".column_list"}, {Child: 7, Field: ".row_value_list"}, {Text: "nullptr"}, {Child: 8, Field: ".table_alias"}, {Child: 8, Field: ".column_list"}, {Child: 9, Field: ".column_list"}, {Child: 9, Field: ".value_list"}}}, + {Syms: "INSERT_SYM insert_lock_option opt_ignore opt_INTO table_ident opt_use_partition SET_SYM update_list opt_values_reference opt_insert_update_list", Kind: ActUnknown}, + {Syms: "INSERT_SYM insert_lock_option opt_ignore opt_INTO table_ident opt_use_partition insert_query_expression opt_insert_update_list", Kind: ActNew, Class: "PT_insert", Params: []string{"pos", "is_replace", "opt_hints", "lock_option", "ignore", "table_ident", "opt_use_partition", "column_list", "row_value_list", "insert_query_expression", "opt_values_table_alias", "opt_values_column_list", "opt_on_duplicate_column_list", "opt_on_duplicate_value_list"}, Args: []Arg{{Text: "@$"}, {Text: "false"}, {Child: 1}, {Child: 2}, {Child: 3}, {Child: 5}, {Child: 6}, {Child: 7, Field: ".column_list"}, {Text: "nullptr"}, {Child: 7, Field: ".insert_query_expression"}, {Text: "NULL_CSTR"}, {Text: "nullptr"}, {Child: 8, Field: ".column_list"}, {Child: 8, Field: ".value_list"}}}, + }, + 606: { // replace_stmt + {Syms: "REPLACE_SYM replace_lock_option opt_INTO table_ident opt_use_partition insert_from_constructor", Kind: ActNew, Class: "PT_insert", Params: []string{"pos", "is_replace", "opt_hints", "lock_option", "ignore", "table_ident", "opt_use_partition", "column_list", "row_value_list", "insert_query_expression", "opt_values_table_alias", "opt_values_column_list", "opt_on_duplicate_column_list", "opt_on_duplicate_value_list"}, Args: []Arg{{Text: "@$"}, {Text: "true"}, {Child: 1}, {Child: 2}, {Text: "false"}, {Child: 4}, {Child: 5}, {Child: 6, Field: ".column_list"}, {Child: 6, Field: ".row_value_list"}, {Text: "nullptr"}, {Text: "NULL_CSTR"}, {Text: "nullptr"}, {Text: "nullptr"}, {Text: "nullptr"}}}, + {Syms: "REPLACE_SYM replace_lock_option opt_INTO table_ident opt_use_partition SET_SYM update_list", Kind: ActUnknown}, + {Syms: "REPLACE_SYM replace_lock_option opt_INTO table_ident opt_use_partition insert_query_expression", Kind: ActNew, Class: "PT_insert", Params: []string{"pos", "is_replace", "opt_hints", "lock_option", "ignore", "table_ident", "opt_use_partition", "column_list", "row_value_list", "insert_query_expression", "opt_values_table_alias", "opt_values_column_list", "opt_on_duplicate_column_list", "opt_on_duplicate_value_list"}, Args: []Arg{{Text: "@$"}, {Text: "true"}, {Child: 1}, {Child: 2}, {Text: "false"}, {Child: 4}, {Child: 5}, {Child: 6, Field: ".column_list"}, {Text: "nullptr"}, {Child: 6, Field: ".insert_query_expression"}, {Text: "NULL_CSTR"}, {Text: "nullptr"}, {Text: "nullptr"}, {Text: "nullptr"}}}, + }, + 607: { // insert_lock_option + {Syms: "", Kind: ActConst, Const: "TL_WRITE_CONCURRENT_DEFAULT"}, + {Syms: "LOW_PRIORITY", Kind: ActConst, Const: "TL_WRITE_LOW_PRIORITY"}, + {Syms: "DELAYED_SYM", Kind: ActConst, Const: "TL_WRITE_CONCURRENT_DEFAULT"}, + {Syms: "HIGH_PRIORITY", Kind: ActConst, Const: "TL_WRITE"}, + }, + 608: { // replace_lock_option + {Syms: "opt_low_priority", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "DELAYED_SYM", Kind: ActConst, Const: "TL_WRITE_DEFAULT"}, + }, + 609: { // opt_INTO + {Syms: "", Kind: ActDefault}, + {Syms: "INTO", Kind: ActDefault}, + }, + 610: { // insert_from_constructor + {Syms: "insert_values", Kind: ActStruct, Fields: []Field{{Name: "column_list", Arg: Arg{Text: "NEW_PTNPT_item_list(POS())"}}, {Name: "row_value_list", Arg: Arg{Child: 1}}}}, + {Syms: "'(' ')' insert_values", Kind: ActStruct, Fields: []Field{{Name: "column_list", Arg: Arg{Text: "NEW_PTNPT_item_list(POS())"}}, {Name: "row_value_list", Arg: Arg{Child: 3}}}}, + {Syms: "'(' insert_columns ')' insert_values", Kind: ActStruct, Fields: []Field{{Name: "column_list", Arg: Arg{Child: 2}}, {Name: "row_value_list", Arg: Arg{Child: 4}}}}, + }, + 611: { // insert_query_expression + {Syms: "query_expression_with_opt_locking_clauses", Kind: ActStruct, Fields: []Field{{Name: "column_list", Arg: Arg{Text: "NEW_PTNPT_item_list(POS())"}}, {Name: "insert_query_expression", Arg: Arg{Child: 1}}}}, + {Syms: "'(' ')' query_expression_with_opt_locking_clauses", Kind: ActStruct, Fields: []Field{{Name: "column_list", Arg: Arg{Text: "NEW_PTNPT_item_list(POS())"}}, {Name: "insert_query_expression", Arg: Arg{Child: 3}}}}, + {Syms: "'(' insert_columns ')' query_expression_with_opt_locking_clauses", Kind: ActStruct, Fields: []Field{{Name: "column_list", Arg: Arg{Child: 2}}, {Name: "insert_query_expression", Arg: Arg{Child: 4}}}}, + }, + 612: { // insert_columns + {Syms: "insert_columns ',' insert_column", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + {Syms: "insert_column", Kind: ActListNew, Class: "PT_item_list", Args: []Arg{{Text: "@$"}, {Child: 1}}}, + }, + 613: { // insert_values + {Syms: "value_or_values values_list", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 614: { // query_expression_with_opt_locking_clauses + {Syms: "query_expression", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "query_expression locking_clause_list", Kind: ActNew, Class: "PT_locking", Params: []string{"pos", "qe", "locking_clauses"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}}}, + }, + 615: { // value_or_values + {Syms: "VALUE_SYM", Kind: ActDefault}, + {Syms: "VALUES", Kind: ActDefault}, + }, + 616: { // values_list + {Syms: "values_list ',' row_value", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + {Syms: "row_value", Kind: ActListNew, Class: "PT_insert_values_list", Args: []Arg{{Child: 1, Field: "->value"}}}, + }, + 617: { // values_row_list + {Syms: "values_row_list ',' row_value_explicit", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + {Syms: "row_value_explicit", Kind: ActListNew, Class: "PT_insert_values_list", Args: []Arg{{Child: 1, Field: "->value"}}}, + }, + 618: { // equal + {Syms: "EQ", Kind: ActDefault}, + {Syms: "SET_VAR", Kind: ActDefault}, + }, + 619: { // opt_equal + {Syms: "", Kind: ActDefault}, + {Syms: "equal", Kind: ActDefault}, + }, + 620: { // row_value + {Syms: "'(' opt_values ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 621: { // row_value_explicit + {Syms: "ROW_SYM '(' opt_values ')'", Kind: ActPass, Args: []Arg{{Child: 3}}}, + }, + 622: { // opt_values + {Syms: "", Kind: ActNew, Class: "PT_item_list", Args: []Arg{{Text: "POS()"}}}, + {Syms: "values", Kind: ActDefault}, + }, + 623: { // values + {Syms: "values ',' expr_or_default", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + {Syms: "expr_or_default", Kind: ActListNew, Class: "PT_item_list", Args: []Arg{{Text: "@$"}, {Child: 1}}}, + }, + 624: { // expr_or_default + {Syms: "expr", Kind: ActDefault}, + {Syms: "DEFAULT_SYM", Kind: ActNew, Class: "Item_default_value", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + }, + 625: { // opt_values_reference + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "table_alias", Arg: Arg{Text: "NULL_CSTR"}}, {Name: "column_list", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "AS ident opt_derived_column_list", Kind: ActUnknown}, + }, + 626: { // opt_insert_update_list + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "value_list", Arg: Arg{Text: "nullptr"}}, {Name: "column_list", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "ON_SYM DUPLICATE_SYM KEY_SYM UPDATE_SYM update_list", Kind: ActPass, Args: []Arg{{Child: 5}}}, + }, + 627: { // update_stmt + {Syms: "opt_with_clause UPDATE_SYM opt_low_priority opt_ignore table_reference_list SET_SYM update_list opt_where_clause opt_order_clause opt_simple_limit", Kind: ActNew, Class: "PT_update", Params: []string{"pos", "with_clause", "opt_hints", "opt_low_priority", "opt_ignore", "join_table_list", "column_list", "value_list", "opt_where_clause", "opt_order_clause", "opt_limit_clause"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}, {Child: 3}, {Child: 4}, {Child: 5}, {Child: 7, Field: ".column_list"}, {Child: 7, Field: ".value_list"}, {Child: 8}, {Child: 9}, {Child: 10}}}, + }, + 628: { // opt_with_clause + {Syms: "", Kind: ActEmpty}, + {Syms: "with_clause", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 629: { // update_list + {Syms: "update_list ',' update_elem", Kind: ActUnknown}, + {Syms: "update_elem", Kind: ActUnknown}, + }, + 630: { // update_elem + {Syms: "simple_ident_nospvar equal expr_or_default", Kind: ActStruct, Fields: []Field{{Name: "column", Arg: Arg{Child: 1}}, {Name: "value", Arg: Arg{Child: 3}}}}, + }, + 631: { // opt_low_priority + {Syms: "", Kind: ActConst, Const: "TL_WRITE_DEFAULT"}, + {Syms: "LOW_PRIORITY", Kind: ActConst, Const: "TL_WRITE_LOW_PRIORITY"}, + }, + 632: { // delete_stmt + {Syms: "opt_with_clause DELETE_SYM opt_delete_options FROM table_ident opt_table_alias opt_use_partition opt_where_clause opt_order_clause opt_simple_limit", Kind: ActNew, Class: "PT_delete", Params: []string{"pos", "with_clause", "opt_hints", "opt_delete_options", "table_ident", "opt_table_alias", "opt_use_partition", "opt_where_clause", "opt_order_clause", "opt_delete_limit_clause"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}, {Child: 3}, {Child: 5}, {Child: 6}, {Child: 7}, {Child: 8}, {Child: 9}, {Child: 10}}}, + {Syms: "opt_with_clause DELETE_SYM opt_delete_options table_alias_ref_list FROM table_reference_list opt_where_clause", Kind: ActNew, Class: "PT_delete", Params: []string{"pos", "with_clause", "opt_hints", "opt_delete_options", "table_list", "join_table_list", "opt_where_clause"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}, {Child: 3}, {Child: 4}, {Child: 6}, {Child: 7}}}, + {Syms: "opt_with_clause DELETE_SYM opt_delete_options FROM table_alias_ref_list USING table_reference_list opt_where_clause", Kind: ActNew, Class: "PT_delete", Params: []string{"pos", "with_clause", "opt_hints", "opt_delete_options", "table_list", "join_table_list", "opt_where_clause"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}, {Child: 3}, {Child: 5}, {Child: 7}, {Child: 8}}}, + }, + 633: { // opt_wild + {Syms: "", Kind: ActDefault}, + {Syms: "'.' '*'", Kind: ActDefault}, + }, + 634: { // opt_delete_options + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "opt_delete_option opt_delete_options", Kind: ActFlags, Args: []Arg{{Child: 1}, {Child: 2}}}, + }, + 635: { // opt_delete_option + {Syms: "QUICK", Kind: ActConst, Const: "DELETE_QUICK"}, + {Syms: "LOW_PRIORITY", Kind: ActConst, Const: "DELETE_LOW_PRIORITY"}, + {Syms: "IGNORE_SYM", Kind: ActConst, Const: "DELETE_IGNORE"}, + }, + 636: { // truncate_stmt + {Syms: "TRUNCATE_SYM opt_table table_ident", Kind: ActNew, Class: "PT_truncate_table_stmt", Params: []string{"pos", "table"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 637: { // opt_table + {Syms: "", Kind: ActDefault}, + {Syms: "TABLE_SYM", Kind: ActDefault}, + }, + 638: { // opt_profile_defs + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "profile_defs", Kind: ActDefault}, + }, + 639: { // profile_defs + {Syms: "profile_def", Kind: ActDefault}, + {Syms: "profile_defs ',' profile_def", Kind: ActFlags, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 640: { // profile_def + {Syms: "CPU_SYM", Kind: ActConst, Const: "PROFILE_CPU"}, + {Syms: "MEMORY_SYM", Kind: ActConst, Const: "PROFILE_MEMORY"}, + {Syms: "BLOCK_SYM IO_SYM", Kind: ActConst, Const: "PROFILE_BLOCK_IO"}, + {Syms: "CONTEXT_SYM SWITCHES_SYM", Kind: ActConst, Const: "PROFILE_CONTEXT"}, + {Syms: "PAGE_SYM FAULTS_SYM", Kind: ActConst, Const: "PROFILE_PAGE_FAULTS"}, + {Syms: "IPC_SYM", Kind: ActConst, Const: "PROFILE_IPC"}, + {Syms: "SWAPS_SYM", Kind: ActConst, Const: "PROFILE_SWAPS"}, + {Syms: "SOURCE_SYM", Kind: ActConst, Const: "PROFILE_SOURCE"}, + {Syms: "ALL", Kind: ActConst, Const: "PROFILE_ALL"}, + }, + 641: { // opt_for_query + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "FOR_SYM QUERY_SYM NUM", Kind: ActUnknown}, + }, + 642: { // show_databases_stmt + {Syms: "SHOW DATABASES opt_wild_or_where", Kind: ActNew, Class: "PT_show_databases", Params: []string{"pos", "wild", "where"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".wild"}, {Child: 3, Field: ".where"}}}, + }, + 643: { // show_tables_stmt + {Syms: "SHOW opt_show_cmd_type TABLES opt_db opt_wild_or_where", Kind: ActNew, Class: "PT_show_tables", Params: []string{"pos", "show_cmd_type", "opt_db", "wild", "where"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Child: 4}, {Child: 5, Field: ".wild"}, {Child: 5, Field: ".where"}}}, + }, + 644: { // show_triggers_stmt + {Syms: "SHOW opt_full TRIGGERS_SYM opt_db opt_wild_or_where", Kind: ActNew, Class: "PT_show_triggers", Params: []string{"pos", "full", "opt_db", "wild", "where"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Child: 4}, {Child: 5, Field: ".wild"}, {Child: 5, Field: ".where"}}}, + }, + 645: { // show_events_stmt + {Syms: "SHOW EVENTS_SYM opt_db opt_wild_or_where", Kind: ActNew, Class: "PT_show_events", Params: []string{"pos", "opt_db", "wild", "where"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 4, Field: ".wild"}, {Child: 4, Field: ".where"}}}, + }, + 646: { // show_table_status_stmt + {Syms: "SHOW TABLE_SYM STATUS_SYM opt_db opt_wild_or_where", Kind: ActNew, Class: "PT_show_table_status", Params: []string{"pos", "opt_db", "wild", "where"}, Args: []Arg{{Text: "@$"}, {Child: 4}, {Child: 5, Field: ".wild"}, {Child: 5, Field: ".where"}}}, + }, + 647: { // show_open_tables_stmt + {Syms: "SHOW OPEN_SYM TABLES opt_db opt_wild_or_where", Kind: ActNew, Class: "PT_show_open_tables", Params: []string{"pos", "opt_db", "wild", "where"}, Args: []Arg{{Text: "@$"}, {Child: 4}, {Child: 5, Field: ".wild"}, {Child: 5, Field: ".where"}}}, + }, + 648: { // show_plugins_stmt + {Syms: "SHOW PLUGINS_SYM", Kind: ActNew, Class: "PT_show_plugins", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + }, + 649: { // show_engine_logs_stmt + {Syms: "SHOW ENGINE_SYM engine_or_all LOGS_SYM", Kind: ActNew, Class: "PT_show_engine_logs", Params: []string{"pos", "opt_engine"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 650: { // show_engine_mutex_stmt + {Syms: "SHOW ENGINE_SYM engine_or_all MUTEX_SYM", Kind: ActNew, Class: "PT_show_engine_mutex", Params: []string{"pos", "opt_engine"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 651: { // show_engine_status_stmt + {Syms: "SHOW ENGINE_SYM engine_or_all STATUS_SYM", Kind: ActNew, Class: "PT_show_engine_status", Params: []string{"pos", "opt_engine"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 652: { // show_columns_stmt + {Syms: "SHOW opt_show_cmd_type COLUMNS from_or_in table_ident opt_db opt_wild_or_where", Kind: ActUnknown}, + }, + 653: { // show_binary_logs_stmt + {Syms: "SHOW BINARY_SYM LOGS_SYM", Kind: ActNew, Class: "PT_show_binlogs", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + }, + 654: { // show_replicas_stmt + {Syms: "SHOW REPLICAS_SYM", Kind: ActNew, Class: "PT_show_replicas", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + }, + 655: { // show_binlog_events_stmt + {Syms: "SHOW BINLOG_SYM EVENTS_SYM opt_binlog_in binlog_from opt_limit_clause", Kind: ActNew, Class: "PT_show_binlog_events", Params: []string{"pos", "opt_log_file_name", "opt_limit_clause"}, Args: []Arg{{Text: "@$"}, {Child: 4}, {Child: 6}}}, + }, + 656: { // show_relaylog_events_stmt + {Syms: "SHOW RELAYLOG_SYM EVENTS_SYM opt_binlog_in binlog_from opt_limit_clause opt_channel", Kind: ActNew, Class: "PT_show_relaylog_events", Params: []string{"pos", "opt_log_file_name", "opt_limit_clause", "opt_channel_name"}, Args: []Arg{{Text: "@$"}, {Child: 4}, {Child: 6}, {Child: 7}}}, + }, + 657: { // show_keys_stmt + {Syms: "SHOW opt_extended keys_or_index from_or_in table_ident opt_db opt_where_clause", Kind: ActUnknown}, + }, + 658: { // show_engines_stmt + {Syms: "SHOW opt_storage ENGINES_SYM", Kind: ActNew, Class: "PT_show_engines", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + }, + 659: { // show_count_warnings_stmt + {Syms: "SHOW COUNT_SYM '(' '*' ')' WARNINGS", Kind: ActNew, Class: "PT_show_count_warnings", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + }, + 660: { // show_count_errors_stmt + {Syms: "SHOW COUNT_SYM '(' '*' ')' ERRORS", Kind: ActNew, Class: "PT_show_count_errors", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + }, + 661: { // show_warnings_stmt + {Syms: "SHOW WARNINGS opt_limit_clause", Kind: ActNew, Class: "PT_show_warnings", Params: []string{"pos", "opt_limit_clause"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 662: { // show_errors_stmt + {Syms: "SHOW ERRORS opt_limit_clause", Kind: ActNew, Class: "PT_show_errors", Params: []string{"pos", "opt_limit_clause"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 663: { // show_profiles_stmt + {Syms: "SHOW PROFILES_SYM", Kind: ActNew, Class: "PT_show_profiles", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + }, + 664: { // show_profile_stmt + {Syms: "SHOW PROFILE_SYM opt_profile_defs opt_for_query opt_limit_clause", Kind: ActNew, Class: "PT_show_profile", Params: []string{"pos", "opt_profile_options", "opt_query_id", "opt_limit_clause"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 4}, {Child: 5}}}, + }, + 665: { // show_status_stmt + {Syms: "SHOW opt_var_type STATUS_SYM opt_wild_or_where", Kind: ActNew, Class: "PT_show_status", Params: []string{"pos", "var_type", "wild", "where"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Child: 4, Field: ".wild"}, {Child: 4, Field: ".where"}}}, + }, + 666: { // show_processlist_stmt + {Syms: "SHOW opt_full PROCESSLIST_SYM", Kind: ActNew, Class: "PT_show_processlist", Params: []string{"pos", "verbose"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + }, + 667: { // show_variables_stmt + {Syms: "SHOW opt_var_type VARIABLES opt_wild_or_where", Kind: ActNew, Class: "PT_show_variables", Params: []string{"pos", "var_type", "wild", "where"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Child: 4, Field: ".wild"}, {Child: 4, Field: ".where"}}}, + }, + 668: { // show_character_set_stmt + {Syms: "SHOW character_set opt_wild_or_where", Kind: ActNew, Class: "PT_show_charsets", Params: []string{"pos", "wild", "where"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".wild"}, {Child: 3, Field: ".where"}}}, + }, + 669: { // show_collation_stmt + {Syms: "SHOW COLLATION_SYM opt_wild_or_where", Kind: ActNew, Class: "PT_show_collations", Params: []string{"pos", "wild", "where"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".wild"}, {Child: 3, Field: ".where"}}}, + }, + 670: { // show_privileges_stmt + {Syms: "SHOW PRIVILEGES", Kind: ActNew, Class: "PT_show_privileges", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + }, + 671: { // show_grants_stmt + {Syms: "SHOW GRANTS", Kind: ActNew, Class: "PT_show_grants", Params: []string{"pos", "opt_for_user", "opt_using_users"}, Args: []Arg{{Text: "@$"}, {Text: "nullptr"}, {Text: "nullptr"}}}, + {Syms: "SHOW GRANTS FOR_SYM user", Kind: ActNew, Class: "PT_show_grants", Params: []string{"pos", "opt_for_user", "opt_using_users"}, Args: []Arg{{Text: "@$"}, {Child: 4}, {Text: "nullptr"}}}, + {Syms: "SHOW GRANTS FOR_SYM user USING user_list", Kind: ActNew, Class: "PT_show_grants", Params: []string{"pos", "opt_for_user", "opt_using_users"}, Args: []Arg{{Text: "@$"}, {Child: 4}, {Child: 6}}}, + }, + 672: { // show_create_database_stmt + {Syms: "SHOW CREATE DATABASE opt_if_not_exists ident", Kind: ActNew, Class: "PT_show_create_database", Params: []string{"pos", "if_not_exists", "name"}, Args: []Arg{{Text: "@$"}, {Child: 4}, {Child: 5}}}, + }, + 673: { // show_create_table_stmt + {Syms: "SHOW CREATE TABLE_SYM table_ident", Kind: ActNew, Class: "PT_show_create_table", Params: []string{"pos", "table_ident"}, Args: []Arg{{Text: "@$"}, {Child: 4}}}, + }, + 674: { // show_create_view_stmt + {Syms: "SHOW CREATE VIEW_SYM table_ident", Kind: ActNew, Class: "PT_show_create_view", Params: []string{"pos", "table_ident"}, Args: []Arg{{Text: "@$"}, {Child: 4}}}, + }, + 675: { // show_binary_log_status_stmt + {Syms: "SHOW BINARY_SYM LOG_SYM STATUS_SYM", Kind: ActNew, Class: "PT_show_binary_log_status", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + }, + 676: { // show_replica_status_stmt + {Syms: "SHOW REPLICA_SYM STATUS_SYM opt_channel", Kind: ActNew, Class: "PT_show_replica_status", Params: []string{"pos", "opt_channel_name"}, Args: []Arg{{Text: "@$"}, {Child: 4}}}, + }, + 677: { // show_create_procedure_stmt + {Syms: "SHOW CREATE PROCEDURE_SYM sp_name", Kind: ActNew, Class: "PT_show_create_procedure", Params: []string{"pos", "procedure_name"}, Args: []Arg{{Text: "@$"}, {Child: 4}}}, + }, + 678: { // show_create_function_stmt + {Syms: "SHOW CREATE FUNCTION_SYM sp_name", Kind: ActNew, Class: "PT_show_create_function", Params: []string{"pos", "function_name"}, Args: []Arg{{Text: "@$"}, {Child: 4}}}, + }, + 679: { // show_create_trigger_stmt + {Syms: "SHOW CREATE TRIGGER_SYM sp_name", Kind: ActNew, Class: "PT_show_create_trigger", Params: []string{"pos", "trigger_name"}, Args: []Arg{{Text: "@$"}, {Child: 4}}}, + }, + 680: { // show_procedure_status_stmt + {Syms: "SHOW PROCEDURE_SYM STATUS_SYM opt_wild_or_where", Kind: ActNew, Class: "PT_show_status_proc", Params: []string{"pos", "wild", "where"}, Args: []Arg{{Text: "@$"}, {Child: 4, Field: ".wild"}, {Child: 4, Field: ".where"}}}, + }, + 681: { // show_function_status_stmt + {Syms: "SHOW FUNCTION_SYM STATUS_SYM opt_wild_or_where", Kind: ActNew, Class: "PT_show_status_func", Params: []string{"pos", "wild", "where"}, Args: []Arg{{Text: "@$"}, {Child: 4, Field: ".wild"}, {Child: 4, Field: ".where"}}}, + }, + 682: { // show_procedure_code_stmt + {Syms: "SHOW PROCEDURE_SYM CODE_SYM sp_name", Kind: ActNew, Class: "PT_show_procedure_code", Params: []string{"pos", "procedure_name"}, Args: []Arg{{Text: "@$"}, {Child: 4}}}, + }, + 683: { // show_function_code_stmt + {Syms: "SHOW FUNCTION_SYM CODE_SYM sp_name", Kind: ActNew, Class: "PT_show_function_code", Params: []string{"pos", "function_name"}, Args: []Arg{{Text: "@$"}, {Child: 4}}}, + }, + 684: { // show_create_event_stmt + {Syms: "SHOW CREATE EVENT_SYM sp_name", Kind: ActNew, Class: "PT_show_create_event", Params: []string{"pos", "event_name"}, Args: []Arg{{Text: "@$"}, {Child: 4}}}, + }, + 685: { // show_create_user_stmt + {Syms: "SHOW CREATE USER user", Kind: ActNew, Class: "PT_show_create_user", Params: []string{"pos", "user"}, Args: []Arg{{Text: "@$"}, {Child: 4}}}, + }, + 686: { // show_parse_tree_stmt + {Syms: "SHOW PARSE_TREE_SYM simple_statement", Kind: ActUnknown}, + }, + 687: { // engine_or_all + {Syms: "ident_or_text", Kind: ActDefault}, + {Syms: "ALL", Kind: ActEmpty}, + }, + 688: { // opt_storage + {Syms: "", Kind: ActDefault}, + {Syms: "STORAGE_SYM", Kind: ActDefault}, + }, + 689: { // opt_db + {Syms: "", Kind: ActEmpty}, + {Syms: "from_or_in ident", Kind: ActPass, Args: []Arg{{Child: 2, Field: ".str"}}}, + }, + 690: { // opt_full + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "FULL", Kind: ActConst, Const: "1"}, + }, + 691: { // opt_extended + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "EXTENDED_SYM", Kind: ActConst, Const: "1"}, + }, + 692: { // opt_show_cmd_type + {Syms: "", Kind: ActConst, Const: "Show_cmd_type::STANDARD"}, + {Syms: "FULL", Kind: ActConst, Const: "Show_cmd_type::FULL_SHOW"}, + {Syms: "EXTENDED_SYM", Kind: ActConst, Const: "Show_cmd_type::EXTENDED_SHOW"}, + {Syms: "EXTENDED_SYM FULL", Kind: ActConst, Const: "Show_cmd_type::EXTENDED_FULL_SHOW"}, + }, + 693: { // from_or_in + {Syms: "FROM", Kind: ActDefault}, + {Syms: "IN_SYM", Kind: ActDefault}, + }, + 694: { // opt_binlog_in + {Syms: "", Kind: ActEmpty}, + {Syms: "IN_SYM TEXT_STRING_sys", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 695: { // binlog_from + {Syms: "", Kind: ActDefault}, + {Syms: "FROM ulonglong_num", Kind: ActStruct, Fields: []Field{{Name: "mi.pos", Arg: Arg{Child: 2}}}}, + }, + 696: { // opt_wild_or_where + {Syms: "", Kind: ActEmpty}, + {Syms: "LIKE TEXT_STRING_literal", Kind: ActStruct, Fields: []Field{{Name: "wild", Arg: Arg{Child: 2}}, {Name: "where", Arg: Arg{Text: "{}"}}}}, + {Syms: "where_clause", Kind: ActStruct, Fields: []Field{{Name: "wild", Arg: Arg{Text: "{}"}}, {Name: "where", Arg: Arg{Child: 1}}}}, + }, + 697: { // describe_stmt + {Syms: "describe_command table_ident opt_describe_column", Kind: ActNew, Class: "PT_show_fields", Params: []string{"pos", "show_cmd_type", "table", "opt_wild"}, Args: []Arg{{Text: "@$"}, {Text: "Show_cmd_type::STANDARD"}, {Child: 2}, {Child: 3}}}, + }, + 698: { // explain_stmt + {Syms: "describe_command opt_explain_options explainable_stmt", Kind: ActNew, Class: "PT_explain", Params: []string{"pos", "format", "is_analyze", "is_explicit_format", "explainable_stmt", "explain_into_variable_name", "schema_name_for_explain"}, Args: []Arg{{Text: "@$"}, {Child: 2, Field: ".explain_format_type"}, {Child: 2, Field: ".is_analyze"}, {Child: 2, Field: ".is_explicit"}, {Child: 3, Field: ".statement"}, {Text: "$2.explain_into_variable_name.length?std::optional(to_string_view($2.explain_into_variable_name)):std::optional(std::nullopt)"}, {Child: 3, Field: ".schema_name_for_explain"}}}, + }, + 699: { // explainable_stmt + {Syms: "opt_explain_for_schema select_stmt", Kind: ActStruct, Fields: []Field{{Name: "statement", Arg: Arg{Child: 2}}, {Name: "schema_name_for_explain", Arg: Arg{Child: 1}}}}, + {Syms: "opt_explain_for_schema insert_stmt", Kind: ActStruct, Fields: []Field{{Name: "statement", Arg: Arg{Child: 2}}, {Name: "schema_name_for_explain", Arg: Arg{Child: 1}}}}, + {Syms: "opt_explain_for_schema replace_stmt", Kind: ActStruct, Fields: []Field{{Name: "statement", Arg: Arg{Child: 2}}, {Name: "schema_name_for_explain", Arg: Arg{Child: 1}}}}, + {Syms: "opt_explain_for_schema update_stmt", Kind: ActStruct, Fields: []Field{{Name: "statement", Arg: Arg{Child: 2}}, {Name: "schema_name_for_explain", Arg: Arg{Child: 1}}}}, + {Syms: "opt_explain_for_schema delete_stmt", Kind: ActStruct, Fields: []Field{{Name: "statement", Arg: Arg{Child: 2}}, {Name: "schema_name_for_explain", Arg: Arg{Child: 1}}}}, + {Syms: "FOR_SYM CONNECTION_SYM real_ulong_num", Kind: ActStruct, Fields: []Field{{Name: "statement", Arg: Arg{Text: "NEW_PTNPT_explain_for_connection(@$,$3)"}}, {Name: "schema_name_for_explain", Arg: Arg{Text: "NULL_CSTR"}}}}, + }, + 700: { // describe_command + {Syms: "DESC", Kind: ActDefault}, + {Syms: "DESCRIBE", Kind: ActDefault}, + }, + 701: { // opt_explain_format + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "is_explicit", Arg: Arg{Text: "false"}}, {Name: "explain_format_type", Arg: Arg{Text: "YYTHD->variables.explain_format"}}}}, + {Syms: "FORMAT_SYM EQ ident_or_text", Kind: ActUnknown}, + }, + 702: { // opt_explain_options + {Syms: "ANALYZE_SYM opt_explain_format", Kind: ActStruct, Fields: []Field{{Name: "$base", Arg: Arg{Child: 2}}, {Name: "is_analyze", Arg: Arg{Text: "true"}}, {Name: "explain_into_variable_name", Arg: Arg{Text: "NULL_STR"}}}}, + {Syms: "opt_explain_format opt_explain_into", Kind: ActUnknown}, + }, + 703: { // opt_explain_into + {Syms: "", Kind: ActEmpty}, + {Syms: "INTO '@' ident_or_text", Kind: ActUnknown}, + }, + 704: { // opt_explain_for_schema + {Syms: "", Kind: ActEmpty}, + {Syms: "FOR_SYM DATABASE ident_or_text", Kind: ActPass, Args: []Arg{{Child: 3}}}, + }, + 705: { // opt_describe_column + {Syms: "", Kind: ActEmpty}, + {Syms: "text_string", Kind: ActUnknown}, + {Syms: "ident", Kind: ActDefault}, + }, + 706: { // flush + {Syms: "FLUSH_SYM opt_no_write_to_binlog flush_options", Kind: ActDefault}, + }, + 707: { // flush_options + {Syms: "table_or_tables opt_table_list opt_flush_lock", Kind: ActDefault}, + {Syms: "flush_options_list", Kind: ActDefault}, + }, + 708: { // opt_flush_lock + {Syms: "", Kind: ActDefault}, + {Syms: "WITH READ_SYM LOCK_SYM", Kind: ActDefault}, + {Syms: "FOR_SYM EXPORT_SYM", Kind: ActDefault}, + }, + 709: { // flush_options_list + {Syms: "flush_options_list ',' flush_option", Kind: ActDefault}, + {Syms: "flush_option", Kind: ActDefault}, + }, + 710: { // flush_option + {Syms: "ERROR_SYM LOGS_SYM", Kind: ActDefault}, + {Syms: "ENGINE_SYM LOGS_SYM", Kind: ActDefault}, + {Syms: "GENERAL LOGS_SYM", Kind: ActDefault}, + {Syms: "SLOW LOGS_SYM", Kind: ActDefault}, + {Syms: "BINARY_SYM LOGS_SYM", Kind: ActDefault}, + {Syms: "RELAY LOGS_SYM opt_channel", Kind: ActDefault}, + {Syms: "PRIVILEGES", Kind: ActDefault}, + {Syms: "LOGS_SYM", Kind: ActDefault}, + {Syms: "STATUS_SYM", Kind: ActDefault}, + {Syms: "RESOURCES", Kind: ActDefault}, + {Syms: "OPTIMIZER_COSTS_SYM", Kind: ActDefault}, + }, + 711: { // opt_table_list + {Syms: "", Kind: ActEmpty}, + {Syms: "table_list", Kind: ActDefault}, + }, + 712: { // reset + {Syms: "RESET_SYM reset_options", Kind: ActDefault}, + {Syms: "RESET_SYM PERSIST_SYM opt_if_exists_ident", Kind: ActDefault}, + }, + 713: { // reset_options + {Syms: "reset_options ',' reset_option", Kind: ActDefault}, + {Syms: "reset_option", Kind: ActDefault}, + }, + 714: { // opt_if_exists_ident + {Syms: "", Kind: ActDefault}, + {Syms: "if_exists persisted_variable_ident", Kind: ActStruct, Fields: []Field{{Name: "drop_if_exists", Arg: Arg{Child: 1}}, {Name: "name", Arg: Arg{Child: 2}}}}, + }, + 715: { // persisted_variable_ident + {Syms: "ident", Kind: ActDefault}, + {Syms: "ident '.' ident", Kind: ActUnknown}, + {Syms: "DEFAULT_SYM '.' ident", Kind: ActUnknown}, + }, + 716: { // reset_option + {Syms: "REPLICA_SYM opt_replica_reset_options opt_channel", Kind: ActDefault}, + {Syms: "BINARY_SYM LOGS_SYM AND_SYM GTIDS_SYM source_reset_options", Kind: ActDefault}, + }, + 717: { // opt_replica_reset_options + {Syms: "", Kind: ActDefault}, + {Syms: "ALL", Kind: ActDefault}, + }, + 718: { // source_reset_options + {Syms: "", Kind: ActDefault}, + {Syms: "TO_SYM real_ulonglong_num", Kind: ActUnknown}, + }, + 719: { // purge + {Syms: "PURGE purge_options", Kind: ActDefault}, + }, + 720: { // purge_options + {Syms: "BINARY_SYM LOGS_SYM purge_option", Kind: ActDefault}, + }, + 721: { // purge_option + {Syms: "TO_SYM TEXT_STRING_sys", Kind: ActStruct, Fields: []Field{{Name: "to_log", Arg: Arg{Child: 2, Field: ".str"}}}}, + {Syms: "BEFORE_SYM expr", Kind: ActUnknown}, + }, + 722: { // kill + {Syms: "KILL_SYM kill_option expr", Kind: ActUnknown}, + }, + 723: { // kill_option + {Syms: "", Kind: ActDefault}, + {Syms: "CONNECTION_SYM", Kind: ActDefault}, + {Syms: "QUERY_SYM", Kind: ActDefault}, + }, + 724: { // use + {Syms: "USE_SYM ident", Kind: ActUnknown}, + }, + 725: { // load_stmt + {Syms: "LOAD data_or_xml load_data_lock opt_from_keyword opt_local load_source_type TEXT_STRING_filesystem opt_source_count opt_source_order opt_duplicate INTO TABLE_SYM table_ident opt_use_partition opt_load_data_charset opt_compression_algorithm opt_xml_rows_identified_by opt_field_term opt_line_term opt_ignore_lines opt_field_or_var_spec opt_load_data_set_spec opt_load_parallel opt_load_memory opt_load_algorithm", Kind: ActNew, Class: "PT_load_table", Params: []string{"pos", "filetype", "lock_type", "is_local_file", "source_type", "filename", "file_count", "in_key_order", "on_duplicate", "table", "opt_partitions", "opt_charset", "compression_algorithm", "opt_xml_rows_identified_by", "opt_field_separators", "opt_line_separators", "opt_ignore_lines", "opt_fields_or_vars", "opt_set_fields", "opt_set_exprs", "opt_set_expr_strings", "parallel", "memory_size", "is_bulk_operation"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Child: 3}, {Child: 5}, {Child: 6}, {Child: 7}, {Child: 8}, {Child: 9}, {Child: 10}, {Child: 13}, {Child: 14}, {Child: 15}, {Child: 16}, {Child: 17}, {Child: 18}, {Child: 19}, {Child: 20}, {Child: 21}, {Child: 22, Field: ".set_var_list"}, {Child: 22, Field: ".set_expr_list"}, {Child: 22, Field: ".set_expr_str_list"}, {Child: 23}, {Child: 24}, {Child: 25}}}, + }, + 726: { // data_or_xml + {Syms: "DATA_SYM", Kind: ActConst, Const: "FILETYPE_CSV"}, + {Syms: "XML_SYM", Kind: ActConst, Const: "FILETYPE_XML"}, + }, + 727: { // opt_local + {Syms: "", Kind: ActConst, Const: "false"}, + {Syms: "LOCAL_SYM", Kind: ActConst, Const: "true"}, + }, + 728: { // opt_from_keyword + {Syms: "", Kind: ActDefault}, + {Syms: "FROM", Kind: ActDefault}, + }, + 729: { // load_data_lock + {Syms: "", Kind: ActConst, Const: "TL_WRITE_DEFAULT"}, + {Syms: "CONCURRENT", Kind: ActConst, Const: "TL_WRITE_CONCURRENT_INSERT"}, + {Syms: "LOW_PRIORITY", Kind: ActConst, Const: "TL_WRITE_LOW_PRIORITY"}, + }, + 730: { // load_source_type + {Syms: "INFILE_SYM", Kind: ActConst, Const: "LOAD_SOURCE_FILE"}, + {Syms: "URL_SYM", Kind: ActConst, Const: "LOAD_SOURCE_URL"}, + {Syms: "S3_SYM", Kind: ActConst, Const: "LOAD_SOURCE_S3"}, + }, + 731: { // opt_source_count + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "IDENT_sys NUM", Kind: ActUnknown}, + }, + 732: { // opt_source_order + {Syms: "", Kind: ActConst, Const: "false"}, + {Syms: "IN_SYM PRIMARY_SYM KEY_SYM ORDER_SYM", Kind: ActConst, Const: "true"}, + }, + 733: { // opt_duplicate + {Syms: "", Kind: ActConst, Const: "On_duplicate::ERROR"}, + {Syms: "duplicate", Kind: ActDefault}, + }, + 734: { // duplicate + {Syms: "REPLACE_SYM", Kind: ActConst, Const: "On_duplicate::REPLACE_DUP"}, + {Syms: "IGNORE_SYM", Kind: ActConst, Const: "On_duplicate::IGNORE_DUP"}, + }, + 735: { // opt_field_term + {Syms: "", Kind: ActUnknown}, + {Syms: "COLUMNS field_term_list", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 736: { // field_term_list + {Syms: "field_term_list field_term", Kind: ActUnknown}, + {Syms: "field_term", Kind: ActDefault}, + }, + 737: { // field_term + {Syms: "TERMINATED BY text_string", Kind: ActUnknown}, + {Syms: "OPTIONALLY ENCLOSED BY text_string", Kind: ActUnknown}, + {Syms: "ENCLOSED BY text_string", Kind: ActUnknown}, + {Syms: "ESCAPED BY text_string", Kind: ActUnknown}, + }, + 738: { // opt_line_term + {Syms: "", Kind: ActUnknown}, + {Syms: "LINES line_term_list", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 739: { // line_term_list + {Syms: "line_term_list line_term", Kind: ActUnknown}, + {Syms: "line_term", Kind: ActDefault}, + }, + 740: { // line_term + {Syms: "TERMINATED BY text_string", Kind: ActUnknown}, + {Syms: "STARTING BY text_string", Kind: ActUnknown}, + }, + 741: { // opt_xml_rows_identified_by + {Syms: "", Kind: ActEmpty}, + {Syms: "ROWS_SYM IDENTIFIED_SYM BY text_string", Kind: ActPass, Args: []Arg{{Child: 4}}}, + }, + 742: { // opt_ignore_lines + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "IGNORE_SYM NUM lines_or_rows", Kind: ActNumber, Args: []Arg{{Child: 2}}}, + }, + 743: { // lines_or_rows + {Syms: "LINES", Kind: ActDefault}, + {Syms: "ROWS_SYM", Kind: ActDefault}, + }, + 744: { // opt_field_or_var_spec + {Syms: "", Kind: ActEmpty}, + {Syms: "'(' fields_or_vars ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + {Syms: "'(' ')'", Kind: ActEmpty}, + }, + 745: { // fields_or_vars + {Syms: "fields_or_vars ',' field_or_var", Kind: ActUnknown}, + {Syms: "field_or_var", Kind: ActListNew, Class: "PT_item_list", Args: []Arg{{Text: "@$"}, {Child: 1}}}, + }, + 746: { // field_or_var + {Syms: "simple_ident_nospvar", Kind: ActDefault}, + {Syms: "'@' ident_or_text", Kind: ActNew, Class: "Item_user_var_as_out_param", Params: []string{"pos", "a"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + }, + 747: { // opt_load_data_set_spec + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "set_var_list", Arg: Arg{Text: "nullptr"}}, {Name: "set_expr_list", Arg: Arg{Text: "nullptr"}}, {Name: "set_expr_str_list", Arg: Arg{Text: "nullptr"}}}}, + {Syms: "SET_SYM load_data_set_list", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 748: { // load_data_set_list + {Syms: "load_data_set_list ',' load_data_set_elem", Kind: ActUnknown}, + {Syms: "load_data_set_elem", Kind: ActUnknown}, + }, + 749: { // load_data_set_elem + {Syms: "simple_ident_nospvar equal expr_or_default", Kind: ActUnknown}, + }, + 750: { // opt_load_algorithm + {Syms: "", Kind: ActConst, Const: "false"}, + {Syms: "ALGORITHM_SYM EQ BULK_SYM", Kind: ActConst, Const: "true"}, + }, + 751: { // opt_compression_algorithm + {Syms: "", Kind: ActEmpty}, + {Syms: "COMPRESSION_SYM EQ TEXT_STRING_sys", Kind: ActPass, Args: []Arg{{Child: 3}}}, + }, + 752: { // opt_load_parallel + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "PARALLEL_SYM EQ NUM", Kind: ActNumber, Args: []Arg{{Child: 3}}}, + }, + 753: { // opt_load_memory + {Syms: "", Kind: ActConst, Const: "0"}, + {Syms: "MEMORY_SYM EQ size_number", Kind: ActPass, Args: []Arg{{Child: 3}}}, + }, + 754: { // text_literal + {Syms: "TEXT_STRING", Kind: ActNew, Class: "PTI_text_literal_text_string", Params: []string{"pos", "is_7bit", "literal"}, Args: []Arg{{Text: "@$"}, {Text: "YYTHD->m_parser_state->m_lip.text_string_is_7bit()"}, {Child: 1}}}, + {Syms: "NCHAR_STRING", Kind: ActNew, Class: "PTI_text_literal_nchar_string", Params: []string{"pos", "is_7bit", "literal"}, Args: []Arg{{Text: "@$"}, {Text: "YYTHD->m_parser_state->m_lip.text_string_is_7bit()"}, {Child: 1}}}, + {Syms: "UNDERSCORE_CHARSET TEXT_STRING", Kind: ActNew, Class: "PTI_text_literal_underscore_charset", Params: []string{"pos", "is_7bit", "cs", "literal"}, Args: []Arg{{Text: "@$"}, {Text: "YYTHD->m_parser_state->m_lip.text_string_is_7bit()"}, {Child: 1}, {Child: 2}}}, + {Syms: "text_literal TEXT_STRING_literal", Kind: ActNew, Class: "PTI_text_literal_concat", Params: []string{"pos", "is_7bit", "head", "tail"}, Args: []Arg{{Text: "@$"}, {Text: "YYTHD->m_parser_state->m_lip.text_string_is_7bit()"}, {Child: 1}, {Child: 2}}}, + }, + 755: { // text_string + {Syms: "TEXT_STRING_literal", Kind: ActNew, Class: "String", Params: []string{"str", "len", "cs"}, Args: []Arg{{Child: 1, Field: ".str"}, {Child: 1, Field: ".length"}, {Text: "YYTHD->variables.collation_connection"}}}, + {Syms: "HEX_NUM", Kind: ActUnknown}, + {Syms: "BIN_NUM", Kind: ActUnknown}, + }, + 756: { // param_marker + {Syms: "PARAM_MARKER", Kind: ActUnknown}, + }, + 757: { // signed_literal + {Syms: "literal", Kind: ActDefault}, + {Syms: "'+' NUM_literal", Kind: ActPass, Args: []Arg{{Child: 2}}}, + {Syms: "'-' NUM_literal", Kind: ActUnknown}, + }, + 758: { // signed_literal_or_null + {Syms: "signed_literal", Kind: ActDefault}, + {Syms: "null_as_literal", Kind: ActDefault}, + }, + 759: { // null_as_literal + {Syms: "NULL_SYM", Kind: ActNew, Class: "Item_null", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + }, + 760: { // literal + {Syms: "text_literal", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "NUM_literal", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "temporal_literal", Kind: ActDefault}, + {Syms: "FALSE_SYM", Kind: ActNew, Class: "Item_func_false", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "TRUE_SYM", Kind: ActNew, Class: "Item_func_true", Params: []string{"pos"}, Args: []Arg{{Text: "@$"}}}, + {Syms: "HEX_NUM", Kind: ActNew, Class: "Item_hex_string", Params: []string{"pos", "literal"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "BIN_NUM", Kind: ActNew, Class: "Item_bin_string", Params: []string{"pos", "literal"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "UNDERSCORE_CHARSET HEX_NUM", Kind: ActNew, Class: "PTI_literal_underscore_charset_hex_num", Params: []string{"pos", "charset", "literal"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}}}, + {Syms: "UNDERSCORE_CHARSET BIN_NUM", Kind: ActNew, Class: "PTI_literal_underscore_charset_bin_num", Params: []string{"pos", "charset", "literal"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}}}, + }, + 761: { // literal_or_null + {Syms: "literal", Kind: ActDefault}, + {Syms: "null_as_literal", Kind: ActDefault}, + }, + 762: { // NUM_literal + {Syms: "int64_literal", Kind: ActDefault}, + {Syms: "DECIMAL_NUM", Kind: ActNew, Class: "Item_decimal", Params: []string{"pos", "str", "length", "charset"}, Args: []Arg{{Text: "@$"}, {Child: 1, Field: ".str"}, {Child: 1, Field: ".length"}, {Text: "YYCSCL"}}}, + {Syms: "FLOAT_NUM", Kind: ActNew, Class: "Item_float", Params: []string{"pos", "str", "length"}, Args: []Arg{{Text: "@$"}, {Child: 1, Field: ".str"}, {Child: 1, Field: ".length"}}}, + }, + 763: { // int64_literal + {Syms: "NUM", Kind: ActNew, Class: "Item_int", Params: []string{"pos", "i"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "LONG_NUM", Kind: ActNew, Class: "Item_int", Params: []string{"pos", "i"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "ULONGLONG_NUM", Kind: ActNew, Class: "Item_uint", Params: []string{"pos", "str", "length"}, Args: []Arg{{Text: "@$"}, {Child: 1, Field: ".str"}, {Child: 1, Field: ".length"}}}, + }, + 764: { // temporal_literal + {Syms: "DATE_SYM TEXT_STRING", Kind: ActNew, Class: "PTI_temporal_literal", Params: []string{"pos", "literal", "field_type", "cs"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Text: "MYSQL_TYPE_DATE"}, {Text: "YYCSCL"}}}, + {Syms: "TIME_SYM TEXT_STRING", Kind: ActNew, Class: "PTI_temporal_literal", Params: []string{"pos", "literal", "field_type", "cs"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Text: "MYSQL_TYPE_TIME"}, {Text: "YYCSCL"}}}, + {Syms: "TIMESTAMP_SYM TEXT_STRING", Kind: ActNew, Class: "PTI_temporal_literal", Params: []string{"pos", "literal", "field_type", "cs"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Text: "MYSQL_TYPE_DATETIME"}, {Text: "YYCSCL"}}}, + }, + 765: { // opt_interval + {Syms: "", Kind: ActConst, Const: "false"}, + {Syms: "INTERVAL_SYM", Kind: ActConst, Const: "true"}, + }, + 766: { // insert_column + {Syms: "simple_ident_nospvar", Kind: ActDefault}, + }, + 767: { // table_wild + {Syms: "ident '.' '*'", Kind: ActNew, Class: "Item_asterisk", Params: []string{"pos", "opt_schema_name", "opt_table_name"}, Args: []Arg{{Text: "@$"}, {Text: "nullptr"}, {Child: 1, Field: ".str"}}}, + {Syms: "ident '.' ident '.' '*'", Kind: ActUnknown}, + }, + 768: { // order_expr + {Syms: "expr opt_ordering_direction", Kind: ActNew, Class: "PT_order_expr", Params: []string{"pos", "item", "dir"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}}}, + }, + 769: { // grouping_expr + {Syms: "expr", Kind: ActNew, Class: "PT_order_expr", Params: []string{"pos", "item", "dir"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Text: "ORDER_NOT_RELEVANT"}}}, + }, + 770: { // simple_ident + {Syms: "ident", Kind: ActNew, Class: "PTI_simple_ident_ident", Params: []string{"pos", "ident"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "simple_ident_q", Kind: ActDefault}, + }, + 771: { // simple_ident_nospvar + {Syms: "ident", Kind: ActNew, Class: "PTI_simple_ident_nospvar_ident", Params: []string{"pos", "ident"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + {Syms: "simple_ident_q", Kind: ActDefault}, + }, + 772: { // simple_ident_q + {Syms: "ident '.' ident", Kind: ActNew, Class: "PTI_simple_ident_q_2d", Params: []string{"pos", "table", "field"}, Args: []Arg{{Text: "@$"}, {Child: 1, Field: ".str"}, {Child: 3, Field: ".str"}}}, + {Syms: "ident '.' ident '.' ident", Kind: ActNew, Class: "PTI_simple_ident_q_3d", Params: []string{"pos", "db", "table", "field"}, Args: []Arg{{Text: "@$"}, {Child: 1, Field: ".str"}, {Child: 3, Field: ".str"}, {Child: 5, Field: ".str"}}}, + }, + 773: { // table_ident + {Syms: "ident", Kind: ActNew, Class: "Table_ident", Params: []string{"table"}, Args: []Arg{{Child: 1}}}, + {Syms: "ident '.' ident", Kind: ActUnknown}, + }, + 774: { // table_ident_opt_wild + {Syms: "ident opt_wild", Kind: ActNew, Class: "Table_ident", Params: []string{"table"}, Args: []Arg{{Child: 1}}}, + {Syms: "ident '.' ident opt_wild", Kind: ActNew, Class: "Table_ident", Params: []string{"protocol", "db", "table", "force"}, Args: []Arg{{Text: "YYTHD->get_protocol()"}, {Child: 1}, {Child: 3}, {Text: "0"}}}, + }, + 775: { // IDENT_sys + {Syms: "IDENT", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "IDENT_QUOTED", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 776: { // TEXT_STRING_sys_nonewline + {Syms: "TEXT_STRING_sys", Kind: ActUnknown}, + }, + 777: { // filter_wild_db_table_string + {Syms: "TEXT_STRING_sys_nonewline", Kind: ActUnknown}, + }, + 778: { // TEXT_STRING_sys + {Syms: "TEXT_STRING", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 779: { // TEXT_STRING_literal + {Syms: "TEXT_STRING", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 780: { // TEXT_STRING_filesystem + {Syms: "TEXT_STRING", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 781: { // TEXT_STRING_password + {Syms: "TEXT_STRING", Kind: ActDefault}, + }, + 782: { // TEXT_STRING_hash + {Syms: "TEXT_STRING_sys", Kind: ActDefault}, + {Syms: "HEX_NUM", Kind: ActNew, Class: "to_lex_string", Params: []string{"s"}, Args: []Arg{{Text: "Item_hex_string::make_hex_str($1.str,$1.length)"}}}, + }, + 783: { // TEXT_STRING_validated + {Syms: "TEXT_STRING", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 784: { // ident + {Syms: "IDENT_sys", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "ident_keyword", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 785: { // role_ident + {Syms: "IDENT_sys", Kind: ActDefault}, + {Syms: "role_keyword", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 786: { // label_ident + {Syms: "IDENT_sys", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "label_keyword", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 787: { // lvalue_ident + {Syms: "IDENT_sys", Kind: ActDefault}, + {Syms: "lvalue_keyword", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 788: { // ident_or_text + {Syms: "ident", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "TEXT_STRING_sys", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "LEX_HOSTNAME", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 789: { // role_ident_or_text + {Syms: "role_ident", Kind: ActDefault}, + {Syms: "TEXT_STRING_sys", Kind: ActDefault}, + {Syms: "LEX_HOSTNAME", Kind: ActDefault}, + }, + 790: { // user_ident_or_text + {Syms: "ident_or_text", Kind: ActNew, Class: "LEX_USER", Args: []Arg{{Text: "YYTHD"}, {Child: 1}, {Text: "nullptr"}}}, + {Syms: "ident_or_text '@' ident_or_text", Kind: ActNew, Class: "LEX_USER", Args: []Arg{{Text: "YYTHD"}, {Child: 1}, {Child: 3}}}, + }, + 791: { // user + {Syms: "user_ident_or_text", Kind: ActPass, Args: []Arg{{Child: 1}}}, + {Syms: "CURRENT_USER optional_braces", Kind: ActNew, Class: "LEX_USER", Args: []Arg{{Text: "YYTHD"}}}, + }, + 792: { // role + {Syms: "role_ident_or_text", Kind: ActNew, Class: "LEX_USER", Args: []Arg{{Text: "YYTHD"}, {Child: 1}, {Text: "nullptr"}}}, + {Syms: "role_ident_or_text '@' ident_or_text", Kind: ActNew, Class: "LEX_USER", Args: []Arg{{Text: "YYTHD"}, {Child: 1}, {Child: 3}}}, + }, + 793: { // schema + {Syms: "ident", Kind: ActUnknown}, + }, + 794: { // ident_keyword + {Syms: "ident_keywords_unambiguous", Kind: ActDefault}, + {Syms: "ident_keywords_ambiguous_1_roles_and_labels", Kind: ActDefault}, + {Syms: "ident_keywords_ambiguous_2_labels", Kind: ActDefault}, + {Syms: "ident_keywords_ambiguous_3_roles", Kind: ActDefault}, + {Syms: "ident_keywords_ambiguous_4_system_variables", Kind: ActDefault}, + }, + 795: { // ident_keywords_ambiguous_1_roles_and_labels + {Syms: "EXECUTE_SYM", Kind: ActDefault}, + {Syms: "RESTART_SYM", Kind: ActDefault}, + {Syms: "SHUTDOWN", Kind: ActDefault}, + }, + 796: { // ident_keywords_ambiguous_2_labels + {Syms: "ASCII_SYM", Kind: ActDefault}, + {Syms: "BEGIN_SYM", Kind: ActDefault}, + {Syms: "BYTE_SYM", Kind: ActDefault}, + {Syms: "CACHE_SYM", Kind: ActDefault}, + {Syms: "CHARSET", Kind: ActDefault}, + {Syms: "CHECKSUM_SYM", Kind: ActDefault}, + {Syms: "CLONE_SYM", Kind: ActDefault}, + {Syms: "COMMENT_SYM", Kind: ActDefault}, + {Syms: "COMMIT_SYM", Kind: ActDefault}, + {Syms: "CONTAINS_SYM", Kind: ActDefault}, + {Syms: "DEALLOCATE_SYM", Kind: ActDefault}, + {Syms: "DO_SYM", Kind: ActDefault}, + {Syms: "END", Kind: ActDefault}, + {Syms: "FLUSH_SYM", Kind: ActDefault}, + {Syms: "FOLLOWS_SYM", Kind: ActDefault}, + {Syms: "HANDLER_SYM", Kind: ActDefault}, + {Syms: "HELP_SYM", Kind: ActDefault}, + {Syms: "IMPORT", Kind: ActDefault}, + {Syms: "INSTALL_SYM", Kind: ActDefault}, + {Syms: "LANGUAGE_SYM", Kind: ActDefault}, + {Syms: "NO_SYM", Kind: ActDefault}, + {Syms: "PRECEDES_SYM", Kind: ActDefault}, + {Syms: "PREPARE_SYM", Kind: ActDefault}, + {Syms: "REPAIR", Kind: ActDefault}, + {Syms: "RESET_SYM", Kind: ActDefault}, + {Syms: "ROLLBACK_SYM", Kind: ActDefault}, + {Syms: "SAVEPOINT_SYM", Kind: ActDefault}, + {Syms: "SIGNED_SYM", Kind: ActDefault}, + {Syms: "SLAVE", Kind: ActDefault}, + {Syms: "START_SYM", Kind: ActDefault}, + {Syms: "STOP_SYM", Kind: ActDefault}, + {Syms: "TRUNCATE_SYM", Kind: ActDefault}, + {Syms: "UNICODE_SYM", Kind: ActDefault}, + {Syms: "UNINSTALL_SYM", Kind: ActDefault}, + {Syms: "XA_SYM", Kind: ActDefault}, + }, + 797: { // label_keyword + {Syms: "ident_keywords_unambiguous", Kind: ActDefault}, + {Syms: "ident_keywords_ambiguous_3_roles", Kind: ActDefault}, + {Syms: "ident_keywords_ambiguous_4_system_variables", Kind: ActDefault}, + }, + 798: { // ident_keywords_ambiguous_3_roles + {Syms: "EVENT_SYM", Kind: ActDefault}, + {Syms: "FILE_SYM", Kind: ActDefault}, + {Syms: "NONE_SYM", Kind: ActDefault}, + {Syms: "PROCESS", Kind: ActDefault}, + {Syms: "PROXY_SYM", Kind: ActDefault}, + {Syms: "RELOAD", Kind: ActDefault}, + {Syms: "REPLICATION", Kind: ActDefault}, + {Syms: "RESOURCE_SYM", Kind: ActDefault}, + {Syms: "SUPER_SYM", Kind: ActDefault}, + }, + 799: { // ident_keywords_unambiguous + {Syms: "ACTION", Kind: ActDefault}, + {Syms: "ACCOUNT_SYM", Kind: ActDefault}, + {Syms: "ACTIVE_SYM", Kind: ActDefault}, + {Syms: "ADDDATE_SYM", Kind: ActDefault}, + {Syms: "ADMIN_SYM", Kind: ActDefault}, + {Syms: "AFTER_SYM", Kind: ActDefault}, + {Syms: "AGAINST", Kind: ActDefault}, + {Syms: "AGGREGATE_SYM", Kind: ActDefault}, + {Syms: "ALGORITHM_SYM", Kind: ActDefault}, + {Syms: "ALWAYS_SYM", Kind: ActDefault}, + {Syms: "ANY_SYM", Kind: ActDefault}, + {Syms: "ARRAY_SYM", Kind: ActDefault}, + {Syms: "AT_SYM", Kind: ActDefault}, + {Syms: "ATTRIBUTE_SYM", Kind: ActDefault}, + {Syms: "AUTHENTICATION_SYM", Kind: ActDefault}, + {Syms: "AUTOEXTEND_SIZE_SYM", Kind: ActDefault}, + {Syms: "AUTO_SYM", Kind: ActDefault}, + {Syms: "AUTO_INC", Kind: ActDefault}, + {Syms: "AVG_ROW_LENGTH", Kind: ActDefault}, + {Syms: "AVG_SYM", Kind: ActDefault}, + {Syms: "BACKUP_SYM", Kind: ActDefault}, + {Syms: "BERNOULLI_SYM", Kind: ActDefault}, + {Syms: "BINLOG_SYM", Kind: ActDefault}, + {Syms: "BIT_SYM", Kind: ActDefault}, + {Syms: "BLOCK_SYM", Kind: ActDefault}, + {Syms: "BOOLEAN_SYM", Kind: ActDefault}, + {Syms: "BOOL_SYM", Kind: ActDefault}, + {Syms: "BTREE_SYM", Kind: ActDefault}, + {Syms: "BUCKETS_SYM", Kind: ActDefault}, + {Syms: "BULK_SYM", Kind: ActDefault}, + {Syms: "CASCADED", Kind: ActDefault}, + {Syms: "CATALOG_NAME_SYM", Kind: ActDefault}, + {Syms: "CHAIN_SYM", Kind: ActDefault}, + {Syms: "CHALLENGE_RESPONSE_SYM", Kind: ActDefault}, + {Syms: "CHANGED", Kind: ActDefault}, + {Syms: "CHANNEL_SYM", Kind: ActDefault}, + {Syms: "CIPHER_SYM", Kind: ActDefault}, + {Syms: "CLASS_ORIGIN_SYM", Kind: ActDefault}, + {Syms: "CLIENT_SYM", Kind: ActDefault}, + {Syms: "CLOSE_SYM", Kind: ActDefault}, + {Syms: "COALESCE", Kind: ActDefault}, + {Syms: "CODE_SYM", Kind: ActDefault}, + {Syms: "COLLATION_SYM", Kind: ActDefault}, + {Syms: "COLUMNS", Kind: ActDefault}, + {Syms: "COLUMN_FORMAT_SYM", Kind: ActDefault}, + {Syms: "COLUMN_NAME_SYM", Kind: ActDefault}, + {Syms: "COMMITTED_SYM", Kind: ActDefault}, + {Syms: "COMPACT_SYM", Kind: ActDefault}, + {Syms: "COMPLETION_SYM", Kind: ActDefault}, + {Syms: "COMPONENT_SYM", Kind: ActDefault}, + {Syms: "COMPRESSED_SYM", Kind: ActDefault}, + {Syms: "COMPRESSION_SYM", Kind: ActDefault}, + {Syms: "CONCURRENT", Kind: ActDefault}, + {Syms: "CONNECTION_SYM", Kind: ActDefault}, + {Syms: "CONSISTENT_SYM", Kind: ActDefault}, + {Syms: "CONSTRAINT_CATALOG_SYM", Kind: ActDefault}, + {Syms: "CONSTRAINT_NAME_SYM", Kind: ActDefault}, + {Syms: "CONSTRAINT_SCHEMA_SYM", Kind: ActDefault}, + {Syms: "CONTEXT_SYM", Kind: ActDefault}, + {Syms: "CPU_SYM", Kind: ActDefault}, + {Syms: "CURRENT_SYM", Kind: ActDefault}, + {Syms: "CURSOR_NAME_SYM", Kind: ActDefault}, + {Syms: "DATAFILE_SYM", Kind: ActDefault}, + {Syms: "DATA_SYM", Kind: ActDefault}, + {Syms: "DATETIME_SYM", Kind: ActDefault}, + {Syms: "DATE_SYM", Kind: ActDefault}, + {Syms: "DAY_SYM", Kind: ActDefault}, + {Syms: "DEFAULT_AUTH_SYM", Kind: ActDefault}, + {Syms: "DEFINER_SYM", Kind: ActDefault}, + {Syms: "DEFINITION_SYM", Kind: ActDefault}, + {Syms: "DELAY_KEY_WRITE_SYM", Kind: ActDefault}, + {Syms: "DESCRIPTION_SYM", Kind: ActDefault}, + {Syms: "DIAGNOSTICS_SYM", Kind: ActDefault}, + {Syms: "DIRECTORY_SYM", Kind: ActDefault}, + {Syms: "DISABLE_SYM", Kind: ActDefault}, + {Syms: "DISCARD_SYM", Kind: ActDefault}, + {Syms: "DISK_SYM", Kind: ActDefault}, + {Syms: "DUMPFILE", Kind: ActDefault}, + {Syms: "DUPLICATE_SYM", Kind: ActDefault}, + {Syms: "DYNAMIC_SYM", Kind: ActDefault}, + {Syms: "ENABLE_SYM", Kind: ActDefault}, + {Syms: "ENCRYPTION_SYM", Kind: ActDefault}, + {Syms: "ENDS_SYM", Kind: ActDefault}, + {Syms: "ENFORCED_SYM", Kind: ActDefault}, + {Syms: "ENGINES_SYM", Kind: ActDefault}, + {Syms: "ENGINE_SYM", Kind: ActDefault}, + {Syms: "ENGINE_ATTRIBUTE_SYM", Kind: ActDefault}, + {Syms: "ENUM_SYM", Kind: ActDefault}, + {Syms: "ERRORS", Kind: ActDefault}, + {Syms: "ERROR_SYM", Kind: ActDefault}, + {Syms: "ESCAPE_SYM", Kind: ActDefault}, + {Syms: "EVENTS_SYM", Kind: ActDefault}, + {Syms: "EVERY_SYM", Kind: ActDefault}, + {Syms: "EXCHANGE_SYM", Kind: ActDefault}, + {Syms: "EXCLUDE_SYM", Kind: ActDefault}, + {Syms: "EXPANSION_SYM", Kind: ActDefault}, + {Syms: "EXPIRE_SYM", Kind: ActDefault}, + {Syms: "EXPORT_SYM", Kind: ActDefault}, + {Syms: "EXTENDED_SYM", Kind: ActDefault}, + {Syms: "EXTENT_SIZE_SYM", Kind: ActDefault}, + {Syms: "FACTOR_SYM", Kind: ActDefault}, + {Syms: "FAILED_LOGIN_ATTEMPTS_SYM", Kind: ActDefault}, + {Syms: "FAST_SYM", Kind: ActDefault}, + {Syms: "FAULTS_SYM", Kind: ActDefault}, + {Syms: "FILE_BLOCK_SIZE_SYM", Kind: ActDefault}, + {Syms: "FILTER_SYM", Kind: ActDefault}, + {Syms: "FINISH_SYM", Kind: ActDefault}, + {Syms: "FIRST_SYM", Kind: ActDefault}, + {Syms: "FIXED_SYM", Kind: ActDefault}, + {Syms: "FOLLOWING_SYM", Kind: ActDefault}, + {Syms: "FORMAT_SYM", Kind: ActDefault}, + {Syms: "FOUND_SYM", Kind: ActDefault}, + {Syms: "FULL", Kind: ActDefault}, + {Syms: "GENERAL", Kind: ActDefault}, + {Syms: "GENERATE_SYM", Kind: ActDefault}, + {Syms: "GEOMETRYCOLLECTION_SYM", Kind: ActDefault}, + {Syms: "GEOMETRY_SYM", Kind: ActDefault}, + {Syms: "GET_FORMAT", Kind: ActDefault}, + {Syms: "GET_SOURCE_PUBLIC_KEY_SYM", Kind: ActDefault}, + {Syms: "GRANTS", Kind: ActDefault}, + {Syms: "GROUP_REPLICATION", Kind: ActDefault}, + {Syms: "GTIDS_SYM", Kind: ActDefault}, + {Syms: "GTID_ONLY_SYM", Kind: ActDefault}, + {Syms: "HASH_SYM", Kind: ActDefault}, + {Syms: "HISTOGRAM_SYM", Kind: ActDefault}, + {Syms: "HISTORY_SYM", Kind: ActDefault}, + {Syms: "HOSTS_SYM", Kind: ActDefault}, + {Syms: "HOST_SYM", Kind: ActDefault}, + {Syms: "HOUR_SYM", Kind: ActDefault}, + {Syms: "IDENTIFIED_SYM", Kind: ActDefault}, + {Syms: "IGNORE_SERVER_IDS_SYM", Kind: ActDefault}, + {Syms: "INACTIVE_SYM", Kind: ActDefault}, + {Syms: "INDEXES", Kind: ActDefault}, + {Syms: "INITIAL_SIZE_SYM", Kind: ActDefault}, + {Syms: "INITIAL_SYM", Kind: ActDefault}, + {Syms: "INITIATE_SYM", Kind: ActDefault}, + {Syms: "INSERT_METHOD", Kind: ActDefault}, + {Syms: "INSTANCE_SYM", Kind: ActDefault}, + {Syms: "INVISIBLE_SYM", Kind: ActDefault}, + {Syms: "INVOKER_SYM", Kind: ActDefault}, + {Syms: "IO_SYM", Kind: ActDefault}, + {Syms: "IPC_SYM", Kind: ActDefault}, + {Syms: "ISOLATION", Kind: ActDefault}, + {Syms: "ISSUER_SYM", Kind: ActDefault}, + {Syms: "JSON_SYM", Kind: ActDefault}, + {Syms: "JSON_VALUE_SYM", Kind: ActDefault}, + {Syms: "KEY_BLOCK_SIZE", Kind: ActDefault}, + {Syms: "KEYRING_SYM", Kind: ActDefault}, + {Syms: "LAST_SYM", Kind: ActDefault}, + {Syms: "LEAVES", Kind: ActDefault}, + {Syms: "LESS_SYM", Kind: ActDefault}, + {Syms: "LEVEL_SYM", Kind: ActDefault}, + {Syms: "LINESTRING_SYM", Kind: ActDefault}, + {Syms: "LIST_SYM", Kind: ActDefault}, + {Syms: "LOCKED_SYM", Kind: ActDefault}, + {Syms: "LOCKS_SYM", Kind: ActDefault}, + {Syms: "LOGFILE_SYM", Kind: ActDefault}, + {Syms: "LOGS_SYM", Kind: ActDefault}, + {Syms: "LOG_SYM", Kind: ActDefault}, + {Syms: "NETWORK_NAMESPACE_SYM", Kind: ActDefault}, + {Syms: "MASTER_SYM", Kind: ActDefault}, + {Syms: "MAX_CONNECTIONS_PER_HOUR", Kind: ActDefault}, + {Syms: "MAX_QUERIES_PER_HOUR", Kind: ActDefault}, + {Syms: "MAX_ROWS", Kind: ActDefault}, + {Syms: "MAX_SIZE_SYM", Kind: ActDefault}, + {Syms: "MAX_UPDATES_PER_HOUR", Kind: ActDefault}, + {Syms: "MAX_USER_CONNECTIONS_SYM", Kind: ActDefault}, + {Syms: "MEDIUM_SYM", Kind: ActDefault}, + {Syms: "MEMBER_SYM", Kind: ActDefault}, + {Syms: "MEMORY_SYM", Kind: ActDefault}, + {Syms: "MERGE_SYM", Kind: ActDefault}, + {Syms: "MESSAGE_TEXT_SYM", Kind: ActDefault}, + {Syms: "MICROSECOND_SYM", Kind: ActDefault}, + {Syms: "MIGRATE_SYM", Kind: ActDefault}, + {Syms: "MINUTE_SYM", Kind: ActDefault}, + {Syms: "MIN_ROWS", Kind: ActDefault}, + {Syms: "MODE_SYM", Kind: ActDefault}, + {Syms: "MODIFY_SYM", Kind: ActDefault}, + {Syms: "MONTH_SYM", Kind: ActDefault}, + {Syms: "MULTILINESTRING_SYM", Kind: ActDefault}, + {Syms: "MULTIPOINT_SYM", Kind: ActDefault}, + {Syms: "MULTIPOLYGON_SYM", Kind: ActDefault}, + {Syms: "MUTEX_SYM", Kind: ActDefault}, + {Syms: "MYSQL_ERRNO_SYM", Kind: ActDefault}, + {Syms: "NAMES_SYM", Kind: ActDefault}, + {Syms: "NAME_SYM", Kind: ActDefault}, + {Syms: "NATIONAL_SYM", Kind: ActDefault}, + {Syms: "NCHAR_SYM", Kind: ActDefault}, + {Syms: "NDBCLUSTER_SYM", Kind: ActDefault}, + {Syms: "NESTED_SYM", Kind: ActDefault}, + {Syms: "NEVER_SYM", Kind: ActDefault}, + {Syms: "NEW_SYM", Kind: ActDefault}, + {Syms: "NEXT_SYM", Kind: ActDefault}, + {Syms: "NODEGROUP_SYM", Kind: ActDefault}, + {Syms: "NOWAIT_SYM", Kind: ActDefault}, + {Syms: "NO_WAIT_SYM", Kind: ActDefault}, + {Syms: "NULLS_SYM", Kind: ActDefault}, + {Syms: "NUMBER_SYM", Kind: ActDefault}, + {Syms: "NVARCHAR_SYM", Kind: ActDefault}, + {Syms: "OFF_SYM", Kind: ActDefault}, + {Syms: "OFFSET_SYM", Kind: ActDefault}, + {Syms: "OJ_SYM", Kind: ActDefault}, + {Syms: "OLD_SYM", Kind: ActDefault}, + {Syms: "ONE_SYM", Kind: ActDefault}, + {Syms: "ONLY_SYM", Kind: ActDefault}, + {Syms: "OPEN_SYM", Kind: ActDefault}, + {Syms: "OPTIONAL_SYM", Kind: ActDefault}, + {Syms: "OPTIONS_SYM", Kind: ActDefault}, + {Syms: "ORDINALITY_SYM", Kind: ActDefault}, + {Syms: "ORGANIZATION_SYM", Kind: ActDefault}, + {Syms: "OTHERS_SYM", Kind: ActDefault}, + {Syms: "OWNER_SYM", Kind: ActDefault}, + {Syms: "PACK_KEYS_SYM", Kind: ActDefault}, + {Syms: "PAGE_SYM", Kind: ActDefault}, + {Syms: "PARSER_SYM", Kind: ActDefault}, + {Syms: "PARSE_TREE_SYM", Kind: ActDefault}, + {Syms: "PARTIAL", Kind: ActDefault}, + {Syms: "PARTITIONING_SYM", Kind: ActDefault}, + {Syms: "PARTITIONS_SYM", Kind: ActDefault}, + {Syms: "PASSWORD", Kind: ActDefault}, + {Syms: "PASSWORD_LOCK_TIME_SYM", Kind: ActDefault}, + {Syms: "PATH_SYM", Kind: ActDefault}, + {Syms: "PHASE_SYM", Kind: ActDefault}, + {Syms: "PLUGINS_SYM", Kind: ActDefault}, + {Syms: "PLUGIN_DIR_SYM", Kind: ActDefault}, + {Syms: "PLUGIN_SYM", Kind: ActDefault}, + {Syms: "POINT_SYM", Kind: ActDefault}, + {Syms: "POLYGON_SYM", Kind: ActDefault}, + {Syms: "PORT_SYM", Kind: ActDefault}, + {Syms: "PRECEDING_SYM", Kind: ActDefault}, + {Syms: "PRESERVE_SYM", Kind: ActDefault}, + {Syms: "PREV_SYM", Kind: ActDefault}, + {Syms: "PRIVILEGES", Kind: ActDefault}, + {Syms: "PRIVILEGE_CHECKS_USER_SYM", Kind: ActDefault}, + {Syms: "PROCESSLIST_SYM", Kind: ActDefault}, + {Syms: "PROFILES_SYM", Kind: ActDefault}, + {Syms: "PROFILE_SYM", Kind: ActDefault}, + {Syms: "QUARTER_SYM", Kind: ActDefault}, + {Syms: "QUERY_SYM", Kind: ActDefault}, + {Syms: "QUICK", Kind: ActDefault}, + {Syms: "RANDOM_SYM", Kind: ActDefault}, + {Syms: "READ_ONLY_SYM", Kind: ActDefault}, + {Syms: "REBUILD_SYM", Kind: ActDefault}, + {Syms: "RECOVER_SYM", Kind: ActDefault}, + {Syms: "REDO_BUFFER_SIZE_SYM", Kind: ActDefault}, + {Syms: "REDUNDANT_SYM", Kind: ActDefault}, + {Syms: "REFERENCE_SYM", Kind: ActDefault}, + {Syms: "REGISTRATION_SYM", Kind: ActDefault}, + {Syms: "RELAY", Kind: ActDefault}, + {Syms: "RELAYLOG_SYM", Kind: ActDefault}, + {Syms: "RELAY_LOG_FILE_SYM", Kind: ActDefault}, + {Syms: "RELAY_LOG_POS_SYM", Kind: ActDefault}, + {Syms: "RELAY_THREAD", Kind: ActDefault}, + {Syms: "REMOVE_SYM", Kind: ActDefault}, + {Syms: "ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_SYM", Kind: ActDefault}, + {Syms: "REORGANIZE_SYM", Kind: ActDefault}, + {Syms: "REPEATABLE_SYM", Kind: ActDefault}, + {Syms: "REPLICAS_SYM", Kind: ActDefault}, + {Syms: "REPLICATE_DO_DB", Kind: ActDefault}, + {Syms: "REPLICATE_DO_TABLE", Kind: ActDefault}, + {Syms: "REPLICATE_IGNORE_DB", Kind: ActDefault}, + {Syms: "REPLICATE_IGNORE_TABLE", Kind: ActDefault}, + {Syms: "REPLICATE_REWRITE_DB", Kind: ActDefault}, + {Syms: "REPLICATE_WILD_DO_TABLE", Kind: ActDefault}, + {Syms: "REPLICATE_WILD_IGNORE_TABLE", Kind: ActDefault}, + {Syms: "REPLICA_SYM", Kind: ActDefault}, + {Syms: "REQUIRE_ROW_FORMAT_SYM", Kind: ActDefault}, + {Syms: "REQUIRE_TABLE_PRIMARY_KEY_CHECK_SYM", Kind: ActDefault}, + {Syms: "RESOURCES", Kind: ActDefault}, + {Syms: "RESPECT_SYM", Kind: ActDefault}, + {Syms: "RESTORE_SYM", Kind: ActDefault}, + {Syms: "RESUME_SYM", Kind: ActDefault}, + {Syms: "RETAIN_SYM", Kind: ActDefault}, + {Syms: "RETURNED_SQLSTATE_SYM", Kind: ActDefault}, + {Syms: "RETURNING_SYM", Kind: ActDefault}, + {Syms: "RETURNS_SYM", Kind: ActDefault}, + {Syms: "REUSE_SYM", Kind: ActDefault}, + {Syms: "REVERSE_SYM", Kind: ActDefault}, + {Syms: "ROLE_SYM", Kind: ActDefault}, + {Syms: "ROLLUP_SYM", Kind: ActDefault}, + {Syms: "ROTATE_SYM", Kind: ActDefault}, + {Syms: "ROUTINE_SYM", Kind: ActDefault}, + {Syms: "ROW_COUNT_SYM", Kind: ActDefault}, + {Syms: "ROW_FORMAT_SYM", Kind: ActDefault}, + {Syms: "RTREE_SYM", Kind: ActDefault}, + {Syms: "S3_SYM", Kind: ActDefault}, + {Syms: "SCHEDULE_SYM", Kind: ActDefault}, + {Syms: "SCHEMA_NAME_SYM", Kind: ActDefault}, + {Syms: "SECONDARY_ENGINE_SYM", Kind: ActDefault}, + {Syms: "SECONDARY_ENGINE_ATTRIBUTE_SYM", Kind: ActDefault}, + {Syms: "SECONDARY_LOAD_SYM", Kind: ActDefault}, + {Syms: "SECONDARY_SYM", Kind: ActDefault}, + {Syms: "SECONDARY_UNLOAD_SYM", Kind: ActDefault}, + {Syms: "SECOND_SYM", Kind: ActDefault}, + {Syms: "SECURITY_SYM", Kind: ActDefault}, + {Syms: "SERIALIZABLE_SYM", Kind: ActDefault}, + {Syms: "SERIAL_SYM", Kind: ActDefault}, + {Syms: "SERVER_SYM", Kind: ActDefault}, + {Syms: "SHARE_SYM", Kind: ActDefault}, + {Syms: "SIMPLE_SYM", Kind: ActDefault}, + {Syms: "SKIP_SYM", Kind: ActDefault}, + {Syms: "SLOW", Kind: ActDefault}, + {Syms: "SNAPSHOT_SYM", Kind: ActDefault}, + {Syms: "SOCKET_SYM", Kind: ActDefault}, + {Syms: "SONAME_SYM", Kind: ActDefault}, + {Syms: "SOUNDS_SYM", Kind: ActDefault}, + {Syms: "SOURCE_AUTO_POSITION_SYM", Kind: ActDefault}, + {Syms: "SOURCE_BIND_SYM", Kind: ActDefault}, + {Syms: "SOURCE_COMPRESSION_ALGORITHM_SYM", Kind: ActDefault}, + {Syms: "SOURCE_CONNECTION_AUTO_FAILOVER_SYM", Kind: ActDefault}, + {Syms: "SOURCE_CONNECT_RETRY_SYM", Kind: ActDefault}, + {Syms: "SOURCE_DELAY_SYM", Kind: ActDefault}, + {Syms: "SOURCE_HEARTBEAT_PERIOD_SYM", Kind: ActDefault}, + {Syms: "SOURCE_HOST_SYM", Kind: ActDefault}, + {Syms: "SOURCE_LOG_FILE_SYM", Kind: ActDefault}, + {Syms: "SOURCE_LOG_POS_SYM", Kind: ActDefault}, + {Syms: "SOURCE_PASSWORD_SYM", Kind: ActDefault}, + {Syms: "SOURCE_PORT_SYM", Kind: ActDefault}, + {Syms: "SOURCE_PUBLIC_KEY_PATH_SYM", Kind: ActDefault}, + {Syms: "SOURCE_RETRY_COUNT_SYM", Kind: ActDefault}, + {Syms: "SOURCE_SSL_CAPATH_SYM", Kind: ActDefault}, + {Syms: "SOURCE_SSL_CA_SYM", Kind: ActDefault}, + {Syms: "SOURCE_SSL_CERT_SYM", Kind: ActDefault}, + {Syms: "SOURCE_SSL_CIPHER_SYM", Kind: ActDefault}, + {Syms: "SOURCE_SSL_CRLPATH_SYM", Kind: ActDefault}, + {Syms: "SOURCE_SSL_CRL_SYM", Kind: ActDefault}, + {Syms: "SOURCE_SSL_KEY_SYM", Kind: ActDefault}, + {Syms: "SOURCE_SSL_SYM", Kind: ActDefault}, + {Syms: "SOURCE_SSL_VERIFY_SERVER_CERT_SYM", Kind: ActDefault}, + {Syms: "SOURCE_SYM", Kind: ActDefault}, + {Syms: "SOURCE_TLS_CIPHERSUITES_SYM", Kind: ActDefault}, + {Syms: "SOURCE_TLS_VERSION_SYM", Kind: ActDefault}, + {Syms: "SOURCE_USER_SYM", Kind: ActDefault}, + {Syms: "SOURCE_ZSTD_COMPRESSION_LEVEL_SYM", Kind: ActDefault}, + {Syms: "SQL_AFTER_GTIDS", Kind: ActDefault}, + {Syms: "SQL_AFTER_MTS_GAPS", Kind: ActDefault}, + {Syms: "SQL_BEFORE_GTIDS", Kind: ActDefault}, + {Syms: "SQL_BUFFER_RESULT", Kind: ActDefault}, + {Syms: "SQL_NO_CACHE_SYM", Kind: ActDefault}, + {Syms: "SQL_THREAD", Kind: ActDefault}, + {Syms: "SRID_SYM", Kind: ActDefault}, + {Syms: "STACKED_SYM", Kind: ActDefault}, + {Syms: "STARTS_SYM", Kind: ActDefault}, + {Syms: "STATS_AUTO_RECALC_SYM", Kind: ActDefault}, + {Syms: "STATS_PERSISTENT_SYM", Kind: ActDefault}, + {Syms: "STATS_SAMPLE_PAGES_SYM", Kind: ActDefault}, + {Syms: "STATUS_SYM", Kind: ActDefault}, + {Syms: "STORAGE_SYM", Kind: ActDefault}, + {Syms: "STREAM_SYM", Kind: ActDefault}, + {Syms: "STRING_SYM", Kind: ActDefault}, + {Syms: "ST_COLLECT_SYM", Kind: ActDefault}, + {Syms: "SUBCLASS_ORIGIN_SYM", Kind: ActDefault}, + {Syms: "SUBDATE_SYM", Kind: ActDefault}, + {Syms: "SUBJECT_SYM", Kind: ActDefault}, + {Syms: "SUBPARTITIONS_SYM", Kind: ActDefault}, + {Syms: "SUBPARTITION_SYM", Kind: ActDefault}, + {Syms: "SUSPEND_SYM", Kind: ActDefault}, + {Syms: "SWAPS_SYM", Kind: ActDefault}, + {Syms: "SWITCHES_SYM", Kind: ActDefault}, + {Syms: "TABLES", Kind: ActDefault}, + {Syms: "TABLESPACE_SYM", Kind: ActDefault}, + {Syms: "TABLE_CHECKSUM_SYM", Kind: ActDefault}, + {Syms: "TABLE_NAME_SYM", Kind: ActDefault}, + {Syms: "TEMPORARY", Kind: ActDefault}, + {Syms: "TEMPTABLE_SYM", Kind: ActDefault}, + {Syms: "TEXT_SYM", Kind: ActDefault}, + {Syms: "THAN_SYM", Kind: ActDefault}, + {Syms: "THREAD_PRIORITY_SYM", Kind: ActDefault}, + {Syms: "TIES_SYM", Kind: ActDefault}, + {Syms: "TIMESTAMP_ADD", Kind: ActDefault}, + {Syms: "TIMESTAMP_DIFF", Kind: ActDefault}, + {Syms: "TIMESTAMP_SYM", Kind: ActDefault}, + {Syms: "TIME_SYM", Kind: ActDefault}, + {Syms: "TLS_SYM", Kind: ActDefault}, + {Syms: "TRANSACTION_SYM", Kind: ActDefault}, + {Syms: "TRIGGERS_SYM", Kind: ActDefault}, + {Syms: "TYPES_SYM", Kind: ActDefault}, + {Syms: "TYPE_SYM", Kind: ActDefault}, + {Syms: "UNBOUNDED_SYM", Kind: ActDefault}, + {Syms: "UNCOMMITTED_SYM", Kind: ActDefault}, + {Syms: "UNDEFINED_SYM", Kind: ActDefault}, + {Syms: "UNDOFILE_SYM", Kind: ActDefault}, + {Syms: "UNDO_BUFFER_SIZE_SYM", Kind: ActDefault}, + {Syms: "UNKNOWN_SYM", Kind: ActDefault}, + {Syms: "UNREGISTER_SYM", Kind: ActDefault}, + {Syms: "UNTIL_SYM", Kind: ActDefault}, + {Syms: "UPGRADE_SYM", Kind: ActDefault}, + {Syms: "URL_SYM", Kind: ActDefault}, + {Syms: "USER", Kind: ActDefault}, + {Syms: "USE_FRM", Kind: ActDefault}, + {Syms: "VALIDATION_SYM", Kind: ActDefault}, + {Syms: "VALUE_SYM", Kind: ActDefault}, + {Syms: "VARIABLES", Kind: ActDefault}, + {Syms: "VCPU_SYM", Kind: ActDefault}, + {Syms: "VIEW_SYM", Kind: ActDefault}, + {Syms: "VISIBLE_SYM", Kind: ActDefault}, + {Syms: "WAIT_SYM", Kind: ActDefault}, + {Syms: "WARNINGS", Kind: ActDefault}, + {Syms: "WEEK_SYM", Kind: ActDefault}, + {Syms: "WEIGHT_STRING_SYM", Kind: ActDefault}, + {Syms: "WITHOUT_SYM", Kind: ActDefault}, + {Syms: "WORK_SYM", Kind: ActDefault}, + {Syms: "WRAPPER_SYM", Kind: ActDefault}, + {Syms: "X509_SYM", Kind: ActDefault}, + {Syms: "XID_SYM", Kind: ActDefault}, + {Syms: "XML_SYM", Kind: ActDefault}, + {Syms: "YEAR_SYM", Kind: ActDefault}, + {Syms: "ZONE_SYM", Kind: ActDefault}, + }, + 800: { // role_keyword + {Syms: "ident_keywords_unambiguous", Kind: ActDefault}, + {Syms: "ident_keywords_ambiguous_2_labels", Kind: ActDefault}, + {Syms: "ident_keywords_ambiguous_4_system_variables", Kind: ActDefault}, + }, + 801: { // lvalue_keyword + {Syms: "ident_keywords_unambiguous", Kind: ActDefault}, + {Syms: "ident_keywords_ambiguous_1_roles_and_labels", Kind: ActDefault}, + {Syms: "ident_keywords_ambiguous_2_labels", Kind: ActDefault}, + {Syms: "ident_keywords_ambiguous_3_roles", Kind: ActDefault}, + }, + 802: { // ident_keywords_ambiguous_4_system_variables + {Syms: "GLOBAL_SYM", Kind: ActDefault}, + {Syms: "LOCAL_SYM", Kind: ActDefault}, + {Syms: "PERSIST_SYM", Kind: ActDefault}, + {Syms: "PERSIST_ONLY_SYM", Kind: ActDefault}, + {Syms: "SESSION_SYM", Kind: ActDefault}, + }, + 803: { // set + {Syms: "SET_SYM start_option_value_list", Kind: ActNew, Class: "PT_set", Params: []string{"pos", "set_pos", "list"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Child: 2}}}, + }, + 804: { // start_option_value_list + {Syms: "option_value_no_option_type option_value_list_continued", Kind: ActNew, Class: "PT_start_option_value_list_no_type", Params: []string{"pos", "head", "head_pos", "tail"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Text: "@1"}, {Child: 2}}}, + {Syms: "TRANSACTION_SYM transaction_characteristics", Kind: ActNew, Class: "PT_start_option_value_list_transaction", Params: []string{"pos", "characteristics", "end_pos"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Text: "@2"}}}, + {Syms: "option_type start_option_value_list_following_option_type", Kind: ActNew, Class: "PT_start_option_value_list_type", Params: []string{"pos", "type", "list"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}}}, + {Syms: "PASSWORD equal TEXT_STRING_password opt_replace_password opt_retain_current_password", Kind: ActNew, Class: "PT_option_value_no_option_type_password", Params: []string{"pos", "password", "current_password", "retain_current", "random_password", "expr_pos"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".str"}, {Child: 4, Field: ".str"}, {Child: 5}, {Text: "false"}, {Text: "@4"}}}, + {Syms: "PASSWORD TO_SYM RANDOM_SYM opt_replace_password opt_retain_current_password", Kind: ActNew, Class: "PT_option_value_no_option_type_password", Params: []string{"pos", "password", "current_password", "retain_current", "random_password", "expr_pos"}, Args: []Arg{{Text: "@$"}, {Child: 3, Field: ".str"}, {Child: 4, Field: ".str"}, {Child: 5}, {Text: "true"}, {Text: "@4"}}}, + {Syms: "PASSWORD FOR_SYM user equal TEXT_STRING_password opt_replace_password opt_retain_current_password", Kind: ActNew, Class: "PT_option_value_no_option_type_password_for", Params: []string{"pos", "user", "password", "current_password", "retain_current", "random_pass", "expr_pos"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5, Field: ".str"}, {Child: 6, Field: ".str"}, {Child: 7}, {Text: "false"}, {Text: "@6"}}}, + {Syms: "PASSWORD FOR_SYM user TO_SYM RANDOM_SYM opt_replace_password opt_retain_current_password", Kind: ActNew, Class: "PT_option_value_no_option_type_password_for", Params: []string{"pos", "user", "password", "current_password", "retain_current", "random_pass", "expr_pos"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Child: 5, Field: ".str"}, {Child: 6, Field: ".str"}, {Child: 7}, {Text: "true"}, {Text: "@6"}}}, + }, + 805: { // set_role_stmt + {Syms: "SET_SYM ROLE_SYM role_list", Kind: ActNew, Class: "PT_set_role", Params: []string{"pos", "roles"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + {Syms: "SET_SYM ROLE_SYM NONE_SYM", Kind: ActUnknown}, + {Syms: "SET_SYM ROLE_SYM DEFAULT_SYM", Kind: ActUnknown}, + {Syms: "SET_SYM DEFAULT_SYM ROLE_SYM role_list TO_SYM role_list", Kind: ActNew, Class: "PT_alter_user_default_role", Params: []string{"pos", "if_exists", "users", "roles", "role_type"}, Args: []Arg{{Text: "@$"}, {Text: "false"}, {Child: 6}, {Child: 4}, {Text: "role_enum::ROLE_NAME"}}}, + {Syms: "SET_SYM DEFAULT_SYM ROLE_SYM NONE_SYM TO_SYM role_list", Kind: ActNew, Class: "PT_alter_user_default_role", Params: []string{"pos", "if_exists", "users", "roles", "role_type"}, Args: []Arg{{Text: "@$"}, {Text: "false"}, {Child: 6}, {Text: "nullptr"}, {Text: "role_enum::ROLE_NONE"}}}, + {Syms: "SET_SYM DEFAULT_SYM ROLE_SYM ALL TO_SYM role_list", Kind: ActNew, Class: "PT_alter_user_default_role", Params: []string{"pos", "if_exists", "users", "roles", "role_type"}, Args: []Arg{{Text: "@$"}, {Text: "false"}, {Child: 6}, {Text: "nullptr"}, {Text: "role_enum::ROLE_ALL"}}}, + {Syms: "SET_SYM ROLE_SYM ALL opt_except_role_list", Kind: ActUnknown}, + }, + 806: { // opt_except_role_list + {Syms: "", Kind: ActEmpty}, + {Syms: "EXCEPT_SYM role_list", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 807: { // set_resource_group_stmt + {Syms: "SET_SYM RESOURCE_SYM GROUP_SYM ident", Kind: ActNew, Class: "PT_set_resource_group", Params: []string{"pos", "name", "thread_id_list"}, Args: []Arg{{Text: "@$"}, {Child: 4}, {Text: "nullptr"}}}, + {Syms: "SET_SYM RESOURCE_SYM GROUP_SYM ident FOR_SYM thread_id_list_options", Kind: ActNew, Class: "PT_set_resource_group", Params: []string{"pos", "name", "thread_id_list"}, Args: []Arg{{Text: "@$"}, {Child: 4}, {Child: 6}}}, + }, + 808: { // thread_id_list + {Syms: "real_ulong_num", Kind: ActListNew, Class: "Mem_root_array", Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 1}}}, + {Syms: "thread_id_list opt_comma real_ulong_num", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 809: { // thread_id_list_options + {Syms: "thread_id_list", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 810: { // start_option_value_list_following_option_type + {Syms: "option_value_following_option_type option_value_list_continued", Kind: ActNew, Class: "PT_start_option_value_list_following_option_type_eq", Params: []string{"pos", "head", "head_pos", "opt_tail"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Text: "@1"}, {Child: 2}}}, + {Syms: "TRANSACTION_SYM transaction_characteristics", Kind: ActNew, Class: "PT_start_option_value_list_following_option_type_transaction", Params: []string{"pos", "characteristics", "characteristics_pos"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Text: "@2"}}}, + }, + 811: { // option_value_list_continued + {Syms: "", Kind: ActEmpty}, + {Syms: "',' option_value_list", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 812: { // option_value_list + {Syms: "option_value", Kind: ActNew, Class: "PT_option_value_list_head", Params: []string{"pos", "delimiter_pos", "value", "value_pos"}, Args: []Arg{{Text: "@$"}, {Text: "@0"}, {Child: 1}, {Text: "@1"}}}, + {Syms: "option_value_list ',' option_value", Kind: ActNew, Class: "PT_option_value_list", Params: []string{"pos", "head", "delimiter_pos", "tail", "tail_pos"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Text: "@2"}, {Child: 3}, {Text: "@3"}}}, + }, + 813: { // option_value + {Syms: "option_type option_value_following_option_type", Kind: ActNew, Class: "PT_option_value_type", Params: []string{"pos", "type", "value"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}}}, + {Syms: "option_value_no_option_type", Kind: ActPass, Args: []Arg{{Child: 1}}}, + }, + 814: { // option_type + {Syms: "GLOBAL_SYM", Kind: ActConst, Const: "OPT_GLOBAL"}, + {Syms: "PERSIST_SYM", Kind: ActConst, Const: "OPT_PERSIST"}, + {Syms: "PERSIST_ONLY_SYM", Kind: ActConst, Const: "OPT_PERSIST_ONLY"}, + {Syms: "LOCAL_SYM", Kind: ActConst, Const: "OPT_SESSION"}, + {Syms: "SESSION_SYM", Kind: ActConst, Const: "OPT_SESSION"}, + }, + 815: { // opt_var_type + {Syms: "", Kind: ActConst, Const: "OPT_SESSION"}, + {Syms: "GLOBAL_SYM", Kind: ActConst, Const: "OPT_GLOBAL"}, + {Syms: "LOCAL_SYM", Kind: ActConst, Const: "OPT_SESSION"}, + {Syms: "SESSION_SYM", Kind: ActConst, Const: "OPT_SESSION"}, + }, + 816: { // opt_rvalue_system_variable_type + {Syms: "", Kind: ActConst, Const: "OPT_DEFAULT"}, + {Syms: "GLOBAL_SYM '.'", Kind: ActConst, Const: "OPT_GLOBAL"}, + {Syms: "LOCAL_SYM '.'", Kind: ActConst, Const: "OPT_SESSION"}, + {Syms: "SESSION_SYM '.'", Kind: ActConst, Const: "OPT_SESSION"}, + }, + 817: { // opt_set_var_ident_type + {Syms: "", Kind: ActConst, Const: "OPT_DEFAULT"}, + {Syms: "PERSIST_SYM '.'", Kind: ActConst, Const: "OPT_PERSIST"}, + {Syms: "PERSIST_ONLY_SYM '.'", Kind: ActConst, Const: "OPT_PERSIST_ONLY"}, + {Syms: "GLOBAL_SYM '.'", Kind: ActConst, Const: "OPT_GLOBAL"}, + {Syms: "LOCAL_SYM '.'", Kind: ActConst, Const: "OPT_SESSION"}, + {Syms: "SESSION_SYM '.'", Kind: ActConst, Const: "OPT_SESSION"}, + }, + 818: { // option_value_following_option_type + {Syms: "lvalue_variable equal set_expr_or_default", Kind: ActNew, Class: "PT_set_scoped_system_variable", Params: []string{"pos", "var_pos", "opt_prefix", "name", "opt_expr"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Child: 1, Field: ".prefix"}, {Child: 1, Field: ".name"}, {Child: 3}}}, + }, + 819: { // option_value_no_option_type + {Syms: "lvalue_variable equal set_expr_or_default", Kind: ActNew, Class: "PT_set_variable", Params: []string{"pos", "varpos", "opt_prefix", "name", "expr_pos", "opt_expr"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Child: 1, Field: ".prefix"}, {Child: 1, Field: ".name"}, {Text: "@3"}, {Child: 3}}}, + {Syms: "'@' ident_or_text equal expr", Kind: ActNew, Class: "PT_option_value_no_option_type_user_var", Params: []string{"pos", "name", "expr"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Child: 4}}}, + {Syms: "'@' '@' opt_set_var_ident_type lvalue_variable equal set_expr_or_default", Kind: ActNew, Class: "PT_set_system_variable", Params: []string{"pos", "scope", "name_pos", "opt_prefix", "name", "opt_expr"}, Args: []Arg{{Text: "@$"}, {Child: 3}, {Text: "@4"}, {Child: 4, Field: ".prefix"}, {Child: 4, Field: ".name"}, {Child: 6}}}, + {Syms: "character_set old_or_new_charset_name_or_default", Kind: ActNew, Class: "PT_option_value_no_option_type_charset", Params: []string{"pos", "opt_charset"}, Args: []Arg{{Text: "@$"}, {Child: 2}}}, + {Syms: "NAMES_SYM equal expr", Kind: ActNew, Class: "PT_option_value_no_option_type_names", Params: []string{"pos", "error_pos"}, Args: []Arg{{Text: "@$"}, {Text: "@2"}}}, + {Syms: "NAMES_SYM charset_name opt_collate", Kind: ActNew, Class: "PT_set_names", Params: []string{"pos", "opt_charset", "opt_collation"}, Args: []Arg{{Text: "@$"}, {Child: 2}, {Child: 3}}}, + {Syms: "NAMES_SYM DEFAULT_SYM", Kind: ActNew, Class: "PT_set_names", Params: []string{"pos", "opt_charset", "opt_collation"}, Args: []Arg{{Text: "@$"}, {Text: "nullptr"}, {Text: "nullptr"}}}, + }, + 820: { // lvalue_variable + {Syms: "lvalue_ident", Kind: ActNew, Class: "Bipartite_name", Args: []Arg{{Text: "{}"}, {Child: 1}}}, + {Syms: "lvalue_ident '.' ident", Kind: ActNew, Class: "Bipartite_name", Args: []Arg{{Child: 1}, {Child: 3}}}, + {Syms: "DEFAULT_SYM '.' ident", Kind: ActNew, Class: "Bipartite_name", Args: []Arg{{Text: "{STRING_WITH_LEN(\"default\")}"}, {Child: 3}}}, + }, + 821: { // rvalue_system_variable + {Syms: "ident_or_text", Kind: ActNew, Class: "Bipartite_name", Args: []Arg{{Text: "{}"}, {Child: 1}}}, + {Syms: "ident_or_text '.' ident", Kind: ActNew, Class: "Bipartite_name", Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 822: { // transaction_characteristics + {Syms: "transaction_access_mode opt_isolation_level", Kind: ActNew, Class: "PT_transaction_characteristics", Params: []string{"pos", "head", "opt_tail"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}}}, + {Syms: "isolation_level opt_transaction_access_mode", Kind: ActNew, Class: "PT_transaction_characteristics", Params: []string{"pos", "head", "opt_tail"}, Args: []Arg{{Text: "@$"}, {Child: 1}, {Child: 2}}}, + }, + 823: { // transaction_access_mode + {Syms: "transaction_access_mode_types", Kind: ActNew, Class: "PT_transaction_access_mode", Params: []string{"pos", "is_read_only"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + }, + 824: { // opt_transaction_access_mode + {Syms: "", Kind: ActEmpty}, + {Syms: "',' transaction_access_mode", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 825: { // isolation_level + {Syms: "ISOLATION LEVEL_SYM isolation_types", Kind: ActNew, Class: "PT_isolation_level", Params: []string{"pos", "level"}, Args: []Arg{{Text: "@$"}, {Child: 3}}}, + }, + 826: { // opt_isolation_level + {Syms: "", Kind: ActEmpty}, + {Syms: "',' isolation_level", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 827: { // transaction_access_mode_types + {Syms: "READ_SYM ONLY_SYM", Kind: ActConst, Const: "true"}, + {Syms: "READ_SYM WRITE_SYM", Kind: ActConst, Const: "false"}, + }, + 828: { // isolation_types + {Syms: "READ_SYM UNCOMMITTED_SYM", Kind: ActConst, Const: "ISO_READ_UNCOMMITTED"}, + {Syms: "READ_SYM COMMITTED_SYM", Kind: ActConst, Const: "ISO_READ_COMMITTED"}, + {Syms: "REPEATABLE_SYM READ_SYM", Kind: ActConst, Const: "ISO_REPEATABLE_READ"}, + {Syms: "SERIALIZABLE_SYM", Kind: ActConst, Const: "ISO_SERIALIZABLE"}, + }, + 829: { // set_expr_or_default + {Syms: "expr", Kind: ActDefault}, + {Syms: "DEFAULT_SYM", Kind: ActEmpty}, + {Syms: "ON_SYM", Kind: ActNew, Class: "Item_string", Params: []string{"pos", "str", "length", "cs"}, Args: []Arg{{Text: "@$"}, {Text: "\"ON\""}, {Text: "2"}, {Text: "system_charset_info"}}}, + {Syms: "ALL", Kind: ActNew, Class: "Item_string", Params: []string{"pos", "str", "length", "cs"}, Args: []Arg{{Text: "@$"}, {Text: "\"ALL\""}, {Text: "3"}, {Text: "system_charset_info"}}}, + {Syms: "BINARY_SYM", Kind: ActNew, Class: "Item_string", Params: []string{"pos", "str", "length", "cs"}, Args: []Arg{{Text: "@$"}, {Text: "\"binary\""}, {Text: "6"}, {Text: "system_charset_info"}}}, + {Syms: "ROW_SYM", Kind: ActNew, Class: "Item_string", Params: []string{"pos", "str", "length", "cs"}, Args: []Arg{{Text: "@$"}, {Text: "\"ROW\""}, {Text: "3"}, {Text: "system_charset_info"}}}, + {Syms: "SYSTEM_SYM", Kind: ActNew, Class: "Item_string", Params: []string{"pos", "str", "length", "cs"}, Args: []Arg{{Text: "@$"}, {Text: "\"SYSTEM\""}, {Text: "6"}, {Text: "system_charset_info"}}}, + }, + 830: { // lock + {Syms: "LOCK_SYM table_or_tables table_lock_list", Kind: ActDefault}, + {Syms: "LOCK_SYM INSTANCE_SYM FOR_SYM BACKUP_SYM", Kind: ActDefault}, + }, + 831: { // table_or_tables + {Syms: "TABLE_SYM", Kind: ActDefault}, + {Syms: "TABLES", Kind: ActDefault}, + }, + 832: { // table_lock_list + {Syms: "table_lock", Kind: ActDefault}, + {Syms: "table_lock_list ',' table_lock", Kind: ActDefault}, + }, + 833: { // table_lock + {Syms: "table_ident opt_table_alias lock_option", Kind: ActUnknown}, + }, + 834: { // lock_option + {Syms: "READ_SYM", Kind: ActConst, Const: "TL_READ_NO_INSERT"}, + {Syms: "WRITE_SYM", Kind: ActConst, Const: "TL_WRITE_DEFAULT"}, + {Syms: "READ_SYM LOCAL_SYM", Kind: ActConst, Const: "TL_READ"}, + }, + 835: { // unlock + {Syms: "UNLOCK_SYM table_or_tables", Kind: ActDefault}, + {Syms: "UNLOCK_SYM INSTANCE_SYM", Kind: ActDefault}, + }, + 836: { // shutdown_stmt + {Syms: "SHUTDOWN", Kind: ActUnknown}, + }, + 837: { // restart_server_stmt + {Syms: "RESTART_SYM", Kind: ActNew, Class: "PT_restart_server"}, + }, + 838: { // alter_instance_stmt + {Syms: "ALTER INSTANCE_SYM alter_instance_action", Kind: ActUnknown}, + }, + 839: { // alter_instance_action + {Syms: "ROTATE_SYM ident_or_text MASTER_SYM KEY_SYM", Kind: ActUnknown}, + {Syms: "RELOAD TLS_SYM", Kind: ActNew, Class: "PT_alter_instance", Params: []string{"pos", "alter_instance_action", "channel"}, Args: []Arg{{Text: "@$"}, {Text: "ALTER_INSTANCE_RELOAD_TLS_ROLLBACK_ON_ERROR"}, {Text: "to_lex_cstring(\"mysql_main\")"}}}, + {Syms: "RELOAD TLS_SYM NO_SYM ROLLBACK_SYM ON_SYM ERROR_SYM", Kind: ActNew, Class: "PT_alter_instance", Params: []string{"pos", "alter_instance_action", "channel"}, Args: []Arg{{Text: "@$"}, {Text: "ALTER_INSTANCE_RELOAD_TLS"}, {Text: "to_lex_cstring(\"mysql_main\")"}}}, + {Syms: "RELOAD TLS_SYM FOR_SYM CHANNEL_SYM ident", Kind: ActNew, Class: "PT_alter_instance", Params: []string{"pos", "alter_instance_action", "channel"}, Args: []Arg{{Text: "@$"}, {Text: "ALTER_INSTANCE_RELOAD_TLS_ROLLBACK_ON_ERROR"}, {Child: 5}}}, + {Syms: "RELOAD TLS_SYM FOR_SYM CHANNEL_SYM ident NO_SYM ROLLBACK_SYM ON_SYM ERROR_SYM", Kind: ActNew, Class: "PT_alter_instance", Params: []string{"pos", "alter_instance_action", "channel"}, Args: []Arg{{Text: "@$"}, {Text: "ALTER_INSTANCE_RELOAD_TLS"}, {Child: 5}}}, + {Syms: "ENABLE_SYM ident ident", Kind: ActNew, Class: "PT_alter_instance", Params: []string{"pos", "alter_instance_action", "channel"}, Args: []Arg{{Text: "@$"}, {Text: "ALTER_INSTANCE_ENABLE_INNODB_REDO"}, {Text: "EMPTY_CSTR"}}}, + {Syms: "DISABLE_SYM ident ident", Kind: ActNew, Class: "PT_alter_instance", Params: []string{"pos", "alter_instance_action", "channel"}, Args: []Arg{{Text: "@$"}, {Text: "ALTER_INSTANCE_DISABLE_INNODB_REDO"}, {Text: "EMPTY_CSTR"}}}, + {Syms: "RELOAD KEYRING_SYM", Kind: ActNew, Class: "PT_alter_instance", Params: []string{"pos", "alter_instance_action", "channel"}, Args: []Arg{{Text: "@$"}, {Text: "RELOAD_KEYRING"}, {Text: "EMPTY_CSTR"}}}, + }, + 840: { // handler_stmt + {Syms: "HANDLER_SYM table_ident OPEN_SYM opt_table_alias", Kind: ActNew, Class: "PT_handler_open", Params: []string{"table", "opt_table_alias"}, Args: []Arg{{Child: 2}, {Child: 4}}}, + {Syms: "HANDLER_SYM ident CLOSE_SYM", Kind: ActNew, Class: "PT_handler_close", Params: []string{"table"}, Args: []Arg{{Child: 2}}}, + {Syms: "HANDLER_SYM ident READ_SYM handler_scan_function opt_where_clause opt_limit_clause", Kind: ActNew, Class: "PT_handler_table_scan", Params: []string{"table", "direction", "opt_where_clause", "opt_limit_clause"}, Args: []Arg{{Child: 2}, {Child: 4}, {Child: 5}, {Child: 6}}}, + {Syms: "HANDLER_SYM ident READ_SYM ident handler_rkey_function opt_where_clause opt_limit_clause", Kind: ActNew, Class: "PT_handler_index_scan", Params: []string{"table", "index", "direction", "opt_where_clause", "opt_limit_clause"}, Args: []Arg{{Child: 2}, {Child: 4}, {Child: 5}, {Child: 6}, {Child: 7}}}, + {Syms: "HANDLER_SYM ident READ_SYM ident handler_rkey_mode '(' values ')' opt_where_clause opt_limit_clause", Kind: ActNew, Class: "PT_handler_index_range_scan", Params: []string{"table", "index", "key_function", "keypart_values", "opt_where_clause", "opt_limit_clause"}, Args: []Arg{{Child: 2}, {Child: 4}, {Child: 5}, {Child: 7}, {Child: 9}, {Child: 10}}}, + }, + 841: { // handler_scan_function + {Syms: "FIRST_SYM", Kind: ActConst, Const: "enum_ha_read_modes::RFIRST"}, + {Syms: "NEXT_SYM", Kind: ActConst, Const: "enum_ha_read_modes::RNEXT"}, + }, + 842: { // handler_rkey_function + {Syms: "FIRST_SYM", Kind: ActConst, Const: "enum_ha_read_modes::RFIRST"}, + {Syms: "NEXT_SYM", Kind: ActConst, Const: "enum_ha_read_modes::RNEXT"}, + {Syms: "PREV_SYM", Kind: ActConst, Const: "enum_ha_read_modes::RPREV"}, + {Syms: "LAST_SYM", Kind: ActConst, Const: "enum_ha_read_modes::RLAST"}, + }, + 843: { // handler_rkey_mode + {Syms: "EQ", Kind: ActConst, Const: "HA_READ_KEY_EXACT"}, + {Syms: "GE", Kind: ActConst, Const: "HA_READ_KEY_OR_NEXT"}, + {Syms: "LE", Kind: ActConst, Const: "HA_READ_KEY_OR_PREV"}, + {Syms: "GT_SYM", Kind: ActConst, Const: "HA_READ_AFTER_KEY"}, + {Syms: "LT", Kind: ActConst, Const: "HA_READ_BEFORE_KEY"}, + }, + 844: { // revoke + {Syms: "REVOKE if_exists role_or_privilege_list FROM user_list opt_ignore_unknown_user", Kind: ActUnknown}, + {Syms: "REVOKE if_exists role_or_privilege_list ON_SYM opt_acl_type grant_ident FROM user_list opt_ignore_unknown_user", Kind: ActUnknown}, + {Syms: "REVOKE if_exists ALL opt_privileges ON_SYM opt_acl_type grant_ident FROM user_list opt_ignore_unknown_user", Kind: ActUnknown}, + {Syms: "REVOKE if_exists ALL opt_privileges ',' GRANT OPTION FROM user_list opt_ignore_unknown_user", Kind: ActUnknown}, + {Syms: "REVOKE if_exists PROXY_SYM ON_SYM user FROM user_list opt_ignore_unknown_user", Kind: ActUnknown}, + }, + 845: { // grant + {Syms: "GRANT role_or_privilege_list TO_SYM user_list opt_with_admin_option", Kind: ActUnknown}, + {Syms: "GRANT role_or_privilege_list ON_SYM opt_acl_type grant_ident TO_SYM user_list grant_options opt_grant_as", Kind: ActUnknown}, + {Syms: "GRANT ALL opt_privileges ON_SYM opt_acl_type grant_ident TO_SYM user_list grant_options opt_grant_as", Kind: ActUnknown}, + {Syms: "GRANT PROXY_SYM ON_SYM user TO_SYM user_list opt_grant_option", Kind: ActUnknown}, + }, + 846: { // opt_acl_type + {Syms: "", Kind: ActConst, Const: "Acl_type::TABLE"}, + {Syms: "TABLE_SYM", Kind: ActConst, Const: "Acl_type::TABLE"}, + {Syms: "FUNCTION_SYM", Kind: ActConst, Const: "Acl_type::FUNCTION"}, + {Syms: "PROCEDURE_SYM", Kind: ActConst, Const: "Acl_type::PROCEDURE"}, + }, + 847: { // opt_privileges + {Syms: "", Kind: ActDefault}, + {Syms: "PRIVILEGES", Kind: ActDefault}, + }, + 848: { // role_or_privilege_list + {Syms: "role_or_privilege", Kind: ActListNew, Class: "Mem_root_array", Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 1}}}, + {Syms: "role_or_privilege_list ',' role_or_privilege", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 849: { // role_or_privilege + {Syms: "role_ident_or_text opt_column_list", Kind: ActUnknown}, + {Syms: "role_ident_or_text '@' ident_or_text", Kind: ActNew, Class: "PT_role_at_host", Params: []string{"pos", "errpos", "role", "host"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Child: 1}, {Child: 3}}}, + {Syms: "SELECT_SYM opt_column_list", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant", "columns"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "SELECT_ACL"}, {Child: 2}}}, + {Syms: "INSERT_SYM opt_column_list", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant", "columns"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "INSERT_ACL"}, {Child: 2}}}, + {Syms: "UPDATE_SYM opt_column_list", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant", "columns"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "UPDATE_ACL"}, {Child: 2}}}, + {Syms: "REFERENCES opt_column_list", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant", "columns"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "REFERENCES_ACL"}, {Child: 2}}}, + {Syms: "DELETE_SYM", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "DELETE_ACL"}}}, + {Syms: "USAGE", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "0"}}}, + {Syms: "INDEX_SYM", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "INDEX_ACL"}}}, + {Syms: "ALTER", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "ALTER_ACL"}}}, + {Syms: "CREATE", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "CREATE_ACL"}}}, + {Syms: "DROP", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "DROP_ACL"}}}, + {Syms: "EXECUTE_SYM", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "EXECUTE_ACL"}}}, + {Syms: "RELOAD", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "RELOAD_ACL"}}}, + {Syms: "SHUTDOWN", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "SHUTDOWN_ACL"}}}, + {Syms: "PROCESS", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "PROCESS_ACL"}}}, + {Syms: "FILE_SYM", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "FILE_ACL"}}}, + {Syms: "GRANT OPTION", Kind: ActUnknown}, + {Syms: "SHOW DATABASES", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "SHOW_DB_ACL"}}}, + {Syms: "SUPER_SYM", Kind: ActUnknown}, + {Syms: "CREATE TEMPORARY TABLES", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "CREATE_TMP_ACL"}}}, + {Syms: "LOCK_SYM TABLES", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "LOCK_TABLES_ACL"}}}, + {Syms: "REPLICATION SLAVE", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "REPL_SLAVE_ACL"}}}, + {Syms: "REPLICATION CLIENT_SYM", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "REPL_CLIENT_ACL"}}}, + {Syms: "CREATE VIEW_SYM", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "CREATE_VIEW_ACL"}}}, + {Syms: "SHOW VIEW_SYM", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "SHOW_VIEW_ACL"}}}, + {Syms: "CREATE ROUTINE_SYM", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "CREATE_PROC_ACL"}}}, + {Syms: "ALTER ROUTINE_SYM", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "ALTER_PROC_ACL"}}}, + {Syms: "CREATE USER", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "CREATE_USER_ACL"}}}, + {Syms: "EVENT_SYM", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "EVENT_ACL"}}}, + {Syms: "TRIGGER_SYM", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "TRIGGER_ACL"}}}, + {Syms: "CREATE TABLESPACE_SYM", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "CREATE_TABLESPACE_ACL"}}}, + {Syms: "CREATE ROLE_SYM", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "CREATE_ROLE_ACL"}}}, + {Syms: "DROP ROLE_SYM", Kind: ActNew, Class: "PT_static_privilege", Params: []string{"pos", "errpos", "grant"}, Args: []Arg{{Text: "@$"}, {Text: "@1"}, {Text: "DROP_ROLE_ACL"}}}, + }, + 850: { // opt_with_admin_option + {Syms: "", Kind: ActConst, Const: "false"}, + {Syms: "WITH ADMIN_SYM OPTION", Kind: ActConst, Const: "true"}, + }, + 851: { // opt_and + {Syms: "", Kind: ActDefault}, + {Syms: "AND_SYM", Kind: ActDefault}, + }, + 852: { // require_list + {Syms: "require_list_element opt_and require_list", Kind: ActDefault}, + {Syms: "require_list_element", Kind: ActDefault}, + }, + 853: { // require_list_element + {Syms: "SUBJECT_SYM TEXT_STRING", Kind: ActUnknown}, + {Syms: "ISSUER_SYM TEXT_STRING", Kind: ActUnknown}, + {Syms: "CIPHER_SYM TEXT_STRING", Kind: ActStruct, Fields: []Field{{Name: "ssl_cipher", Arg: Arg{Child: 2, Field: ".str"}}}}, + }, + 854: { // grant_ident + {Syms: "'*'", Kind: ActDefault}, + {Syms: "schema '.' '*'", Kind: ActUnknown}, + {Syms: "'*' '.' '*'", Kind: ActDefault}, + {Syms: "ident", Kind: ActUnknown}, + {Syms: "schema '.' ident", Kind: ActUnknown}, + }, + 855: { // user_list + {Syms: "user", Kind: ActListNew, Args: []Arg{{Child: 1}}}, + {Syms: "user_list ',' user", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 856: { // role_list + {Syms: "role", Kind: ActListNew, Args: []Arg{{Child: 1}}}, + {Syms: "role_list ',' role", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 857: { // opt_retain_current_password + {Syms: "", Kind: ActConst, Const: "false"}, + {Syms: "RETAIN_SYM CURRENT_SYM PASSWORD", Kind: ActConst, Const: "true"}, + }, + 858: { // opt_discard_old_password + {Syms: "", Kind: ActConst, Const: "false"}, + {Syms: "DISCARD_SYM OLD_SYM PASSWORD", Kind: ActConst, Const: "true"}, + }, + 859: { // opt_user_registration + {Syms: "factor INITIATE_SYM REGISTRATION_SYM", Kind: ActUnknown}, + {Syms: "factor UNREGISTER_SYM", Kind: ActUnknown}, + {Syms: "factor FINISH_SYM REGISTRATION_SYM SET_SYM CHALLENGE_RESPONSE_SYM AS TEXT_STRING_hash", Kind: ActUnknown}, + }, + 860: { // create_user + {Syms: "user identification opt_create_user_with_mfa", Kind: ActUnknown}, + {Syms: "user identified_with_plugin opt_initial_auth", Kind: ActUnknown}, + {Syms: "user opt_create_user_with_mfa", Kind: ActUnknown}, + }, + 861: { // opt_create_user_with_mfa + {Syms: "", Kind: ActEmpty}, + {Syms: "AND_SYM identification", Kind: ActUnknown}, + {Syms: "AND_SYM identification AND_SYM identification", Kind: ActUnknown}, + }, + 862: { // identification + {Syms: "identified_by_password", Kind: ActDefault}, + {Syms: "identified_by_random_password", Kind: ActDefault}, + {Syms: "identified_with_plugin", Kind: ActDefault}, + {Syms: "identified_with_plugin_as_auth", Kind: ActDefault}, + {Syms: "identified_with_plugin_by_password", Kind: ActDefault}, + {Syms: "identified_with_plugin_by_random_password", Kind: ActDefault}, + }, + 863: { // identified_by_password + {Syms: "IDENTIFIED_SYM BY TEXT_STRING_password", Kind: ActUnknown}, + }, + 864: { // identified_by_random_password + {Syms: "IDENTIFIED_SYM BY RANDOM_SYM PASSWORD", Kind: ActUnknown}, + }, + 865: { // identified_with_plugin + {Syms: "IDENTIFIED_SYM WITH ident_or_text", Kind: ActUnknown}, + }, + 866: { // identified_with_plugin_as_auth + {Syms: "IDENTIFIED_SYM WITH ident_or_text AS TEXT_STRING_hash", Kind: ActUnknown}, + }, + 867: { // identified_with_plugin_by_password + {Syms: "IDENTIFIED_SYM WITH ident_or_text BY TEXT_STRING_password", Kind: ActUnknown}, + }, + 868: { // identified_with_plugin_by_random_password + {Syms: "IDENTIFIED_SYM WITH ident_or_text BY RANDOM_SYM PASSWORD", Kind: ActUnknown}, + }, + 869: { // opt_initial_auth + {Syms: "INITIAL_SYM AUTHENTICATION_SYM identified_by_random_password", Kind: ActUnknown}, + {Syms: "INITIAL_SYM AUTHENTICATION_SYM identified_with_plugin_as_auth", Kind: ActUnknown}, + {Syms: "INITIAL_SYM AUTHENTICATION_SYM identified_by_password", Kind: ActUnknown}, + }, + 870: { // alter_user + {Syms: "user identified_by_password REPLACE_SYM TEXT_STRING_password opt_retain_current_password", Kind: ActUnknown}, + {Syms: "user identified_with_plugin_by_password REPLACE_SYM TEXT_STRING_password opt_retain_current_password", Kind: ActUnknown}, + {Syms: "user identified_by_password opt_retain_current_password", Kind: ActUnknown}, + {Syms: "user identified_by_random_password opt_retain_current_password", Kind: ActUnknown}, + {Syms: "user identified_by_random_password REPLACE_SYM TEXT_STRING_password opt_retain_current_password", Kind: ActUnknown}, + {Syms: "user identified_with_plugin", Kind: ActUnknown}, + {Syms: "user identified_with_plugin_as_auth opt_retain_current_password", Kind: ActUnknown}, + {Syms: "user identified_with_plugin_by_password opt_retain_current_password", Kind: ActUnknown}, + {Syms: "user identified_with_plugin_by_random_password opt_retain_current_password", Kind: ActUnknown}, + {Syms: "user opt_discard_old_password", Kind: ActUnknown}, + {Syms: "user ADD factor identification", Kind: ActUnknown}, + {Syms: "user ADD factor identification ADD factor identification", Kind: ActUnknown}, + {Syms: "user MODIFY_SYM factor identification", Kind: ActUnknown}, + {Syms: "user MODIFY_SYM factor identification MODIFY_SYM factor identification", Kind: ActUnknown}, + {Syms: "user DROP factor", Kind: ActUnknown}, + {Syms: "user DROP factor DROP factor", Kind: ActUnknown}, + }, + 871: { // factor + {Syms: "NUM FACTOR_SYM", Kind: ActUnknown}, + }, + 872: { // create_user_list + {Syms: "create_user", Kind: ActUnknown}, + {Syms: "create_user_list ',' create_user", Kind: ActUnknown}, + }, + 873: { // alter_user_list + {Syms: "alter_user", Kind: ActUnknown}, + {Syms: "alter_user_list ',' alter_user", Kind: ActUnknown}, + }, + 874: { // opt_column_list + {Syms: "", Kind: ActEmpty}, + {Syms: "'(' column_list ')'", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 875: { // column_list + {Syms: "ident", Kind: ActListNew, Class: "Mem_root_array", Args: []Arg{{Text: "YYMEM_ROOT"}, {Child: 1}}}, + {Syms: "column_list ',' ident", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 876: { // require_clause + {Syms: "", Kind: ActDefault}, + {Syms: "REQUIRE_SYM require_list", Kind: ActDefault}, + {Syms: "REQUIRE_SYM SSL_SYM", Kind: ActDefault}, + {Syms: "REQUIRE_SYM X509_SYM", Kind: ActDefault}, + {Syms: "REQUIRE_SYM NONE_SYM", Kind: ActDefault}, + }, + 877: { // grant_options + {Syms: "", Kind: ActDefault}, + {Syms: "WITH GRANT OPTION", Kind: ActDefault}, + }, + 878: { // opt_grant_option + {Syms: "", Kind: ActConst, Const: "false"}, + {Syms: "WITH GRANT OPTION", Kind: ActConst, Const: "true"}, + }, + 879: { // opt_with_roles + {Syms: "", Kind: ActDefault}, + {Syms: "WITH ROLE_SYM role_list", Kind: ActStruct, Fields: []Field{{Name: "grant_as.role_type", Arg: Arg{Text: "role_enum::ROLE_NAME"}}, {Name: "grant_as.role_list", Arg: Arg{Child: 3}}}}, + {Syms: "WITH ROLE_SYM ALL opt_except_role_list", Kind: ActStruct, Fields: []Field{{Name: "grant_as.role_type", Arg: Arg{Text: "role_enum::ROLE_ALL"}}, {Name: "grant_as.role_list", Arg: Arg{Child: 4}}}}, + {Syms: "WITH ROLE_SYM NONE_SYM", Kind: ActDefault}, + {Syms: "WITH ROLE_SYM DEFAULT_SYM", Kind: ActDefault}, + }, + 880: { // opt_grant_as + {Syms: "", Kind: ActDefault}, + {Syms: "AS user opt_with_roles", Kind: ActStruct, Fields: []Field{{Name: "grant_as.grant_as_used", Arg: Arg{Text: "true"}}, {Name: "grant_as.user", Arg: Arg{Child: 2}}}}, + }, + 881: { // begin_stmt + {Syms: "BEGIN_SYM opt_work", Kind: ActDefault}, + }, + 882: { // opt_work + {Syms: "", Kind: ActDefault}, + {Syms: "WORK_SYM", Kind: ActDefault}, + }, + 883: { // opt_chain + {Syms: "", Kind: ActConst, Const: "TVL_UNKNOWN"}, + {Syms: "AND_SYM NO_SYM CHAIN_SYM", Kind: ActConst, Const: "TVL_NO"}, + {Syms: "AND_SYM CHAIN_SYM", Kind: ActConst, Const: "TVL_YES"}, + }, + 884: { // opt_release + {Syms: "", Kind: ActConst, Const: "TVL_UNKNOWN"}, + {Syms: "RELEASE_SYM", Kind: ActConst, Const: "TVL_YES"}, + {Syms: "NO_SYM RELEASE_SYM", Kind: ActConst, Const: "TVL_NO"}, + }, + 885: { // opt_savepoint + {Syms: "", Kind: ActDefault}, + {Syms: "SAVEPOINT_SYM", Kind: ActDefault}, + }, + 886: { // commit + {Syms: "COMMIT_SYM opt_work opt_chain opt_release", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_COMMIT"}}, {Name: "tx_chain", Arg: Arg{Child: 3}}, {Name: "tx_release", Arg: Arg{Child: 4}}}}, + }, + 887: { // rollback + {Syms: "ROLLBACK_SYM opt_work opt_chain opt_release", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_ROLLBACK"}}, {Name: "tx_chain", Arg: Arg{Child: 3}}, {Name: "tx_release", Arg: Arg{Child: 4}}}}, + {Syms: "ROLLBACK_SYM opt_work TO_SYM opt_savepoint ident", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_ROLLBACK_TO_SAVEPOINT"}}, {Name: "ident", Arg: Arg{Child: 5}}}}, + }, + 888: { // savepoint + {Syms: "SAVEPOINT_SYM ident", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_SAVEPOINT"}}, {Name: "ident", Arg: Arg{Child: 2}}}}, + }, + 889: { // release + {Syms: "RELEASE_SYM SAVEPOINT_SYM ident", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_RELEASE_SAVEPOINT"}}, {Name: "ident", Arg: Arg{Child: 3}}}}, + }, + 890: { // union_option + {Syms: "", Kind: ActConst, Const: "1"}, + {Syms: "DISTINCT", Kind: ActConst, Const: "1"}, + {Syms: "ALL", Kind: ActConst, Const: "0"}, + }, + 891: { // row_subquery + {Syms: "subquery", Kind: ActDefault}, + }, + 892: { // table_subquery + {Syms: "subquery", Kind: ActDefault}, + }, + 893: { // subquery + {Syms: "query_expression_parens", Kind: ActNew, Class: "PT_subquery", Params: []string{"pos", "query_expression"}, Args: []Arg{{Text: "@$"}, {Child: 1}}}, + }, + 894: { // query_spec_option + {Syms: "STRAIGHT_JOIN", Kind: ActConst, Const: "SELECT_STRAIGHT_JOIN"}, + {Syms: "HIGH_PRIORITY", Kind: ActConst, Const: "SELECT_HIGH_PRIORITY"}, + {Syms: "DISTINCT", Kind: ActConst, Const: "SELECT_DISTINCT"}, + {Syms: "SQL_SMALL_RESULT", Kind: ActConst, Const: "SELECT_SMALL_RESULT"}, + {Syms: "SQL_BIG_RESULT", Kind: ActConst, Const: "SELECT_BIG_RESULT"}, + {Syms: "SQL_BUFFER_RESULT", Kind: ActConst, Const: "OPTION_BUFFER_RESULT"}, + {Syms: "SQL_CALC_FOUND_ROWS", Kind: ActConst, Const: "OPTION_FOUND_ROWS"}, + {Syms: "ALL", Kind: ActConst, Const: "SELECT_ALL"}, + }, + 895: { // init_lex_create_info + {Syms: "", Kind: ActDefault}, + }, + 896: { // view_or_trigger_or_sp_or_event + {Syms: "definer init_lex_create_info definer_tail", Kind: ActDefault}, + {Syms: "no_definer init_lex_create_info no_definer_tail", Kind: ActDefault}, + {Syms: "view_replace_or_algorithm definer_opt init_lex_create_info view_tail", Kind: ActDefault}, + }, + 897: { // definer_tail + {Syms: "view_tail", Kind: ActDefault}, + {Syms: "trigger_tail", Kind: ActDefault}, + {Syms: "sp_tail", Kind: ActDefault}, + {Syms: "sf_tail", Kind: ActDefault}, + {Syms: "event_tail", Kind: ActDefault}, + }, + 898: { // no_definer_tail + {Syms: "view_tail", Kind: ActDefault}, + {Syms: "trigger_tail", Kind: ActDefault}, + {Syms: "sp_tail", Kind: ActDefault}, + {Syms: "sf_tail", Kind: ActDefault}, + {Syms: "udf_tail", Kind: ActDefault}, + {Syms: "event_tail", Kind: ActDefault}, + }, + 899: { // definer_opt + {Syms: "no_definer", Kind: ActDefault}, + {Syms: "definer", Kind: ActDefault}, + }, + 900: { // no_definer + {Syms: "", Kind: ActDefault}, + }, + 901: { // definer + {Syms: "DEFINER_SYM EQ user", Kind: ActStruct, Fields: []Field{{Name: "definer", Arg: Arg{Text: "get_current_user(YYTHD,$3)"}}}}, + }, + 902: { // view_replace_or_algorithm + {Syms: "view_replace", Kind: ActDefault}, + {Syms: "view_replace view_algorithm", Kind: ActDefault}, + {Syms: "view_algorithm", Kind: ActDefault}, + }, + 903: { // view_replace + {Syms: "OR_SYM REPLACE_SYM", Kind: ActDefault}, + }, + 904: { // view_algorithm + {Syms: "ALGORITHM_SYM EQ UNDEFINED_SYM", Kind: ActDefault}, + {Syms: "ALGORITHM_SYM EQ MERGE_SYM", Kind: ActDefault}, + {Syms: "ALGORITHM_SYM EQ TEMPTABLE_SYM", Kind: ActDefault}, + }, + 905: { // view_suid + {Syms: "", Kind: ActDefault}, + {Syms: "SQL_SYM SECURITY_SYM DEFINER_SYM", Kind: ActDefault}, + {Syms: "SQL_SYM SECURITY_SYM INVOKER_SYM", Kind: ActDefault}, + }, + 906: { // view_tail + {Syms: "view_suid VIEW_SYM table_ident opt_derived_column_list AS view_query_block", Kind: ActDefault}, + }, + 907: { // view_query_block + {Syms: "query_expression_with_opt_locking_clauses view_check_option", Kind: ActUnknown}, + }, + 908: { // view_check_option + {Syms: "", Kind: ActConst, Const: "VIEW_CHECK_NONE"}, + {Syms: "WITH CHECK_SYM OPTION", Kind: ActConst, Const: "VIEW_CHECK_CASCADED"}, + {Syms: "WITH CASCADED CHECK_SYM OPTION", Kind: ActConst, Const: "VIEW_CHECK_CASCADED"}, + {Syms: "WITH LOCAL_SYM CHECK_SYM OPTION", Kind: ActConst, Const: "VIEW_CHECK_LOCAL"}, + }, + 909: { // trigger_action_order + {Syms: "FOLLOWS_SYM", Kind: ActConst, Const: "TRG_ORDER_FOLLOWS"}, + {Syms: "PRECEDES_SYM", Kind: ActConst, Const: "TRG_ORDER_PRECEDES"}, + }, + 910: { // trigger_follows_precedes_clause + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "ordering_clause", Arg: Arg{Text: "TRG_ORDER_NONE"}}, {Name: "anchor_trigger_name", Arg: Arg{Text: "NULL_CSTR"}}}}, + {Syms: "trigger_action_order ident_or_text", Kind: ActStruct, Fields: []Field{{Name: "ordering_clause", Arg: Arg{Child: 1}}, {Name: "anchor_trigger_name", Arg: Arg{Text: "{$2.str,$2.length}"}}}}, + }, + 911: { // trigger_tail + {Syms: "TRIGGER_SYM opt_if_not_exists sp_name trg_action_time trg_event ON_SYM table_ident FOR_SYM EACH_SYM ROW_SYM trigger_follows_precedes_clause sp_proc_stmt", Kind: ActDefault}, + }, + 912: { // udf_tail + {Syms: "AGGREGATE_SYM FUNCTION_SYM opt_if_not_exists ident RETURNS_SYM udf_type SONAME_SYM TEXT_STRING_sys", Kind: ActUnknown}, + {Syms: "FUNCTION_SYM opt_if_not_exists ident RETURNS_SYM udf_type SONAME_SYM TEXT_STRING_sys", Kind: ActUnknown}, + }, + 913: { // sf_tail + {Syms: "FUNCTION_SYM opt_if_not_exists sp_name '(' sp_fdparam_list ')' RETURNS_SYM type opt_collate sp_c_chistics stored_routine_body", Kind: ActDefault}, + }, + 914: { // routine_string + {Syms: "TEXT_STRING_literal", Kind: ActDefault}, + {Syms: "DOLLAR_QUOTED_STRING_SYM", Kind: ActDefault}, + }, + 915: { // stored_routine_body + {Syms: "AS routine_string", Kind: ActUnknown}, + {Syms: "sp_proc_stmt", Kind: ActDefault}, + }, + 916: { // sp_tail + {Syms: "PROCEDURE_SYM opt_if_not_exists sp_name '(' sp_pdparam_list ')' sp_c_chistics stored_routine_body", Kind: ActDefault}, + }, + 917: { // xa + {Syms: "XA_SYM begin_or_start xid opt_join_or_resume", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_XA_START"}}, {Name: "m_sql_cmd", Arg: Arg{Text: "NEW_PTNSql_cmd_xa_start($3,$4)"}}}}, + {Syms: "XA_SYM END xid opt_suspend", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_XA_END"}}, {Name: "m_sql_cmd", Arg: Arg{Text: "NEW_PTNSql_cmd_xa_end($3,$4)"}}}}, + {Syms: "XA_SYM PREPARE_SYM xid", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_XA_PREPARE"}}, {Name: "m_sql_cmd", Arg: Arg{Text: "NEW_PTNSql_cmd_xa_prepare($3)"}}}}, + {Syms: "XA_SYM COMMIT_SYM xid opt_one_phase", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_XA_COMMIT"}}, {Name: "m_sql_cmd", Arg: Arg{Text: "NEW_PTNSql_cmd_xa_commit($3,$4)"}}}}, + {Syms: "XA_SYM ROLLBACK_SYM xid", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_XA_ROLLBACK"}}, {Name: "m_sql_cmd", Arg: Arg{Text: "NEW_PTNSql_cmd_xa_rollback($3)"}}}}, + {Syms: "XA_SYM RECOVER_SYM opt_convert_xid", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_XA_RECOVER"}}, {Name: "m_sql_cmd", Arg: Arg{Text: "NEW_PTNSql_cmd_xa_recover($3)"}}}}, + }, + 918: { // opt_convert_xid + {Syms: "", Kind: ActConst, Const: "false"}, + {Syms: "CONVERT_SYM XID_SYM", Kind: ActConst, Const: "true"}, + }, + 919: { // xid + {Syms: "text_string", Kind: ActUnknown}, + {Syms: "text_string ',' text_string", Kind: ActUnknown}, + {Syms: "text_string ',' text_string ',' ulong_num", Kind: ActUnknown}, + }, + 920: { // begin_or_start + {Syms: "BEGIN_SYM", Kind: ActDefault}, + {Syms: "START_SYM", Kind: ActDefault}, + }, + 921: { // opt_join_or_resume + {Syms: "", Kind: ActConst, Const: "XA_NONE"}, + {Syms: "JOIN_SYM", Kind: ActConst, Const: "XA_JOIN"}, + {Syms: "RESUME_SYM", Kind: ActConst, Const: "XA_RESUME"}, + }, + 922: { // opt_one_phase + {Syms: "", Kind: ActConst, Const: "XA_NONE"}, + {Syms: "ONE_SYM PHASE_SYM", Kind: ActConst, Const: "XA_ONE_PHASE"}, + }, + 923: { // opt_suspend + {Syms: "", Kind: ActConst, Const: "XA_NONE"}, + {Syms: "SUSPEND_SYM", Kind: ActConst, Const: "XA_SUSPEND"}, + {Syms: "SUSPEND_SYM FOR_SYM MIGRATE_SYM", Kind: ActConst, Const: "XA_FOR_MIGRATE"}, + }, + 924: { // install_option_type + {Syms: "", Kind: ActConst, Const: "OPT_GLOBAL"}, + {Syms: "GLOBAL_SYM", Kind: ActConst, Const: "OPT_GLOBAL"}, + {Syms: "PERSIST_SYM", Kind: ActConst, Const: "OPT_PERSIST"}, + }, + 925: { // install_set_rvalue + {Syms: "expr", Kind: ActDefault}, + {Syms: "ON_SYM", Kind: ActNew, Class: "Item_string", Params: []string{"pos", "str", "length", "cs"}, Args: []Arg{{Text: "@$"}, {Text: "\"ON\""}, {Text: "2"}, {Text: "system_charset_info"}}}, + }, + 926: { // install_set_value + {Syms: "install_option_type lvalue_variable equal install_set_rvalue", Kind: ActNew, Class: "NEW_PTNPT_install_component_set_element", Args: []Arg{{Child: 1}, {Child: 2}, {Child: 4}}}, + }, + 927: { // install_set_value_list + {Syms: "install_set_value", Kind: ActUnknown}, + {Syms: "install_set_value_list ',' install_set_value", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 928: { // opt_install_set_value_list + {Syms: "", Kind: ActUnknown}, + {Syms: "SET_SYM install_set_value_list", Kind: ActPass, Args: []Arg{{Child: 2}}}, + }, + 929: { // install_stmt + {Syms: "INSTALL_SYM PLUGIN_SYM ident SONAME_SYM TEXT_STRING_sys", Kind: ActUnknown}, + {Syms: "INSTALL_SYM COMPONENT_SYM TEXT_STRING_sys_list opt_install_set_value_list", Kind: ActNew, Class: "PT_install_component", Params: []string{"pos", "thd", "urns", "set_elements"}, Args: []Arg{{Text: "@$"}, {Text: "YYTHD"}, {Child: 3}, {Child: 4}}}, + }, + 930: { // uninstall + {Syms: "UNINSTALL_SYM PLUGIN_SYM ident", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_UNINSTALL_PLUGIN"}}, {Name: "m_sql_cmd", Arg: Arg{Text: "new(YYMEM_ROOT)Sql_cmd_uninstall_plugin($3)"}}}}, + {Syms: "UNINSTALL_SYM COMPONENT_SYM TEXT_STRING_sys_list", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_UNINSTALL_COMPONENT"}}, {Name: "m_sql_cmd", Arg: Arg{Text: "new(YYMEM_ROOT)Sql_cmd_uninstall_component($3)"}}}}, + }, + 931: { // TEXT_STRING_sys_list + {Syms: "TEXT_STRING_sys", Kind: ActListNew, Args: []Arg{{Child: 1}}}, + {Syms: "TEXT_STRING_sys_list ',' TEXT_STRING_sys", Kind: ActListAppend, Args: []Arg{{Child: 1}, {Child: 3}}}, + }, + 932: { // import_stmt + {Syms: "IMPORT TABLE_SYM FROM TEXT_STRING_sys_list", Kind: ActStruct, Fields: []Field{{Name: "m_sql_cmd", Arg: Arg{Text: "new(YYTHD->mem_root)Sql_cmd_import_table($4)"}}, {Name: "sql_command", Arg: Arg{Text: "SQLCOM_IMPORT"}}}}, + }, + 933: { // clone_stmt + {Syms: "CLONE_SYM LOCAL_SYM DATA_SYM DIRECTORY_SYM opt_equal TEXT_STRING_filesystem", Kind: ActStruct, Fields: []Field{{Name: "sql_command", Arg: Arg{Text: "SQLCOM_CLONE"}}, {Name: "m_sql_cmd", Arg: Arg{Text: "NEW_PTNSql_cmd_clone($6)"}}}}, + {Syms: "CLONE_SYM INSTANCE_SYM FROM user ':' ulong_num IDENTIFIED_SYM BY TEXT_STRING_sys opt_datadir_ssl", Kind: ActUnknown}, + }, + 934: { // opt_datadir_ssl + {Syms: "opt_ssl", Kind: ActEmpty}, + {Syms: "DATA_SYM DIRECTORY_SYM opt_equal TEXT_STRING_filesystem opt_ssl", Kind: ActPass, Args: []Arg{{Child: 4}}}, + }, + 935: { // opt_ssl + {Syms: "", Kind: ActDefault}, + {Syms: "REQUIRE_SYM SSL_SYM", Kind: ActDefault}, + {Syms: "REQUIRE_SYM NO_SYM SSL_SYM", Kind: ActDefault}, + }, + 936: { // resource_group_types + {Syms: "USER", Kind: ActConst, Const: "resourcegroups::Type::USER_RESOURCE_GROUP"}, + {Syms: "SYSTEM_SYM", Kind: ActConst, Const: "resourcegroups::Type::SYSTEM_RESOURCE_GROUP"}, + }, + 937: { // opt_resource_group_vcpu_list + {Syms: "", Kind: ActNew, Class: "Mem_root_array", Params: []string{"root"}, Args: []Arg{{Text: "YYMEM_ROOT"}}}, + {Syms: "VCPU_SYM opt_equal vcpu_range_spec_list", Kind: ActPass, Args: []Arg{{Child: 3}}}, + }, + 938: { // vcpu_range_spec_list + {Syms: "vcpu_num_or_range", Kind: ActUnknown}, + {Syms: "vcpu_range_spec_list opt_comma vcpu_num_or_range", Kind: ActUnknown}, + }, + 939: { // vcpu_num_or_range + {Syms: "NUM", Kind: ActUnknown}, + {Syms: "NUM '-' NUM", Kind: ActUnknown}, + }, + 940: { // signed_num + {Syms: "NUM", Kind: ActUnknown}, + {Syms: "'-' NUM", Kind: ActUnknown}, + }, + 941: { // opt_resource_group_priority + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "is_default", Arg: Arg{Text: "true"}}}}, + {Syms: "THREAD_PRIORITY_SYM opt_equal signed_num", Kind: ActStruct, Fields: []Field{{Name: "is_default", Arg: Arg{Text: "false"}}, {Name: "value", Arg: Arg{Child: 3}}}}, + }, + 942: { // opt_resource_group_enable_disable + {Syms: "", Kind: ActStruct, Fields: []Field{{Name: "is_default", Arg: Arg{Text: "true"}}}}, + {Syms: "ENABLE_SYM", Kind: ActStruct, Fields: []Field{{Name: "is_default", Arg: Arg{Text: "false"}}, {Name: "value", Arg: Arg{Text: "true"}}}}, + {Syms: "DISABLE_SYM", Kind: ActStruct, Fields: []Field{{Name: "is_default", Arg: Arg{Text: "false"}}, {Name: "value", Arg: Arg{Text: "false"}}}}, + }, + 943: { // opt_force + {Syms: "", Kind: ActConst, Const: "false"}, + {Syms: "FORCE_SYM", Kind: ActConst, Const: "true"}, + }, + 944: { // json_attribute + {Syms: "TEXT_STRING_sys", Kind: ActUnknown}, + }, +} diff --git a/check/mysql/internal/mysqlparse/split.go b/check/mysql/internal/mysqlparse/split.go new file mode 100644 index 0000000..3c06a0f --- /dev/null +++ b/check/mysql/internal/mysqlparse/split.go @@ -0,0 +1,203 @@ +package mysqlparse + +import ( + "regexp" + "strings" +) + +// Statement is one statement of a script: its text without the terminating ';' and the +// byte offset of its first character in the script. +type Statement struct { + SQL string + Offset int +} + +// Split cuts a script into statements at the ';' outside quotes and comments, the way the +// mysql client does with the default delimiter. Comments and blank text between statements +// are dropped; a trailing statement without ';' is kept. +// +// Split is Split's mode-0 case: see SplitMode for a version that also cuts compound +// statement bodies (CREATE TRIGGER / PROCEDURE / FUNCTION / EVENT) as a single statement +// and understands the mysql client's `DELIMITER` command. +func Split(script string) []Statement { + return SplitMode(script, 0) +} + +// compoundCreateRe matches the start of a CREATE statement (after any leading comments are +// stripped) whose body is a compound statement that may contain ';' internally: CREATE +// [DEFINER = user] TRIGGER|PROCEDURE|FUNCTION|EVENT ... +var compoundCreateRe = regexp.MustCompile(`(?is)^create\s+(definer\s*=\s*\S+\s+)?(trigger|procedure|function|event)\b`) + +// SplitMode cuts a script into statements the way the mysql client does: at the default +// delimiter (';') outside quotes and comments, except that the body of a CREATE TRIGGER / +// PROCEDURE / FUNCTION / EVENT is a single statement even though it may contain ';' +// internally (BEGIN ... END and the like). A `DELIMITER ` line, as the mysql client +// reads it, is consumed (not emitted as a statement); until the next `DELIMITER` line +// statements are cut at instead of ';', and compound-statement detection is +// skipped (the caller is assumed to have picked so the body needs no help). +// +// mode is the sql_mode used to decide whether a candidate cut is a complete statement; it +// is only consulted while probing candidate cuts of a CREATE TRIGGER / PROCEDURE / FUNCTION +// / EVENT body. +func SplitMode(script string, mode Mode) []Statement { + var out []Statement + appendStmt := func(start, end int) { + text := script[start:end] + trimmed := strings.TrimLeft(text, " \t\r\n") + if strings.TrimSpace(stripLeadingComments(trimmed)) != "" { + out = append(out, Statement{SQL: strings.TrimRight(trimmed, " \t\r\n"), Offset: start + len(text) - len(trimmed)}) + } + } + + delim := ";" + start := 0 + n := len(script) + for start < n { + if newDelim, next, ok := matchDelimiterLine(script, start); ok { + delim = newDelim + start = next + continue + } + end, next, found := findDelimiter(script, start, delim) + if delim == ";" && isCompoundCreate(script[start:end]) { + for !found || parses(script[start:end], mode) != nil { + if !found { + // ran off the end of the script: keep the whole remainder as one + // statement (a syntax error in the body degrades this way) + end, next = n, n + break + } + end2, next2, found2 := findDelimiter(script, next, delim) + if !found2 { + end, next, found = n, n, false + continue + } + end, next, found = end2, next2, found2 + } + } + appendStmt(start, end) + if !found { + break + } + start = next + } + return out +} + +// parses reports the error, if any, from parsing text as a single statement. It is used +// only to decide whether a candidate cut of a compound-statement body is complete; parse +// errors inside a genuinely broken statement are reported later, when the statement is +// parsed for real. +func parses(text string, mode Mode) error { + _, err := Parse(strings.TrimSpace(text), mode) + return err +} + +// isCompoundCreate reports whether text, once its leading comments are stripped, begins a +// CREATE TRIGGER / PROCEDURE / FUNCTION / EVENT statement -- one whose body may be a +// compound statement containing ';' internally. +func isCompoundCreate(text string) bool { + trimmed := strings.TrimLeft(text, " \t\r\n") + return compoundCreateRe.MatchString(stripLeadingComments(trimmed)) +} + +// findDelimiter finds the next occurrence of delim in script at or after pos, outside +// quotes and comments. It returns the index of the occurrence (end) and the index just +// past it (next); found is false if delim does not occur again, in which case end and next +// are both len(script). +func findDelimiter(script string, pos int, delim string) (end, next int, found bool) { + i := pos + n := len(script) + for i < n { + c := script[i] + switch { + case c == '\'' || c == '"' || c == '`': + q := c + i++ + for i < n && script[i] != q { + if script[i] == '\\' && q != '`' { + i++ + } + i++ + } + i++ + case c == '#' || (c == '-' && i+1 < n && script[i+1] == '-' && (i+2 >= n || script[i+2] == ' ' || script[i+2] == '\t' || script[i+2] == '\n')): + for i < n && script[i] != '\n' { + i++ + } + case c == '/' && i+1 < n && script[i+1] == '*': + j := strings.Index(script[i+2:], "*/") + if j < 0 { + i = n + } else { + i += 2 + j + 2 + } + case delim != "" && strings.HasPrefix(script[i:], delim): + return i, i + len(delim), true + default: + i++ + } + } + return n, n, false +} + +// matchDelimiterLine recognizes a `DELIMITER ` line, as the mysql client reads it, +// starting at or after pos (leading blank space is skipped). It returns the new delimiter +// token and the index just past the line (including its line break) if pos begins such a +// line. +func matchDelimiterLine(script string, pos int) (token string, next int, ok bool) { + n := len(script) + i := pos + for i < n && (script[i] == ' ' || script[i] == '\t' || script[i] == '\r' || script[i] == '\n') { + i++ + } + const kw = "delimiter" + if i+len(kw) > n || !strings.EqualFold(script[i:i+len(kw)], kw) { + return "", 0, false + } + j := i + len(kw) + if j >= n || (script[j] != ' ' && script[j] != '\t') { + return "", 0, false + } + for j < n && (script[j] == ' ' || script[j] == '\t') { + j++ + } + tokStart := j + for j < n && script[j] != '\n' && script[j] != '\r' { + j++ + } + token = strings.TrimRight(script[tokStart:j], " \t\r") + if token == "" { + return "", 0, false + } + k := j + for k < n && (script[k] == '\r' || script[k] == '\n') { + k++ + } + return token, k, true +} + +// stripLeadingComments removes comments (and the whitespace around them) from the front of +// a statement, so that a statement consisting only of comments is recognized as empty. The +// statement text itself keeps its comments: `-- sqlshape:` directives live there. +func stripLeadingComments(s string) string { + for { + s = strings.TrimLeft(s, " \t\r\n") + switch { + case strings.HasPrefix(s, "#"), strings.HasPrefix(s, "-- "), strings.HasPrefix(s, "--\n"), s == "--": + if j := strings.IndexByte(s, '\n'); j >= 0 { + s = s[j+1:] + } else { + return "" + } + case strings.HasPrefix(s, "/*") && !strings.HasPrefix(s, "/*!") && !strings.HasPrefix(s, "/*+"): + j := strings.Index(s, "*/") + if j < 0 { + return "" + } + s = s[j+2:] + default: + return s + } + } +} diff --git a/check/mysql/internal/mysqlparse/wasm/mysqlparse_8.4.wasm b/check/mysql/internal/mysqlparse/wasm/mysqlparse_8.4.wasm new file mode 100644 index 0000000..45ceff2 Binary files /dev/null and b/check/mysql/internal/mysqlparse/wasm/mysqlparse_8.4.wasm differ diff --git a/check/mysql/internal/oracle/oracle.go b/check/mysql/internal/oracle/oracle.go new file mode 100644 index 0000000..60b8dd4 --- /dev/null +++ b/check/mysql/internal/oracle/oracle.go @@ -0,0 +1,126 @@ +// Package oracle runs a real mysqld and answers what MySQL itself says about a statement: +// the result columns' types and nullability, or the error it raises. It is the ground +// truth the analyzer's type rules are checked against, and a local tool only: it needs a +// `mysqld` on PATH (`nix-shell -p mysql84`, or a server tarball's bin/), and nothing at +// lint time or in CI depends on it. The server is the one mysqltest boots. +package oracle + +import ( + "context" + "database/sql" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/go-sql-driver/mysql" + + "github.com/kr9ly/sqlshape/mysqltest/v2" + "github.com/kr9ly/sqlshape/v2/x/placeholder" +) + +// ErrNoServer is returned by Start when no mysqld is on PATH; tests skip on it. +var ErrNoServer = mysqltest.ErrNoServer + +// Oracle is a running mysqld with the schema loaded. +type Oracle struct { + Version string // the server's version, as SELECT VERSION() reports it + server *mysqltest.DB + db *sql.DB +} + +// Column is one result column as the server describes it in the result set metadata. +type Column struct { + Name string + Type string // the driver's spelling of the wire type: BIGINT, UNSIGNED BIGINT, DECIMAL, VARCHAR, VARBINARY, TEXT, DATETIME, JSON ... + Nullable bool + Length int64 // for string types; 0 when the driver does not report one + Prec int64 // for DECIMAL + Scale int64 +} + +// Description is what the server says about a statement. +type Description struct { + Columns []Column +} + +// Error is a statement the server rejects. +type Error struct { + Number int + Message string +} + +func (e *Error) Error() string { return fmt.Sprintf("%s (MySQL error %d)", e.Message, e.Number) } + +// Start boots a mysqld (mysqltest.Start) and loads schemaSQL into it. +func Start(ctx context.Context, schemaSQL string) (*Oracle, error) { + server, err := mysqltest.Start(ctx, schemaSQL) + if err != nil { + return nil, err + } + return &Oracle{Version: server.Version, server: server, db: server.Conn()}, nil +} + +// Describe asks the server about sql, whose placeholders are `$n`. A SELECT runs with NULL +// for every parameter (no row need come back: the metadata does); any other statement is +// prepared only, so nothing is written. A statement the server rejects returns *Error. +func (o *Oracle) Describe(ctx context.Context, sql string) (*Description, error) { + text, pm := placeholder.Rewrite(sql) + args := make([]any, pm.Marks()) // one NULL per `?`, the same $n written twice included + d := &Description{} + if !isQuery(text) { + stmt, err := o.db.PrepareContext(ctx, text) + if err != nil { + return nil, wrap(err) + } + stmt.Close() + return d, nil + } + rows, err := o.db.QueryContext(ctx, text, args...) + if err != nil { + return nil, wrap(err) + } + defer rows.Close() + cts, err := rows.ColumnTypes() + if err != nil { + return nil, err + } + for _, ct := range cts { + c := Column{Name: ct.Name(), Type: ct.DatabaseTypeName()} + if n, ok := ct.Nullable(); ok { + c.Nullable = n + } + if l, ok := ct.Length(); ok { + c.Length = l + } + if p, s, ok := ct.DecimalSize(); ok { + c.Prec, c.Scale = p, s + } + d.Columns = append(d.Columns, c) + } + return d, nil +} + +var reLeading = regexp.MustCompile(`^(?:\s|/\*.*?\*/|--[^\n]*\n|#[^\n]*\n)*`) + +// isQuery reports a statement that returns rows: SELECT, TABLE, VALUES, WITH, or one in parentheses. +func isQuery(text string) bool { + rest := strings.ToUpper(strings.TrimLeft(reLeading.ReplaceAllString(text, ""), "(")) + for _, kw := range []string{"SELECT", "TABLE", "VALUES", "WITH"} { + if strings.HasPrefix(rest, kw) { + return true + } + } + return false +} + +func wrap(err error) error { + var me *mysql.MySQLError + if errors.As(err, &me) { + return &Error{Number: int(me.Number), Message: me.Message} + } + return err +} + +// Close stops the server and removes its directory. +func (o *Oracle) Close() { o.server.Close() } diff --git a/check/mysql/internal/oracle/oracle_test.go b/check/mysql/internal/oracle/oracle_test.go new file mode 100644 index 0000000..77f4ecb --- /dev/null +++ b/check/mysql/internal/oracle/oracle_test.go @@ -0,0 +1,77 @@ +package oracle + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +const schema = `-- sqlshape: mysql 8.4 +CREATE TABLE users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(255), + total DECIMAL(10,2) NOT NULL DEFAULT 0, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) +); +` + +func start(t *testing.T) *Oracle { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + t.Cleanup(cancel) + o, err := Start(ctx, schema) + if errors.Is(err, ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + t.Cleanup(o.Close) + return o +} + +func TestDescribe(t *testing.T) { + o := start(t) + if !strings.HasPrefix(o.Version, "8.") { + t.Errorf("version %q", o.Version) + } + ctx := context.Background() + d, err := o.Describe(ctx, "SELECT id, name, email, total, created_at, id = $1, (SELECT COUNT(*) FROM users) FROM users WHERE id = $2") + if err != nil { + t.Fatal(err) + } + want := []Column{ + {Name: "id", Type: "UNSIGNED BIGINT"}, + {Name: "name", Type: "VARCHAR", Length: 400}, + {Name: "email", Type: "VARCHAR", Nullable: true, Length: 1020}, + {Name: "total", Type: "DECIMAL", Prec: 10, Scale: 2}, + {Name: "created_at", Type: "DATETIME", Scale: 6}, + {Name: "id = ?", Type: "BIGINT", Nullable: true}, + {Name: "(SELECT COUNT(*) FROM users)", Type: "BIGINT", Nullable: true}, // a scalar subquery is nullable + } + if len(d.Columns) != len(want) { + t.Fatalf("columns %+v", d.Columns) + } + for i, c := range d.Columns { + if c.Name != want[i].Name || c.Type != want[i].Type || c.Nullable != want[i].Nullable { + t.Errorf("column %d: got %+v, want %+v", i, c, want[i]) + } + } + // a write is prepared, not run + if _, err := o.Describe(ctx, "INSERT INTO users (name) VALUES ($1)"); err != nil { + t.Fatal(err) + } + var n int + if err := o.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&n); err != nil || n != 0 { + t.Errorf("rows after a described INSERT: %d, %v", n, err) + } + // the server's own errors + _, err = o.Describe(ctx, "SELECT nope FROM users") + var oe *Error + if !errors.As(err, &oe) || oe.Number != 1054 { + t.Errorf("unknown column: %v", err) + } +} diff --git a/check/mysql/internal/parsegen/actions.go b/check/mysql/internal/parsegen/actions.go new file mode 100644 index 0000000..37790c5 --- /dev/null +++ b/check/mysql/internal/parsegen/actions.go @@ -0,0 +1,742 @@ +package parsegen + +import ( + "fmt" + "regexp" + "sort" + "strings" +) + +// The semantic actions StripGrammar throws away are the map from the concrete syntax tree +// to the server's parse tree: `$$= NEW_PTN PT_select_stmt(@$, $1, $2)` says which class an +// alternative builds and which children feed which constructor argument. This file reads +// them back so that the AST layer can be generated rather than written. + +// ActionKind classifies what an alternative's action does with its children. +type ActionKind int + +const ( + // ActUnknown: something this reader does not model (legacy LEX mutation, helpers). + ActUnknown ActionKind = iota + // ActDefault: no action; bison's default `$$= $1` applies (keyword lists, mostly). + ActDefault + // ActEmpty: `{}` or `$$= nullptr`: no value. + ActEmpty + // ActPass: `$$= $N` — the alternative is one child. + ActPass + // ActConst: `$$= SOME_CONSTANT` (an enum or literal). + ActConst + // ActNew: `$$= NEW_PTN Class(args)`, all args resolved. + ActNew + // ActListNew: a fresh list holding one child (`$$= NEW_PTN List(...); $$->push_back($N)`). + ActListNew + // ActListAppend: `$$= $A; $$->push_back($B)` — left recursion growing a list. + ActListAppend + // ActFlags: `$$= $A | $B` — option bit sets combined. + ActFlags + // ActNumber: `$$= my_strtoll10($N.str, ...)` — a numeric token read as a number. + ActNumber + // ActStruct: `$$.a= $N; $$.b= $M;` — a by-value struct filled from children/constants. + ActStruct +) + +func (k ActionKind) String() string { + return [...]string{"unknown", "default", "empty", "pass", "const", "new", "list-new", "list-append", "flags", "number", "struct"}[k] +} + +// Arg is one constructor argument as the action wrote it. +type Arg struct { + Child int // 1-based RHS position, 0 when the argument is not a child + Field string // ".str", ".column_list", ... when a field of the child is passed + Text string // the argument as written when it is neither (a constant, a helper call) +} + +// Alt is one grammar alternative with its action read. +type Alt struct { + Rule string + Index int // alternative number within the rule + Syms []string // RHS symbols (terminals and nonterminals; mid-rule actions dropped) + Kind ActionKind + Class string // ActNew / ActListNew: the PT class + Args []Arg // ActNew: constructor arguments; ActPass: the one child; ActListAppend: list, element + Const string // ActConst + Fields []Field // ActStruct + Action string // the action text, normalized + // Guards are the checks the server's action made and this reader dropped (`if (cond) + // MYSQL_YYABORT`, `if (cond) my_error(...)`): errors a parse-time check layer must + // reproduce. + Guards []string +} + +// Field is one `$$.name= value` assignment of a struct-valued action. +type Field struct { + Name string + Arg Arg +} + +// ReadActions parses the rules section of sql_yacc.yy and classifies every alternative. +func ReadActions(src string) ([]Alt, error) { + sections := reSectionSep.FindAllStringIndex(src, -1) + if len(sections) < 1 { + return nil, fmt.Errorf("grammar: no %%%% section separator") + } + rulesEnd := len(src) + if len(sections) > 1 { + rulesEnd = sections[1][0] + } + toks, err := lexRules(src[sections[0][1]:rulesEnd]) + if err != nil { + return nil, err + } + var alts []Alt + i := 0 + for i < len(toks) { + lhs := toks[i] + if lhs.kind != tkID || i+1 >= len(toks) || toks[i+1].kind != tkColon { + return nil, fmt.Errorf("grammar: expected rule head near %q", lhs.text) + } + i += 2 + idx := 0 + for { + var syms []string + action := "" + // $N in the action counts mid-rule actions as symbols; the CST has no node for + // them, so posMap[N] is the child index (1-based) or 0 for a mid-rule action. + posMap := []int{0} + for i < len(toks) { + t := toks[i] + if t.kind == tkBar || t.kind == tkSemi { + break + } + if t.kind == tkID && i+1 < len(toks) && toks[i+1].kind != tkColon || t.kind == tkLit { + i++ + syms = append(syms, t.text) + posMap = append(posMap, len(syms)) + continue + } + if t.kind == tkID { // next rule head + break + } + i++ + switch t.kind { + case tkPrec: + i++ + case tkAction: + final := i >= len(toks) || toks[i].kind == tkBar || toks[i].kind == tkSemi || + (toks[i].kind == tkID && i+1 < len(toks) && toks[i+1].kind == tkColon) + if final { + action = t.text + } else { + posMap = append(posMap, 0) + } + } + } + a := classify(lhs.text, idx, syms, action) + if len(posMap) > len(syms)+1 && a.Kind != ActUnknown && a.Kind != ActDefault { + a = remap(a, posMap) + } + alts = append(alts, a) + idx++ + if i < len(toks) && toks[i].kind == tkBar { + i++ + continue + } + if i < len(toks) && toks[i].kind == tkSemi { + i++ + } + break + } + } + return alts, nil +} + +var ( + reCComment = regexp.MustCompile(`(?s)/\*.*?\*/`) + reLineCmt = regexp.MustCompile(`//[^\n]*`) + reSpace = regexp.MustCompile(`\s+`) + reNullCheck = regexp.MustCompile(`if\(\$\$==(nullptr|NULL)\)MYSQL_YYABORT;`) + reDigest = regexp.MustCompile(`Lex_input_stream\*lip=YYLIP;lip->reduce_digest_token\([^)]*\);`) + reFoundSemicolon = regexp.MustCompile(`YYLIP->found_semicolon=nullptr;`) + // charset conversion of a token: the value is the token + reConvert = regexp.MustCompile(`^THD\*thd=YYTHD;if\(thd->charset_is_\w+\)(\{.*)?\$\$=\$1;.*convert_string\(&\$\$,.*MYSQL_YYABORT;\}$`) + reStrmake = regexp.MustCompile(`^(THD\*thd=YYTHD;)?\$\$\.str=(thd|YYTHD)->strmake\(\$(\d+)\.str,\$\d+\.length\);if\(\$\$\.str==nullptr\)MYSQL_YYABORT;\$\$\.length=\$\d+\.length;$`) + reParseTree = regexp.MustCompile(`^\*parse_tree=\$(\d+);$`) + reContextualize = regexp.MustCompile(`^\$\$=nullptr;CONTEXTUALIZE\(\$(\d+)\);$`) + reHelperNew = regexp.MustCompile(`^\$\$=([a-z][a-z_0-9]*)\((.*)\);$`) + reBraceNew = regexp.MustCompile(`^\$\$=([A-Z][A-Za-z_0-9]*)\{(.*)\};$`) + reDequeNew = regexp.MustCompile(`^\$\$=new\(YYMEM_ROOT\)[A-Za-z_0-9<>*]+\(YYMEM_ROOT\);((\$\$->push_back\(\$\d+\);)+)$`) + reLexSet = regexp.MustCompile(`^(LEX\*lex=Lex;|THD\*thd=YYTHD;LEX\*lex=thd->lex;)?((lex|Lex|YYTHD->lex)->[A-Za-z_.]+\|?=[^;]+;)+$`) + reLexSetField = regexp.MustCompile(`(?:lex|Lex|YYTHD->lex)->([A-Za-z_.]+)\|?=([^;]+);`) + reBraceAnon = regexp.MustCompile(`^\$\$=\{(.*)\};$`) + reNewList = regexp.MustCompile(`^\$\$=(?:NEW_PTN|new\(YYMEM_ROOT\))(?:List|Mem_root_array|mem_root_deque)<[^>]*>(?:\(YYMEM_ROOT\))?;((?:if\((?:\$\$==nullptr\|\|)?)?\$\$->push_(?:back|front)\(\$\d+(?:\.\w+)?\)\)?(?:MYSQL_YYABORT)?;)+$`) + reNewListPB = regexp.MustCompile(`\$\$->push_(back|front)\(\$(\d+)(\.\w+)?\)`) + reNewListVal = regexp.MustCompile(`^\$\$\.init\((?:YYMEM_ROOT|YYTHD->mem_root)\);(?:if\(\$\$\.push_(?:back|front)\((?:to_lex_cstring\()?\$(\d+)\)?\)\)MYSQL_YYABORT;)?$`) + reFlatten = regexp.MustCompile(`^\$\$=flatten_associative_operator<(Item_cond_[a-z]+),[^>]*>\(YYMEM_ROOT,@\$,\$(\d+),\$(\d+)\);(if\(\$\$!=nullptr\)\$\$->m_pos=@\$;)?$`) + reNewPlain = regexp.MustCompile(`^\$\$=new(?:\(YYMEM_ROOT\))?([A-Z][A-Za-z_0-9]*)\((.*)\);(\$\$->m_pos=@\$;)?$`) + reNewFieldList = regexp.MustCompile(`^\$\$=NEW_PTN([A-Za-z_0-9]+)\(@\$(?:,YYMEM_ROOT)?\);if\(\$\$==nullptr\|\|\$\$->push_back\(&?\$(\d+)(->[a-z_]+)?\)\)MYSQL_YYABORT;$`) + reSpGuard = regexp.MustCompile(`if\(lex->sphead\)\{[^{}]*MYSQL_YYABORT;\}`) + reMsgGuard = regexp.MustCompile(`if\([^{};]*\)my_(message|error)\([^;]*\);`) + reStructExt = regexp.MustCompile(`^\$\$=\$(\d+);((\$\$\.\w+=[^;]+;)+)$`) + reAllocNew = regexp.MustCompile(`^if\(!\(\$\$=([A-Za-z_0-9]+)::alloc\((.*)\)\)\)MYSQL_YYABORT;$`) + reCastWrap = regexp.MustCompile(`(?:to_lex_cstring|static_cast<[a-z_0-9:]+>)\((\$\d+(?:\.str)?)\)`) + reGuardBlock = regexp.MustCompile(`if\([^{};]*\)\{[^{}]*MYSQL_YYABORT;\}`) + reGuardStmt = regexp.MustCompile(`if\([^{};]*\)MYSQL_YYABORT;`) + reNewListPlain = regexp.MustCompile(`^\$\$=NEW_PTN([A-Za-z_0-9]+)\(@\$(?:,YYMEM_ROOT)?\);(?:if\((?:\$\$==nullptr\|\|)?)?\$\$->push_back\(&?\$(\d+)(->[a-z_]+)?\)\)?(?:MYSQL_YYABORT)?;$`) + reAppendOnly = regexp.MustCompile(`^if\(\$\$->push_(back|front)\(&?\$(\d+)(?:->\w+)?\)\)MYSQL_YYABORT;(\$\$->m_pos=@\$;)?$`) + reSetCall = regexp.MustCompile(`\$\$\.([\w.]+)\.set\(([^()]*)\);`) + reInitCall = regexp.MustCompile(`\$\$(\.[\w.]+)?\.init\(\);`) + reNumberHex = regexp.MustCompile(`^\$\$=\((?:ulong|ulonglong|int|uint|longlong)\)my_strtoll\(\$(\d+)\.str,nullptr,16\);$`) + reAtol = regexp.MustCompile(`^\$\$=atol\(\$(\d+)\.str\);$`) + reNullStruct = regexp.MustCompile(`^\$\$=(?:LEX_STRING|LEX_CSTRING)\{nullptr,0\};$`) + reAppendValPre = regexp.MustCompile(`^if\(\$(\d+)\.push_back\(\$(\d+)\)\)MYSQL_YYABORT;\$\$=\$(\d+);$`) + reAppendPlain = regexp.MustCompile(`^\$\$=\$(\d+);\$\$->push_(back|front)\(\$(\d+)\);(\$\$->m_pos=@\$;)?$`) + reNullValue = regexp.MustCompile(`^\$\$=(null_lex_str|NULL_STR|NULL_CSTR|EMPTY_CSTR|EMPTY_STR);$`) + rePass = regexp.MustCompile(`^\$\$=\$(\d+);$`) + reConst = regexp.MustCompile(`^\$\$=(-?\d+|true|false|&?[A-Za-z][A-Za-z_0-9]*(::[A-Za-z_0-9]+)*);$`) + reNew = regexp.MustCompile(`^\$\$=NEW_PTN([A-Za-z_0-9]+)(<[^>]*>)?\(([^;]*)\);$`) + reListAppend = regexp.MustCompile(`^\$\$=\$(\d+);if\(\$\$->push_(back|front)\(\$(\d+)\)\)MYSQL_YYABORT;$`) + reListAppend2 = regexp.MustCompile(`^\$(\d+)->push_(back|front)\(\$(\d+)\);\$\$=\$(\d+);$`) + reListNew = regexp.MustCompile(`^\$\$=NEW_PTN([A-Za-z_0-9]+)(<[^>]*>)?\((.*?)\);if\(\$\$==(nullptr|NULL)\|\|\$\$->push_(back|front)\(\$(\d+)\)\)MYSQL_YYABORT;$`) + rePassPos = regexp.MustCompile(`^\$\$=\$(\d+);if\(\$\$!=nullptr\)\$\$->m_pos=@\$;$`) + rePassField = regexp.MustCompile(`^\$\$=(to_lex_cstring\()?\$(\d+)(\.str|\.node)?\)?;$`) + reItemize = regexp.MustCompile(`^ITEMIZE\(\$(\d+),&\$\$\);$`) + reFlags = regexp.MustCompile(`^\$\$=\$(\d+)\|\$(\d+);$`) + reNumber = regexp.MustCompile(`^interror;\$\$=\((ulong|ulonglong|int|uint|longlong)\)my_strtoll10\(\$(\d+)\.str,nullptr,&error\);(if\(error!=0\)\{[^}]*\})?$`) + reAppend3 = regexp.MustCompile(`^(if\(\$(\d+)==nullptr\|\|\$(\d+)->push_(back|front)\(\$(\d+)\)\)MYSQL_YYABORT;|if\(\$(\d+)->push_(back|front)\(\$(\d+)\)\)MYSQL_YYABORT;|\$(\d+)->push_(back|front)\(\$(\d+)\);)\$\$=\$(\d+);(\$\$->m_pos=@\$;)?$`) + reAppendDflt = regexp.MustCompile(`^if\(\$\$->push_(back|front)\(&?\$(\d+)(?:->\w+)?\)\)MYSQL_YYABORT;\$\$=\$(\d+);(\$\$->m_pos=@\$;)?$`) + reAppendVal = regexp.MustCompile(`^\$\$=\$(\d+);if\(\$\$\.push_(back|front)\(\$(\d+)\)\)MYSQL_YYABORT;$`) + reAppendNull = regexp.MustCompile(`^\$\$=\$(\d+);if\(\$\$==nullptr\|\|\$\$->push_(back|front)\(\$(\d+)\)\)MYSQL_YYABORT;$`) + reListInitVal = regexp.MustCompile(`^\$\$\.init\(YYMEM_ROOT\);(if\(\$\$\.push_(back|front)\(\$(\d+)\)\)MYSQL_YYABORT;)?$`) + reStruct = regexp.MustCompile(`^(\$\$\.[A-Za-z_0-9]+=[^;]+;)+$`) + reStructField = regexp.MustCompile(`\$\$\.([A-Za-z_0-9]+)=([^;]+);`) + reArgChild = regexp.MustCompile(`^\$(\d+)$`) + reArgField = regexp.MustCompile(`^\$(\d+)((?:\.|->)[A-Za-z_0-9.]+?)(?:\.get_or_default\(\))?$`) +) + +// normalize strips comments and whitespace so that the shapes can be matched textually; +// it also returns the error guards it removed. +func normalize(action string) (string, []string) { + a := reCComment.ReplaceAllString(action, "") + a = reLineCmt.ReplaceAllString(a, "") + a = strings.TrimSpace(a) + for strings.HasPrefix(a, "{") && strings.HasSuffix(a, "}") { // `{ { ... } }` + a = strings.TrimSpace(a[1 : len(a)-1]) + } + a = reSpace.ReplaceAllString(a, "") + a = reNullCheck.ReplaceAllString(a, "") + a = stripCalls(a, "push_warning(", "push_warning_printf(", "push_deprecated_warn(", "push_deprecated_warn_no_replacement(", + "warn_on_deprecated_user_defined_collation(", "warn_about_deprecated_national(", "warn_about_deprecated_binary(", "DBUG_EXECUTE_IF(", "MYSQL_YYABORT_UNLESS(", "MAKE_CMD_DDL_DUMMY(") + var guards []string + for _, re := range []*regexp.Regexp{reSpGuard, reMsgGuard} { + guards = append(guards, re.FindAllString(a, -1)...) + a = re.ReplaceAllString(a, "") + } + a = reCastWrap.ReplaceAllString(a, "$1") + var g2 []string + a, g2 = stripGuards(a) + guards = append(guards, g2...) + a = reSetCall.ReplaceAllString(a, "$$$$.$1=$2;") // `$$.algo.set(x)` -> `$$.algo=x` + a = reInitCall.ReplaceAllString(a, "") // `$$.init()`, `$$.flags.init()` + a = reDigest.ReplaceAllString(a, "") + a = reFoundSemicolon.ReplaceAllString(a, "") + return a, guards +} + +// stripGuards removes error checks: `if (cond) MYSQL_YYABORT;` and `if (cond) { ... +// MYSQL_YYABORT; }` whose condition does not build anything (no push_back, no $$=). +func stripGuards(a string) (string, []string) { + var removed []string + for _, re := range []*regexp.Regexp{reGuardBlock, reGuardStmt} { + a = re.ReplaceAllStringFunc(a, func(m string) string { + cond := m[strings.Index(m, "(")+1:] + if strings.Contains(cond, "push_") || strings.Contains(cond, "$$") { + return m + } + removed = append(removed, m) + return "" + }) + } + return a, removed +} + +// stripCalls removes statements that are a call to one of the named functions (warnings +// and other bookkeeping the server does besides building the tree). +func stripCalls(a string, names ...string) string { + for { + changed := false + for _, name := range names { + i := strings.Index(a, name) + if i < 0 || (i > 0 && a[i-1] != ';' && a[i-1] != '}') { + continue + } + depth := 0 + j := i + len(name) - 1 + for ; j < len(a); j++ { + if a[j] == '(' { + depth++ + } else if a[j] == ')' { + depth-- + if depth == 0 { + break + } + } + } + if j+1 < len(a) && a[j+1] == ';' { + a = a[:i] + a[j+2:] + changed = true + } + } + if !changed { + return a + } + } +} + +func classify(rule string, idx int, syms []string, action string) Alt { + norm, guards := normalize(action) + a := Alt{Rule: rule, Index: idx, Syms: syms, Action: norm, Guards: guards} + n := a.Action + switch { + case action == "": + a.Kind = ActDefault + case n == "$$=nullptr;" || n == "$$=NULL;" || n == "$$={};": + a.Kind = ActEmpty + case !strings.Contains(n, "$"): + // only side effects on the server's state (or nothing left after stripping): + // the value is bison's default, the first child + a.Kind = ActDefault + case reStructExt.MatchString(n): + m := reStructExt.FindStringSubmatch(n) + a.Kind = ActStruct + a.Fields = []Field{{Name: "$base", Arg: Arg{Child: atoi(m[1])}}} + for _, f := range reStructField.FindAllStringSubmatch(m[2], -1) { + a.Fields = append(a.Fields, Field{Name: f[1], Arg: parseArgs(f[2])[0]}) + } + case reAllocNew.MatchString(n): + m := reAllocNew.FindStringSubmatch(n) + a.Kind = ActNew + a.Class = m[1] + a.Args = parseArgs(m[2]) + case rePass.MatchString(n): + m := rePass.FindStringSubmatch(n) + a.Kind = ActPass + a.Args = []Arg{{Child: atoi(m[1])}} + case rePassPos.MatchString(n): + a.Kind = ActPass + a.Args = []Arg{{Child: atoi(rePassPos.FindStringSubmatch(n)[1])}} + case rePassField.MatchString(n): + m := rePassField.FindStringSubmatch(n) + a.Kind = ActPass + a.Args = []Arg{{Child: atoi(m[2]), Field: m[3]}} + case reItemize.MatchString(n): + a.Kind = ActPass + a.Args = []Arg{{Child: atoi(reItemize.FindStringSubmatch(n)[1])}} + case reNullValue.MatchString(n): + a.Kind = ActEmpty + case reConvert.MatchString(n), reStrmake.MatchString(n): + a.Kind = ActPass + a.Args = []Arg{{Child: 1}} + case reParseTree.MatchString(n): + a.Kind = ActPass + a.Args = []Arg{{Child: atoi(reParseTree.FindStringSubmatch(n)[1])}} + case reContextualize.MatchString(n): + a.Kind = ActPass + a.Args = []Arg{{Child: atoi(reContextualize.FindStringSubmatch(n)[1])}} + case reDequeNew.MatchString(n): + a.Kind = ActListNew + for _, m := range regexp.MustCompile(`\$\$->push_back\(\$(\d+)\);`).FindAllStringSubmatch(n, -1) { + a.Args = append(a.Args, Arg{Child: atoi(m[1])}) + } + case reLexSet.MatchString(n): + // legacy: the alternative sets fields of the statement's LEX; keep them as a struct + a.Kind = ActStruct + for _, f := range reLexSetField.FindAllStringSubmatch(n, -1) { + a.Fields = append(a.Fields, Field{Name: f[1], Arg: parseArgs(f[2])[0]}) + } + case reFlags.MatchString(n): + m := reFlags.FindStringSubmatch(n) + a.Kind = ActFlags + a.Args = []Arg{{Child: atoi(m[1])}, {Child: atoi(m[2])}} + case reNumber.MatchString(n): + a.Kind = ActNumber + a.Args = []Arg{{Child: atoi(reNumber.FindStringSubmatch(n)[2])}} + case reNumberHex.MatchString(n): + a.Kind = ActNumber + a.Const = "16" + a.Args = []Arg{{Child: atoi(reNumberHex.FindStringSubmatch(n)[1])}} + case reAtol.MatchString(n): + a.Kind = ActNumber + a.Args = []Arg{{Child: atoi(reAtol.FindStringSubmatch(n)[1])}} + case reNullStruct.MatchString(n): + a.Kind = ActEmpty + case reAppendDflt.MatchString(n): + m := reAppendDflt.FindStringSubmatch(n) + a.Kind = ActListAppend + a.Args = []Arg{{Child: atoi(m[3])}, {Child: atoi(m[2])}} + case reAppendValPre.MatchString(n): + m := reAppendValPre.FindStringSubmatch(n) + if m[1] == m[3] { + a.Kind = ActListAppend + a.Args = []Arg{{Child: atoi(m[1])}, {Child: atoi(m[2])}} + } + case reAppendPlain.MatchString(n): + m := reAppendPlain.FindStringSubmatch(n) + a.Kind = ActListAppend + a.Args = []Arg{{Child: atoi(m[1])}, {Child: atoi(m[3])}} + case reAppendVal.MatchString(n): + m := reAppendVal.FindStringSubmatch(n) + a.Kind = ActListAppend + a.Args = []Arg{{Child: atoi(m[1])}, {Child: atoi(m[3])}} + case reAppendNull.MatchString(n): + m := reAppendNull.FindStringSubmatch(n) + a.Kind = ActListAppend + a.Args = []Arg{{Child: atoi(m[1])}, {Child: atoi(m[3])}} + case reAppend3.MatchString(n): + m := reAppend3.FindStringSubmatch(n) + list, elem := firstNonEmpty(m[3], m[6], m[9]), firstNonEmpty(m[5], m[8], m[11]) + if list == m[12] { + a.Kind = ActListAppend + a.Args = []Arg{{Child: atoi(list)}, {Child: atoi(elem)}} + } + case reListInitVal.MatchString(n): + m := reListInitVal.FindStringSubmatch(n) + a.Kind = ActListNew + if m[3] != "" { + a.Args = []Arg{{Child: atoi(m[3])}} + } + case reStruct.MatchString(n): + a.Kind = ActStruct + for _, f := range reStructField.FindAllStringSubmatch(n, -1) { + a.Fields = append(a.Fields, Field{Name: f[1], Arg: parseArgs(f[2])[0]}) + } + case reConst.MatchString(n): + a.Kind = ActConst + a.Const = reConst.FindStringSubmatch(n)[1] + case reListAppend.MatchString(n): + m := reListAppend.FindStringSubmatch(n) + a.Kind = ActListAppend + a.Args = []Arg{{Child: atoi(m[1])}, {Child: atoi(m[3])}} + case reListAppend2.MatchString(n): + m := reListAppend2.FindStringSubmatch(n) + if m[1] == m[4] { + a.Kind = ActListAppend + a.Args = []Arg{{Child: atoi(m[1])}, {Child: atoi(m[3])}} + } + case reListNew.MatchString(n): + m := reListNew.FindStringSubmatch(n) + a.Kind = ActListNew + a.Class = m[1] + a.Args = append(parseArgs(m[3]), Arg{Child: atoi(m[6])}) + case reNew.MatchString(n): + m := reNew.FindStringSubmatch(n) + a.Kind = ActNew + a.Class = m[1] + a.Args = parseArgs(m[3]) + case reHelperNew.MatchString(n) && reHelperNew.FindStringSubmatch(n)[1] != "new": + // a helper that builds the node (create_func_cast, make_index_engine_attribute ...) + m := reHelperNew.FindStringSubmatch(n) + a.Kind = ActNew + a.Class = m[1] + a.Args = parseArgs(m[2]) + case reBraceNew.MatchString(n): + m := reBraceNew.FindStringSubmatch(n) + a.Kind = ActNew + a.Class = m[1] + a.Args = parseArgs(m[2]) + case reBraceAnon.MatchString(n): + // `$$= {$1, false}`: a by-value struct initialized positionally + a.Kind = ActStruct + for i, g := range parseArgs(reBraceAnon.FindStringSubmatch(n)[1]) { + a.Fields = append(a.Fields, Field{Name: fmt.Sprintf("%d", i), Arg: g}) + } + case reNewList.MatchString(n): + a.Kind = ActListNew + for _, m := range reNewListPB.FindAllStringSubmatch(n, -1) { + g := Arg{Child: atoi(m[2]), Field: m[3]} + if m[1] == "front" { + a.Args = append([]Arg{g}, a.Args...) + } else { + a.Args = append(a.Args, g) + } + } + case reNewListVal.MatchString(n): + a.Kind = ActListNew + if m := reNewListVal.FindStringSubmatch(n); m[1] != "" { + a.Args = []Arg{{Child: atoi(m[1])}} + } + case reNewListPlain.MatchString(n): + m := reNewListPlain.FindStringSubmatch(n) + a.Kind = ActListNew + a.Class = m[1] + a.Args = []Arg{{Child: atoi(m[2]), Field: m[3]}} + case reAppendOnly.MatchString(n): + // `if ($$->push_back($3)) MYSQL_YYABORT;` on top of bison's default $$ = $1 + m := reAppendOnly.FindStringSubmatch(n) + a.Kind = ActListAppend + a.Args = []Arg{{Child: 1}, {Child: atoi(m[2])}} + case reNewFieldList.MatchString(n): + m := reNewFieldList.FindStringSubmatch(n) + a.Kind = ActListNew + a.Class = m[1] + a.Args = []Arg{{Child: atoi(m[2]), Field: m[3]}} + case reFlatten.MatchString(n): + m := reFlatten.FindStringSubmatch(n) + a.Kind = ActNew + a.Class = m[1] + a.Args = []Arg{{Child: atoi(m[2])}, {Child: atoi(m[3])}} + case reNewPlain.MatchString(n): + m := reNewPlain.FindStringSubmatch(n) + a.Kind = ActNew + a.Class = m[1] + a.Args = parseArgs(m[2]) + } + return a +} + +// remap renumbers $N references from RHS positions (with mid-rule actions) to CST child +// indexes; a reference to a mid-rule action's own value makes the alternative unknown. +func remap(a Alt, posMap []int) Alt { + fix := func(g *Arg) bool { + if g.Child == 0 { + return true + } + if g.Child >= len(posMap) || posMap[g.Child] == 0 { + return false + } + g.Child = posMap[g.Child] + return true + } + for i := range a.Args { + if !fix(&a.Args[i]) { + a.Kind = ActUnknown + return a + } + } + for i := range a.Fields { + if !fix(&a.Fields[i].Arg) { + a.Kind = ActUnknown + return a + } + } + return a +} + +func parseArgs(s string) []Arg { + var out []Arg + for _, x := range splitTopLevel(s) { + x = strings.TrimPrefix(x, "&") // passing a child by address + switch { + case reArgChild.MatchString(x): + out = append(out, Arg{Child: atoi(reArgChild.FindStringSubmatch(x)[1])}) + case reArgField.MatchString(x): + m := reArgField.FindStringSubmatch(x) + out = append(out, Arg{Child: atoi(m[1]), Field: m[2]}) + default: + out = append(out, Arg{Text: x}) + } + } + return out +} + +// splitTopLevel splits a C argument list on the commas outside parentheses/brackets. +func splitTopLevel(s string) []string { + var out []string + depth := 0 + start := 0 + for i := 0; i < len(s); i++ { + switch s[i] { // '<' '>' are not brackets here: `->` is common, templates are not + case '(', '[', '{': + depth++ + case ')', ']', '}': + depth-- + case ',': + if depth == 0 { + out = append(out, s[start:i]) + start = i + 1 + } + } + } + if strings.TrimSpace(s[start:]) != "" { + out = append(out, s[start:]) + } + return out +} + +func firstNonEmpty(xs ...string) string { + for _, x := range xs { + if x != "" { + return x + } + } + return "" +} + +func atoi(s string) int { + n := 0 + for _, c := range s { + n = n*10 + int(c-'0') + } + return n +} + +// Reach returns the rules reachable from roots, following RHS nonterminals. +func Reach(alts []Alt, roots []string) map[string]bool { + byRule := map[string][]Alt{} + for _, a := range alts { + byRule[a.Rule] = append(byRule[a.Rule], a) + } + seen := map[string]bool{} + var visit func(string) + visit = func(r string) { + if seen[r] { + return + } + if _, ok := byRule[r]; !ok { + return + } + seen[r] = true + for _, a := range byRule[r] { + for _, s := range a.Syms { + visit(s) + } + } + } + for _, r := range roots { + visit(r) + } + return seen +} + +// ReachReport lists, for the rules reachable from roots, the alternatives whose actions +// are not read: the hand-written scope. +func ReachReport(alts []Alt, roots []string) string { + reach := Reach(alts, roots) + var b strings.Builder + total, unknown := 0, 0 + byRule := map[string]int{} + for _, a := range alts { + if !reach[a.Rule] { + continue + } + total++ + if a.Kind == ActUnknown { + unknown++ + byRule[a.Rule]++ + } + } + fmt.Fprintf(&b, "reachable from %s: rules=%d alternatives=%d unknown=%d\n", strings.Join(roots, ","), len(reach), total, unknown) + for _, kv := range top(byRule, 80) { + var shape string + for _, a := range alts { + if a.Rule == kv.k && a.Kind == ActUnknown { + shape = a.Action + break + } + } + if len(shape) > 90 { + shape = shape[:90] + } + fmt.Fprintf(&b, " %3d %-36s %s\n", kv.n, kv.k, shape) + } + return b.String() +} + +// GuardReport lists the error guards dropped from the alternatives reachable from roots +// (all alternatives when roots is empty): the parse-time checks a later layer owes. +func GuardReport(alts []Alt, roots []string) string { + reach := map[string]bool{} + if len(roots) > 0 { + reach = Reach(alts, roots) + } + var b strings.Builder + n := 0 + for _, a := range alts { + if len(roots) > 0 && !reach[a.Rule] { + continue + } + for _, g := range a.Guards { + n++ + if len(g) > 110 { + g = g[:110] + } + fmt.Fprintf(&b, "%s/%d %s\n", a.Rule, a.Index, g) + } + } + return fmt.Sprintf("dropped guards: %d\n", n) + b.String() +} + +// ActionReport summarizes how far the actions are read: counts per kind, the NEW_PTN +// arguments that are not plain children, and the unknown actions grouped by shape. +func ActionReport(alts []Alt) string { + var b strings.Builder + counts := map[ActionKind]int{} + argPlain, argOther := 0, 0 + otherArgs := map[string]int{} + unknown := map[string][]string{} + for _, a := range alts { + counts[a.Kind]++ + if a.Kind == ActNew || a.Kind == ActListNew { + for _, g := range a.Args { + if g.Text == "" || g.Text == "@$" || g.Text == "nullptr" || g.Text == "NULL" || regexp.MustCompile(`^(@\d+|-?\d+|true|false|[A-Z][A-Za-z_0-9]*(::[A-Za-z_0-9]+)*)$`).MatchString(g.Text) { + argPlain++ + } else { + argOther++ + otherArgs[regexp.MustCompile(`\d+`).ReplaceAllLiteralString(g.Text, "N")]++ + } + } + } + if a.Kind == ActUnknown { + shape := regexp.MustCompile(`\$\d+`).ReplaceAllLiteralString(a.Action, "$N") + if len(shape) > 70 { + shape = shape[:70] + } + unknown[shape] = append(unknown[shape], a.Rule) + } + } + fmt.Fprintf(&b, "alternatives=%d\n", len(alts)) + for k := ActUnknown; k <= ActStruct; k++ { + fmt.Fprintf(&b, " %-12s %d\n", k, counts[k]) + } + fmt.Fprintf(&b, "NEW_PTN args: plain=%d other=%d\n", argPlain, argOther) + for _, kv := range top(otherArgs, 15) { + fmt.Fprintf(&b, " %4d %s\n", kv.n, kv.k) + } + fmt.Fprintf(&b, "unknown shapes (rules):\n") + shapes := map[string]int{} + for s, rs := range unknown { + shapes[s] = len(rs) + } + for _, kv := range top(shapes, 40) { + rs := unknown[kv.k] + sort.Strings(rs) + fmt.Fprintf(&b, " %4d %-70s %s\n", kv.n, kv.k, strings.Join(uniq(rs)[:min(3, len(uniq(rs)))], ",")) + } + return b.String() +} + +type kv struct { + k string + n int +} + +func top(m map[string]int, n int) []kv { + var out []kv + for k, v := range m { + out = append(out, kv{k, v}) + } + sort.Slice(out, func(i, j int) bool { return out[i].n > out[j].n || out[i].n == out[j].n && out[i].k < out[j].k }) + if len(out) > n { + out = out[:n] + } + return out +} + +func uniq(s []string) []string { + var out []string + for i, x := range s { + if i == 0 || x != s[i-1] { + out = append(out, x) + } + } + return out +} diff --git a/check/mysql/internal/parsegen/catalog.go b/check/mysql/internal/parsegen/catalog.go new file mode 100644 index 0000000..9b63855 --- /dev/null +++ b/check/mysql/internal/parsegen/catalog.go @@ -0,0 +1,634 @@ +package parsegen + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// The function catalog. MySQL has no pg_proc; what a function is called, how many +// arguments it takes and which Item class evaluates it is written in +// sql/item_create.cc (the native function registry), what that class returns is written +// in its base class and its resolve_type() (item*.h / item*.cc). This file reads those +// three sources. + +// Func is one entry of the native function registry. +type Func struct { + Name string // upper case, as the registry spells it + Class string // the Item class the factory instantiates + Min, Max int // argument count range; Max = -1 for unbounded + Odd bool // argument count must be odd + Even bool // argument count must be even + Internal bool // only callable from system views + // Factory is the special instantiator, when the registry used one (Round_instantiator); + // its class is then what it finally constructs. + Factory string +} + +// ItemClass is what the headers say about an Item class. +type ItemClass struct { + Name string + Base string + // Family is the nearest base that fixes the result kind: Item_int_func, Item_str_func, + // Item_real_func, Item_dec_func, Item_func_numhybrid, Item_bool_func, + // Item_datetime_func, Item_date_func, Item_time_func, Item_temporal_hybrid_func, + // Item_json_func, Item_geometry_func, Item_sum ..., or "" when none applies. + Family string + // Facts are the declarative statements of the class's resolve_type(): set_data_type_*, + // set_nullable, param_type_is_default, aggregate_type, fix_char_length ... in order. + Facts []string + // InheritsResolve is set when resolve_type() defers to a base class's. + InheritsResolve string + // FixFields is set when the class overrides fix_fields(): the base class's fix_fields it + // calls, or "own" when it calls none (Item_str_func::fix_fields is where strict mode + // makes every string function nullable, so the chain decides who inherits that). + FixFields string + // FixNullable is set when that fix_fields sets nullability itself, after everything + // resolve_type and the bases decided. + FixNullable bool + // Header is the file that declares the class. + Header string +} + +// Catalog is everything read from the server source about functions. +type Catalog struct { + Funcs []Func + Classes map[string]*ItemClass + // FieldTypes is the index order of sql/field.cc's type tables (field_type2index): the + // enum_field_types below the tear, then those above it, without the MYSQL_TYPE_ prefix. + FieldTypes []string + // MergeRules[i][j] is field_types_merge_rules[i][j], the type two values of types + // FieldTypes[i] and FieldTypes[j] aggregate to (CASE, IF, COALESCE, GREATEST, UNION). + MergeRules [][]string + // ResultKinds[i] is field_types_result_type[i]: INT_RESULT, DECIMAL_RESULT, REAL_RESULT, STRING_RESULT. + ResultKinds []string +} + +var ( + reRegistryEntry = regexp.MustCompile(`\{"([A-Z_0-9]+)",\s*(SQL_[A-Z_]+)\(([^)]*)\)\}`) + reInstantiator = regexp.MustCompile(`(?s)class\s+([A-Za-z_0-9]+(?:_instantiator|Instantiator[A-Za-z_0-9]*))(?:<[^{]*>)?\s*\{(.*?)\n\};`) + reUsing = regexp.MustCompile(`(?s)(?:template\s*<[^>]*>\s*)?using\s+([A-Za-z_0-9]+)\s*=\s*([^;]+);`) + reArgcount = regexp.MustCompile(`static const uint (Min|Max)_argcount = (\d+);`) + reReturnNew = regexp.MustCompile(`return\s+new\s*\([^)]*\)\s*([A-Za-z_0-9]+)\s*\(`) + reClassDecl = regexp.MustCompile(`(?m)^class\s+(Item_[A-Za-z_0-9]*)\s*(?:final\s*)?:\s*public\s+([A-Za-z_0-9:<>]+)`) + reResolveType = regexp.MustCompile(`(?m)^bool\s+(Item_[A-Za-z_0-9]+)::resolve_type(?:_inner)?\(THD\s*\*\s*\w*\)\s*\{`) + reFact = regexp.MustCompile(`\b(set_data_type_[a-z_0-9]+|set_data_type|set_nullable|param_type_is_default|param_type_uses_non_param|aggregate_type|aggregate_num_type|aggregate_string_properties|agg_arg_charsets_for_string_result|agg_arg_charsets_for_comparison|fix_char_length|count_datetime_length|set_data_type_from_item|reject_geometry_args|null_on_null)\s*\(`) + reBaseResolve = regexp.MustCompile(`(?:if\s*\(\s*|return\s+)(Item_[A-Za-z_0-9]+)::resolve_type\(`) + reFixFields = regexp.MustCompile(`(?m)^bool\s+(Item_[A-Za-z_0-9]+)::fix_fields\(THD\s*\*\s*\w*,\s*Item\s*\*\*\s*\w*\)\s*\{`) + reInlineFix = regexp.MustCompile(`\bbool\s+fix_fields\(THD\s*\*\s*\w*,\s*Item\s*\*\*\s*\w*\)\s*override\s*\{`) + reBaseFix = regexp.MustCompile(`(Item_[A-Za-z_0-9]+)::fix_fields\(`) + reBodyUnsigned = regexp.MustCompile(`\bunsigned_flag\s*=\s*true\s*;`) + reBodyBinary = regexp.MustCompile(`collation\.set\(\s*&my_charset_bin`) + reInlineResolve = regexp.MustCompile(`\bbool\s+resolve_type\(THD\s*\*\s*\w*\)\s*(?:const\s*)?override\s*\{`) + reAssignFact = regexp.MustCompile(`\b(max_length|decimals|unsigned_flag|collation\.set|set_nullable)\s*(=|\()\s*([^;]+);`) +) + +// ReadCatalog reads the registry, the class hierarchy and the resolve_type facts. +func ReadCatalog(src string) (*Catalog, error) { + cat := &Catalog{Classes: map[string]*ItemClass{}} + create, err := os.ReadFile(filepath.Join(src, "sql", "item_create.cc")) + if err != nil { + return nil, err + } + createSrc := stripComments(string(create)) + instantiators := map[string]struct { + min, max int + class string + }{} + for _, m := range reInstantiator.FindAllStringSubmatch(createSrc, -1) { + body := m[2] + ent := struct { + min, max int + class string + }{min: -1, max: -1} + for _, a := range reArgcount.FindAllStringSubmatch(body, -1) { + n, _ := strconv.Atoi(a[2]) + if a[1] == "Min" { + ent.min = n + } else { + ent.max = n + } + } + // the class the instantiator finally returns: the last `return new (...) Class(` in + // the body, which wraps whatever it built before + news := reReturnNew.FindAllStringSubmatch(body, -1) + if len(news) > 0 { + ent.class = news[len(news)-1][1] + } + instantiators[m[1]] = ent + } + // `using X_instantiator = Template` aliases, through alias templates and + // class aliases (I_txt = Item_func_geometry_from_text) + aliases := map[string]string{} + for _, m := range reUsing.FindAllStringSubmatch(createSrc, -1) { + aliases[m[1]] = reSpace.ReplaceAllString(m[2], "") + } + resolveAlias := func(name string) (class string, min, max int, ok bool) { + cur := name + for range 8 { + def, found := aliases[cur] + if !found { + break + } + tmpl, args := def, []string{} + if i := strings.Index(def, "<"); i >= 0 { + tmpl = def[:i] + inner, _ := balanced(def, i, '<', '>') + args = splitTopLevel(inner) + } + // the class: the first template argument naming an Item class, through class aliases + for _, a := range args { + a = strings.TrimSpace(a) + if v, isAlias := aliases[a]; isAlias && strings.HasPrefix(v, "Item_") { + a = v + } + if strings.HasPrefix(a, "Item_func") || strings.HasPrefix(a, "Item_") && !strings.Contains(a, "::") { + class = a + break + } + } + switch { + case tmpl == "Instantiator_with_functype" && len(args) >= 3: + min, _ = strconv.Atoi(strings.TrimSpace(args[len(args)-1])) + max = min + if len(args) >= 4 { // + min, _ = strconv.Atoi(strings.TrimSpace(args[2])) + max, _ = strconv.Atoi(strings.TrimSpace(args[3])) + } + return class, min, max, class != "" + case tmpl == "Instantiator" && len(args) >= 2: + min, _ = strconv.Atoi(strings.TrimSpace(args[1])) + max = min + if len(args) >= 3 { + max, _ = strconv.Atoi(strings.TrimSpace(args[2])) + } + return class, min, max, class != "" + } + if ent, found := instantiators[tmpl]; found { // a template class with Min/Max_argcount + return class, ent.min, ent.max, class != "" + } + cur = tmpl // an alias template of another alias + } + return "", 0, 0, false + } + start := strings.Index(createSrc, "func_array[] = {") + if start < 0 { + return nil, fmt.Errorf("item_create.cc: no func_array") + } + registry, err := balanced(createSrc, strings.Index(createSrc[start:], "{")+start, '{', '}') + if err != nil { + return nil, err + } + for _, m := range reRegistryEntry.FindAllStringSubmatch(registry, -1) { + f := Func{Name: m[1]} + args := splitTopLevel(m[3]) + for i := range args { + args[i] = strings.TrimSpace(args[i]) + } + macro := m[2] + switch macro { + case "SQL_FACTORY": + f.Factory = args[0] + if ent, ok := instantiators[args[0]]; ok { + f.Class, f.Min, f.Max = ent.class, ent.min, ent.max + } else if class, min, max, ok := resolveAlias(args[0]); ok { + f.Class, f.Min, f.Max = class, min, max + } else { + return nil, fmt.Errorf("item_create.cc: %s uses unknown instantiator %s", f.Name, args[0]) + } + default: + f.Class = args[0] + f.Min, _ = strconv.Atoi(args[1]) + f.Max = f.Min + if len(args) > 2 { + if args[2] == "MAX_ARGLIST_SIZE" { + f.Max = -1 + } else { + f.Max, _ = strconv.Atoi(args[2]) + } + } + f.Odd = macro == "SQL_FN_ODD" + f.Even = macro == "SQL_FN_EVEN" + f.Internal = strings.Contains(macro, "INTERNAL") + } + cat.Funcs = append(cat.Funcs, f) + } + + // class hierarchy from the headers + headers, _ := filepath.Glob(filepath.Join(src, "sql", "item*.h")) + sort.Strings(headers) + for _, h := range headers { + b, err := os.ReadFile(h) + if err != nil { + return nil, err + } + text := stripComments(string(b)) + for _, m := range reClassDecl.FindAllStringSubmatchIndex(text, -1) { + name, base := text[m[2]:m[3]], text[m[4]:m[5]] + if _, dup := cat.Classes[name]; dup { + continue + } + c := &ItemClass{Name: name, Base: base, Header: filepath.Base(h)} + cat.Classes[name] = c + // a resolve_type defined inline in the class body (109 of them in 8.4) reads the + // same way as one in a .cc file + open := strings.IndexByte(text[m[1]:], '{') + if open < 0 { + continue + } + class, err := balanced(text, m[1]+open, '{', '}') + if err != nil { + continue + } + if im := reInlineResolve.FindStringIndex(class); im != nil { + if body, err := balanced(class, im[1]-1, '{', '}'); err == nil { + c.readFacts(body) + } + } + if im := reInlineFix.FindStringIndex(class); im != nil { + if body, err := balanced(class, im[1]-1, '{', '}'); err == nil { + c.readFixFields(body) + } + } + // what a constructor states about the result is a fact too (set_nullable(true) in + // MAKEDATE's, unsigned_flag = true in CRC32's) + ctor := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\s*\((?:[^()]|\([^()]*\))*\)\s*(?::[^{;]*)?\{`) + for _, cm := range ctor.FindAllStringIndex(class, -1) { + if body, err := balanced(class, cm[1]-1, '{', '}'); err == nil { + c.readFacts(body) + } + } + if reBodyUnsigned.MatchString(class) { + c.Facts = append(c.Facts, "unsigned_flag=true") + } + if reBodyBinary.MatchString(class) { + c.Facts = append(c.Facts, "collation.set=&my_charset_bin)") + } + } + } + for _, c := range cat.Classes { + c.Family = cat.family(c.Name) + } + + // resolve_type facts from the implementations + sources, _ := filepath.Glob(filepath.Join(src, "sql", "item*.cc")) + sort.Strings(sources) + for _, f := range sources { + b, err := os.ReadFile(f) + if err != nil { + return nil, err + } + text := stripComments(string(b)) + for _, m := range reResolveType.FindAllStringSubmatchIndex(text, -1) { + name := text[m[2]:m[3]] + body, err := balanced(text, m[1]-1, '{', '}') + if err != nil { + continue + } + c := cat.Classes[name] + if c == nil { + c = &ItemClass{Name: name} + cat.Classes[name] = c + } + c.readFacts(body) + } + for _, m := range reFixFields.FindAllStringSubmatchIndex(text, -1) { + name := text[m[2]:m[3]] + body, err := balanced(text, m[1]-1, '{', '}') + if err != nil { + continue + } + c := cat.Classes[name] + if c == nil { + c = &ItemClass{Name: name} + cat.Classes[name] = c + } + c.readFixFields(body) + } + } + if err := readTypeTables(src, cat); err != nil { + return nil, err + } + return cat, nil +} + +// families are the bases that fix a result kind; the walk up the hierarchy stops at the +// first of them. +var families = map[string]bool{ + "Item_int_func": true, "Item_bool_func": true, "Item_str_func": true, "Item_str_ascii_func": true, + "Item_real_func": true, "Item_dec_func": true, "Item_func_numhybrid": true, "Item_func_num1": true, "Item_num_op": true, + "Item_datetime_func": true, "Item_date_func": true, "Item_time_func": true, "Item_temporal_func": true, "Item_temporal_hybrid_func": true, + "Item_json_func": true, "Item_geometry_func": true, "Item_func_bit": true, + "Item_sum_num": true, "Item_sum_int": true, "Item_sum_sum": true, "Item_sum_bit": true, "Item_sum_hybrid": true, "Item_sum": true, + "Item_non_framing_wf": true, "Item_func": true, + "Item_static_string_func": true, "Item_float": true, "Item_int": true, "Item_string": true, +} + +func (cat *Catalog) family(name string) string { + seen := map[string]bool{} + for cur := name; cur != "" && !seen[cur]; { + seen[cur] = true + if families[cur] && cur != name { + return cur + } + c := cat.Classes[cur] + if c == nil { + return "" + } + cur = c.Base + if i := strings.Index(cur, "<"); i >= 0 { + cur = cur[:i] + } + } + return "" +} + +// Report summarizes the catalog read: how many functions, how many resolve to a class +// with a family, and the classes without one. +func (cat *Catalog) Report() string { + var b strings.Builder + noClass, noFamily := 0, 0 + var missing []string + for _, f := range cat.Funcs { + c := cat.Classes[f.Class] + switch { + case c == nil: + noClass++ + missing = append(missing, f.Name+"→"+f.Class+" (no class)") + case c.Family == "": + noFamily++ + missing = append(missing, f.Name+"→"+f.Class+" (base "+c.Base+")") + } + } + withFacts := 0 + for _, c := range cat.Classes { + if len(c.Facts) > 0 { + withFacts++ + } + } + fmt.Fprintf(&b, "registry: %d functions; %d without a known class, %d whose class has no family; %d Item classes, %d with resolve_type facts\n", + len(cat.Funcs), noClass, noFamily, len(cat.Classes), withFacts) + sort.Strings(missing) + for _, m := range missing { + fmt.Fprintf(&b, " %s\n", m) + } + fam := map[string]int{} + for _, f := range cat.Funcs { + if c := cat.Classes[f.Class]; c != nil { + fam[c.Family]++ + } + } + for _, kv := range top(fam, 30) { + fmt.Fprintf(&b, " %4d %s\n", kv.n, kv.k) + } + return b.String() +} + +// CatalogGo is the Go source of the function catalog: the registry entries with their +// classes, and what the headers say about every class the registry or the grammar builds +// (its family, its resolve_type facts), for package catalog to interpret. +func CatalogGo(pkg, version string, cat *Catalog, grammarClasses []string) string { + var b strings.Builder + fmt.Fprintf(&b, "// Code generated by parsegen from MySQL %s sql/item_create.cc and sql/item*. DO NOT EDIT.\n\npackage %s\n\n", version, pkg) + b.WriteString("// Functions is the native function registry (sql/item_create.cc func_array), in the server's order.\nvar Functions = [...]Function{\n") + for _, f := range cat.Funcs { + fmt.Fprintf(&b, "\t{Name: %s, Class: %s, Min: %d, Max: %d", strconv.Quote(f.Name), strconv.Quote(f.Class), f.Min, f.Max) + if f.Odd { + b.WriteString(", Odd: true") + } + if f.Even { + b.WriteString(", Even: true") + } + if f.Internal { + b.WriteString(", Internal: true") + } + if f.Factory != "" { + fmt.Fprintf(&b, ", Factory: %s", strconv.Quote(f.Factory)) + } + b.WriteString("},\n") + } + b.WriteString("}\n\n") + // classes: the registry's, the grammar's, and every base on their way up + want := map[string]bool{} + var add func(string) + add = func(name string) { + for cur := name; cur != "" && !want[cur]; { + want[cur] = true + c := cat.Classes[cur] + if c == nil { + return + } + cur = c.Base + if i := strings.Index(cur, "<"); i >= 0 { + cur = cur[:i] + } + } + } + for _, f := range cat.Funcs { + add(f.Class) + } + for _, c := range grammarClasses { + add(c) + } + var names []string + for n := range want { + if cat.Classes[n] != nil { + names = append(names, n) + } + } + sort.Strings(names) + b.WriteString("// Items is what the server declares about each Item class: its base, the family that\n// fixes its result kind, and the declarative facts of its resolve_type().\nvar Items = map[string]Item{\n") + for _, n := range names { + c := cat.Classes[n] + fmt.Fprintf(&b, "\t%s: {Base: %s, Family: %s", strconv.Quote(n), strconv.Quote(c.Base), strconv.Quote(c.Family)) + if c.InheritsResolve != "" { + fmt.Fprintf(&b, ", InheritsResolve: %s", strconv.Quote(c.InheritsResolve)) + } + if c.FixFields != "" { + fmt.Fprintf(&b, ", FixFields: %s", strconv.Quote(c.FixFields)) + } + if c.FixNullable { + b.WriteString(", FixNullable: true") + } + if len(c.Facts) > 0 { + b.WriteString(", Facts: []string{") + for i, f := range c.Facts { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(strconv.Quote(f)) + } + b.WriteString("}") + } + b.WriteString("},\n") + } + b.WriteString("}\n\n") + b.WriteString("// FieldTypes is the index order of MergeRules and ResultKinds (sql/field.cc field_type2index):\n// the enum_field_types names without their MYSQL_TYPE_ prefix.\nvar FieldTypes = [...]string{") + for i, t := range cat.FieldTypes { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(strconv.Quote(t)) + } + b.WriteString("}\n\n") + fmt.Fprintf(&b, "// MergeRules is field_types_merge_rules: the type two values of types FieldTypes[i] and\n// FieldTypes[j] aggregate to, as CASE, IF, COALESCE, GREATEST and UNION do.\nvar MergeRules = [...][%d]string{\n", len(cat.FieldTypes)) + for i, row := range cat.MergeRules { + fmt.Fprintf(&b, "\t/* %s */ {", cat.FieldTypes[i]) + for j, t := range row { + if j > 0 { + b.WriteString(", ") + } + b.WriteString(strconv.Quote(t)) + } + b.WriteString("},\n") + } + b.WriteString("}\n\n") + b.WriteString("// ResultKinds is field_types_result_type: the Item_result of each of FieldTypes.\nvar ResultKinds = [...]string{") + for i, k := range cat.ResultKinds { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(strconv.Quote(k)) + } + b.WriteString("}\n") + return b.String() +} + +var ( + reEnumMember = regexp.MustCompile(`(?m)^\s*MYSQL_TYPE_([A-Z_0-9]+)(?:\s*=\s*(\d+))?\s*,?`) + reTypeToken = regexp.MustCompile(`MYSQL_TYPE_([A-Z_0-9]+)|\b((?:INT|DECIMAL|REAL|STRING|ROW|INVALID)_RESULT)\b`) +) + +// readTypeTables reads include/field_types.h for the enum order and sql/field.cc for the +// two tables indexed by field_type2index: field_types_merge_rules (what two types +// aggregate to) and field_types_result_type (the result kind of a type). The tear in the +// enum (17..242 unused) is what field_type2index folds; FIELDTYPE_NUM follows from it. +func readTypeTables(src string, cat *Catalog) error { + hdr, err := os.ReadFile(filepath.Join(src, "include", "field_types.h")) + if err != nil { + return err + } + body := stripComments(string(hdr)) + start := strings.Index(body, "enum enum_field_types {") + end := strings.Index(body[start:], "};") + if start < 0 || end < 0 { + return fmt.Errorf("catalog: enum_field_types not found in field_types.h") + } + type member struct { + name string + val int + } + var members []member + next := 0 + for _, m := range reEnumMember.FindAllStringSubmatch(body[start:start+end], -1) { + if m[2] != "" { + next, _ = strconv.Atoi(m[2]) + } + members = append(members, member{m[1], next}) + next++ + } + fieldCC, err := os.ReadFile(filepath.Join(src, "sql", "field.cc")) + if err != nil { + return err + } + fsrc := stripComments(string(fieldCC)) + tearFrom, tearTo := -1, -1 + if m := regexp.MustCompile(`#define FIELDTYPE_TEAR_FROM \(MYSQL_TYPE_([A-Z_0-9]+) \+ 1\)`).FindStringSubmatch(fsrc); m != nil { + for _, mem := range members { + if mem.name == m[1] { + tearFrom = mem.val + 1 + } + } + } + if m := regexp.MustCompile(`#define FIELDTYPE_TEAR_TO \((\d+) - 1\)`).FindStringSubmatch(fsrc); m != nil { + n, _ := strconv.Atoi(m[1]) + tearTo = n - 1 + } + if tearFrom < 0 || tearTo < 0 { + return fmt.Errorf("catalog: FIELDTYPE_TEAR_FROM / TEAR_TO not found in field.cc") + } + for _, mem := range members { + if mem.val < tearFrom || mem.val > tearTo { + cat.FieldTypes = append(cat.FieldTypes, mem.name) + } + } + n := len(cat.FieldTypes) + table := func(decl string) ([]string, error) { + i := strings.Index(fsrc, decl) + if i < 0 { + return nil, fmt.Errorf("catalog: %s not found in field.cc", decl) + } + j := strings.Index(fsrc[i:], "};") + var out []string + for _, m := range reTypeToken.FindAllStringSubmatch(fsrc[i+len(decl):i+j], -1) { + if m[1] != "" { + out = append(out, m[1]) + } else { + out = append(out, m[2]) + } + } + return out, nil + } + merge, err := table("field_types_merge_rules[FIELDTYPE_NUM][FIELDTYPE_NUM] =") + if err != nil { + return err + } + if len(merge) != n*n { + return fmt.Errorf("catalog: field_types_merge_rules has %d entries, want %d×%d", len(merge), n, n) + } + for i := 0; i < n; i++ { + cat.MergeRules = append(cat.MergeRules, merge[i*n:(i+1)*n]) + } + kinds, err := table("field_types_result_type[FIELDTYPE_NUM] =") + if err != nil { + return err + } + if len(kinds) != n { + return fmt.Errorf("catalog: field_types_result_type has %d entries, want %d", len(kinds), n) + } + cat.ResultKinds = kinds + return nil +} + +// readFixFields records a fix_fields override: the base fix_fields it calls, or "own". +func (c *ItemClass) readFixFields(body string) { + c.FixFields = "own" + if m := reBaseFix.FindStringSubmatch(body); m != nil { + c.FixFields = m[1] + } + // an unconditional set_nullable(true / false) is a fact; anything else computed there + // is the override deciding for itself + switch { + case strings.Contains(body, "set_nullable(true)"): + c.Facts = append(c.Facts, "set_nullable(true)") + case strings.Contains(body, "set_nullable(false)"): + c.Facts = append(c.Facts, "set_nullable(false)") + case strings.Contains(body, "set_nullable("): + c.FixNullable = true + } +} + +// readFacts reads the declarative statements of a resolve_type body into the class's +// facts, and the base class whose resolve_type it calls first. +func (c *ItemClass) readFacts(body string) { + if bm := reBaseResolve.FindStringSubmatch(body); bm != nil { + c.InheritsResolve = bm[1] + } + for _, fm := range reFact.FindAllStringSubmatchIndex(body, -1) { + call, err := balanced(body, fm[1]-1, '(', ')') + if err != nil { + continue + } + c.Facts = append(c.Facts, body[fm[2]:fm[3]]+"("+reSpace.ReplaceAllString(call, "")+")") + } + for _, am := range reAssignFact.FindAllStringSubmatch(body, -1) { + if am[1] == "set_nullable" { + continue // already a call fact + } + c.Facts = append(c.Facts, am[1]+"="+reSpace.ReplaceAllString(am[3], "")) + } +} diff --git a/check/mysql/internal/parsegen/cc/cst.cc b/check/mysql/internal/parsegen/cc/cst.cc new file mode 100644 index 0000000..424f87d --- /dev/null +++ b/check/mysql/internal/parsegen/cc/cst.cc @@ -0,0 +1,51 @@ +#include +#include +#include +#include "mysql/strings/m_ctype.h" +#include "cst.h" +#include "sql/parse_location.h" +#include "sql/sql_lex.h" + +Node *mk(THD *thd, int kind, int alt, int n, ...) { + Node *nd = (Node *)calloc(1, sizeof *nd); + nd->kind = (uint16_t)kind; + nd->alt = (uint16_t)alt; + nd->n = n; + nd->kids = (Node **)calloc(n ? n : 1, sizeof(Node *)); + va_list ap; + va_start(ap, n); + for (int i = 0; i < n; i++) nd->kids[i] = va_arg(ap, Node *); + va_end(ap); + thd->cst_nodes.push_back(nd); + return nd; +} + +Node *L(THD *thd, int kind, const MY_SQL_PARSER_LTYPE *loc) { + Node *nd = (Node *)calloc(1, sizeof *nd); + nd->kind = (uint16_t)kind; + nd->leaf = true; + nd->start = loc->raw.start; + nd->end = loc->raw.end; + thd->cst_nodes.push_back(nd); + return nd; +} + +Node *LS(THD *thd, int kind, const MY_SQL_PARSER_LTYPE *loc, const MYSQL_LEX_STRING *s) { + Node *nd = L(thd, kind, loc); + if (s->str) { + nd->val = s->str; + nd->vlen = (uint32_t)s->length; + nd->hasval = true; + } + return nd; +} + +Node *LC(THD *thd, int kind, const MY_SQL_PARSER_LTYPE *loc, const CHARSET_INFO *cs) { + Node *nd = L(thd, kind, loc); + if (cs) { + nd->val = cs->csname; + nd->vlen = (uint32_t)strlen(cs->csname); + nd->hasval = true; + } + return nd; +} diff --git a/check/mysql/internal/parsegen/cc/cst.h b/check/mysql/internal/parsegen/cc/cst.h new file mode 100644 index 0000000..322ea13 --- /dev/null +++ b/check/mysql/internal/parsegen/cc/cst.h @@ -0,0 +1,28 @@ +// The concrete syntax tree the generated actions build, and its wire format. +#pragma once +#include +#include +struct MY_SQL_PARSER_LTYPE; +class THD; + +struct Node { + uint16_t kind; // index into kind_names (kinds.h): a rule, or a token for leaves + uint16_t alt; // rules: which alternative of the rule was reduced (0-based, grammar order) + bool leaf; + const char *start; // leaves: the token's span in the raw input + const char *end; + const char *val; // leaves with a lexer value (identifiers unquoted, literals unescaped): the value + uint32_t vlen; + bool hasval; + int n; + Node **kids; +}; +struct MYSQL_LEX_STRING; +struct CHARSET_INFO; + +// mk builds a rule node for alternative alt over n children; L a leaf for the token at loc. +// LS is a leaf with the lexer's string value, LC one whose value is a charset's name. +Node *mk(THD *thd, int kind, int alt, int n, ...); +Node *L(THD *thd, int kind, const MY_SQL_PARSER_LTYPE *loc); +Node *LS(THD *thd, int kind, const MY_SQL_PARSER_LTYPE *loc, const MYSQL_LEX_STRING *s); +Node *LC(THD *thd, int kind, const MY_SQL_PARSER_LTYPE *loc, const CHARSET_INFO *cs); diff --git a/check/mysql/internal/parsegen/cc/lexer_head.cc b/check/mysql/internal/parsegen/cc/lexer_head.cc new file mode 100644 index 0000000..2f73092 --- /dev/null +++ b/check/mysql/internal/parsegen/cc/lexer_head.cc @@ -0,0 +1,14 @@ +// Lexer body, cut from sql/sql_lex.cc (find_keyword .. lex_one_token) with the +// server-side plumbing (digest, utf8 body, optimizer hints) shimmed. +#include "sql/sql_lex.h" +#include +#include +#include "m_string.h" +#include "mysql_version.h" +#include "my_dbug.h" +#include "sql/lex_symbol.h" +#include "sql/sql_lex_hash.h" +#include "sql/sql_yacc.h" + +static int lex_one_token(Lexer_yystype *yylval, THD *thd); + diff --git a/check/mysql/internal/parsegen/cc/lexer_shims.cc b/check/mysql/internal/parsegen/cc/lexer_shims.cc new file mode 100644 index 0000000..7b5ac1c --- /dev/null +++ b/check/mysql/internal/parsegen/cc/lexer_shims.cc @@ -0,0 +1,31 @@ +// --- shims for what the server does with the token stream besides parsing +void Lex_input_stream::body_utf8_start(THD *, const char *) {} +void Lex_input_stream::body_utf8_append(const char *, const char *) {} +void Lex_input_stream::body_utf8_append(const char *) {} +void Lex_input_stream::body_utf8_append_literal(THD *, const LEX_STRING *, const CHARSET_INFO *, const char *) {} +void Lex_input_stream::add_digest_token(uint, Lexer_yystype *) {} +void Lex_input_stream::reduce_digest_token(uint, uint) {} + +// Optimizer hints: skip the /*+ ... */ comment instead of parsing it. +static bool consume_optimizer_hints(Lex_input_stream *lip) { + const my_lex_states *state_map = lip->query_charset->state_maps->main_map; + int whitespace = 0; + uchar c = lip->yyPeek(); + size_t newlines = 0; + for (; state_map[c] == MY_LEX_SKIP; whitespace++, c = lip->yyPeekn(whitespace)) { + if (c == '\n') newlines++; + } + if (lip->yyPeekn(whitespace) == '/' && lip->yyPeekn(whitespace + 1) == '*' && lip->yyPeekn(whitespace + 2) == '+') { + lip->yylineno += newlines; + lip->yySkipn(whitespace + 3); + for (;;) { + if (lip->eof()) return true; // unterminated hint comment + c = lip->yyGet(); + if (c == '\n') lip->yylineno++; + if (c == '*' && lip->yyPeek() == '/') { lip->yySkip(); break; } + } + lip->yylval->optimizer_hints = nullptr; + } + return false; +} + diff --git a/check/mysql/internal/parsegen/cc/main.cc b/check/mysql/internal/parsegen/cc/main.cc new file mode 100644 index 0000000..b7cd27a --- /dev/null +++ b/check/mysql/internal/parsegen/cc/main.cc @@ -0,0 +1,36 @@ +// Probe driver: parse each statement of stdin (statements separated by a line ";;", each +// starting with a "/* file:line */" header) and print accept / reject per statement. Used +// to measure acceptance over mysql-test/t; see parsegen -corpus. +#include +#include +#include +#include +#include "parse.h" + +int main() { + if (mysqlparse_init() != 0) { fprintf(stderr, "charset registry did not come up\n"); return 2; } + std::string all((std::istreambuf_iterator(std::cin)), {}); + size_t pos = 0; + int ok = 0, ng = 0; + while (pos < all.size()) { + size_t e = all.find("\n;;\n", pos); + std::string stmt = all.substr(pos, e == std::string::npos ? std::string::npos : e - pos); + pos = e == std::string::npos ? all.size() : e + 4; + if (stmt.find_first_not_of(" \t\r\n") == std::string::npos) continue; + size_t nl = stmt.find('\n'); + std::string head = stmt.substr(0, nl); + std::string body = nl == std::string::npos ? "" : stmt.substr(nl + 1); + mysqlparse_result *r = mysqlparse_parse(stmt.data(), (uint32_t)stmt.size(), 0); + for (char &ch : body) if (ch == '\n' || ch == '\t') ch = ' '; + if (const char *err = mysqlparse_error(r)) { + ng++; + printf("FAIL\t%s\t%ld\t%.100s\n", head.c_str(), (long)mysqlparse_cursor(r) - (long)nl - 1, body.c_str()); + } else { + ok++; + printf("OK\t%s\t%u\t%.100s\n", head.c_str(), mysqlparse_len(r), body.c_str()); + } + mysqlparse_free(r); + } + fprintf(stderr, "ok=%d fail=%d\n", ok, ng); + return 0; +} diff --git a/check/mysql/internal/parsegen/cc/parse.cc b/check/mysql/internal/parsegen/cc/parse.cc new file mode 100644 index 0000000..b6f2af6 --- /dev/null +++ b/check/mysql/internal/parsegen/cc/parse.cc @@ -0,0 +1,99 @@ +#include "parse.h" +#include +#include +#include +#include +#include "cst.h" +#include "kinds.h" +#include "mysql/strings/collations.h" +#include "mysql/strings/m_ctype.h" +#include "sql/sql_lex.h" +#include "sql/sql_yacc.h" + +struct Loader : MY_CHARSET_LOADER { + void reporter(enum loglevel, unsigned, ...) override {} + void *read_file(const char *, size_t *) override { return nullptr; } +}; + +static const CHARSET_INFO *utf8mb4; + +int mysqlparse_init(void) { + if (utf8mb4) return 0; + mysql::collation::initialize(nullptr, new Loader); // the registry also builds the lexer's state maps + utf8mb4 = mysql::collation::find_primary("utf8mb4"); + return utf8mb4 && utf8mb4->state_maps ? 0 : 1; +} + +struct mysqlparse_result { + std::string error; + uint32_t cursor = 0; + std::string data; +}; + +// The parser reports through this; the message is bison's, the position is the token the +// lexer last started. +void my_sql_parser_error(MY_SQL_PARSER_LTYPE *, THD *thd, Node **, const char *msg) { + thd->parse_error = msg; +} + +static void put16(std::string &s, uint32_t v) { s.push_back((char)(v & 0xff)); s.push_back((char)(v >> 8)); } +static void put32(std::string &s, uint32_t v) { for (int i = 0; i < 4; i++) s.push_back((char)((v >> (8 * i)) & 0xff)); } + +// Spans are clamped to the text: END_OF_INPUT is the NUL the lexer reads past the end. +static void serialize(std::string &s, const Node *n, const char *base, uint32_t len) { + if (n->leaf) { + uint32_t start = n->start ? (uint32_t)(n->start - base) : len; + uint32_t end = n->end ? (uint32_t)(n->end - base) : len; + if (start > len) start = len; + if (end > len) end = len; + put16(s, n->kind | 0x8000u | (n->hasval ? 0x4000u : 0)); + put32(s, start); + put32(s, end); + if (n->hasval) { + put32(s, n->vlen); + s.append(n->val, n->vlen); + } + return; + } + put16(s, n->kind); + put16(s, n->alt); + put16(s, (uint32_t)n->n); + for (int i = 0; i < n->n; i++) serialize(s, n->kids[i], base, len); +} + +mysqlparse_result *mysqlparse_parse(const char *sql, uint32_t len, uint32_t sql_mode) { + auto *r = new mysqlparse_result; + if (!utf8mb4) { r->error = "mysqlparse_init was not called"; return r; } + // The lexer wants a NUL after the text and may patch version comments in place. + std::string buf(sql, len); + buf.push_back(0); + THD thd; + thd.m_charset = utf8mb4; + thd.variables.sql_mode = sql_mode; + thd.variables.default_collation_for_utf8mb4 = utf8mb4; + thd.variables.character_set_client = utf8mb4; + Parser_state ps; + thd.m_parser_state = &ps; + ps.init(&thd, buf.data(), len); + ps.m_lip.stmt_prepare_mode = true; // '?' is a placeholder + Node *out = nullptr; + int rc = my_sql_parser_parse(&thd, &out); + if (rc != 0 || out == nullptr) { + r->error = thd.parse_error ? thd.parse_error : "syntax error"; + const char *tok = ps.m_lip.get_tok_start(); + r->cursor = tok && tok >= buf.data() ? (uint32_t)(tok - buf.data()) : 0; + } else { + r->data.reserve(thd.cst_nodes.size() * 6); + serialize(r->data, out, buf.data(), len); + } + for (Node *n : thd.cst_nodes) { free(n->kids); free(n); } + return r; +} + +const char *mysqlparse_error(const mysqlparse_result *r) { return r->error.empty() ? nullptr : r->error.c_str(); } +uint32_t mysqlparse_cursor(const mysqlparse_result *r) { return r->cursor; } +const uint8_t *mysqlparse_data(const mysqlparse_result *r) { return (const uint8_t *)r->data.data(); } +uint32_t mysqlparse_len(const mysqlparse_result *r) { return (uint32_t)r->data.size(); } +void mysqlparse_free(mysqlparse_result *r) { delete r; } +const char *mysqlparse_kind_name(uint32_t kind) { return kind < KIND_COUNT ? kind_names[kind] : nullptr; } +uint32_t mysqlparse_kind_count(void) { return KIND_COUNT; } diff --git a/check/mysql/internal/parsegen/cc/parse.h b/check/mysql/internal/parsegen/cc/parse.h new file mode 100644 index 0000000..bf5ca1a --- /dev/null +++ b/check/mysql/internal/parsegen/cc/parse.h @@ -0,0 +1,31 @@ +// The parser's C entry points: what the wasm module exports and what the probe driver calls. +#pragma once +#include +#ifdef __cplusplus +extern "C" { +#endif +// mysqlparse_init brings up the charset registry. Once per process/instance, before any parse. +int mysqlparse_init(void); + +typedef struct mysqlparse_result mysqlparse_result; +// mysqlparse_parse parses one statement. sql_mode carries MySQL's own bits; only +// PIPES_AS_CONCAT, ANSI_QUOTES, IGNORE_SPACE, NO_BACKSLASH_ESCAPES and +// HIGH_NOT_PRECEDENCE reach the lexer. +mysqlparse_result *mysqlparse_parse(const char *sql, uint32_t len, uint32_t sql_mode); +// On error, the message and the byte offset of the token it points at; else NULL / 0. +const char *mysqlparse_error(const mysqlparse_result *r); +uint32_t mysqlparse_cursor(const mysqlparse_result *r); +// On success, the CST in preorder: per node u16 kind (bit 15 set on a leaf, bit 14 on a +// leaf that carries a value), then for a leaf u32 start, u32 end (byte offsets into sql) +// and, with bit 14, u32 length + bytes of the lexer's value (the identifier unquoted, the +// string literal unescaped, the charset of an introducer); for a rule u16 alternative index +// and u16 child count. Little-endian. +const uint8_t *mysqlparse_data(const mysqlparse_result *r); +uint32_t mysqlparse_len(const mysqlparse_result *r); +void mysqlparse_free(mysqlparse_result *r); +// mysqlparse_kind_name names a kind (see kinds.h); NULL when out of range. +const char *mysqlparse_kind_name(uint32_t kind); +uint32_t mysqlparse_kind_count(void); +#ifdef __cplusplus +} +#endif diff --git a/check/mysql/internal/parsegen/cc/shim/my_config.h b/check/mysql/internal/parsegen/cc/shim/my_config.h new file mode 100644 index 0000000..ea1e9f4 --- /dev/null +++ b/check/mysql/internal/parsegen/cc/shim/my_config.h @@ -0,0 +1,13 @@ +#pragma once +#define SIZEOF_VOIDP 8 +#define SIZEOF_CHARP 8 +#define SIZEOF_LONG 8 +#define SIZEOF_SHORT 2 +#define SIZEOF_INT 4 +#define SIZEOF_LONG_LONG 8 +#define SIZEOF_TIME_T 8 +#define HAVE_STRINGS_H 1 +#define HAVE_SYS_TYPES_H 1 +#define HAVE_STDINT_H 1 +#define HAVE_INTTYPES_H 1 +#define HAVE_ARPA_INET_H 1 diff --git a/check/mysql/internal/parsegen/cc/shim/sql/sql_lex_head.h b/check/mysql/internal/parsegen/cc/shim/sql/sql_lex_head.h new file mode 100644 index 0000000..04f9378 --- /dev/null +++ b/check/mysql/internal/parsegen/cc/shim/sql/sql_lex_head.h @@ -0,0 +1,10 @@ +// Lex_input_stream, cut from sql/sql_lex.h, plus the THD shim. +#pragma once +#include +#include +#include "my_inttypes.h" +#include "lex_string.h" +#include "mysql/strings/m_ctype.h" +#include "strings/sql_chars.h" +#include "sql/lexer_yystype.h" +#include "sql/thd_shim.h" diff --git a/check/mysql/internal/parsegen/cc/shim/sql/sql_lex_tail.h b/check/mysql/internal/parsegen/cc/shim/sql/sql_lex_tail.h new file mode 100644 index 0000000..ae8a88e --- /dev/null +++ b/check/mysql/internal/parsegen/cc/shim/sql/sql_lex_tail.h @@ -0,0 +1,8 @@ +class Parser_state { + public: + Parser_state() : m_lip(~0U) {} + bool init(THD *thd, const char *buff, size_t length) { return m_lip.init(thd, buff, length); } + void add_comment() { m_comment = true; } + Lex_input_stream m_lip; + bool m_comment = false; +}; diff --git a/check/mysql/internal/parsegen/cc/shim/sql/sql_yacc.h b/check/mysql/internal/parsegen/cc/shim/sql/sql_yacc.h new file mode 100644 index 0000000..0d2c455 --- /dev/null +++ b/check/mysql/internal/parsegen/cc/shim/sql/sql_yacc.h @@ -0,0 +1,2 @@ +#pragma once +#include "parser.h" diff --git a/check/mysql/internal/parsegen/cc/shim/sql/stream_cipher.h b/check/mysql/internal/parsegen/cc/shim/sql/stream_cipher.h new file mode 100644 index 0000000..cfadec7 --- /dev/null +++ b/check/mysql/internal/parsegen/cc/shim/sql/stream_cipher.h @@ -0,0 +1,3 @@ +#pragma once +#include +class Stream_cipher; diff --git a/check/mysql/internal/parsegen/cc/shim/sql/thd_shim.h b/check/mysql/internal/parsegen/cc/shim/sql/thd_shim.h new file mode 100644 index 0000000..dc8bc18 --- /dev/null +++ b/check/mysql/internal/parsegen/cc/shim/sql/thd_shim.h @@ -0,0 +1,53 @@ +// THD shim: the five things the lexer reads from the server's thread object. +#pragma once +#include +#include +#include +#include +#include "mysql/strings/m_ctype.h" +#include "lex_string.h" +struct Node; + +using sql_mode_t = uint64_t; +inline constexpr sql_mode_t MODE_PIPES_AS_CONCAT = 2; +inline constexpr sql_mode_t MODE_ANSI_QUOTES = 4; +inline constexpr sql_mode_t MODE_IGNORE_SPACE = 8; +inline constexpr sql_mode_t MODE_NO_BACKSLASH_ESCAPES = 0x40000ULL * 4; // MODE_ANSI * 2 * 2, as system_variables.h derives it +inline constexpr sql_mode_t MODE_HIGH_NOT_PRECEDENCE = 1ULL << 29; + +inline bool operator==(const LEX_CSTRING &a, const LEX_CSTRING &b) { return a.length == b.length && (a.length == 0 || memcmp(a.str, b.str, a.length) == 0); } +class Lex_input_stream; +class Parser_state; +struct sql_digest_state; + +struct System_variables { + sql_mode_t sql_mode = 0; + const CHARSET_INFO *default_collation_for_utf8mb4 = nullptr; + const CHARSET_INFO *character_set_client = nullptr; +}; + +class THD { + public: + System_variables variables; + std::vector cst_nodes; // every node built during this parse, freed together + const char *parse_error = nullptr; // bison's message, set by my_sql_parser_error + Parser_state *m_parser_state = nullptr; + const CHARSET_INFO *m_charset = nullptr; + const CHARSET_INFO *charset() const { return m_charset; } + bool is_error() const { return false; } + struct Da { bool has_sql_condition(int) const { return false; } } m_da; + const Da *get_parser_da() const { return &m_da; } + void *alloc(size_t n) { return malloc(n); } + char *strmake(const char *s, size_t n) { char *p = (char *)malloc(n + 1); memcpy(p, s, n); p[n] = 0; return p; } +}; + +struct Sql_condition { enum enum_severity_level { SL_NOTE, SL_WARNING, SL_ERROR }; }; +enum { ER_WARN_NO_SPACE_VERSION_COMMENT = 1, ER_WARN_DEPRECATED_SYNTAX_NO_REPLACEMENT, ER_WARN_DEPRECATED_NESTED_COMMENT_SYNTAX, ER_CAPACITY_EXCEEDED }; +inline const char *ER_THD(const THD *, int) { return ""; } +inline void push_warning(THD *, Sql_condition::enum_severity_level, unsigned, const char *) {} +inline void push_deprecated_warn(THD *, const char *, const char *) {} +inline void push_deprecated_warn_no_replacement(THD *, const char *) {} +inline void warn_on_deprecated_charset(THD *, const CHARSET_INFO *, const char *) {} +inline void warn_on_deprecated_collation(THD *, const CHARSET_INFO *) {} +extern CHARSET_INFO my_charset_utf8mb4_0900_ai_ci; // referenced by the lexer; the spike never has it as a real collation +#include "mysql/strings/collations.h" diff --git a/check/mysql/internal/parsegen/cmd/parsegen/main.go b/check/mysql/internal/parsegen/cmd/parsegen/main.go new file mode 100644 index 0000000..c9f1401 --- /dev/null +++ b/check/mysql/internal/parsegen/cmd/parsegen/main.go @@ -0,0 +1,144 @@ +// Command parsegen regenerates the MySQL CST parser from the server source. +// +// go run ./internal/parsegen/cmd/parsegen -src ~/.cache/sqlshape/mysql-server -out /tmp/mysqlparse [-wasm] +// go run ./internal/parsegen/cmd/parsegen -src ... -corpus corpus.sql # split mysql-test/t for the driver +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/parsegen" +) + +func main() { + home, _ := os.UserHomeDir() + src := flag.String("src", filepath.Join(home, ".cache", "sqlshape", "mysql-server"), "MySQL server source tree") + out := flag.String("out", "", "output directory (default ~/.cache/sqlshape/mysqlparse/)") + wasm := flag.Bool("wasm", false, "also build the wasm module with em++") + genOnly := flag.Bool("generate-only", false, "write the sources and stop before the toolchain") + corpus := flag.String("corpus", "", "write mysql-test/t split into statements to this file and exit") + catalog := flag.Bool("catalog", false, "print what the server source says about its functions (registry, classes, resolve_type), and exit") + actions := flag.Bool("actions", false, "print how far the grammar's semantic actions are read, and exit") + show := flag.String("show", "", "with -actions: comma-separated rule/alt pairs whose symbols and action to print") + guards := flag.Bool("guards", false, "with -actions: list the error checks dropped from the actions (with -roots: only those reachable)") + roots := flag.String("roots", "", "with -actions: comma-separated start rules; report the unread actions reachable from them") + catpkg := flag.String("catpkg", "", "directory of package catalog: write functions.go there") + sysvars := flag.Bool("sysvars", false, "write the system variable scope table (sysvars.go) to -catpkg and exit") + astpkg := flag.String("astpkg", "", "directory of package mysqlast: write views.go there") + pkg := flag.String("pkg", "", "directory of package mysqlparse: write kinds.go there and, with -wasm, install the module") + flag.Parse() + + if *sysvars { + vars, err := parsegen.ReadSysVars(*src) + if err != nil { + fatal(err) + } + v, err := parsegen.ReadVersion(*src) + if err != nil { + fatal(err) + } + code := parsegen.SysVarsGo("catalog", v.String(), vars) + if *catpkg == "" { + fmt.Print(code) + return + } + if err := os.WriteFile(filepath.Join(*catpkg, "sysvars.go"), []byte(code), 0o644); err != nil { + fatal(err) + } + fmt.Fprintf(os.Stderr, "sysvars.go: %d variables\n", len(vars)) + return + } + if *catalog { + cat, err := parsegen.ReadCatalog(*src) + if err != nil { + fatal(err) + } + fmt.Print(cat.Report()) + if len(flag.Args()) > 0 { // class names to dump + for _, name := range flag.Args() { + c := cat.Classes[name] + if c == nil { + fmt.Println(name, ": not found") + continue + } + fmt.Printf("%s : %s (family %s, resolve via %s) %s\n %s\n", c.Name, c.Base, c.Family, c.InheritsResolve, c.Header, strings.Join(c.Facts, "\n ")) + } + } + return + } + if *actions { + yacc, err := os.ReadFile(filepath.Join(*src, "sql", "sql_yacc.yy")) + if err != nil { + fatal(err) + } + alts, err := parsegen.ReadActions(string(yacc)) + if err != nil { + fatal(err) + } + if *show != "" { + want := map[string]bool{} + for _, k := range strings.Split(*show, ",") { + want[k] = true + } + for _, a := range alts { + // "rule/N" names one alternative; "rule" every unread alternative of the rule + if want[fmt.Sprintf("%s/%d", a.Rule, a.Index)] || want[a.Rule] && a.Kind == parsegen.ActUnknown { + fmt.Printf("%s/%d: %s\n %s\n", a.Rule, a.Index, strings.Join(a.Syms, " "), a.Action) + } + } + } else if *guards { + var rs []string + if *roots != "" { + rs = strings.Split(*roots, ",") + } + fmt.Print(parsegen.GuardReport(alts, rs)) + } else if *roots != "" { + fmt.Print(parsegen.ReachReport(alts, strings.Split(*roots, ","))) + } else { + fmt.Print(parsegen.ActionReport(alts)) + names, err := parsegen.ReadNames(*src, string(yacc), alts) + if err != nil { + fatal(err) + } + fmt.Print(names.Report()) + } + return + } + if *corpus != "" { + stmts, total, err := parsegen.SplitTestDir(filepath.Join(*src, "mysql-test", "t")) + if err != nil { + fatal(err) + } + if err := os.WriteFile(*corpus, []byte(parsegen.JoinForDriver(stmts)), 0o644); err != nil { + fatal(err) + } + fmt.Fprintf(os.Stderr, "statements=%d kept=%d (dropped those with $vars)\n", total, len(stmts)) + return + } + if *out == "" { + v, err := parsegen.ReadVersion(*src) + if err != nil { + fatal(err) + } + *out = filepath.Join(home, ".cache", "sqlshape", "mysqlparse", v.String()) + } + b := &parsegen.Build{Src: *src, Out: *out, Wasm: *wasm, Pkg: *pkg, ASTPkg: *astpkg, CatPkg: *catpkg, Log: func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }} + if _, err := b.Generate(); err != nil { + fatal(err) + } + if *genOnly { + return + } + if err := b.Compile(); err != nil { + fatal(err) + } +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, "parsegen:", err) + os.Exit(1) +} diff --git a/check/mysql/internal/parsegen/corpus.go b/check/mysql/internal/parsegen/corpus.go new file mode 100644 index 0000000..3166199 --- /dev/null +++ b/check/mysql/internal/parsegen/corpus.go @@ -0,0 +1,228 @@ +package parsegen + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// Statement is one SQL statement cut out of a mysqltest .test file. +type Statement struct { + File string + Line int + Expect string // the --error name/number that preceded it, or "" + SQL string +} + +// Header is the comment the spike driver prints back: "/* file:line [expect=X] */". +func (s Statement) Header() string { + if s.Expect != "" { + return fmt.Sprintf("/* %s:%d expect=%s */", s.File, s.Line, s.Expect) + } + return fmt.Sprintf("/* %s:%d */", s.File, s.Line) +} + +// SplitTestDir reads every *.test under dir the way mysqltest does: a command +// runs to the current delimiter found outside quotes and comments, whatever +// follows the delimiter on the same line starts the next command, and +// `delimiter X` changes the terminator. Only commands that start like SQL are +// kept; mysqltest's own commands (let, eval, connect, ...) and statements +// containing $variables are dropped. Files are read as latin-1 so that tests +// in other client charsets survive as bytes. +func SplitTestDir(dir string) ([]Statement, int, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, 0, err + } + var names []string + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".test") { + names = append(names, e.Name()) + } + } + sort.Strings(names) + var out []Statement + total := 0 + for _, name := range names { + b, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + return nil, 0, err + } + stmts, n := splitTest(name, string(b)) + out = append(out, stmts...) + total += n + } + return out, total, nil +} + +var ( + reSQLFirst = regexp.MustCompile(`(?i)^(select|insert|update|delete|replace|create|drop|alter|set|show|explain|describe|desc|with|grant|revoke|flush|reset|start|begin|commit|rollback|savepoint|release|lock|unlock|analyze|optimize|repair|check|checksum|truncate|rename|load|call|do|handler|help|use|prepare|execute|deallocate|xa|install|uninstall|binlog|cache|kill|purge|change|stop|resume|import|clone|restart|shutdown|get|signal|resignal|table|values|declare|open|fetch|close|return|iterate|leave|loop|repeat|while|if|case)\b`) + reCtlFlow = regexp.MustCompile(`(?i)^(if|while)\s*\(`) + reErrorCmd = regexp.MustCompile(`(?i)^(?:--\s*)?error\s+(.+?)\s*;?\s*$`) + reDelimCmd = regexp.MustCompile(`(?i)^(?:--\s*)?delimiter\s+(.*)$`) + reHeredoc = regexp.MustCompile(`(?i)^(?:--\s*)?(perl|write_file|append_file)\b`) + reHeredocTo = regexp.MustCompile(`(?i)\b(perl|write_file|append_file)\b(?:\s+\S+)?\s+(\w+)\s*;?\s*$`) + reVar = regexp.MustCompile(`\$\w+`) + reBlockOnly = regexp.MustCompile(`^\{?\s*$`) +) + +type quoteState struct { + q byte + blockComment bool +} + +// findDelim returns the index of delim in line outside quotes and comments, or -1. +func findDelim(line, delim string, st *quoteState) int { + for i := 0; i < len(line); i++ { + c := line[i] + if st.blockComment { + if c == '*' && i+1 < len(line) && line[i+1] == '/' { + st.blockComment = false + i++ + } + continue + } + if st.q != 0 { + if c == '\\' && st.q != '`' { + i++ + continue + } + if c == st.q { + st.q = 0 + } + continue + } + switch { + case c == '\'' || c == '"' || c == '`': + st.q = c + case c == '/' && i+1 < len(line) && line[i+1] == '*': + st.blockComment = true + i++ + case c == '#': + return -1 + case c == '-' && i+1 < len(line) && line[i+1] == '-' && (i == 0 || isSpace(line[i-1])) && (i+2 >= len(line) || isSpace(line[i+2])): + return -1 + case strings.HasPrefix(line[i:], delim): + return i + } + } + return -1 +} + +func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\r' || c == '\n' } + +func splitTest(name, text string) ([]Statement, int) { + lines := strings.Split(text, "\n") + delim := ";" + expect := "" + heredoc := "" + inHeredoc := false + var buf []string + bufLine := 0 + isSQL := false + st := quoteState{} + var out []Statement + total := 0 + i := 0 + var pending *string + pendingLine := 0 + for i < len(lines) || pending != nil { + var raw string + var lineNo int + if pending != nil { + raw = *pending + lineNo = pendingLine + pending = nil + } else { + raw = lines[i] + lineNo = i + 1 + i++ + } + if inHeredoc { + if strings.TrimSpace(raw) == heredoc { + inHeredoc = false + } + continue + } + if len(buf) == 0 { + t := strings.TrimSpace(raw) + if t == "" || strings.HasPrefix(t, "#") { + continue + } + if m := reErrorCmd.FindStringSubmatch(t); m != nil { + expect = m[1] + continue + } + if m := reDelimCmd.FindStringSubmatch(t); m != nil { + d := strings.TrimSpace(m[1]) + if strings.HasSuffix(d, delim) && len(d) > len(delim) { + d = d[:len(d)-len(delim)] + } + if strings.HasSuffix(d, ";") && len(d) > 1 && delim == ";" { + d = d[:len(d)-1] + } + if d == "" { + d = ";" + } + delim = d + continue + } + if reHeredoc.MatchString(t) { + heredoc = "EOF" + if m := reHeredocTo.FindStringSubmatch(t); m != nil { + heredoc = m[2] + } + inHeredoc = true + continue + } + if strings.HasPrefix(t, "--") { + continue // other one-line commands; --error still applies to the next statement + } + isSQL = reSQLFirst.MatchString(t) && !reCtlFlow.MatchString(t) + bufLine = lineNo + st = quoteState{} + } + at := findDelim(raw, delim, &st) + if at < 0 { + buf = append(buf, raw) + continue + } + buf = append(buf, raw[:at]) + if rest := raw[at+len(delim):]; strings.TrimSpace(rest) != "" { + r := rest + pending = &r + pendingLine = lineNo + } + stmt := strings.TrimSpace(strings.Join(buf, "\n")) + buf = nil + if isSQL { + total++ + if !reVar.MatchString(stmt) { + out = append(out, Statement{File: name, Line: bufLine, Expect: expect, SQL: stmt}) + } + expect = "" + } else if !reBlockOnly.MatchString(stmt) { + expect = "" // a non-SQL command consumed the --error; `{` / `}` do not + } + } + return out, total +} + +// JoinForDriver renders statements in the spike driver's input format: +// header line, statement, and "\n;;\n" between statements. +func JoinForDriver(stmts []Statement) string { + var b strings.Builder + for i, s := range stmts { + if i > 0 { + b.WriteString("\n;;\n") + } + b.WriteString(s.Header()) + b.WriteByte('\n') + b.WriteString(s.SQL) + } + b.WriteString("\n;;\n") + return b.String() +} diff --git a/check/mysql/internal/parsegen/grammar.go b/check/mysql/internal/parsegen/grammar.go new file mode 100644 index 0000000..e645c43 --- /dev/null +++ b/check/mysql/internal/parsegen/grammar.go @@ -0,0 +1,650 @@ +// Package parsegen turns the MySQL server's own grammar and lexer into a +// concrete-syntax-tree parser: it strips the semantic actions from +// sql/sql_yacc.yy, cuts the token functions out of sql/sql_lex.cc behind a +// small THD shim, and drives bison, the server's table generators and the C++ +// compiler (natively and through emcc). +// +// Everything copied from the server source is generated; only the shim and the +// generator are hand-written. Every cut is anchored on a signature and fails +// loudly when the server source moves. +package parsegen + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" +) + +func strconvQuote(s string) string { return strconv.Quote(s) } + +// Grammar is the action-stripped grammar and the counts the spike established. +type Grammar struct { + Text string + Rules int + Alternatives int + MidRuleActions int + Start string + // Kinds names every CST node kind: the rules in order of definition, then the + // terminals in order of first use. Index = the kind id the generated actions + // pass to mk/L and the id the Go side decodes. + Kinds []string + // ValueTokens are the tokens whose value the lexer sets as a string (identifiers, + // literals: `%type `); their leaves carry that value besides the span. + ValueTokens []string +} + +// kindID returns the id of name, adding it to Kinds when new. +func (g *Grammar) kindID(name string, seen map[string]int) int { + if id, ok := seen[name]; ok { + return id + } + id := len(g.Kinds) + g.Kinds = append(g.Kinds, name) + seen[name] = id + return id +} + +// StripGrammar rewrites sql_yacc.yy so that every alternative builds a generic +// CST node: terminals become leaves carrying their source span (via the +// location), nonterminals pass their node up. Token numbering, precedence and +// %expect are kept, so bison verifies the grammar is untouched. +func StripGrammar(src string) (*Grammar, error) { + sections := reSectionSep.FindAllStringIndex(src, -1) + if len(sections) < 1 { + return nil, fmt.Errorf("grammar: no %%%% section separator") + } + decl := src[:sections[0][0]] + rulesEnd := len(src) + if len(sections) > 1 { + rulesEnd = sections[1][0] + } + rules := src[sections[0][1]:rulesEnd] + + tokens := map[string]bool{} + for _, m := range reTokenDecl.FindAllStringSubmatch(decl, -1) { + for _, name := range strings.Fields(m[1]) { // one %token line may declare several + if isIdentStart(name[0]) { + tokens[name] = true + } + } + } + // tokens the lexer gives a string value: the unquoted identifier, the unescaped literal + valueTokens := map[string]bool{} + for _, m := range reLexStrType.FindAllStringSubmatch(decl, -1) { + for _, name := range strings.Fields(m[1]) { + if tokens[name] { + valueTokens[name] = true + } + } + } + charsetTokens := map[string]bool{} + for _, m := range reCharsetType.FindAllStringSubmatch(decl, -1) { + for _, name := range strings.Fields(m[1]) { + if tokens[name] { + charsetTokens[name] = true + } + } + } + startM := reStart.FindStringSubmatch(decl) + if startM == nil { + return nil, fmt.Errorf("grammar: no %%start") + } + start := startM[1] + + d := decl + d = rePrologue.ReplaceAllString(d, "") + d = reParseParam.ReplaceAllString(d, "") + d = reLexParam.ReplaceAllString(d, "") + d = reApiPure.ReplaceAllString(d, "") + d = reTypeDecl.ReplaceAllString(d, "") // api.value.type is one union; %type lines are dropped + d = reTaggedDecl.ReplaceAllString(d, "$1") + + toks, err := lexRules(rules) + if err != nil { + return nil, err + } + g := &Grammar{Start: start} + for name := range valueTokens { + g.ValueTokens = append(g.ValueTokens, name) + } + sort.Strings(g.ValueTokens) + seen := map[string]int{} + for i := 0; i+1 < len(toks); i++ { // rules first, so that their ids are dense and stable + if toks[i].kind == tkID && toks[i+1].kind == tkColon && (i == 0 || toks[i-1].kind != tkPrec) { + g.kindID(toks[i].text, seen) + } + } + var o strings.Builder + i := 0 + for i < len(toks) { + lhs := toks[i] + if lhs.kind != tkID || i+1 >= len(toks) || toks[i+1].kind != tkColon { + return nil, fmt.Errorf("grammar: expected rule head near %q", lhs.text) + } + i += 2 + g.Rules++ + fmt.Fprintf(&o, "%s:\n", lhs.text) + first := true + alt := 0 + for { + var syms []string + prec := "" + mids := 0 + for i < len(toks) { + t := toks[i] + if t.kind == tkBar || t.kind == tkSemi { + break + } + if t.kind == tkID && i+1 < len(toks) && toks[i+1].kind == tkColon { + break // next rule head; the previous rule had no ';' + } + i++ + switch t.kind { + case tkID, tkLit: + syms = append(syms, t.text) + case tkEmpty: + case tkPrec: + if i >= len(toks) { + return nil, fmt.Errorf("grammar: %%prec without a token in %s", lhs.text) + } + prec = " %prec " + toks[i].text + i++ + case tkAction: + // final if followed by '|' or ';' (or a rule head); otherwise a mid-rule action + if i < len(toks) && toks[i].kind != tkBar && toks[i].kind != tkSemi && + !(toks[i].kind == tkID && i+1 < len(toks) && toks[i+1].kind == tkColon) { + syms = append(syms, "{}") + mids++ + } + } + } + g.Alternatives++ + g.MidRuleActions += mids + var real, args []string + for k, x := range syms { + if x == "{}" { + continue + } + real = append(real, x) + if strings.HasPrefix(x, "'") || strings.HasPrefix(x, `"`) || tokens[x] { + switch { + case valueTokens[x]: + args = append(args, fmt.Sprintf("LS(YYTHD, %d, &@%d, &$%d.lexer.lex_str)", g.kindID(x, seen), k+1, k+1)) + case charsetTokens[x]: + args = append(args, fmt.Sprintf("LC(YYTHD, %d, &@%d, $%d.lexer.charset)", g.kindID(x, seen), k+1, k+1)) + default: + args = append(args, fmt.Sprintf("L(YYTHD, %d, &@%d)", g.kindID(x, seen), k+1)) + } + } else { + if _, ok := seen[x]; !ok { + return nil, fmt.Errorf("grammar: %s uses %s, which is neither a token nor a rule", lhs.text, x) + } + args = append(args, fmt.Sprintf("$%d.node", k+1)) + } + } + body := "%empty" + if len(real) > 0 { + body = strings.Join(syms, " ") + } + sep := "| " + if first { + sep = " " + } + call := fmt.Sprintf(`mk(YYTHD, %d /* %s */, %d, %d`, g.kindID(lhs.text, seen), lhs.text, alt, len(real)) + alt++ + if len(args) > 0 { + call += ", " + strings.Join(args, ", ") + } + call += ")" + out := "" + if lhs.text == start { + out = " *out = $$.node;" + } + fmt.Fprintf(&o, "%s%s%s { $$.node = %s;%s }\n", sep, body, prec, call, out) + first = false + if i < len(toks) && toks[i].kind == tkBar { + i++ + continue + } + if i < len(toks) && toks[i].kind == tkSemi { + i++ + } + break + } + o.WriteString(";\n\n") + } + g.Text = grammarPrologue + d + "\n%%\n\n" + o.String() + "\n%%\n" + return g, nil +} + +// cQuote renders a name as a C string literal (only ' " \ need care). +func cQuote(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + return `"` + s + `"` +} + +// KindsH is the C header naming every kind, for the CST builder and the probe driver. +func (g *Grammar) KindsH() string { + var b strings.Builder + b.WriteString("// Generated by parsegen from sql_yacc.yy: one name per CST node kind.\n#pragma once\n") + fmt.Fprintf(&b, "#define KIND_COUNT %d\n#define KIND_FIRST_TERMINAL %d\n", len(g.Kinds), g.Rules) + b.WriteString("static const char *const kind_names[KIND_COUNT] = {\n") + for _, k := range g.Kinds { + fmt.Fprintf(&b, " %s,\n", cQuote(k)) + } + b.WriteString("};\n") + return b.String() +} + +// KindsGo is the Go source naming every kind, for package mysqlparse. +func (g *Grammar) KindsGo(pkg string, version string) string { + var b strings.Builder + fmt.Fprintf(&b, "// Code generated by parsegen from MySQL %s sql_yacc.yy. DO NOT EDIT.\n\npackage %s\n\n", version, pkg) + fmt.Fprintf(&b, "// firstTerminal is the first kind that is a token rather than a rule.\nconst firstTerminal Kind = %d\n\n", g.Rules) + b.WriteString("var kindNames = [...]string{\n") + for _, k := range g.Kinds { + fmt.Fprintf(&b, "\t%s,\n", strconvQuote(k)) + } + b.WriteString("}\n") + return b.String() +} + +// ShapesGo is the Go source of the action table: for every (rule, alternative), what the +// server's action did with the children, as ReadActions read it. Package mysqlparse builds +// the AST from it. +func ShapesGo(pkg string, version string, kinds []string, alts []Alt, names *Names) string { + byRule := map[string][]Alt{} + for _, a := range alts { + byRule[a.Rule] = append(byRule[a.Rule], a) + } + var b strings.Builder + fmt.Fprintf(&b, "// Code generated by parsegen from MySQL %s sql_yacc.yy. DO NOT EDIT.\n\npackage %s\n\n", version, pkg) + b.WriteString("// shapes[rule kind][alternative] is what the server's semantic action built.\nvar shapes = [...][]Shape{\n") + for id, name := range kinds { + as, ok := byRule[name] + if !ok { + break // terminals follow the rules + } + fmt.Fprintf(&b, "\t%d: { // %s\n", id, name) + for _, a := range as { + fmt.Fprintf(&b, "\t\t{Syms: %s, Kind: Act%s", strconv.Quote(strings.Join(a.Syms, " ")), exportName(a.Kind.String())) + if a.Class != "" { + fmt.Fprintf(&b, ", Class: %s", strconv.Quote(a.Class)) + } + if a.Const != "" { + fmt.Fprintf(&b, ", Const: %s", strconv.Quote(a.Const)) + } + if names != nil && a.Kind == ActNew && a.Class != "" { + if params := names.ParamNames(a.Class, len(a.Args), HasPosArg(a)); params != nil { + b.WriteString(", Params: []string{") + for i, p := range params { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(strconv.Quote(p)) + } + b.WriteString("}") + } + } + if names != nil && a.Kind == ActStruct { + // positional fields ("0", "1") take the names of the rule's value type + if fields := names.StructFields(a.Rule); fields != nil { + for i := range a.Fields { + if k, err := strconv.Atoi(a.Fields[i].Name); err == nil && k < len(fields) { + a.Fields[i].Name = fields[k] + } + } + } + } + if len(a.Args) > 0 { + b.WriteString(", Args: []Arg{") + for i, g := range a.Args { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(argGo(g)) + } + b.WriteString("}") + } + if len(a.Fields) > 0 { + b.WriteString(", Fields: []Field{") + for i, f := range a.Fields { + if i > 0 { + b.WriteString(", ") + } + fmt.Fprintf(&b, "{Name: %s, Arg: Arg%s}", strconv.Quote(f.Name), argGo(f.Arg)) + } + b.WriteString("}") + } + b.WriteString("},\n") + } + b.WriteString("\t},\n") + } + b.WriteString("}\n") + return b.String() +} + +func argGo(g Arg) string { + switch { + case g.Text != "": + return fmt.Sprintf("{Text: %s}", strconv.Quote(g.Text)) + case g.Field != "": + return fmt.Sprintf("{Child: %d, Field: %s}", g.Child, strconv.Quote(g.Field)) + default: + return fmt.Sprintf("{Child: %d}", g.Child) + } +} + +// ViewsGo is the Go source of the typed views: one struct per parse-tree class the actions +// build, with an accessor per constructor parameter name, so that an analyzer reads +// `AsPTQuerySpecification(v).OptWhereClause()` instead of positions. +func ViewsGo(pkg string, version string, alts []Alt, names *Names) string { + type class struct { + name string + params []string + } + var classes []class + index := map[string]int{} + for _, a := range alts { + if a.Kind != ActNew || a.Class == "" { + continue + } + params := names.ParamNames(a.Class, len(a.Args), HasPosArg(a)) + if params == nil { + continue + } + i, ok := index[a.Class] + if !ok { + i = len(classes) + index[a.Class] = i + classes = append(classes, class{name: a.Class}) + } + // the parameters that stand for arguments the AST keeps (positions, the thread and + // the arena are dropped by the builder) + for k, g := range a.Args { + if isPositionArg(g) || k >= len(params) || params[k] == "" { + continue + } + if !contains(classes[i].params, params[k]) { + classes[i].params = append(classes[i].params, params[k]) + } + } + } + var b strings.Builder + fmt.Fprintf(&b, "// Code generated by parsegen from MySQL %s. DO NOT EDIT.\n\npackage %s\n\n", version, pkg) + b.WriteString("// The typed views: one per parse-tree class the server builds, an accessor per\n// constructor parameter. Class names and parameter names are the server's.\n\n") + for _, c := range classes { + g := goName(c.name) + fmt.Fprintf(&b, "// %s views a %s node.\ntype %s struct{ N *Node }\n\n", g, c.name, g) + fmt.Fprintf(&b, "// As%s returns the view when v is a %s.\nfunc As%s(v Value) (%s, bool) {\n\tn, ok := v.(*Node)\n\tif !ok || n.Class != %s {\n\t\treturn %s{}, false\n\t}\n\treturn %s{n}, true\n}\n\n", + g, c.name, g, g, strconv.Quote(c.name), g, g) + seen := map[string]bool{"N": true} + for _, p := range c.params { + m := goName(p) + for seen[m] { + m += "_" + } + seen[m] = true + fmt.Fprintf(&b, "func (x %s) %s() Value { return x.N.Arg(%s) }\n", g, m, strconv.Quote(p)) + } + b.WriteString("\n") + } + return b.String() +} + +func isPositionArg(g Arg) bool { + if g.Child != 0 { + return false + } + switch g.Text { + case "@$", "POS()", "YYTHD", "YYMEM_ROOT", "YYTHD->mem_root", "thd": + return true + } + return strings.HasPrefix(g.Text, "@") && len(g.Text) > 1 && g.Text[1] >= '0' && g.Text[1] <= '9' +} + +func contains(xs []string, s string) bool { + for _, x := range xs { + if x == s { + return true + } + } + return false +} + +// goName turns PT_query_specification into PTQuerySpecification and opt_where_clause into +// OptWhereClause. +func goName(s string) string { + var b strings.Builder + up := true + for _, r := range s { + if r == '_' { + up = true + continue + } + if up { + b.WriteString(strings.ToUpper(string(r))) + up = false + } else { + b.WriteRune(r) + } + } + out := b.String() + if out == "" || !(out[0] >= 'A' && out[0] <= 'Z') { + out = "X" + out + } + return out +} + +// exportName turns "list-append" into "ListAppend". +func exportName(s string) string { + var b strings.Builder + up := true + for _, r := range s { + if r == '-' { + up = true + continue + } + if up { + b.WriteString(strings.ToUpper(string(r))) + up = false + } else { + b.WriteRune(r) + } + } + return b.String() +} + +const grammarPrologue = `%{ +#include +#include +#include +#include "cst.h" +#define YYINITDEPTH 100 +#define YYMAXDEPTH 3200 +%} +%code requires { +#include "cst.h" +#include "sql/lexer_yystype.h" +#include "sql/parse_location.h" +union CST_STYPE { Node *node; Lexer_yystype lexer; }; +} +%define api.pure full +%define api.value.type { CST_STYPE } +%define api.location.type { MY_SQL_PARSER_LTYPE } +%locations +%parse-param { class THD *YYTHD } { Node **out } +%lex-param { class THD *YYTHD } +%code provides { +int my_sql_parser_lex(CST_STYPE *v, MY_SQL_PARSER_LTYPE *l, class THD *thd); +void my_sql_parser_error(MY_SQL_PARSER_LTYPE *l, class THD *thd, Node **out, const char *msg); +} +` + +var ( + reSectionSep = regexp.MustCompile(`(?m)^%%[ \t]*$`) + reTokenDecl = regexp.MustCompile(`(?m)^%token\s*(?:<[^>]*>)?\s+([^\n/]*)`) + reStart = regexp.MustCompile(`(?m)^%start\s+(\S+)`) + rePrologue = regexp.MustCompile(`(?s)%\{.*?%\}`) + reParseParam = regexp.MustCompile(`(?m)^%parse-param.*$`) + reLexParam = regexp.MustCompile(`(?m)^%lex-param.*$`) + reApiPure = regexp.MustCompile(`(?m)^%define api\.pure.*$`) + reTypeDecl = regexp.MustCompile(`(?m)^%type\b[^\n]*(\n[ \t]+[^\n%][^\n]*)*`) + reLexStrType = regexp.MustCompile(`(?m)^%type\s*((?:[^\n]*)(?:\n[ \t]+[^\n%][^\n]*)*)`) + reCharsetType = regexp.MustCompile(`(?m)^%type\s*((?:[^\n]*)(?:\n[ \t]+[^\n%][^\n]*)*)`) + reTaggedDecl = regexp.MustCompile(`(?m)^(%token|%left|%right|%nonassoc|%precedence)\s*<[^>]*>`) +) + +type tokKind int + +const ( + tkID tokKind = iota + tkLit + tkColon + tkBar + tkSemi + tkAction + tkPrec + tkEmpty +) + +type ruleTok struct { + kind tokKind + text string +} + +// lexRules tokenizes the rules section: identifiers, character/string +// literals, ':' '|' ';', brace-balanced actions, %prec and %empty. Comments and +// whitespace are dropped. +func lexRules(s string) ([]ruleTok, error) { + var out []ruleTok + n := len(s) + i := 0 + lineOf := func(pos int) int { return strings.Count(s[:pos], "\n") + 1 } + for i < n { + c := s[i] + switch { + case c == '/' && i+1 < n && s[i+1] == '*': + j := strings.Index(s[i+2:], "*/") + if j < 0 { + return nil, fmt.Errorf("grammar: unterminated comment at line %d", lineOf(i)) + } + i += 2 + j + 2 + case c == '/' && i+1 < n && s[i+1] == '/': + j := strings.IndexByte(s[i:], '\n') + if j < 0 { + j = n - i + } + i += j + case c == ' ' || c == '\t' || c == '\n' || c == '\r': + i++ + case c == '{': + j, err := matchBrace(s, i) + if err != nil { + return nil, fmt.Errorf("grammar: %v at line %d", err, lineOf(i)) + } + out = append(out, ruleTok{tkAction, s[i:j]}) + i = j + case c == '\'' || c == '"': + j := i + 1 + for j < n && s[j] != c { + if s[j] == '\\' { + j++ + } + j++ + } + if j >= n { + return nil, fmt.Errorf("grammar: unterminated literal at line %d", lineOf(i)) + } + out = append(out, ruleTok{tkLit, s[i : j+1]}) + i = j + 1 + case c == ':': + out = append(out, ruleTok{tkColon, ":"}) + i++ + case c == '|': + out = append(out, ruleTok{tkBar, "|"}) + i++ + case c == ';': + out = append(out, ruleTok{tkSemi, ";"}) + i++ + case c == '%': + switch { + case strings.HasPrefix(s[i:], "%prec") && !isIdentByte(at(s, i+5)): + out = append(out, ruleTok{tkPrec, "%prec"}) + i += 5 + case strings.HasPrefix(s[i:], "%empty") && !isIdentByte(at(s, i+6)): + out = append(out, ruleTok{tkEmpty, "%empty"}) + i += 6 + default: + return nil, fmt.Errorf("grammar: unknown %% directive in rules at line %d", lineOf(i)) + } + case isIdentStart(c): + j := i + for j < n && isIdentByte(s[j]) { + j++ + } + out = append(out, ruleTok{tkID, s[i:j]}) + i = j + default: + return nil, fmt.Errorf("grammar: unexpected %q at line %d", s[i:min(i+20, n)], lineOf(i)) + } + } + return out, nil +} + +// matchBrace returns the index just past the '}' closing the '{' at i, skipping +// C strings, chars and comments inside the action. +func matchBrace(s string, i int) (int, error) { + depth := 0 + n := len(s) + for j := i; j < n; j++ { + c := s[j] + switch { + case c == '/' && j+1 < n && s[j+1] == '*': + k := strings.Index(s[j+2:], "*/") + if k < 0 { + return 0, fmt.Errorf("unterminated comment in action") + } + j += 2 + k + 1 + case c == '/' && j+1 < n && s[j+1] == '/': + k := strings.IndexByte(s[j:], '\n') + if k < 0 { + return 0, fmt.Errorf("unterminated action") + } + j += k + case c == '"' || c == '\'': + k := j + 1 + for k < n && s[k] != c { + if s[k] == '\\' { + k++ + } + k++ + } + j = k + case c == '{': + depth++ + case c == '}': + depth-- + if depth == 0 { + return j + 1, nil + } + } + } + return 0, fmt.Errorf("unbalanced brace") +} + +func at(s string, i int) byte { + if i < len(s) { + return s[i] + } + return 0 +} + +func isIdentStart(c byte) bool { + return c == '_' || c == '.' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') +} + +func isIdentByte(c byte) bool { return isIdentStart(c) || (c >= '0' && c <= '9') } diff --git a/check/mysql/internal/parsegen/lexer.go b/check/mysql/internal/parsegen/lexer.go new file mode 100644 index 0000000..5907261 --- /dev/null +++ b/check/mysql/internal/parsegen/lexer.go @@ -0,0 +1,153 @@ +package parsegen + +import ( + "embed" + "fmt" + "regexp" + "strings" +) + +// cc holds the hand-written C++: the THD shim, the CST builder, the driver, +// and the head/tail files the server code is spliced between. +// +//go:embed cc +var cc embed.FS + +// Lexer is the server's lexer cut out of sql_lex.cc / sql_lex.h. +type Lexer struct { + // LexerCC is lexer.cc: head + Lex_input_stream::init/reset + shims + the token functions. + LexerCC string + // SQLLexH replaces sql/sql_lex.h: enum_comment_state + class Lex_input_stream + the shim's Parser_state. + SQLLexH string +} + +// CutLexer extracts the lexer. Every cut is anchored on a signature; a missing +// anchor is an error, never an empty output. +func CutLexer(sqlLexCC, sqlLexH string) (*Lexer, error) { + head, err := ccFile("cc/lexer_head.cc") + if err != nil { + return nil, err + } + shims, err := ccFile("cc/lexer_shims.cc") + if err != nil { + return nil, err + } + hHead, err := ccFile("cc/shim/sql/sql_lex_head.h") + if err != nil { + return nil, err + } + hTail, err := ccFile("cc/shim/sql/sql_lex_tail.h") + if err != nil { + return nil, err + } + + initFn, err := cutFunction(sqlLexCC, "bool Lex_input_stream::init(THD *thd") + if err != nil { + return nil, err + } + resetFn, err := cutFunction(sqlLexCC, "void Lex_input_stream::reset(const char *buffer") + if err != nil { + return nil, err + } + body, err := cutBetweenLines(sqlLexCC, "static int find_keyword(Lex_input_stream *lip", "void trim_whitespace(") + if err != nil { + return nil, err + } + // The special-purpose parser states (partition, gcol, ...) are constructed in + // the same stretch; they belong to the server's Parser_state hierarchy, not + // to the lexer. + ctors := reParserStateCtor.FindAllStringIndex(body, -1) + if len(ctors) == 0 { + return nil, fmt.Errorf("lexer: expected the *_parser_state constructors inside find_keyword..trim_whitespace; the server source moved") + } + body = reParserStateCtor.ReplaceAllString(body, "") + + commentState, err := cutBetweenLines(sqlLexH, "enum enum_comment_state {", "class Lex_input_stream {") + if err != nil { + return nil, err + } + class, err := cutClass(sqlLexH, "class Lex_input_stream {") + if err != nil { + return nil, err + } + + var l strings.Builder + l.WriteString(head) + l.WriteString("// --- sql_lex.cc: Lex_input_stream::init / reset\n") + l.WriteString(initFn) + l.WriteString(resetFn) + l.WriteString(shims) + l.WriteString("// --- sql_lex.cc: find_keyword .. lex_one_token\n") + l.WriteString(body) + + var h strings.Builder + h.WriteString(hHead) + h.WriteString(commentState) + h.WriteString(class) + h.WriteString(hTail) + return &Lexer{LexerCC: l.String(), SQLLexH: h.String()}, nil +} + +var reParserStateCtor = regexp.MustCompile(`(?m)^[A-Za-z_]*_parser_state::[A-Za-z_]*_parser_state\(\)\n : Parser_state\(.*\) \{\}\n`) + +func ccFile(name string) (string, error) { + b, err := cc.ReadFile(name) + if err != nil { + return "", fmt.Errorf("embedded %s: %w", name, err) + } + return string(b), nil +} + +// lineStart returns the offset of the first line beginning with prefix. +func lineStart(src, prefix string) (int, error) { + if strings.HasPrefix(src, prefix) { + return 0, nil + } + i := strings.Index(src, "\n"+prefix) + if i < 0 { + return 0, fmt.Errorf("anchor %q not found; the server source moved", prefix) + } + return i + 1, nil +} + +// cutFunction returns the lines from the one starting with sig through the +// first line that is exactly "}". +func cutFunction(src, sig string) (string, error) { + s, err := lineStart(src, sig) + if err != nil { + return "", err + } + e := strings.Index(src[s:], "\n}\n") + if e < 0 { + return "", fmt.Errorf("anchor %q: no closing brace line", sig) + } + return src[s : s+e+3], nil +} + +// cutClass returns the lines from the one starting with sig through the first +// line that is exactly "};". +func cutClass(src, sig string) (string, error) { + s, err := lineStart(src, sig) + if err != nil { + return "", err + } + e := strings.Index(src[s:], "\n};\n") + if e < 0 { + return "", fmt.Errorf("anchor %q: no closing brace line", sig) + } + return src[s : s+e+4], nil +} + +// cutBetweenLines returns the lines from the one starting with from up to, and +// excluding, the one starting with to. +func cutBetweenLines(src, from, to string) (string, error) { + s, err := lineStart(src, from) + if err != nil { + return "", err + } + e, err := lineStart(src[s:], to) + if err != nil { + return "", fmt.Errorf("after %q: %w", from, err) + } + return src[s : s+e], nil +} diff --git a/check/mysql/internal/parsegen/parsegen_test.go b/check/mysql/internal/parsegen/parsegen_test.go new file mode 100644 index 0000000..aeceeaa --- /dev/null +++ b/check/mysql/internal/parsegen/parsegen_test.go @@ -0,0 +1,178 @@ +package parsegen + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestStripGrammarSmall(t *testing.T) { + src := `%{ +#include "server.h" +%} +%start stmt +%parse-param { class THD *YYTHD } +%define api.pure +%token SELECT_SYM 258 +%token NUM 259 +%type stmt expr +%left '+' +%% +stmt: + SELECT_SYM expr ';' { $$= NEW_PTN PT_select($2); } + | %empty { } + ; +expr: + NUM { $$= $1; } + | expr '+' expr %prec '+' { $$= NEW_PTN Item_plus(@$, $1, $3); } + | NUM { mid(); } NUM { $$= nullptr; } + +trailing: + NUM +%% +epilogue(); +` + g, err := StripGrammar(src) + if err != nil { + t.Fatal(err) + } + if g.Rules != 3 || g.Alternatives != 6 || g.MidRuleActions != 1 || g.Start != "stmt" { + t.Fatalf("counts: %+v", g) + } + // kinds: the rules in definition order, then the terminals in order of first use + if got := strings.Join(g.Kinds, " "); got != "stmt expr trailing SELECT_SYM ';' NUM '+'" { + t.Errorf("kinds %q", got) + } + for _, want := range []string{ + `%token SELECT_SYM 258`, + "\n%%\n", + ` SELECT_SYM expr ';' { $$.node = mk(YYTHD, 0 /* stmt */, 0, 3, L(YYTHD, 3, &@1), $2.node, L(YYTHD, 4, &@3)); *out = $$.node; }`, + `| %empty { $$.node = mk(YYTHD, 0 /* stmt */, 1, 0); *out = $$.node; }`, + `| expr '+' expr %prec '+' { $$.node = mk(YYTHD, 1 /* expr */, 1, 3, $1.node, L(YYTHD, 6, &@2), $3.node); }`, + `| NUM {} NUM { $$.node = mk(YYTHD, 1 /* expr */, 2, 2, L(YYTHD, 5, &@1), L(YYTHD, 5, &@3)); }`, + "trailing:\n NUM { $$.node = mk(YYTHD, 2 /* trailing */, 0, 1, L(YYTHD, 5, &@1)); }\n;", + } { + if !strings.Contains(g.Text, want) { + t.Errorf("missing %q in\n%s", want, g.Text) + } + } + for _, gone := range []string{"server.h", "%type", "NEW_PTN", "epilogue", "%parse-param { class THD *YYTHD }\n", "api.pure "} { + if strings.Contains(strings.TrimPrefix(g.Text, grammarPrologue), gone) { + t.Errorf("%q should have been stripped", gone) + } + } +} + +// The alternative index the generated actions record must be the one ReadActions assigns. +func TestAlternativeIndexAgree(t *testing.T) { + src := "%start s\n%token A B\n%%\ns: A { $$= $1; } | B x { $$= NEW_PTN PT_x(@$, $2); } | %empty ;\nx: A\n | B { $$= nullptr; }\n%%\n" + g, err := StripGrammar(src) + if err != nil { + t.Fatal(err) + } + alts, err := ReadActions(src) + if err != nil { + t.Fatal(err) + } + want := []string{"s/0/pass", "s/1/new", "s/2/default", "x/0/default", "x/1/empty"} + var got []string + for _, a := range alts { + got = append(got, fmt.Sprintf("%s/%d/%s", a.Rule, a.Index, a.Kind)) + } + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Errorf("alts %v", got) + } + for _, m := range []string{"mk(YYTHD, 0 /* s */, 0, 1,", "mk(YYTHD, 0 /* s */, 1, 2,", "mk(YYTHD, 0 /* s */, 2, 0)", "mk(YYTHD, 1 /* x */, 0, 1,", "mk(YYTHD, 1 /* x */, 1, 1,"} { + if !strings.Contains(g.Text, m) { + t.Errorf("grammar lacks %q", m) + } + } + if g.Alternatives != len(alts) { + t.Errorf("StripGrammar saw %d alternatives, ReadActions %d", g.Alternatives, len(alts)) + } +} + +func TestSplitTest(t *testing.T) { + src := strings.Join([]string{ + "# comment", + "--disable_warnings", + "DROP TABLE IF EXISTS t1; # trailing comment", + "--error ER_PARSE_ERROR", + "SELECT 1 +;", + "let $x = 3;", + "eval SELECT $x;", + "SELECT 'a;b', \"c;d\", `e;f`; SELECT 2;", + "DELIMITER //;", + "CREATE PROCEDURE p() BEGIN SELECT 1; END//", + "DELIMITER ;//", + "--error 1064", + "connect (con1,localhost,root,,);", + "SELECT 3;", + "perl;", + "print 'x;';", + "EOF", + "SELECT /* c ; d */ 4", + " ;", + }, "\n") + stmts, total := splitTest("a.test", src) + if total != 7 { + t.Fatalf("total=%d", total) + } + got := make([]string, len(stmts)) + for i, s := range stmts { + got[i] = s.Header() + " " + strings.ReplaceAll(s.SQL, "\n", " ") + } + want := []string{ + "/* a.test:3 */ DROP TABLE IF EXISTS t1", + "/* a.test:5 expect=ER_PARSE_ERROR */ SELECT 1 +", + "/* a.test:8 */ SELECT 'a;b', \"c;d\", `e;f`", + "/* a.test:8 */ SELECT 2", + "/* a.test:10 */ CREATE PROCEDURE p() BEGIN SELECT 1; END", + "/* a.test:14 */ SELECT 3", + "/* a.test:18 */ SELECT /* c ; d */ 4", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("got\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +func TestCutLexerAnchors(t *testing.T) { + _, err := CutLexer("nothing here\n", "nothing here\n") + if err == nil || !strings.Contains(err.Error(), "anchor") { + t.Fatalf("expected an anchor error, got %v", err) + } +} + +// TestGenerateFromServerSource needs the MySQL source checkout the spike used. +func TestGenerateFromServerSource(t *testing.T) { + home, _ := os.UserHomeDir() + src := filepath.Join(home, ".cache", "sqlshape", "mysql-server") + if _, err := os.Stat(filepath.Join(src, "sql", "sql_yacc.yy")); err != nil { + t.Skip("no MySQL source at", src) + } + b := &Build{Src: src, Out: t.TempDir()} + g, err := b.Generate() + if err != nil { + t.Fatal(err) + } + v, _ := ReadVersion(src) + if v.String() == "8.4.6" && (g.Rules != 945 || g.Alternatives != 3125 || g.MidRuleActions != 67 || len(g.Kinds) != 1721) { + t.Errorf("8.4.6 counts changed: %+v", g) + } + for _, f := range []string{"grammar.y", "kinds.h", "lexer.cc", "shim/sql/sql_lex.h", "shim/sql/thd_shim.h", "main.cc", "parse.cc", "cst.cc"} { + if _, err := os.Stat(filepath.Join(b.Out, f)); err != nil { + t.Error(err) + } + } + lex, _ := os.ReadFile(filepath.Join(b.Out, "lexer.cc")) + for _, want := range []string{"static int find_keyword(", "static int lex_one_token(", "int my_sql_parser_lex(", "void Lex_input_stream::reset("} { + if !strings.Contains(string(lex), want) { + t.Errorf("lexer.cc lacks %s", want) + } + } + if strings.Contains(string(lex), "_parser_state::") { + t.Error("lexer.cc still carries the *_parser_state constructors") + } +} diff --git a/check/mysql/internal/parsegen/sysvars.go b/check/mysql/internal/parsegen/sysvars.go new file mode 100644 index 0000000..d682dfb --- /dev/null +++ b/check/mysql/internal/parsegen/sysvars.go @@ -0,0 +1,298 @@ +package parsegen + +// The system variable scope table: which names `@@session.x` / `@@global.x` may read +// (sql/sys_vars.cc's declarations -- the server refuses the wrong scope with 1238, +// "Variable 'x' is a GLOBAL variable" / "... is a SESSION variable"). Each `static +// Sys_var_* name(...)` declaration carries its scope as the SESSION_VAR / GLOBAL_VAR / +// SESSION_ONLY macro (sys_vars.h) or the raw sys_var::ONLY_SESSION / sys_var::GLOBAL +// constant; the handful of classes that fix the scope in their own constructor +// (Sys_var_gtid_executed and friends, sys_vars.h) are listed here by class. A +// Sys_var_deprecated_alias("old", Sys_new) takes the aliased declaration's scope. +// +// Declarations under a preprocessor condition that a release Linux server does not +// compile (#ifndef NDEBUG's debug, ENABLED_DEBUG_SYNC's debug_sync, _WIN32's named +// pipes) are left out: the generated table only says what every stock server has, and +// a name outside the table is not judged (a plugin's or component's variable, say). +// TestSysVarScopeServer pins every generated entry against mysqld. + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// SysVarScope is one variable's scope: 's' session-only, 'g' global-only, 'b' both. +type SysVarScope byte + +// sysVarClassScope fixes the scope of the classes whose constructor sets it without a +// scope macro in the declaration (sys_vars.h's own class definitions). +var sysVarClassScope = map[string]SysVarScope{ + "Sys_var_gtid_executed": 'g', // Sys_var_gtid_executed's ctor: GLOBAL + "Sys_var_gtid_owned": 'b', // Sys_var_gtid_owned's ctor: SESSION (both scopes) + "Sys_var_proxy_user": 's', // Sys_var_proxy_user's ctor: ONLY_SESSION + "Sys_var_external_user": 's', // Sys_var_external_user extends Sys_var_proxy_user + "Sys_var_test_flag": 'g', // Sys_var_test_flag's ctor: READ_ONLY GLOBAL_VAR + "Sys_var_system_time_zone": 'g', // Sys_var_system_time_zone's ctor: GLOBAL +} + +// sysVarDefined is the preprocessor state of a stock release Linux build: the symbols a +// release server defines (NDEBUG among them) and, by absence, the ones it does not +// (_WIN32, WITH_LOCK_ORDER, ENABLED_DEBUG_SYNC, WITH_HYPERGRAPH_OPTIMIZER, HAVE_UBSAN). +var sysVarDefined = map[string]bool{ + "NDEBUG": true, + "HAVE_SYS_TIME_H": true, + "HAVE_UNISTD_H": true, + "HAVE_MLOCKALL": true, + "WITH_PERFSCHEMA_STORAGE_ENGINE": true, + "MYSQL_ICU_DATADIR": true, + "HAVE_BUILD_ID_SUPPORT": true, +} + +var ( + sysVarDecl = regexp.MustCompile(`(?m)^static\s+(Sys_var_\w+)(?:<[^>]*>)?\s+(\w+)\s*\(`) + sysVarString = regexp.MustCompile(`"((?:[^"\\]|\\.)*)"`) + sysVarPP = regexp.MustCompile(`^\s*#\s*(if|ifdef|ifndef|elif|else|endif)\b(.*)$`) +) + +// ReadSysVars reads the server's system variable declarations into name -> scope. +func ReadSysVars(src string) (map[string]SysVarScope, error) { + out := map[string]SysVarScope{} + type alias struct { + name string // the alias's own variable name + base string // the Sys_* identifier it forwards to + } + var aliases []alias + var unclassified []string + byIdent := map[string]string{} // Sys_* identifier -> variable name + // a handful of declarations name the variable through a macro (set_var.h's + // PERSISTED_GLOBALS_LOAD and friends) rather than a string literal + nameMacros := map[string]string{} + if setVar, err := os.ReadFile(filepath.Join(src, "sql", "set_var.h")); err == nil { + for _, m := range regexp.MustCompile(`(?m)^#define\s+(\w+)\s+"([a-z0-9_]+)"`).FindAllStringSubmatch(string(setVar), -1) { + nameMacros[m[1]] = m[2] + } + } + for _, file := range []string{"sys_vars.cc", "ssl_init_callback.cc"} { + text, err := os.ReadFile(filepath.Join(src, "sql", file)) + if err != nil { + return nil, err + } + lines := strings.Split(string(text), "\n") + // live[i] reports whether line i is compiled under sysVarDefined. + live := ppLive(lines) + body := string(text) + offsets := lineOffsets(body) + for _, m := range sysVarDecl.FindAllStringSubmatchIndex(body, -1) { + class := body[m[2]:m[3]] + line := sort.Search(len(offsets), func(i int) bool { return offsets[i] > m[0] }) - 1 + if !live[line] { + continue + } + decl := declText(body, m[1]-1) // from the opening parenthesis + if strings.Contains(decl, "NOT_VISIBLE") { + continue // a command-line-only setting: @@ cannot read it (1193) + } + // the name is the first argument: a string literal, or a macro + // (nameMacros) that expands to one + first := strings.TrimLeft(decl[1:], " \n\t") + var name string + if strings.HasPrefix(first, `"`) { + names := sysVarString.FindAllStringSubmatch(decl, 1) + if len(names) == 0 { + continue + } + name = strings.ToLower(names[0][1]) + } else if mac := regexp.MustCompile(`^\w+`).FindString(first); nameMacros[mac] != "" { + name = nameMacros[mac] + } else { + unclassified = append(unclassified, fmt.Sprintf("%s %s: unreadable name argument %.30q", class, body[m[4]:m[5]], first)) + continue + } + ident := body[m[4]:m[5]] + byIdent[ident] = name + if class == "Sys_var_deprecated_alias" { + // ("old_name", Sys_base): the base identifier is the second argument + parts := strings.SplitN(decl, ",", 3) + if len(parts) >= 2 { + aliases = append(aliases, alias{name: name, base: strings.TrimFunc(parts[1], func(r rune) bool { + return r == ' ' || r == '\n' || r == ')' || r == ';' || r == '\t' + })}) + } + continue + } + scope, ok := declScope(class, decl) + if !ok { + unclassified = append(unclassified, fmt.Sprintf("%s %s (%s)", class, ident, name)) + continue + } + out[name] = scope + } + } + if len(unclassified) > 0 { + return nil, fmt.Errorf("sysvars: no scope marker and no class default:\n %s", strings.Join(unclassified, "\n ")) + } + for _, al := range aliases { + base, ok := byIdent[al.base] + if !ok { + return nil, fmt.Errorf("sysvars: alias %s forwards to unknown identifier %s", al.name, al.base) + } + scope, ok := out[base] + if !ok { + return nil, fmt.Errorf("sysvars: alias %s forwards to %s, which has no scope", al.name, base) + } + out[al.name] = scope + } + return out, nil +} + +// declScope reads one declaration's scope marker. +func declScope(class, decl string) (SysVarScope, bool) { + switch { + case strings.Contains(decl, "SESSION_ONLY(") || strings.Contains(decl, "sys_var::ONLY_SESSION"): + return 's', true + case strings.Contains(decl, "SESSION_VAR("): + return 'b', true + case strings.Contains(decl, "GLOBAL_VAR(") || strings.Contains(decl, "KEYCACHE_VAR(") || regexp.MustCompile(`sys_var::GLOBAL\b`).MatchString(decl): + return 'g', true + } + s, ok := sysVarClassScope[class] + return s, ok +} + +// declText returns the declaration's text from its opening parenthesis to the matching +// close, skipping string literals (a ')' inside one does not count). +func declText(body string, open int) string { + depth := 0 + for i := open; i < len(body); i++ { + switch body[i] { + case '"': + for i++; i < len(body) && body[i] != '"'; i++ { + if body[i] == '\\' { + i++ + } + } + case '(': + depth++ + case ')': + depth-- + if depth == 0 { + return body[open : i+1] + } + } + } + return body[open:] +} + +// lineOffsets is each line's byte offset. +func lineOffsets(body string) []int { + offs := []int{0} + for i := 0; i < len(body); i++ { + if body[i] == '\n' { + offs = append(offs, i+1) + } + } + return offs +} + +// ppLive walks the preprocessor conditionals and reports, per line, whether a release +// Linux build compiles it. A condition it cannot read (an arithmetic #if) is taken as +// live on both branches' declarations being rare enough for the server pin to catch. +func ppLive(lines []string) []bool { + type frame struct { + live bool // this branch is compiled (under the enclosing frames) + taken bool // some branch of this #if was already taken + known bool // the condition was readable; unknown conditions keep both branches live + } + live := make([]bool, len(lines)) + var stack []frame + cur := func() bool { + for _, f := range stack { + if !f.live { + return false + } + } + return true + } + for i, ln := range lines { + m := sysVarPP.FindStringSubmatch(ln) + if m == nil { + live[i] = cur() + continue + } + live[i] = false // the directive line itself holds no declaration + switch m[1] { + case "if", "ifdef", "ifndef": + v, known := ppEval(m[1], m[2]) + stack = append(stack, frame{live: !known || v, taken: known && v, known: known}) + case "elif": + if len(stack) > 0 { + f := &stack[len(stack)-1] + v, known := ppEval("if", m[2]) + if !f.known || !known { + f.live, f.known = true, false + break + } + f.live = !f.taken && v + f.taken = f.taken || v + } + case "else": + if len(stack) > 0 { + f := &stack[len(stack)-1] + if f.known { + f.live = !f.taken + } + } + case "endif": + if len(stack) > 0 { + stack = stack[:len(stack)-1] + } + } + } + return live +} + +// ppEval reads an #if / #ifdef / #ifndef condition against sysVarDefined; ok is false +// for an expression it does not understand. +func ppEval(kind, cond string) (value, ok bool) { + cond = strings.TrimSpace(strings.SplitN(cond, "//", 2)[0]) + if i := strings.Index(cond, "/*"); i >= 0 { + cond = strings.TrimSpace(cond[:i]) + } + switch kind { + case "ifdef": + return sysVarDefined[cond], true + case "ifndef": + return !sysVarDefined[cond], true + } + if m := regexp.MustCompile(`^!?\s*defined\s*\(\s*(\w+)\s*\)$`).FindStringSubmatch(cond); m != nil { + v := sysVarDefined[m[1]] + if strings.HasPrefix(cond, "!") { + v = !v + } + return v, true + } + if m := regexp.MustCompile(`^defined\s*\(\s*(\w+)\s*\)\s*&&\s*defined\s*\(\s*(\w+)\s*\)$`).FindStringSubmatch(cond); m != nil { + return sysVarDefined[m[1]] && sysVarDefined[m[2]], true + } + return false, false +} + +// SysVarsGo renders the generated catalog table. +func SysVarsGo(pkg, version string, vars map[string]SysVarScope) string { + names := make([]string, 0, len(vars)) + for n := range vars { + names = append(names, n) + } + sort.Strings(names) + var b strings.Builder + fmt.Fprintf(&b, "// Code generated from MySQL %s (sql/sys_vars.cc) by parsegen. DO NOT EDIT.\n\npackage %s\n\n", version, pkg) + b.WriteString("// SysVars is each system variable's scope: 's' session-only, 'g' global-only,\n// 'b' both. A name outside the map (a plugin's or component's variable) is not judged.\nvar SysVars = map[string]byte{\n") + for _, n := range names { + fmt.Fprintf(&b, "\t%q: '%c',\n", n, vars[n]) + } + b.WriteString("}\n") + return b.String() +} diff --git a/check/mysql/internal/parsegen/testdata/smoke.sql b/check/mysql/internal/parsegen/testdata/smoke.sql new file mode 100644 index 0000000..4ea5fb9 --- /dev/null +++ b/check/mysql/internal/parsegen/testdata/smoke.sql @@ -0,0 +1,18 @@ +SELECT 1 +;; +SELECT a, b FROM t WHERE c = 1 AND d IN (SELECT e FROM u) ORDER BY a LIMIT 10 +;; +INSERT INTO t (a, b) VALUES (1, "x"), (2, `y`) +;; +CREATE TABLE t (id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255) NOT NULL, created DATETIME DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB +;; +SELECT /*+ NO_INDEX(t) */ * FROM t +;; +SELECT * FROM t WHERE json_col->>"$.a" = 1 +;; +SELECT 1 + +;; +WITH RECURSIVE cte AS (SELECT 1 AS n UNION ALL SELECT n+1 FROM cte WHERE n < 10) SELECT * FROM cte +;; +UPDATE t SET a = a + 1 WHERE id = ? +;; diff --git a/check/mysql/internal/parsegen/toolchain.go b/check/mysql/internal/parsegen/toolchain.go new file mode 100644 index 0000000..f11ec2b --- /dev/null +++ b/check/mysql/internal/parsegen/toolchain.go @@ -0,0 +1,359 @@ +package parsegen + +import ( + "fmt" + "go/format" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strconv" + "strings" + "sync" +) + +// Build describes one generation run. +type Build struct { + Src string // MySQL server source tree (sparse checkout is enough) + Out string // output directory; library objects are cached under Out/obj and Out/wobj + Wasm bool // also build Out/wasm/mysqlparse.wasm with em++ + // Pkg is the directory of Go package mysqlparse; when set, Generate writes kinds.go + // there and Compile (with Wasm) installs the module as wasm/mysqlparse_.wasm. + Pkg string + // ASTPkg is the directory of Go package mysqlast; when set, Generate writes views.go there. + ASTPkg string + // CatPkg is the directory of Go package catalog; when set, Generate writes functions.go there. + CatPkg string + Log func(format string, args ...any) +} + +// Wasm exports: the parse API (parse.h) and the allocator the host uses for its input. +var wasmExports = []string{"_malloc", "_free", "_mysqlparse_init", "_mysqlparse_parse", "_mysqlparse_error", + "_mysqlparse_cursor", "_mysqlparse_data", "_mysqlparse_len", "_mysqlparse_free", + "_mysqlparse_kind_name", "_mysqlparse_kind_count"} + +// Version is the server version read from MYSQL_VERSION. +type Version struct{ Major, Minor, Patch int } + +func (v Version) ID() int { return v.Major*10000 + v.Minor*100 + v.Patch } +func (v Version) String() string { return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) } +func (b *Build) log(f string, a ...any) { + if b.Log != nil { + b.Log(f, a...) + } +} + +// ReadVersion parses MYSQL_VERSION at the root of the server source. +func ReadVersion(src string) (Version, error) { + b, err := os.ReadFile(filepath.Join(src, "MYSQL_VERSION")) + if err != nil { + return Version{}, err + } + var v Version + for line := range strings.SplitSeq(string(b), "\n") { + k, val, ok := strings.Cut(strings.TrimSpace(line), "=") + if !ok { + continue + } + n, _ := strconv.Atoi(val) + switch k { + case "MYSQL_VERSION_MAJOR": + v.Major = n + case "MYSQL_VERSION_MINOR": + v.Minor = n + case "MYSQL_VERSION_PATCH": + v.Patch = n + } + } + if v.Major == 0 { + return Version{}, fmt.Errorf("MYSQL_VERSION: no MYSQL_VERSION_MAJOR") + } + return v, nil +} + +// Generate writes every generated and hand-written source into Out and returns +// the grammar counts. It runs no compiler. +func (b *Build) Generate() (*Grammar, error) { + ver, err := ReadVersion(b.Src) + if err != nil { + return nil, err + } + yacc, err := os.ReadFile(filepath.Join(b.Src, "sql", "sql_yacc.yy")) + if err != nil { + return nil, err + } + g, err := StripGrammar(string(yacc)) + if err != nil { + return nil, err + } + lexCC, err := os.ReadFile(filepath.Join(b.Src, "sql", "sql_lex.cc")) + if err != nil { + return nil, err + } + lexH, err := os.ReadFile(filepath.Join(b.Src, "sql", "sql_lex.h")) + if err != nil { + return nil, err + } + lx, err := CutLexer(string(lexCC), string(lexH)) + if err != nil { + return nil, err + } + + // hand-written C++ (everything under cc/ except the head/tail pieces spliced above) + err = fs.WalkDir(cc, "cc", func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + base := filepath.Base(p) + switch base { + case "lexer_head.cc", "lexer_shims.cc", "sql_lex_head.h", "sql_lex_tail.h": + return nil + } + data, err := cc.ReadFile(p) + if err != nil { + return err + } + return writeFile(filepath.Join(b.Out, strings.TrimPrefix(p, "cc/")), data) + }) + if err != nil { + return nil, err + } + files := map[string]string{ + "grammar.y": g.Text, + "kinds.h": g.KindsH(), + "lexer.cc": lx.LexerCC, + "shim/sql/sql_lex.h": lx.SQLLexH, + "shim/mysql_version.h": fmt.Sprintf("#pragma once\n#define MYSQL_VERSION_ID %d\n#define MYSQL_SERVER_VERSION %q\n", ver.ID(), ver.String()), + } + for name, text := range files { + if err := writeFile(filepath.Join(b.Out, name), []byte(text)); err != nil { + return nil, err + } + } + if b.Pkg != "" { + if err := writeFile(filepath.Join(b.Pkg, "kinds.go"), []byte(g.KindsGo("mysqlparse", ver.String()))); err != nil { + return nil, err + } + alts, err := ReadActions(string(yacc)) + if err != nil { + return nil, err + } + if len(alts) != g.Alternatives { + return nil, fmt.Errorf("actions: read %d alternatives, the grammar has %d", len(alts), g.Alternatives) + } + names, err := ReadNames(b.Src, string(yacc), alts) + if err != nil { + return nil, err + } + b.log("%s", strings.TrimSpace(strings.SplitN(names.Report(), "\n", 2)[0])) + if err := writeFile(filepath.Join(b.Pkg, "shapes.go"), []byte(ShapesGo("mysqlparse", ver.String(), g.Kinds, alts, names))); err != nil { + return nil, err + } + if b.ASTPkg != "" { + if err := writeFile(filepath.Join(b.ASTPkg, "views.go"), []byte(ViewsGo("mysqlast", ver.String(), alts, names))); err != nil { + return nil, err + } + } + if b.CatPkg != "" { + cat, err := ReadCatalog(b.Src) + if err != nil { + return nil, err + } + var grammarClasses []string + for _, a := range alts { + if a.Kind == ActNew && strings.HasPrefix(a.Class, "Item_") { + grammarClasses = append(grammarClasses, a.Class) + } + } + // the Item classes only mysqlast's hand-written hooks build (their actions are + // ActUnknown here, so the loop above never sees them); the catalog still needs + // their base chains and resolve facts + grammarClasses = append(grammarClasses, + "Item_func_in", "Item_sum_json_array", "Item_sum_json_object", "Item_lead_lag", "Item_nth_value") + if err := writeFile(filepath.Join(b.CatPkg, "functions.go"), []byte(CatalogGo("catalog", ver.String(), cat, grammarClasses))); err != nil { + return nil, err + } + b.log("%s", strings.SplitN(cat.Report(), "\n", 2)[0]) + } + } + b.log("generated for MySQL %s: rules=%d alternatives=%d mid-rule-actions=%d kinds=%d", ver, g.Rules, g.Alternatives, g.MidRuleActions, len(g.Kinds)) + return g, nil +} + +// Compile runs the toolchain over Generate's output: bison for the grammar and +// the hint tokens, the server's gen_lex_hash and uca9dump, then g++ (and em++). +func (b *Build) Compile() error { + out := b.Out + src := b.Src + inc := []string{"-I" + out, "-I" + filepath.Join(out, "shim"), "-I" + filepath.Join(out, "gen"), + "-I" + filepath.Join(src, "include"), "-I" + src, "-I" + filepath.Join(src, "strings")} + cxxflags := append([]string{"-std=c++20", "-DNDEBUG", "-O2", "-w"}, inc...) + gen := filepath.Join(out, "gen") + for _, d := range []string{filepath.Join(gen, "sql"), filepath.Join(gen, "strings"), filepath.Join(out, "wasm")} { + if err := os.MkdirAll(d, 0o755); err != nil { + return err + } + } + + // 1. grammar → parser.cc / parser.h. bison itself checks %expect. + if err := b.run(out, "bison", "-Wnone", "--defines=parser.h", "-o", "parser.cc", "grammar.y"); err != nil { + return err + } + // 2. hint tokens header (lex.h includes it), then the keyword hash from the server's generator + if err := b.run(out, "bison", "-Wnone", "--defines="+filepath.Join(gen, "sql", "sql_hints.yy.h"), "-o", os.DevNull, filepath.Join(src, "sql", "sql_hints.yy")); err != nil { + return err + } + if err := b.run(out, "g++", append(cxxflags, "-o", filepath.Join(gen, "gen_lex_hash"), filepath.Join(src, "sql", "gen_lex_hash.cc"))...); err != nil { + return err + } + if err := b.runTo(filepath.Join(gen, "sql", "lex_hash.h"), out, filepath.Join(gen, "gen_lex_hash")); err != nil { + return err + } + // 3. the two generated UCA han tables + if err := b.run(out, "g++", append(cxxflags, "-o", filepath.Join(gen, "uca9dump"), filepath.Join(src, "strings", "uca9-dump.cc"))...); err != nil { + return err + } + for _, lang := range []string{"ja", "zh"} { + dst := filepath.Join(gen, "strings", "uca900_"+lang+"_tbls.cc") + if _, err := os.Stat(dst); err == nil { + continue + } + if err := b.run(out, filepath.Join(gen, "uca9dump"), lang, "--in_file="+filepath.Join(src, "strings", "lang_data", lang+"_hans.txt"), "--out_file="+dst); err != nil { + return err + } + } + // 4. the charset library: every compiled collation the server has + lib := []string{filepath.Join(src, "sql", "sql_lex_hash.cc"), filepath.Join(gen, "strings", "uca900_ja_tbls.cc"), filepath.Join(gen, "strings", "uca900_zh_tbls.cc")} + for _, pat := range []string{"ctype*.cc", "collations*.cc"} { + m, err := filepath.Glob(filepath.Join(src, "strings", pat)) + if err != nil { + return err + } + sort.Strings(m) + lib = append(lib, m...) + } + for _, f := range []string{"xml.cc", "int2str.cc", "my_strchr.cc", "str_alloc.cc", "sql_chars.cc", "my_uctype.cc", "my_strtoll10.cc", "dtoa.cc"} { + lib = append(lib, filepath.Join(src, "strings", f)) + } + ours := []string{"parse.cc", "cst.cc", "lexer.cc", "parser.cc"} + + // the native probe driver + if err := b.compile("g++", cxxflags, nil, lib, append([]string{"main.cc"}, ours...), filepath.Join(out, "obj"), filepath.Join(out, "mysqlparse")); err != nil { + return err + } + if b.Wasm { + wasm := filepath.Join(out, "wasm", "mysqlparse.wasm") + link := []string{"--no-entry", "-sEXPORTED_FUNCTIONS=" + strings.Join(wasmExports, ","), + "-sALLOW_MEMORY_GROWTH=1", "-sSTACK_SIZE=4194304", "-sERROR_ON_UNDEFINED_SYMBOLS=1"} + if err := b.compile("em++", cxxflags, link, lib, ours, filepath.Join(out, "wobj"), wasm); err != nil { + return err + } + if b.Pkg != "" { + ver, err := ReadVersion(src) + if err != nil { + return err + } + data, err := os.ReadFile(wasm) + if err != nil { + return err + } + dst := filepath.Join(b.Pkg, "wasm", fmt.Sprintf("mysqlparse_%d.%d.wasm", ver.Major, ver.Minor)) + if err := writeFile(dst, data); err != nil { + return err + } + b.log("installed %s (%d bytes)", dst, len(data)) + } + } + return nil +} + +// compile builds the library objects (cached in objdir) in parallel and links them with +// ours; linkflags go to the link step only. +func (b *Build) compile(cxx string, flags, linkflags, lib, ours []string, objdir, output string) error { + if err := os.MkdirAll(objdir, 0o755); err != nil { + return err + } + sem := make(chan struct{}, runtime.NumCPU()) + var wg sync.WaitGroup + var mu sync.Mutex + var firstErr error + var objs []string + for _, f := range lib { + o := filepath.Join(objdir, strings.TrimSuffix(filepath.Base(f), ".cc")+".o") + objs = append(objs, o) + if _, err := os.Stat(o); err == nil { + continue + } + wg.Go(func() { + sem <- struct{}{} + defer func() { <-sem }() + if err := b.run(b.Out, cxx, append(append([]string{}, flags...), "-c", "-o", o, f)...); err != nil { + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + } + }) + } + wg.Wait() + if firstErr != nil { + return firstErr + } + args := append(append([]string{}, flags...), linkflags...) + args = append(args, "-o", output) + args = append(args, ours...) + args = append(args, objs...) + if err := b.run(b.Out, cxx, args...); err != nil { + return err + } + b.log("built %s", output) + return nil +} + +func (b *Build) run(dir, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = dir + outb, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s %s: %w\n%s", name, strings.Join(shorten(args), " "), err, outb) + } + return nil +} + +func (b *Build) runTo(path, dir, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = dir + outb, err := cmd.Output() + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + return writeFile(path, outb) +} + +var reLongPath = regexp.MustCompile(`^(/[^/]+){3,}/`) + +func shorten(args []string) []string { + out := make([]string, len(args)) + for i, a := range args { + out[i] = reLongPath.ReplaceAllString(a, ".../") + } + return out +} + +func writeFile(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + if strings.HasSuffix(path, ".go") { + formatted, err := format.Source(data) + if err != nil { + return fmt.Errorf("%s: generated Go does not parse: %w", path, err) + } + data = formatted + } + return os.WriteFile(path, data, 0o644) +} diff --git a/check/mysql/internal/parsegen/types.go b/check/mysql/internal/parsegen/types.go new file mode 100644 index 0000000..fa26b22 --- /dev/null +++ b/check/mysql/internal/parsegen/types.go @@ -0,0 +1,442 @@ +package parsegen + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// The names of things. The actions pass children positionally into constructors +// (`NEW_PTN PT_query_specification(@$, $1, $2, ...)`) and into by-value structs +// (`$$= {$1, false}`); the server's headers say what each position is called. This file +// reads those names so that the AST can be addressed by them. + +// TypeInfo is one member of the parser's value union (parser_yystype.h): the C++ type +// and, for struct-valued members, the field names in declaration order. +type TypeInfo struct { + Type string + Fields []string +} + +// ReadTypeTags returns nonterminal -> %type tag from the grammar's declarations. +func ReadTypeTags(decl string) map[string]string { + out := map[string]string{} + for _, m := range reTypeTagged.FindAllStringSubmatch(decl, -1) { + for name := range strings.FieldsSeq(m[2]) { + if isIdentStart(name[0]) { + out[name] = m[1] + } + } + } + return out +} + +var reTypeTagged = regexp.MustCompile(`(?m)^%type\s*<([^>]+)>((?:[^\n]*)(?:\n[ \t]+[^\n%][^\n]*)*)`) + +// ReadYYStype reads the value union: tag -> type and fields. +func ReadYYStype(src string) (map[string]TypeInfo, error) { + src = stripComments(src) + structs := map[string][]string{} // named struct -> fields, from this header + for _, m := range reStructDef.FindAllStringSubmatchIndex(src, -1) { + name := src[m[2]:m[3]] + body, _ := balanced(src, m[4]-1, '{', '}') + structs[name] = fieldNames(body) + } + i := strings.Index(src, "union MY_SQL_PARSER_STYPE") + if i < 0 { + return nil, fmt.Errorf("parser_yystype.h: no union MY_SQL_PARSER_STYPE") + } + body, err := balanced(src, strings.Index(src[i:], "{")+i, '{', '}') + if err != nil { + return nil, err + } + out := map[string]TypeInfo{} + for _, decl := range splitDecls(body) { + decl = strings.TrimSpace(decl) + if decl == "" { + continue + } + if strings.HasPrefix(decl, "struct") && strings.Contains(decl, "{") { + // anonymous struct member: `struct { a; b; } name` + inner, _ := balanced(decl, strings.Index(decl, "{"), '{', '}') + name := strings.TrimSpace(decl[strings.LastIndex(decl, "}")+1:]) + out[name] = TypeInfo{Type: "struct", Fields: fieldNames(inner)} + continue + } + // `Type a, b;` or `Type name;` + typ, names := splitTypeNames(decl) + for _, n := range names { + out[n] = TypeInfo{Type: typ, Fields: structs[typ]} + } + } + return out, nil +} + +var reStructDef = regexp.MustCompile(`(?m)^struct\s+([A-Za-z_][A-Za-z_0-9]*)\s*(\{)`) + +// fieldNames lists the declared names in a struct body, in order, skipping methods, +// nested types and access specifiers. +func fieldNames(body string) []string { + var out []string + depth := 0 + start := 0 + for i := 0; i < len(body); i++ { + switch body[i] { + case '{', '(': + depth++ + case '}', ')': + depth-- + case ';': + if depth == 0 { + decl := strings.TrimSpace(body[start:i]) + start = i + 1 + if decl == "" || strings.Contains(decl, "(") || strings.HasPrefix(decl, "using ") || + strings.HasPrefix(decl, "static ") || strings.HasPrefix(decl, "typedef ") || strings.HasPrefix(decl, "return ") { + continue + } + if j := strings.LastIndex(decl, "}"); j >= 0 { // nested `struct {..} name` + decl = strings.TrimSpace(decl[j+1:]) + out = append(out, decl) + continue + } + _, names := splitTypeNames(decl) + out = append(out, names...) + } + } + } + return out +} + +// splitTypeNames splits `const T *a, *b = x` into the type and the declared names. +func splitTypeNames(decl string) (string, []string) { + decl = strings.TrimSpace(strings.TrimSuffix(decl, ";")) + // drop initializers + parts := splitTopLevel(decl) + var names []string + typ := "" + for k, p := range parts { + p = strings.TrimSpace(p) + if eq := strings.Index(p, "="); eq >= 0 { + p = strings.TrimSpace(p[:eq]) + } + if br := strings.Index(p, "{"); br >= 0 { + p = strings.TrimSpace(p[:br]) + } + if k == 0 { + // type is everything up to the last identifier + m := reLastIdent.FindStringSubmatchIndex(p) + if m == nil { + return decl, nil + } + typ = strings.TrimSpace(strings.TrimRight(p[:m[2]], " *&")) + typ = strings.TrimPrefix(typ, "const ") + typ = strings.TrimPrefix(typ, "enum ") + typ = strings.TrimPrefix(typ, "struct ") + names = append(names, p[m[2]:m[3]]) + } else { + m := reLastIdent.FindStringSubmatch(p) + if m != nil { + names = append(names, m[1]) + } + } + } + return typ, names +} + +var reLastIdent = regexp.MustCompile(`([A-Za-z_][A-Za-z_0-9]*)\s*(?:\[[^\]]*\])?\s*$`) + +// splitDecls splits a struct/union body into member declarations at top-level ';'. +func splitDecls(body string) []string { + var out []string + depth := 0 + start := 0 + for i := 0; i < len(body); i++ { + switch body[i] { + case '{', '(': + depth++ + case '}', ')': + depth-- + case ';': + if depth == 0 { + out = append(out, body[start:i]) + start = i + 1 + } + } + } + return out +} + +// balanced returns the text between the bracket at open (inclusive of neither) and its match. +func balanced(s string, open int, lb, rb byte) (string, error) { + depth := 0 + for i := open; i < len(s); i++ { + switch s[i] { + case lb: + depth++ + case rb: + depth-- + if depth == 0 { + return s[open+1 : i], nil + } + } + } + return "", fmt.Errorf("unbalanced %c at %d", lb, open) +} + +var ( + reBlockComment = regexp.MustCompile(`(?s)/\*.*?\*/`) + reLineComment = regexp.MustCompile(`//[^\n]*`) +) + +func stripComments(s string) string { + return reLineComment.ReplaceAllString(reBlockComment.ReplaceAllString(s, ""), "") +} + +// Ctor is one constructor (or builder function) signature: the parameter names in order, +// how many have no default, and whether the first is the parse position. +type Ctor struct { + Params []string + Required int + HasPos bool +} + +// ReadCtors scans header files for the constructors of the named classes (and the +// declarations of builder functions of the same names) and returns their parameter names. +func ReadCtors(files []string, names map[string]bool) (map[string][]Ctor, error) { + out := map[string][]Ctor{} + aliases := map[string]string{} // typedef / using alias -> the template or class it names + structs := map[string][]string{} + var sources []string + for _, f := range files { + b, err := os.ReadFile(f) + if err != nil { + return nil, err + } + src := stripComments(string(b)) + sources = append(sources, src) + for _, m := range reTypedef.FindAllStringSubmatch(src, -1) { + if names[m[2]] { + aliases[m[2]] = m[1] + } + } + for _, m := range reTypedefPlain.FindAllStringSubmatch(src, -1) { + if names[m[2]] { + aliases[m[2]] = m[1] + } + } + for _, m := range reUsingAlias.FindAllStringSubmatch(src, -1) { + if names[m[1]] { + aliases[m[1]] = m[2] + } + } + for _, m := range reStructDef.FindAllStringSubmatchIndex(src, -1) { + name := src[m[2]:m[3]] + if names[name] { + if body, err := balanced(src, m[4]-1, '{', '}'); err == nil { + structs[name] = fieldNames(body) + } + } + } + } + // aliases of templates: read the template's constructors under the alias name + want := map[string]bool{} + for n := range names { + want[n] = true + } + for _, target := range aliases { + want[target] = true + } + for _, src := range sources { + for name := range want { + if !strings.Contains(src, name+"(") && !strings.Contains(src, name+" (") { + continue + } + re := ctorRegexp(name) + for _, m := range re.FindAllStringIndex(src, -1) { + open := m[1] - 1 + params, err := balanced(src, open, '(', ')') + if err != nil { + continue + } + // a declaration, not a call: what follows is `;`, `:` (init list), `{`, `const`, `override`, `= default` + rest := strings.TrimSpace(src[open+len(params)+2:]) + if rest == "" || !(rest[0] == ';' || rest[0] == ':' || rest[0] == '{' || strings.HasPrefix(rest, "const") || strings.HasPrefix(rest, "override") || strings.HasPrefix(rest, "=")) { + continue + } + c := Ctor{} + if strings.TrimSpace(params) != "" { + for k, p := range splitTopLevel(params) { + p = strings.TrimSpace(p) + hasDefault := false + if eq := strings.Index(p, "="); eq >= 0 { + p = strings.TrimSpace(p[:eq]) + hasDefault = true + } + if k == 0 && strings.Contains(p, "POS") { + c.HasPos = true + } + if !hasDefault { + c.Required = k + 1 + } + if pm := reLastIdent.FindStringSubmatch(p); pm != nil && !isTypeOnly(p, pm[1]) { + c.Params = append(c.Params, pm[1]) + } else { + c.Params = append(c.Params, "") + } + } + } + out[name] = append(out[name], c) + } + } + } + for alias, target := range aliases { + if len(out[alias]) == 0 { + out[alias] = out[target] + } + } + // a plain struct built with braces takes its fields in order + for name, fields := range structs { + if len(out[name]) == 0 && len(fields) > 0 { + out[name] = []Ctor{{Params: fields}} // any prefix may be brace-initialized + } + } + return out, nil +} + +var ( + reTypedef = regexp.MustCompile(`(?s)typedef\s+([A-Za-z_][A-Za-z_0-9]*)\s*<[^;]*?>\s*([A-Za-z_][A-Za-z_0-9]*)\s*;`) + reTypedefPlain = regexp.MustCompile(`typedef\s+(?:struct\s+)?([A-Za-z_][A-Za-z_0-9]*)\s+([A-Za-z_][A-Za-z_0-9]*)\s*;`) + reUsingAlias = regexp.MustCompile(`using\s+([A-Za-z_][A-Za-z_0-9]*)\s*=\s*([A-Za-z_][A-Za-z_0-9]*)\s*<`) +) + +// isTypeOnly reports a parameter declared without a name (`Item *`, `const POS &`). +func isTypeOnly(p, last string) bool { + t := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(p), last)) + if t == "" { + return true // just a type name + } + return strings.HasSuffix(t, "::") || strings.HasSuffix(t, "<") || strings.HasSuffix(t, ",") +} + +// ctorRegexp matches a declaration of Name( at the start of a declaration: after a newline +// with optional specifiers, or after a return type for builder functions. +func ctorRegexp(name string) *regexp.Regexp { + return regexp.MustCompile(`(?m)^[ \t]*(?:(?:explicit|inline|constexpr|static|virtual|friend)\s+)*(?:[A-Za-z_][A-Za-z_0-9:<>*&, ]*\s+[*&]*)?\b` + regexp.QuoteMeta(name) + `\s*\(`) +} + +// HeaderFiles lists the server headers worth scanning for constructors. +func HeaderFiles(src string) ([]string, error) { + var out []string + for _, pat := range []string{"sql/*.h", "sql/auth/*.h", "include/*.h", "sql-common/*.h"} { + m, err := filepath.Glob(filepath.Join(src, pat)) + if err != nil { + return nil, err + } + out = append(out, m...) + } + sort.Strings(out) + return out, nil +} + +// Names resolves, for every ActNew / ActListNew alternative, the constructor parameter +// names matching its argument count, and for every positional struct the field names of +// the rule's type. It reports what it could not resolve. +type Names struct { + Ctor map[string][]Ctor // class -> constructors + Types map[string]TypeInfo // %type tag -> type + Tags map[string]string // rule -> tag + Missing map[string]int // class -> alternatives with no matching constructor +} + +// ParamNames returns the parameter names for class with n arguments, or nil. MySQL's +// constructors name a parameter after the member it initializes with an `_arg` suffix; +// the suffix is dropped. +func (nm *Names) ParamNames(class string, n int, hasPos bool) []string { + var best []string + for _, c := range nm.Ctor[class] { + if c.HasPos != hasPos { + continue + } + if len(c.Params) == n { + best = c.Params + break + } + // a constructor with defaulted trailing parameters also fits + if len(c.Params) > n && (n >= c.Required || c.Required == 0) && best == nil { + best = c.Params[:n] + } + } + if best == nil { + return nil + } + out := make([]string, len(best)) + for i, p := range best { + out[i] = strings.TrimSuffix(p, "_arg") + } + return out +} + +// ReadNames gathers everything: type tags from the grammar declarations, the value union, +// and the constructors of every class the actions build. +func ReadNames(src string, yacc string, alts []Alt) (*Names, error) { + sections := reSectionSep.FindAllStringIndex(yacc, -1) + if len(sections) < 1 { + return nil, fmt.Errorf("grammar: no %%%% section separator") + } + nm := &Names{Tags: ReadTypeTags(yacc[:sections[0][0]]), Missing: map[string]int{}} + yy, err := os.ReadFile(filepath.Join(src, "sql", "parser_yystype.h")) + if err != nil { + return nil, err + } + nm.Types, err = ReadYYStype(string(yy)) + if err != nil { + return nil, err + } + classes := map[string]bool{} + for _, a := range alts { + if (a.Kind == ActNew || a.Kind == ActListNew) && a.Class != "" { + classes[a.Class] = true + } + } + files, err := HeaderFiles(src) + if err != nil { + return nil, err + } + nm.Ctor, err = ReadCtors(files, classes) + if err != nil { + return nil, err + } + for _, a := range alts { + if a.Kind == ActNew && a.Class != "" && nm.ParamNames(a.Class, len(a.Args), HasPosArg(a)) == nil { + nm.Missing[a.Class]++ + } + } + return nm, nil +} + +// HasPosArg reports whether the action passes the parse position first. +func HasPosArg(a Alt) bool { + return len(a.Args) > 0 && a.Args[0].Child == 0 && (a.Args[0].Text == "@$" || strings.HasPrefix(a.Args[0].Text, "@")) +} + +// Report lists the classes whose constructor could not be matched. +func (nm *Names) Report() string { + var b strings.Builder + fmt.Fprintf(&b, "constructors: %d classes read, %d unresolved\n", len(nm.Ctor), len(nm.Missing)) + for _, kv := range top(nm.Missing, 60) { + fmt.Fprintf(&b, " %3d %s\n", kv.n, kv.k) + } + return b.String() +} + +// StructFields returns the field names of rule's value type when it is a struct. +func (nm *Names) StructFields(rule string) []string { + tag, ok := nm.Tags[rule] + if !ok { + return nil + } + return nm.Types[tag].Fields +} diff --git a/check/mysql/internal/schema/adv3b_schema_test.go b/check/mysql/internal/schema/adv3b_schema_test.go new file mode 100644 index 0000000..3b11c40 --- /dev/null +++ b/check/mysql/internal/schema/adv3b_schema_test.go @@ -0,0 +1,181 @@ +package schema + +// Adversarial pass 3, second half (schema lane): follow-up on 2nd pass's schema.go additions +// (CHECK OPTION reading on views). See scratchpad/adv3/brief-followup-schema-runtime.md +// (face 5) for the questions this file answers. Each finding states the server-measured +// behavior and currently FAILS against the loader as it stands today. + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// TestAdv3bCreateOrReplaceViewFalsePositive (severity: medium — a false positive that stops +// a legitimate, everyday statement): mysqld 8.4.11 accepts CREATE OR REPLACE VIEW naming an +// already-existing view without complaint, and the view afterward has the *new* definition +// (measured below via information_schema.views' CHECK_OPTION). The loader's createView +// (check/mysql/internal/schema/schema.go:1377) instead reports "view already exists" as a +// Problem and leaves the *old* view untouched in the model. +// +// The likely cause: createView decides whether OR REPLACE was written by testing +// `n.Arg("replace") == nil` (schema.go:1385), where "replace" is the "create_view_mode" +// field the grammar hook wires in (check/mysql/internal/mysqlast/hooks_dml.go's viewHead, +// `mode, algorithm = st.Fields["create_view_mode"], st.Fields["create_view_algorithm"]`). +// Neither "view_replace_or_algorithm" (mysqlparse/shapes.go rule 902) nor "view_replace" +// (rule 903, "OR_SYM REPLACE_SYM") is ever built as a Struct carrying a "create_view_mode" +// field — both are plain ActDefault pass-throughs — so `mode` is nil whether or not OR +// REPLACE was written, and createView always takes the "already exists" branch for a +// second CREATE (OR REPLACE or not) of the same view name. +func TestAdv3bCreateOrReplaceViewFalsePositive(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + sql := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, tenant_id INT NOT NULL); +CREATE VIEW v AS SELECT id, tenant_id FROM t WHERE tenant_id = 1; +CREATE OR REPLACE VIEW v AS SELECT id, tenant_id FROM t WHERE tenant_id = 1 WITH CASCADED CHECK OPTION; +` + db, err := mysqltest.Start(ctx, sql) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatalf("server: test premise wrong -- CREATE OR REPLACE VIEW of an existing view "+ + "must succeed: %v", err) + } + defer db.Close() + var checkOpt string + if err := db.Conn().QueryRowContext(ctx, + "SELECT CHECK_OPTION FROM information_schema.views WHERE table_schema='sqlshape' AND table_name='v'", + ).Scan(&checkOpt); err != nil { + t.Fatal(err) + } + if checkOpt != "CASCADED" { + t.Fatalf("server: test premise wrong -- v's CHECK_OPTION should read back CASCADED "+ + "after the OR REPLACE, got %q", checkOpt) + } + + s, err := Load(sql) + if err != nil { + t.Fatal(err) + } + for _, p := range s.Problems { + t.Errorf("loader: unexpected problem for a valid CREATE OR REPLACE VIEW: %s", p) + } + v := s.View("v") + if v == nil { + t.Fatal("loader: no view v") + } + if v.CheckOption != "CASCADED" { + t.Errorf("loader: v.CheckOption = %q, want %q (the OR REPLACE's own definition, matching the server)", v.CheckOption, "CASCADED") + } +} + +// TestAdv3bCheckOptionOnNonUpdatableViewNotFlagged (severity: high — the loader says a +// schema has no problems, but applying it to a real server fails outright: a false claim of +// "this DDL is fine"): mysqld 8.4.11 refuses CREATE VIEW ... WITH CHECK OPTION on a +// non-updatable view (one with GROUP BY here) at CREATE time, Error 1368 "CHECK OPTION on +// non-updatable view 'sqlshape.v_bad'". The loader's Load (check/mysql/internal/schema) +// builds the same statement with zero Problems and a CheckOption of "CASCADED" on the +// view, as if the CREATE would succeed -- there is no updatability check anywhere in +// createView (schema.go:1377) mirroring the server's own 1368 rule. A migration plan that +// creates this view (check/mysql/migrate) would only discover the failure when actually +// applied against a live server, not from the loader/diff/plan step that is supposed to +// catch schema-level mistakes ahead of time. +func TestAdv3bCheckOptionOnNonUpdatableViewNotFlagged(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + sql := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, tenant_id INT NOT NULL); +CREATE VIEW v_bad AS SELECT tenant_id, COUNT(*) c FROM t GROUP BY tenant_id WITH CHECK OPTION; +` + db, err := mysqltest.Start(ctx, sql) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err == nil { + db.Close() + t.Fatal("server: test premise wrong -- CREATE VIEW ... WITH CHECK OPTION on a GROUP BY (non-updatable) view must fail with 1368") + } + if !strings.Contains(err.Error(), "1368") { + t.Fatalf("server: test premise wrong -- expected 1368, got: %v", err) + } + + s, err := Load(sql) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) == 0 { + t.Errorf("loader: Load reports no problems for a view whose WITH CHECK OPTION the server always refuses at CREATE time (1368); want a Problem flagging the non-updatable view") + } +} + +// TestAdv3bViewAlgorithmNeverRead (severity: high — feeds a false "may be updatable" verdict +// downstream): mysqld 8.4.11 preserves a view's own ALGORITHM (measured below via `SHOW +// CREATE VIEW`, which echoes back "ALGORITHM=TEMPTABLE" verbatim) and reports it +// non-updatable in information_schema.views.is_updatable, because ALGORITHM=TEMPTABLE always +// forces materialization regardless of how simple the query looks. The loader's View.Algorithm +// (check/mysql/internal/schema/schema.go:292/1380, `Algorithm: +// strings.TrimPrefix(str(n.Arg("algorithm")), "VIEW_ALGORITHM_")`) is "" for every view +// tested here -- MERGE, TEMPTABLE, UNDEFINED and the default -- never the declared value. +// +// Root cause, the same as TestAdv3bCreateOrReplaceViewFalsePositive above: viewHead +// (mysqlast/hooks_dml.go) reads `algorithm` from `st.Fields["create_view_algorithm"]`, a +// field name that "view_replace_or_algorithm" / "view_algorithm" (mysqlparse/shapes.go rules +// 902/904) never actually construct (both are plain ActDefault pass-throughs with no Struct +// building that field), so the algorithm is silently dropped no matter what was written. +// +// This is not an isolated cosmetic gap: check/mysql/internal/analyze/analyze.go:1046 reads +// `v.Algorithm == "TEMPTABLE"` as (half of) its own test for whether a view may be written +// through -- with Algorithm always "", that half of the test can never fire, so a view whose +// ALGORITHM=TEMPTABLE was written explicitly (forcing non-updatability even when the query +// shape alone would otherwise look mergeable) is invisible to that check, which then falls +// back to judging updatability from the query shape alone. +func TestAdv3bViewAlgorithmNeverRead(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + sql := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY); +CREATE ALGORITHM=TEMPTABLE VIEW v AS SELECT id FROM t; +` + db, err := mysqltest.Start(ctx, sql) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + var name, ddl, definer, security string + if err := db.Conn().QueryRowContext(ctx, "SHOW CREATE VIEW v").Scan(&name, &ddl, &definer, &security); err != nil { + t.Fatal(err) + } + if !strings.Contains(ddl, "ALGORITHM=TEMPTABLE") { + t.Fatalf("server: test premise wrong -- expected the declared ALGORITHM=TEMPTABLE to read back in SHOW CREATE VIEW, got: %s", ddl) + } + var updatable string + if err := db.Conn().QueryRowContext(ctx, + "SELECT is_updatable FROM information_schema.views WHERE table_schema='sqlshape' AND table_name='v'", + ).Scan(&updatable); err != nil { + t.Fatal(err) + } + if updatable != "NO" { + t.Fatalf("server: test premise wrong -- expected is_updatable='NO' for an ALGORITHM=TEMPTABLE view, got %q", updatable) + } + + s, err := Load(sql) + if err != nil { + t.Fatal(err) + } + v := s.View("v") + if v == nil { + t.Fatal("loader: no view v") + } + if v.Algorithm != "TEMPTABLE" { + t.Errorf("loader: v.Algorithm = %q, want %q (the view's own declared ALGORITHM, matching the server's SHOW CREATE VIEW readback)", v.Algorithm, "TEMPTABLE") + } +} diff --git a/check/mysql/internal/schema/adv_schema_test.go b/check/mysql/internal/schema/adv_schema_test.go new file mode 100644 index 0000000..35af6f4 --- /dev/null +++ b/check/mysql/internal/schema/adv_schema_test.go @@ -0,0 +1,172 @@ +package schema + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// Adversarial findings against a real mysqld (round 1, schema lane). Each test states the +// behavior the server actually has; today the loader disagrees (see the failure each one +// reports), which is exactly what these tests are here to pin down. + +// TestAdvDropUnnamedForeignKeyByGeneratedName: MySQL accepts DROP FOREIGN KEY naming an +// unnamed constraint by the name it generates for it (`
_ibfk_`), and the +// constraint is gone afterward. The loader's dropConstraint compares the ALTER's name +// against ForeignKey.Name, which is "" for an unnamed key until ForeignKeyName() renders +// it — so the DROP never matches, is recorded as a Problem, and the foreign key survives +// in the model although the server has removed it. +func TestAdvDropUnnamedForeignKeyByGeneratedName(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + sql := `CREATE TABLE p (id INT PRIMARY KEY); +CREATE TABLE c (id INT PRIMARY KEY, pid INT, FOREIGN KEY (pid) REFERENCES p(id)); +ALTER TABLE c DROP FOREIGN KEY c_ibfk_1;` + + db, err := mysqltest.Start(ctx, sql) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatalf("server: DROP FOREIGN KEY c_ibfk_1 (the name it would itself report for the "+ + "unnamed constraint) must succeed: %v", err) + } + defer db.Close() + var count int + if err := db.Conn().QueryRowContext(ctx, + "SELECT COUNT(*) FROM information_schema.table_constraints WHERE table_schema='sqlshape' AND table_name='c' AND constraint_type='FOREIGN KEY'", + ).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("server: expected the foreign key gone after DROP FOREIGN KEY c_ibfk_1, still %d", count) + } + + s, err := Load(sql) + if err != nil { + t.Fatal(err) + } + for _, p := range s.Problems { + t.Errorf("loader: unexpected problem: %s", p) + } + c := s.Table("c") + if c == nil { + t.Fatal("loader: no table c") + } + if len(c.ForeignKeys) != 0 { + t.Errorf("loader: expected the foreign key gone after DROP FOREIGN KEY c_ibfk_1, still have %+v", c.ForeignKeys) + } +} + +// TestAdvDropUnnamedCheckByGeneratedName: same defect on CHECK constraints. MySQL accepts +// DROP CHECK t_chk_1 for an unnamed CHECK, generating a fresh table for what remains where +// the surviving checks keep their own generated numbers (the counter does not reuse the +// freed slot); the loader neither drops the check nor reflects that renumbering. +func TestAdvDropUnnamedCheckByGeneratedName(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + sql := `CREATE TABLE t (a INT, b INT, c INT, CHECK (a > 0), CHECK (b > 0)); +ALTER TABLE t DROP CHECK t_chk_1; +ALTER TABLE t ADD CHECK (c > 0);` + + db, err := mysqltest.Start(ctx, sql) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatalf("server: DROP CHECK t_chk_1 (the name it would itself report for the first "+ + "unnamed check) must succeed: %v", err) + } + defer db.Close() + rows, err := db.Conn().QueryContext(ctx, + "SELECT constraint_name FROM information_schema.table_constraints WHERE table_schema='sqlshape' AND table_name='t' AND constraint_type='CHECK' ORDER BY constraint_name") + if err != nil { + t.Fatal(err) + } + var names []string + for rows.Next() { + var n string + if err := rows.Scan(&n); err != nil { + t.Fatal(err) + } + names = append(names, n) + } + rows.Close() + // the server's generator counter is not rewound by the DROP: b>0 keeps t_chk_2, and the + // check added afterward becomes t_chk_3, not a reused t_chk_1. + if len(names) != 2 || names[0] != "t_chk_2" || names[1] != "t_chk_3" { + t.Fatalf("server: expected [t_chk_2 t_chk_3], got %v", names) + } + + s, err := Load(sql) + if err != nil { + t.Fatal(err) + } + for _, p := range s.Problems { + t.Errorf("loader: unexpected problem: %s", p) + } + tb := s.Table("t") + if tb == nil { + t.Fatal("loader: no table t") + } + var got []string + for _, c := range tb.Checks { + got = append(got, tb.CheckName(c)) + } + if len(got) != 2 || got[0] != "t_chk_2" || got[1] != "t_chk_3" { + t.Errorf("loader: expected [t_chk_2 t_chk_3] surviving, got %v", got) + } +} + +// TestAdvCaseSensitiveTableNames: on the platform default the loader is built and tested +// against (Linux, lower_case_table_names=0), MySQL table names are case-sensitive: `Foo` +// and `foo` are two distinct tables. The loader looks tables up with strings.EqualFold +// throughout (Schema.Table, createTable's duplicate check, needTable, ...), so the second +// CREATE TABLE is rejected as a duplicate of the first and one of the two tables silently +// disappears from the model. +func TestAdvCaseSensitiveTableNames(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + sql := `CREATE TABLE Foo (id INT PRIMARY KEY); +CREATE TABLE foo (id INT PRIMARY KEY);` + + db, err := mysqltest.Start(ctx, sql) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatalf("server: Foo and foo must both be creatable as distinct tables under the "+ + "default lower_case_table_names=0: %v", err) + } + defer db.Close() + var lower int + if err := db.Conn().QueryRowContext(ctx, "SELECT @@lower_case_table_names").Scan(&lower); err != nil { + t.Fatal(err) + } + if lower != 0 { + t.Skipf("server has lower_case_table_names=%d, not the Linux default 0 this test assumes", lower) + } + var count int + if err := db.Conn().QueryRowContext(ctx, + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='sqlshape' AND table_name IN ('Foo','foo')", + ).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 2 { + t.Fatalf("server: expected both Foo and foo to exist, information_schema has %d", count) + } + + s, err := Load(sql) + if err != nil { + t.Fatal(err) + } + for _, p := range s.Problems { + t.Errorf("loader: unexpected problem: %s", p) + } + if len(s.Tables) != 2 { + t.Errorf("loader: expected 2 distinct tables (Foo, foo), got %d: %v", len(s.Tables), s.Tables) + } +} diff --git a/check/mysql/internal/schema/alter_view_test.go b/check/mysql/internal/schema/alter_view_test.go new file mode 100644 index 0000000..a4211db --- /dev/null +++ b/check/mysql/internal/schema/alter_view_test.go @@ -0,0 +1,84 @@ +package schema + +import ( + "strings" + "testing" +) + +// ALTER VIEW replaces the view's definition whole (measured on 8.4: the column list, the +// algorithm and the check option are the new statement's), and the server's own refusals +// are problems: no such view (1146), a table of that name (1347), CHECK OPTION on a view it +// would not merge (1368). +func TestAlterView(t *testing.T) { + s, err := Load(`-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY, a INT NOT NULL, b INT); +CREATE VIEW v AS SELECT id, a FROM t; +ALTER ALGORITHM=TEMPTABLE VIEW v (x, y, z) AS SELECT id, a, b FROM t; +ALTER VIEW nope AS SELECT id FROM t; +ALTER VIEW t AS SELECT id FROM t; +ALTER VIEW v AS SELECT a, COUNT(*) n FROM t GROUP BY a WITH CHECK OPTION; +`) + if err != nil { + t.Fatal(err) + } + v := s.View("v") + if v == nil { + t.Fatal("view v not loaded") + } + if got := strings.Join(v.Columns, ","); got != "x,y,z" || v.Algorithm != "TEMPTABLE" || !strings.HasPrefix(v.Definition, "ALTER ALGORITHM=TEMPTABLE") { + t.Errorf("after ALTER VIEW: columns %q, algorithm %q, definition %q", got, v.Algorithm, v.Definition) + } + var got []string + for _, p := range s.Problems { + got = append(got, p.Message) + } + want := []string{ + "ALTER VIEW nope: Table 'nope' doesn't exist (1146)", + "ALTER VIEW t: 't' is not VIEW (1347)", + "ALTER VIEW v: CHECK OPTION on non-updatable view (1368)", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("problems:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + if len(v.Columns) != 3 { + t.Errorf("the refused ALTER VIEW replaced the view: %v", v.Columns) + } +} + +// LOCK TABLES, UNLOCK TABLES, LOAD DATA and ALTER VIEW inside any body are refused by the +// server's grammar at CREATE time (1314 " is not allowed in stored procedures", measured +// for a PROCEDURE, a FUNCTION and a TRIGGER alike): a problem at load, the body still +// loaded for analyze to raise the same error. +func TestBodyRefusesLockLoadAlterView(t *testing.T) { + s, err := Load(`-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY, a INT NOT NULL); +CREATE TABLE u (id INT PRIMARY KEY); +CREATE VIEW v AS SELECT id FROM t; +CREATE PROCEDURE p1() BEGIN LOCK TABLES t WRITE; END; +CREATE FUNCTION f1() RETURNS INT DETERMINISTIC BEGIN UNLOCK TABLES; RETURN 1; END; +CREATE TRIGGER tr1 BEFORE INSERT ON u FOR EACH ROW BEGIN LOAD DATA INFILE '/tmp/x' INTO TABLE t; END; +CREATE PROCEDURE p2() BEGIN IF 1 THEN ALTER VIEW v AS SELECT a FROM t; END IF; END; +CREATE PROCEDURE p3() BEGIN SELECT a INTO OUTFILE '/tmp/o' FROM t; END; +`) + if err != nil { + t.Fatal(err) + } + var got []string + for _, p := range s.Problems { + got = append(got, p.Message) + } + want := []string{ + "CREATE PROCEDURE p1: LOCK is not allowed in stored procedures", + "CREATE FUNCTION f1: UNLOCK is not allowed in stored procedures", + "CREATE TRIGGER tr1: LOAD DATA is not allowed in stored procedures", + "CREATE PROCEDURE p2: ALTER VIEW is not allowed in stored procedures", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("problems:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + for _, name := range []string{"p1", "p2", "p3"} { + if s.RoutineOf(Procedure, name) == nil { + t.Errorf("procedure %s not loaded", name) + } + } +} diff --git a/check/mysql/internal/schema/directives.go b/check/mysql/internal/schema/directives.go new file mode 100644 index 0000000..3539d7b --- /dev/null +++ b/check/mysql/internal/schema/directives.go @@ -0,0 +1,190 @@ +package schema + +import ( + "regexp" + "strconv" + "strings" + + "github.com/kr9ly/sqlshape/v2/x/dialect" + "github.com/kr9ly/sqlshape/v2/x/obligation" +) + +// Directives: the `-- sqlshape: ` comment lines written above a CREATE TABLE / CREATE +// VIEW annotate it. A table's are the obligations x/obligation parses (`require ...`, +// `aggregate ...`, `context ...`, `sensitive ...`, `transitions ...`, `visible where ...`); +// a view's are those plus its own opt-outs (`unfiltered t1, t2`, `waive t pinned(x)`), +// which the analyzer carries on the leaves of the view's body. + +var directiveLine = regexp.MustCompile(`^[ \t]*--[ \t]*sqlshape:[ \t]*(.+?)[ \t]*$`) + +// RaisedError is a trigger's / routine's own `-- sqlshape: error = ` +// directive, parsed: Code as written (a MYSQL_ERRNO in decimal, or a SQLSTATE), and its +// application-side Name (a Go identifier the schema's author chose to name it: what a +// program's `sqlshape.Error(Code)` declaration and an expect line may spell instead of +// Code). +type RaisedError struct { + Code string + Name string +} + +// validErrorName reports whether a `-- sqlshape: error` directive's name is a Go +// identifier: what vet requires of the `var X = sqlshape.Error(code)` declaration it +// must match. +var validErrorName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`).MatchString + +// ParseRaises reads a trigger's/routine's own `error = ` directives (kept +// verbatim in Directives by spDirectives, which already refused every other kind, and +// validated the name there); this is the "later stage" that parses them, used both by the +// body analyzer (to resolve a SIGNAL's key to its Name) and by the dialect (to list the +// schema's Errors()). +func ParseRaises(directives []string) []RaisedError { + var out []RaisedError + for _, d := range directives { + if len(d) < 6 || !strings.EqualFold(d[:6], "error ") { + continue + } + rest := strings.TrimSpace(d[6:]) + key, name, ok := strings.Cut(rest, "=") + if !ok { + continue + } + out = append(out, RaisedError{Code: strings.TrimSpace(key), Name: strings.TrimSpace(name)}) + } + return out +} + +// leadingDirectives reads the directives among the comment lines in front of a statement +// (the splitter keeps them with the statement); the file's version declaration and its +// server settings are not one. +func leadingDirectives(sql string) []string { + var out []string + for _, line := range strings.Split(sql, "\n") { + trimmed := strings.TrimSpace(line) + switch { + case trimmed == "": + continue + case strings.HasPrefix(trimmed, "--"), strings.HasPrefix(trimmed, "#"): + if m := directiveLine.FindStringSubmatch(line); m != nil && !versionLine.MatchString(line) && !dialect.IsSetting(m[1]) { + out = append(out, strings.Join(strings.Fields(m[1]), " ")) + } + continue + } + return out // the statement proper + } + return out +} + +// obligationDirective reports whether a normalized directive is one x/obligation reads. +func obligationDirective(norm string) bool { + lower := strings.ToLower(norm) + for _, p := range []string{"require ", "aggregate ", "context ", "sensitive ", "transitions ", "visible where "} { + if strings.HasPrefix(lower, p) { + return true + } + } + return false +} + +// tableDirectives applies the directives written above a CREATE TABLE. +func (s *Schema) tableDirectives(t *Table, sql string, pos int) { + for _, d := range leadingDirectives(sql) { + if !obligationDirective(d) { + if lower := strings.ToLower(d); strings.HasPrefix(lower, "unfiltered ") || strings.HasPrefix(lower, "waive ") { + s.problem(pos, "table %s: directive %q is a view's or a statement's opt-out: write it above the CREATE VIEW, or above the statement, whose reads it waives", t.Name, d) + continue + } + s.problem(pos, "table %s: unknown directive %q", t.Name, d) + continue + } + t.Directives = append(t.Directives, d) + } +} + +// viewDirectives applies the directives written above a CREATE VIEW. +func (s *Schema) viewDirectives(v *View, sql string, pos int) { + for _, d := range leadingDirectives(sql) { + lower := strings.ToLower(d) + switch { + case strings.HasPrefix(lower, "require "), strings.HasPrefix(lower, "context "), strings.HasPrefix(lower, "sensitive "): + v.Directives = append(v.Directives, d) + case strings.HasPrefix(lower, "unfiltered "): + for _, t := range strings.Split(d[len("unfiltered "):], ",") { + if t = strings.TrimSpace(t); t != "" { + t = s.CanonicalName(t) + if v.Unfiltered == nil { + v.Unfiltered = map[string]bool{} + } + v.Unfiltered[t] = true + v.Waived = obligation.AddWaiver(v.Waived, t, "unfiltered") + } + } + case strings.HasPrefix(lower, "waive "): + for table, spec := range obligation.Waivers(d[len("waive "):]) { + v.Waived = obligation.AddWaiver(v.Waived, s.CanonicalName(table), spec...) + } + default: + s.problem(pos, "view %s: unknown directive %q", v.Name, d) + } + } +} + +// CheckName is the CHECK constraint's name as the server reports it: the declared one, or +// the generated `
_chk_` numbering the unnamed constraints in order. +func (t *Table) CheckName(c *Check) string { + t.nameUnnamed() + return c.Name +} + +// ForeignKeyName is the REFERENCES constraint's name as the server reports it: the +// declared one, or the generated `
_ibfk_`. +func (t *Table) ForeignKeyName(fk *ForeignKey) string { + t.nameUnnamed() + return fk.Name +} + +// nameUnnamed gives every unnamed CHECK and FOREIGN KEY the name the server generates +// for it: `
_chk_` / `
_ibfk_` with n one above the highest number +// already generated (dropping one does not free its number for the next). +func (t *Table) nameUnnamed() { + chk, ibfk := t.Name+"_chk_", t.Name+"_ibfk_" + next := func(prefix string, names []string) int { + n := 0 + for _, name := range names { + if strings.HasPrefix(name, prefix) { + if k, err := strconv.Atoi(name[len(prefix):]); err == nil && k > n { + n = k + } + } + } + return n + 1 + } + for _, c := range t.Checks { + if c.Name == "" { + var names []string + for _, o := range t.Checks { + names = append(names, o.Name) + } + c.Name = chk + itoa(next(chk, names)) + } + } + for _, fk := range t.ForeignKeys { + if fk.Name == "" { + var names []string + for _, o := range t.ForeignKeys { + names = append(names, o.Name) + } + fk.Name = ibfk + itoa(next(ibfk, names)) + } + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b []byte + for ; n > 0; n /= 10 { + b = append([]byte{byte('0' + n%10)}, b...) + } + return string(b) +} diff --git a/check/mysql/internal/schema/events_test.go b/check/mysql/internal/schema/events_test.go new file mode 100644 index 0000000..dc22ad7 --- /dev/null +++ b/check/mysql/internal/schema/events_test.go @@ -0,0 +1,117 @@ +package schema + +import ( + "strings" + "testing" +) + +// TestCreateEvent reads CREATE EVENT's two schedule forms with their options, tells a +// literal time from an evaluated one, and applies DROP EVENT and IF NOT EXISTS; ALTER EVENT +// and a duplicate are problems. +func TestCreateEvent(t *testing.T) { + s, err := Load(`-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, v INT NOT NULL); +CREATE EVENT e1 ON SCHEDULE EVERY 1 DAY DO DELETE FROM t WHERE id < 0; +CREATE DEFINER=root@localhost EVENT e2 ON SCHEDULE EVERY 1 HOUR STARTS '2030-01-01 00:00:00' ENDS '2031-01-01 00:00:00' ON COMPLETION PRESERVE DISABLE COMMENT 'c' +DO BEGIN + DELETE FROM t WHERE id < 0; + UPDATE t SET v = v + 1 WHERE id = 1; +END; +CREATE EVENT e3 ON SCHEDULE AT '2030-01-01 00:00:00' DO INSERT INTO t VALUES (1, 1); +CREATE EVENT e4 ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 YEAR DO INSERT INTO t VALUES (2, 1); +CREATE EVENT e5 ON SCHEDULE EVERY 1 DAY STARTS CURRENT_TIMESTAMP DO DELETE FROM t WHERE id < 0; +CREATE EVENT IF NOT EXISTS e1 ON SCHEDULE EVERY 2 DAY DO DELETE FROM t; +CREATE EVENT gone ON SCHEDULE EVERY 1 DAY DO DELETE FROM t; +DROP EVENT gone; +DROP EVENT IF EXISTS gone; +`) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("problems: %v", s.Problems) + } + if len(s.Events) != 5 { + t.Fatalf("got %d events, want 5", len(s.Events)) + } + e1 := s.Event("e1") + if e1.Every != "1 DAY" || e1.Starts != "" || e1.StartsLiteral || e1.Completion != "NOT PRESERVE" || e1.Status != "ENABLE" || e1.Comment != "" || e1.BodyText != "DELETE FROM t WHERE id < 0" { + t.Errorf("e1: %+v", e1) + } + e2 := s.Event("e2") + if e2.Every != "1 HOUR" || e2.Starts != "'2030-01-01 00:00:00'" || !e2.StartsLiteral || e2.Ends != "'2031-01-01 00:00:00'" || !e2.EndsLiteral || e2.Completion != "PRESERVE" || e2.Status != "DISABLE" || e2.Comment != "c" || !strings.HasPrefix(e2.BodyText, "BEGIN") || !strings.HasSuffix(e2.BodyText, "END") { + t.Errorf("e2: %+v", e2) + } + if e3 := s.Event("e3"); e3.At != "'2030-01-01 00:00:00'" || !e3.AtLiteral || e3.Every != "" { + t.Errorf("e3: %+v", e3) + } + if e4 := s.Event("e4"); e4.At != "CURRENT_TIMESTAMP + INTERVAL 1 YEAR" || e4.AtLiteral { + t.Errorf("e4: %+v", e4) + } + if e5 := s.Event("e5"); e5.Starts != "CURRENT_TIMESTAMP" || e5.StartsLiteral { + t.Errorf("e5: %+v", e5) + } + if s.Event("gone") != nil { + t.Error("DROP EVENT did not drop") + } + + s, err = Load(`-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY); +CREATE EVENT e1 ON SCHEDULE EVERY 1 DAY DO DELETE FROM t; +CREATE EVENT e1 ON SCHEDULE EVERY 1 DAY DO DELETE FROM t; +CREATE EVENT e2 ON SCHEDULE EVERY 1 DAY DO DELETE FROM t; +ALTER EVENT e1 RENAME TO e2; +ALTER EVENT nope ON SCHEDULE EVERY 2 DAY; +DROP EVENT nope; +`) + if err != nil { + t.Fatal(err) + } + var msgs []string + for _, p := range s.Problems { + msgs = append(msgs, p.Message) + } + got := strings.Join(msgs, "\n") + for _, want := range []string{"CREATE EVENT e1: event already exists", "ALTER EVENT e1 RENAME TO e2: event already exists", "ALTER EVENT nope: no such event", "DROP EVENT nope: no such event"} { + if !strings.Contains(got, want) { + t.Errorf("problems lack %q:\n%s", want, got) + } + } +} + +// TestAlterEvent: each ALTER EVENT clause replaces that part of the event and the rest +// stays; a new schedule replaces the whole schedule (STARTS included); RENAME TO renames. +func TestAlterEvent(t *testing.T) { + s, err := Load(`-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL PRIMARY KEY, v INT NOT NULL); +CREATE EVENT e ON SCHEDULE EVERY 1 HOUR STARTS '2030-01-01 00:00:00' ON COMPLETION PRESERVE DISABLE COMMENT 'c' DO DELETE FROM t WHERE id < 0; +ALTER EVENT e ON SCHEDULE EVERY 2 DAY; +ALTER EVENT e ENABLE COMMENT 'd'; +ALTER EVENT e DO UPDATE t SET v = v + 1 WHERE id = 1; +ALTER EVENT e ON COMPLETION NOT PRESERVE RENAME TO f; +CREATE EVENT once ON SCHEDULE EVERY 1 DAY DO DELETE FROM t; +ALTER EVENT once ON SCHEDULE AT '2030-01-01 00:00:00' ON COMPLETION PRESERVE; +`) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("problems: %v", s.Problems) + } + if s.Event("e") != nil { + t.Error("RENAME TO left the old name") + } + f := s.Event("f") + if f == nil { + t.Fatal("renamed event not found") + } + if f.Every != "2 DAY" || f.Starts != "" || f.StartsLiteral || f.Completion != "NOT PRESERVE" || f.Status != "ENABLE" || f.Comment != "d" || f.BodyText != "UPDATE t SET v = v + 1 WHERE id = 1" { + t.Errorf("f: %+v", f) + } + if f.Body == nil { + t.Error("f: the new body was not kept") + } + if o := s.Event("once"); o.At != "'2030-01-01 00:00:00'" || !o.AtLiteral || o.Every != "" || o.Completion != "PRESERVE" { + t.Errorf("once: %+v", o) + } +} diff --git a/check/mysql/internal/schema/load_more_test.go b/check/mysql/internal/schema/load_more_test.go new file mode 100644 index 0000000..0a32c70 --- /dev/null +++ b/check/mysql/internal/schema/load_more_test.go @@ -0,0 +1,268 @@ +package schema + +import ( + "strings" + "testing" +) + +// loadOK loads schemaSQL and fails the test on any problem. +func loadOK(t *testing.T, schemaSQL string) *Schema { + t.Helper() + s, err := Load(schemaSQL) + if err != nil { + t.Fatal(err) + } + for _, p := range s.Problems { + t.Errorf("problem: %s", p) + } + return s +} + +// loadProblem loads schemaSQL and returns the schema, requiring at least one problem whose +// message contains want. +func loadProblem(t *testing.T, schemaSQL, want string) *Schema { + t.Helper() + s, err := Load(schemaSQL) + if err != nil { + t.Fatal(err) + } + for _, p := range s.Problems { + if strings.Contains(p.Message, want) { + return s + } + } + t.Errorf("no problem containing %q; problems: %v", want, s.Problems) + return s +} + +func TestCreateTableLike(t *testing.T) { + s := loadOK(t, ` +CREATE TABLE src ( + id INT NOT NULL PRIMARY KEY, + name VARCHAR(10), + parent_id INT, + UNIQUE KEY uq_name (name), + CONSTRAINT src_ok CHECK (id > 0), + CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES src (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC + PARTITION BY HASH (id) PARTITIONS 2; +CREATE TABLE dup LIKE src; +`) + d := s.Table("dup") + if d == nil { + t.Fatal("no dup") + } + if len(d.Columns) != 3 || len(d.Keys) != 2 || len(d.Checks) != 1 { + t.Errorf("copied %d columns, %d keys, %d checks", len(d.Columns), len(d.Keys), len(d.Checks)) + } + if d.Engine != "InnoDB" || d.Charset != "utf8mb4" || d.Collation != "utf8mb4_bin" || d.RowFormat != "DYNAMIC" { + t.Errorf("options not copied: %q %q %q %q", d.Engine, d.Charset, d.Collation, d.RowFormat) + } + // LIKE copies no foreign keys and no partitioning (measured against mysqld 8.4) + if len(d.ForeignKeys) != 0 { + t.Errorf("LIKE copied %d foreign keys", len(d.ForeignKeys)) + } + if d.Partitioning != nil { + t.Error("LIKE copied the partitioning") + } + // the copies are the new table's own: renaming a copied column must not touch src + if c := d.Columns[1]; c.Name != "name" { + t.Fatalf("column %q", c.Name) + } + d.Columns[1].Name = "renamed" + if s.Table("src").Columns[1].Name != "name" { + t.Error("the copy shares src's column") + } + + loadProblem(t, "CREATE TABLE t2 LIKE nope;", "no such table") + loadProblem(t, "CREATE TABLE t (a INT);\nCREATE TABLE t (b INT);", "already exists") + loadOK(t, "CREATE TABLE t (a INT);\nCREATE TABLE IF NOT EXISTS t (b INT);") + loadProblem(t, "CREATE TABLE t AS SELECT 1 AS a;", "columns come from the query") +} + +func TestRenameTableStatement(t *testing.T) { + s := loadOK(t, ` +CREATE TABLE a (id INT PRIMARY KEY); +CREATE TABLE c (id INT PRIMARY KEY); +CREATE VIEW v AS SELECT id FROM a; +CREATE TRIGGER trg BEFORE INSERT ON a FOR EACH ROW SET NEW.id = 1; +RENAME TABLE a TO b, v TO w; +`) + if s.Table("b") == nil || s.Table("a") != nil { + t.Error("a not renamed to b") + } + if s.View("w") == nil || s.View("v") != nil { + t.Error("v not renamed to w") + } + if len(s.Triggers) != 1 || s.Triggers[0].Table != "b" { + t.Errorf("trigger follows the rename: %+v", s.Triggers) + } + loadProblem(t, "RENAME TABLE nope TO x;", "no such table") +} + +func TestRowFormats(t *testing.T) { + s := loadOK(t, ` +CREATE TABLE f (a INT) ROW_FORMAT=FIXED; +CREATE TABLE z (a INT) ROW_FORMAT=COMPRESSED; +CREATE TABLE r (a INT) ROW_FORMAT=REDUNDANT; +CREATE TABLE c (a INT) ROW_FORMAT=COMPACT; +`) + for name, want := range map[string]string{"f": "FIXED", "z": "COMPRESSED", "r": "REDUNDANT", "c": "COMPACT"} { + if got := s.Table(name).RowFormat; got != want { + t.Errorf("%s: ROW_FORMAT %q, want %q", name, got, want) + } + } +} + +func TestPartitioningForms(t *testing.T) { + s := loadOK(t, ` +CREATE TABLE lc (a INT NOT NULL, b INT NOT NULL) PARTITION BY LIST COLUMNS (a, b) ( + PARTITION p0 VALUES IN ((1, 2), (3, 4)), + PARTITION p1 VALUES IN ((5, 6)) COMMENT = 'rest' +); +CREATE TABLE rc (a INT NOT NULL, b INT NOT NULL) PARTITION BY RANGE COLUMNS (a, b) ( + PARTITION p0 VALUES LESS THAN (10, 20), + PARTITION p1 VALUES LESS THAN (MAXVALUE, MAXVALUE) +); +CREATE TABLE rmax (a INT NOT NULL) PARTITION BY RANGE (a) ( + PARTITION p0 VALUES LESS THAN (10), + PARTITION p1 VALUES LESS THAN MAXVALUE +); +CREATE TABLE sk (a INT NOT NULL, b INT NOT NULL, PRIMARY KEY (a, b)) + PARTITION BY RANGE (a) + SUBPARTITION BY LINEAR KEY ALGORITHM=2 (b) SUBPARTITIONS 2 ( + PARTITION p0 VALUES LESS THAN (10) +); +`) + lc := s.Table("lc").Partitioning + if lc == nil || lc.Kind != "LIST" || !lc.Columns || len(lc.Cols) != 2 { + t.Fatalf("lc partitioning: %+v", lc) + } + if lc.Parts[0].Bound != "(1, 2), (3, 4)" || lc.Parts[1].Bound != "(5, 6)" { + t.Errorf("lc bounds: %q, %q", lc.Parts[0].Bound, lc.Parts[1].Bound) + } + if lc.Parts[1].Comment != "rest" { + t.Errorf("lc comment: %q", lc.Parts[1].Comment) + } + rc := s.Table("rc").Partitioning + if rc.Parts[0].Bound != "10, 20" || rc.Parts[1].Bound != "MAXVALUE, MAXVALUE" { + t.Errorf("rc bounds: %q, %q", rc.Parts[0].Bound, rc.Parts[1].Bound) + } + // a COLUMNS partition's per-column MAXVALUE folds into the bound's text, never MaxValue + if rc.Parts[1].MaxValue { + t.Error("rc p1 marked MaxValue") + } + rmax := s.Table("rmax").Partitioning + if rmax.Parts[0].MaxValue || rmax.Parts[0].Bound != "10" || !rmax.Parts[1].MaxValue { + t.Errorf("rmax parts: %+v", rmax.Parts) + } + sub := s.Table("sk").Partitioning.Sub + if sub == nil || sub.Kind != "KEY" || !sub.Linear || sub.Algorithm != 2 || len(sub.Cols) != 1 || sub.Num != 2 { + t.Errorf("sk subpartitioning: %+v", sub) + } + + // an explicit partition list under HASH, and a per-partition option this package does + // not model, are problems rather than differences the model cannot see + s = loadProblem(t, "CREATE TABLE h (a INT) PARTITION BY HASH (a) (PARTITION p0, PARTITION p1);", + "an explicit partition list is not supported") + if s.Table("h").Partitioning != nil { + t.Error("h kept a partitioning") + } + loadProblem(t, "CREATE TABLE r2 (a INT) PARTITION BY RANGE (a) (PARTITION p0 VALUES LESS THAN (10) MAX_ROWS = 100);", + "definition not understood") +} + +func TestAlterTableForms(t *testing.T) { + s := loadOK(t, ` +CREATE TABLE t ( + a INT NOT NULL PRIMARY KEY, + b VARCHAR(10), + KEY ix_b (b), + CONSTRAINT c_pos CHECK (a > 0) +); +ALTER TABLE t ALTER COLUMN b SET DEFAULT 'x'; +ALTER TABLE t ALTER COLUMN b SET INVISIBLE; +ALTER TABLE t ALTER INDEX ix_b INVISIBLE; +ALTER TABLE t ALTER CHECK c_pos NOT ENFORCED; +ALTER TABLE t RENAME INDEX ix_b TO ix_b2; +ALTER TABLE t CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; +ALTER TABLE t RENAME TO t2; +`) + t2 := s.Table("t2") + if t2 == nil { + t.Fatal("no t2") + } + b := t2.Column("b") + if b.Default == nil || !b.Invisible { + t.Errorf("b: default %v, invisible %v", b.Default, b.Invisible) + } + if k := t2.key("ix_b2"); k == nil || !k.Invisible { + t.Errorf("ix_b2: %+v", k) + } + if t2.Checks[0].Enforced { + t.Error("c_pos still enforced") + } + if t2.Charset != "utf8mb4" || t2.Collation != "utf8mb4_bin" { + t.Errorf("charset %q collation %q", t2.Charset, t2.Collation) + } + + base := "CREATE TABLE t (a INT NOT NULL PRIMARY KEY, b VARCHAR(10), KEY ix_b (b));\n" + loadProblem(t, base+"ALTER TABLE t ADD COLUMN a INT;", "duplicate column") + loadProblem(t, base+"ALTER TABLE t DROP COLUMN nope;", "no such column") + loadProblem(t, base+"ALTER TABLE t MODIFY nope INT;", "no such column") + loadProblem(t, base+"ALTER TABLE t RENAME COLUMN nope TO x;", "no such column") + loadProblem(t, base+"ALTER TABLE t RENAME INDEX nope TO x;", "no such index") + loadProblem(t, base+"ALTER TABLE t DROP INDEX nope;", "no such index") + loadProblem(t, base+"ALTER TABLE t DROP CONSTRAINT nope;", "no such constraint") + loadProblem(t, base+"ALTER TABLE t ALTER COLUMN nope SET DEFAULT 1;", "no such column") + + // HASH partition arithmetic: ADD PARTITION PARTITIONS / COALESCE PARTITION move Num, + // DROP PARTITION and REORGANIZE PARTITION rewrite a RANGE's part list + s = loadOK(t, ` +CREATE TABLE h (a INT NOT NULL PRIMARY KEY) PARTITION BY HASH (a) PARTITIONS 4; +ALTER TABLE h ADD PARTITION PARTITIONS 2; +ALTER TABLE h COALESCE PARTITION 3; +CREATE TABLE r (a INT NOT NULL PRIMARY KEY) PARTITION BY RANGE (a) ( + PARTITION p0 VALUES LESS THAN (10), + PARTITION p1 VALUES LESS THAN (20), + PARTITION pmax VALUES LESS THAN MAXVALUE +); +ALTER TABLE r DROP PARTITION p0; +ALTER TABLE r REORGANIZE PARTITION p1 INTO ( + PARTITION p1a VALUES LESS THAN (15), + PARTITION p1b VALUES LESS THAN (20) +); +`) + if n := s.Table("h").Partitioning.Num; n != 3 { + t.Errorf("h partitions: %d", n) + } + var names []string + for _, p := range s.Table("r").Partitioning.Parts { + names = append(names, p.Name) + } + if strings.Join(names, ",") != "p1a,p1b,pmax" { + t.Errorf("r parts: %v", names) + } + loadProblem(t, ` +CREATE TABLE r (a INT NOT NULL PRIMARY KEY) PARTITION BY RANGE (a) (PARTITION p0 VALUES LESS THAN (10)); +ALTER TABLE r REORGANIZE PARTITION p0 INTO (PARTITION p0a VALUES LESS THAN (5) MAX_ROWS = 10);`, + "REORGANIZE PARTITION: definition not understood") +} + +func TestCreateViewDDL(t *testing.T) { + base := "CREATE TABLE t (a INT NOT NULL PRIMARY KEY);\nCREATE VIEW v AS SELECT a FROM t;\n" + loadProblem(t, base+"CREATE VIEW v AS SELECT a FROM t;", "view already exists") + loadProblem(t, base+"CREATE VIEW t AS SELECT a FROM t;", "a table of that name exists") + s := loadOK(t, base+"CREATE OR REPLACE VIEW v (b) AS SELECT a + 1 FROM t;") + if v := s.View("v"); len(v.Columns) != 1 || v.Columns[0] != "b" { + t.Errorf("v not replaced: %+v", v) + } +} + +func TestLoadProblemStatements(t *testing.T) { + loadProblem(t, "DROP INDEX nope ON missing;", "no such table") + loadProblem(t, "CREATE TABLE t (a INT);\nDROP INDEX nope ON t;", "no such index") + loadProblem(t, "DROP VIEW nope;", "no such view") + loadProblem(t, "-- sqlshape: server max_sp_recursion_depth = 300\nCREATE TABLE t (a INT);", "want 0-255") + loadProblem(t, "KILL 1;", "not applied to the schema") +} diff --git a/check/mysql/internal/schema/main_test.go b/check/mysql/internal/schema/main_test.go new file mode 100644 index 0000000..9f2bbd7 --- /dev/null +++ b/check/mysql/internal/schema/main_test.go @@ -0,0 +1,12 @@ +package schema + +import ( + "os" + "testing" + + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// One mysqld per set of server settings serves the whole package (mysqltest.Main); a test +// gets it with the database dropped, not a server of its own. +func TestMain(m *testing.M) { os.Exit(mysqltest.Main(m)) } diff --git a/check/mysql/internal/schema/routines_more_test.go b/check/mysql/internal/schema/routines_more_test.go new file mode 100644 index 0000000..6172b70 --- /dev/null +++ b/check/mysql/internal/schema/routines_more_test.go @@ -0,0 +1,293 @@ +package schema + +import "testing" + +// TestRoutine_NotFound covers Schema.Routine's own "not found" return. +func TestRoutine_NotFound(t *testing.T) { + s, err := Load(`-- sqlshape: mysql 8.4 +CREATE PROCEDURE p() BEGIN SELECT 1; END; +`) + if err != nil { + t.Fatal(err) + } + if r := s.Routine("no_such"); r != nil { + t.Errorf("got %+v, want nil", r) + } +} + +// TestRoutineOf covers Schema.RoutineOf directly (within this package's own coverage; +// check/mysql/internal/analyze already exercises it heavily, but from a different test +// binary). +func TestRoutineOf(t *testing.T) { + s, err := Load(`-- sqlshape: mysql 8.4 +CREATE PROCEDURE p() BEGIN SELECT 1; END; +CREATE FUNCTION f() RETURNS INT BEGIN RETURN 1; END; +`) + if err != nil { + t.Fatal(err) + } + if s.RoutineOf(Procedure, "p") == nil { + t.Error("RoutineOf(Procedure, p) = nil") + } + if s.RoutineOf(Function, "p") != nil { + t.Error("RoutineOf(Function, p) should be nil: p is a procedure") + } + if s.RoutineOf(Procedure, "no_such") != nil { + t.Error("RoutineOf(Procedure, no_such) should be nil") + } +} + +// TestCreateTrigger_UnknownTable is a Problem: CREATE TRIGGER on a table the schema does +// not declare. +func TestCreateTrigger_UnknownTable(t *testing.T) { + s, err := Load(`-- sqlshape: mysql 8.4 +CREATE TRIGGER trg BEFORE INSERT ON no_such_table FOR EACH ROW SET @x = 1; +`) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) != 1 { + t.Fatalf("want 1 problem (unknown table), got %v", s.Problems) + } + if len(s.Triggers) != 0 { + t.Errorf("trigger should not be added: %v", s.Triggers) + } +} + +// TestCreateTrigger_AlreadyExists (no IF NOT EXISTS) is a Problem, as opposed to +// TestCreateTriggerIfNotExistsAndDropIfExists's own silently-skipped repeat. +func TestCreateTrigger_AlreadyExists(t *testing.T) { + s, err := Load(`-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY); +CREATE TRIGGER trg BEFORE INSERT ON t FOR EACH ROW SET @x = 1; +CREATE TRIGGER trg BEFORE INSERT ON t FOR EACH ROW SET @x = 2; +`) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) != 1 { + t.Fatalf("want 1 problem (trigger already exists), got %v", s.Problems) + } + if len(s.Triggers) != 1 { + t.Errorf("want the first trigger kept, got %v", s.Triggers) + } +} + +// TestDropTrigger_NotExists (no IF EXISTS) is a Problem. +func TestDropTrigger_NotExists(t *testing.T) { + s, err := Load(`-- sqlshape: mysql 8.4 +DROP TRIGGER no_such_trigger; +`) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) != 1 { + t.Fatalf("want 1 problem (no such trigger), got %v", s.Problems) + } +} + +// TestCreateRoutine_IfNotExists_AlreadyExists: CREATE PROCEDURE/FUNCTION IF NOT EXISTS +// silently keeps the first declaration (createRoutine's own early return, distinct from +// TestRoutineNamespacesSeparate and the "already exists" Problem below). +func TestCreateRoutine_IfNotExists_AlreadyExists(t *testing.T) { + s, err := Load(`-- sqlshape: mysql 8.4 +CREATE PROCEDURE IF NOT EXISTS p() BEGIN SELECT 1; END; +CREATE PROCEDURE IF NOT EXISTS p() BEGIN SELECT 2; END; +CREATE FUNCTION IF NOT EXISTS f() RETURNS INT BEGIN RETURN 1; END; +CREATE FUNCTION IF NOT EXISTS f() RETURNS INT BEGIN RETURN 2; END; +`) + if err != nil { + t.Fatal(err) + } + for _, p := range s.Problems { + t.Errorf("problem: %s", p) + } + if len(s.Routines) != 2 { + t.Fatalf("want 2 routines (p, f), got %v", s.Routines) + } +} + +// TestCreateRoutine_AlreadyExists (no IF NOT EXISTS) is a Problem, for both PROCEDURE and +// FUNCTION. +func TestCreateRoutine_AlreadyExists(t *testing.T) { + s, err := Load(`-- sqlshape: mysql 8.4 +CREATE PROCEDURE p() BEGIN SELECT 1; END; +CREATE PROCEDURE p() BEGIN SELECT 2; END; +CREATE FUNCTION f() RETURNS INT BEGIN RETURN 1; END; +CREATE FUNCTION f() RETURNS INT BEGIN RETURN 2; END; +`) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) != 2 { + t.Fatalf("want 2 problems (p and f already exist), got %v", s.Problems) + } +} + +// TestDropRoutine_NotExists and TestAlterRoutine_NotExists cover their own "no such +// PROCEDURE/FUNCTION" Problems (as opposed to TestLoadTriggersAndRoutines's own ALTER/DROP +// of routines that do exist). +func TestDropRoutine_NotExists(t *testing.T) { + s, err := Load(`-- sqlshape: mysql 8.4 +DROP PROCEDURE no_such_proc; +`) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) != 1 { + t.Fatalf("want 1 problem, got %v", s.Problems) + } +} + +func TestAlterRoutine_NotExists(t *testing.T) { + s, err := Load(`-- sqlshape: mysql 8.4 +ALTER PROCEDURE no_such_proc COMMENT 'x'; +`) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) != 1 { + t.Fatalf("want 1 problem, got %v", s.Problems) + } +} + +// TestApplyChistics_AllKinds exercises every characteristic applyChistics reads +// (DETERMINISTIC, SQL data access, SQL SECURITY), not just DETERMINISTIC (already exercised +// by routinesSample's own total_for_customer). +func TestApplyChistics_AllKinds(t *testing.T) { + s, err := Load(`-- sqlshape: mysql 8.4 +CREATE FUNCTION f(x INT) RETURNS INT +NOT DETERMINISTIC +MODIFIES SQL DATA +SQL SECURITY INVOKER +BEGIN + RETURN x; +END; +`) + if err != nil { + t.Fatal(err) + } + for _, p := range s.Problems { + t.Errorf("problem: %s", p) + } + r := s.Routine("f") + if r == nil { + t.Fatal("function not loaded") + } + if r.Deterministic { + t.Error("Deterministic = true, want false (NOT DETERMINISTIC)") + } + if r.DataAccess != "MODIFIES_SQL_DATA" && r.DataAccess != "CONTAINS_SQL" { + // exact spelling is whatever hooks_sp.go's sp_chistic value carries; either way it + // must have been read from MODIFIES SQL DATA, not left at the CONTAINS_SQL default, + // unless the server itself spells it that way. + t.Logf("DataAccess = %q", r.DataAccess) + } + if r.Security != "INVOKER" { + t.Errorf("Security = %q, want INVOKER", r.Security) + } +} + +// TestParamModes_INOUT_OUT exercises paramMode's OUT and INOUT branches (IN is already +// exercised throughout routinesSample). +func TestParamModes_INOUT_OUT(t *testing.T) { + s, err := Load(`-- sqlshape: mysql 8.4 +CREATE PROCEDURE p(IN a INT, OUT b INT, INOUT c INT) +BEGIN + SET b = a, c = c + 1; +END; +`) + if err != nil { + t.Fatal(err) + } + for _, p := range s.Problems { + t.Errorf("problem: %s", p) + } + r := s.Routine("p") + if r == nil { + t.Fatal("procedure not loaded") + } + if len(r.Params) != 3 || r.Params[0].Mode != "IN" || r.Params[1].Mode != "OUT" || r.Params[2].Mode != "INOUT" { + t.Fatalf("got %+v, want IN, OUT, INOUT", r.Params) + } +} + +// TestSpDirectives_InvalidErrorName is a Problem: the declared name is not a Go identifier. +func TestSpDirectives_InvalidErrorName(t *testing.T) { + s, err := Load(`-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY); +-- sqlshape: error 30001 = 123bad +CREATE TRIGGER trg BEFORE INSERT ON t FOR EACH ROW SET @x = 1; +`) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) != 1 { + t.Fatalf("want 1 problem (invalid error name), got %v", s.Problems) + } +} + +// TestSpDirectives_MalformedErrorDirective: `error` without `= Name` is not itself refused +// by spDirectives (only ParseRaises' own Cut fails to find "="), but is kept in Directives +// verbatim -- a later ParseRaises(trg.Directives) call (body.go, dialect/schema.go) simply +// finds nothing to report for it (directives.go's own !ok branch). +func TestSpDirectives_MalformedErrorDirective(t *testing.T) { + s, err := Load(`-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY); +-- sqlshape: error not-well-formed +CREATE TRIGGER trg BEFORE INSERT ON t FOR EACH ROW SET @x = 1; +`) + if err != nil { + t.Fatal(err) + } + for _, p := range s.Problems { + t.Errorf("problem: %s", p) + } + tg := s.Trigger("trg") + if tg == nil { + t.Fatal("trigger not loaded") + } + if len(tg.Directives) != 1 { + t.Fatalf("want the malformed directive kept verbatim, got %v", tg.Directives) + } + if got := ParseRaises(tg.Directives); len(got) != 0 { + t.Errorf("ParseRaises(%v) = %v, want none (no '=')", tg.Directives, got) + } +} + +// TestSpDirectives_HashCommentSkipped: a `#`-style comment line preceding a `--` +// directive is itself skipped (not mistaken for the statement proper, which would stop +// leadingDirectives from reading the directive below it) -- it does not carry a directive +// of its own (only `--` does, directiveLine's own regex). +func TestSpDirectives_HashCommentSkipped(t *testing.T) { + s, err := Load(`-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY); +# just a note, not a directive +-- sqlshape: error 30001 = Foo +CREATE TRIGGER trg BEFORE INSERT ON t FOR EACH ROW SET @x = 1; +`) + if err != nil { + t.Fatal(err) + } + for _, p := range s.Problems { + t.Errorf("problem: %s", p) + } + tg := s.Trigger("trg") + if tg == nil { + t.Fatal("trigger not loaded") + } + if got := ParseRaises(tg.Directives); len(got) != 1 || got[0].Name != "Foo" { + t.Fatalf("got %+v, want one RaisedError{30001, Foo} (the # line is skipped, not read as one)", got) + } +} + +// TestParseRaises_Direct is a direct unit test of the exported ParseRaises, covering its +// own "not an error directive" skip -- unreachable through schema.Load (spDirectives +// already refuses anything but an "error " directive as a Problem before it ever reaches +// Directives). +func TestParseRaises_Direct(t *testing.T) { + got := ParseRaises([]string{"mysql 8.4", "error 30001 = Foo", "error malformed"}) + if len(got) != 1 || got[0].Code != "30001" || got[0].Name != "Foo" { + t.Fatalf("got %+v, want one RaisedError{30001, Foo}", got) + } +} diff --git a/check/mysql/internal/schema/routines_test.go b/check/mysql/internal/schema/routines_test.go new file mode 100644 index 0000000..9cb49b9 --- /dev/null +++ b/check/mysql/internal/schema/routines_test.go @@ -0,0 +1,304 @@ +package schema + +import ( + "strings" + "testing" +) + +const routinesSample = `-- sqlshape: mysql 8.4 +CREATE TABLE customers ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + balance DECIMAL(12,2) NOT NULL DEFAULT 0 +); + +CREATE TABLE orders ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + customer_id BIGINT UNSIGNED NOT NULL, + amount DECIMAL(12,2) NOT NULL +); + +CREATE TABLE audit ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + what VARCHAR(50) +); + +-- sqlshape: error 30001 = OrderTooLarge +CREATE DEFINER=root@localhost TRIGGER trg_orders_check BEFORE INSERT ON orders FOR EACH ROW +BEGIN + IF NEW.amount > 1000 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'too large', MYSQL_ERRNO = 30001; + END IF; + INSERT INTO audit (what) VALUES ('order'); +END; + +CREATE TRIGGER trg_orders_after AFTER INSERT ON orders FOR EACH ROW FOLLOWS trg_orders_check +BEGIN + INSERT INTO audit (what) VALUES ('after'); +END; + +CREATE PROCEDURE add_order(IN cust BIGINT UNSIGNED, IN amt DECIMAL(12,2), OUT new_id INT) +COMMENT 'adds an order' +BEGIN + INSERT INTO orders (customer_id, amount) VALUES (cust, amt); + SELECT LAST_INSERT_ID() INTO new_id; +END; + +CREATE FUNCTION total_for_customer(cust BIGINT UNSIGNED) RETURNS DECIMAL(12,2) +DETERMINISTIC +READS SQL DATA +BEGIN + DECLARE total DECIMAL(12,2); + SELECT SUM(amount) INTO total FROM orders WHERE customer_id = cust; + RETURN total; +END; + +ALTER PROCEDURE add_order COMMENT 'adds an order, updated' SQL SECURITY INVOKER; +ALTER FUNCTION total_for_customer COMMENT 'totals'; +DROP TRIGGER trg_orders_after; +DROP PROCEDURE IF EXISTS no_such_proc; +DROP FUNCTION IF EXISTS no_such_func; +` + +func TestLoadTriggersAndRoutines(t *testing.T) { + s, err := Load(routinesSample) + if err != nil { + t.Fatal(err) + } + for _, p := range s.Problems { + t.Errorf("problem: %s", p) + } + + if len(s.Triggers) != 1 { + t.Fatalf("triggers: got %d, want 1 (trg_orders_after dropped): %v", len(s.Triggers), s.Triggers) + } + trg := s.Trigger("trg_orders_check") + if trg == nil { + t.Fatal("no trg_orders_check") + } + if trg.Table != "orders" || trg.Timing != "BEFORE" || trg.Event != "INSERT" { + t.Errorf("trigger fields: %+v", trg) + } + if trg.OrderClause != "" || trg.OrderTrigger != "" { + t.Errorf("trigger order: %+v", trg) + } + if len(trg.Directives) != 1 || !strings.Contains(trg.Directives[0], "error 30001") { + t.Errorf("trigger directives: %v", trg.Directives) + } + if trg.Body == nil { + t.Error("trigger body nil") + } + if trg.Definition == "" || !strings.Contains(trg.Definition, "SIGNAL") { + t.Errorf("trigger definition: %q", trg.Definition) + } + + if s.Trigger("trg_orders_after") != nil { + t.Error("trg_orders_after should have been dropped by DROP TRIGGER") + } + + proc := s.Routine("add_order") + if proc == nil { + t.Fatal("no add_order") + } + if proc.Kind != Procedure { + t.Errorf("add_order kind: %v", proc.Kind) + } + if len(proc.Params) != 3 { + t.Fatalf("add_order params: %+v", proc.Params) + } + if proc.Params[0].Mode != "IN" || proc.Params[0].Name != "cust" || proc.Params[0].Type.Name != "bigint" || !proc.Params[0].Type.Unsigned { + t.Errorf("add_order param 0: %+v", proc.Params[0]) + } + if proc.Params[2].Mode != "OUT" || proc.Params[2].Name != "new_id" { + t.Errorf("add_order param 2: %+v", proc.Params[2]) + } + + fn := s.Routine("total_for_customer") + if fn == nil { + t.Fatal("no total_for_customer") + } + if fn.Kind != Function { + t.Errorf("total_for_customer kind: %v", fn.Kind) + } + if !fn.Deterministic { + t.Error("total_for_customer should be DETERMINISTIC") + } + if fn.DataAccess != "READS_SQL_DATA" { + t.Errorf("total_for_customer data access: %q", fn.DataAccess) + } + if fn.Returns.Name != "decimal" { + t.Errorf("total_for_customer returns: %+v", fn.Returns) + } + if fn.Security != "DEFINER" { // ALTER FUNCTION did not change it (no schema effect) + t.Errorf("total_for_customer security: %q", fn.Security) + } + + if proc.Security != "DEFINER" { + // ALTER PROCEDURE has no schema effect on Security either: characteristics are not + // modeled as changed by ALTER. Kept at the CREATE-time default. + t.Errorf("add_order security: %q (ALTER has no schema effect, want DEFINER)", proc.Security) + } +} + +func TestTriggerDropWithTable(t *testing.T) { + sql := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY); +CREATE TRIGGER trg BEFORE INSERT ON t FOR EACH ROW SET @x = 1; +DROP TABLE t; +` + s, err := Load(sql) + if err != nil { + t.Fatal(err) + } + for _, p := range s.Problems { + t.Errorf("problem: %s", p) + } + if len(s.Triggers) != 0 { + t.Errorf("DROP TABLE should drop its triggers, got %v", s.Triggers) + } +} + +func TestTriggerRenameWithTable(t *testing.T) { + sql := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY); +CREATE TRIGGER trg BEFORE INSERT ON t FOR EACH ROW SET @x = 1; +RENAME TABLE t TO t2; +` + s, err := Load(sql) + if err != nil { + t.Fatal(err) + } + for _, p := range s.Problems { + t.Errorf("problem: %s", p) + } + trg := s.Trigger("trg") + if trg == nil || trg.Table != "t2" { + t.Errorf("trigger should follow the rename: %+v", trg) + } +} + +func TestCreateTriggerIfNotExistsAndDropIfExists(t *testing.T) { + sql := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY); +CREATE TRIGGER IF NOT EXISTS trg BEFORE INSERT ON t FOR EACH ROW SET @x = 1; +CREATE TRIGGER IF NOT EXISTS trg BEFORE INSERT ON t FOR EACH ROW SET @x = 2; +DROP TRIGGER IF EXISTS no_such; +DROP PROCEDURE IF EXISTS no_such_proc; +` + s, err := Load(sql) + if err != nil { + t.Fatal(err) + } + for _, p := range s.Problems { + t.Errorf("problem: %s", p) + } + if len(s.Triggers) != 1 { + t.Errorf("triggers: %v", s.Triggers) + } +} + +func TestRoutineNamespacesSeparate(t *testing.T) { + sql := `-- sqlshape: mysql 8.4 +CREATE PROCEDURE p() BEGIN SELECT 1; END; +CREATE FUNCTION p() RETURNS INT BEGIN RETURN 1; END; +DROP PROCEDURE p; +` + s, err := Load(sql) + if err != nil { + t.Fatal(err) + } + for _, p := range s.Problems { + t.Errorf("problem: %s", p) + } + if len(s.Routines) != 1 { + t.Fatalf("routines: %v", s.Routines) + } + if s.Routines[0].Kind != Function { + t.Errorf("remaining routine should be the function: %+v", s.Routines[0]) + } +} + +// TestFunctionNotNullDirective: `-- sqlshape: not null` above a CREATE FUNCTION is read, +// the same override postgres/schema.go's createFunction accepts, and sets Routine.NotNull +// rather than being rejected as an unknown directive. +func TestFunctionNotNullDirective(t *testing.T) { + sql := `-- sqlshape: mysql 8.4 +-- sqlshape: not null +CREATE FUNCTION f() RETURNS INT BEGIN RETURN 1; END; +` + s, err := Load(sql) + if err != nil { + t.Fatal(err) + } + for _, p := range s.Problems { + t.Errorf("problem: %s", p) + } + if len(s.Routines) != 1 || !s.Routines[0].NotNull { + t.Fatalf("routines: %+v", s.Routines) + } +} + +// TestNotNullDirectiveOnProcedureIsProblem: `not null` names a function's own result, so a +// PROCEDURE (which returns nothing) rejects it as an unknown directive, same as any other +// directive it does not recognize. +func TestNotNullDirectiveOnProcedureIsProblem(t *testing.T) { + sql := `-- sqlshape: mysql 8.4 +-- sqlshape: not null +CREATE PROCEDURE p() BEGIN SELECT 1; END; +` + s, err := Load(sql) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) != 1 { + t.Errorf("want 1 problem for not null on a procedure, got %v", s.Problems) + } +} + +// TestNotNullDirectiveOnTriggerIsProblem: a trigger has no result at all, so `not null` +// above one is an unknown directive too. +func TestNotNullDirectiveOnTriggerIsProblem(t *testing.T) { + sql := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY); +-- sqlshape: not null +CREATE TRIGGER trg BEFORE INSERT ON t FOR EACH ROW SET @x = 1; +` + s, err := Load(sql) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) != 1 { + t.Errorf("want 1 problem for not null on a trigger, got %v", s.Problems) + } +} + +func TestUnknownDirectiveOnTriggerIsProblem(t *testing.T) { + sql := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY); +-- sqlshape: bogus +CREATE TRIGGER trg BEFORE INSERT ON t FOR EACH ROW SET @x = 1; +` + s, err := Load(sql) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) != 1 { + t.Errorf("want 1 problem for the unknown directive, got %v", s.Problems) + } +} + +func TestTriggerFollowsMissingAnchorIsProblem(t *testing.T) { + sql := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY); +CREATE TRIGGER trg2 BEFORE INSERT ON t FOR EACH ROW FOLLOWS ghost SET @x = 1; +` + s, err := Load(sql) + if err != nil { + t.Fatal(err) + } + if len(s.Problems) != 1 { + t.Errorf("want 1 problem for the missing FOLLOWS anchor, got %v", s.Problems) + } + if len(s.Triggers) != 0 { + t.Errorf("trigger should not be added: %v", s.Triggers) + } +} diff --git a/check/mysql/internal/schema/schema.go b/check/mysql/internal/schema/schema.go new file mode 100644 index 0000000..b4ae5f9 --- /dev/null +++ b/check/mysql/internal/schema/schema.go @@ -0,0 +1,2464 @@ +// Package schema loads a MySQL schema.sql into a model of its tables and views. +// +// It reads the DDL through mysqlast, the server's own parse tree: CREATE TABLE (column +// definitions, keys, foreign keys, checks, table options), CREATE INDEX, CREATE VIEW, +// ALTER TABLE, RENAME TABLE, DROP TABLE / INDEX / VIEW. Statements it cannot apply are +// reported as Problems with their position, and the schema is still built from the rest, +// so that a checker can say what it did not understand instead of failing outright. +// +// The model is MySQL's: types keep their length, precision, signedness, charset and +// collation; keys are the four MySQL kinds; a column knows its default, its ON UPDATE, +// its generation expression and its visibility. +package schema + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlast" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlparse" + "github.com/kr9ly/sqlshape/v2/x/dialect" + "github.com/kr9ly/sqlshape/v2/x/sqlmode" +) + +// Schema is the loaded schema. +type Schema struct { + // Version is the declared MySQL version ("8.4"), from `-- sqlshape: mysql 8.4`. + Version string + // Settings are the server variables the schema declares (`-- sqlshape: server ...`), + // the server's defaults where it declares none. + Settings Settings + Tables []*Table + Views []*View + Triggers []*Trigger + Routines []*Routine + Events []*Event + Problems []Problem + // cur is the statement being applied, for the element texts (Column.Text ...) + cur string +} + +// Settings are the server variables that change how statements are judged. The schema +// declares them as `-- sqlshape: server = ` lines; these two are the +// variables MySQL's loader reads, any other name is a problem. +type Settings struct { + // SQLMode is the sql_mode, expanded (sqlmode.Default when undeclared). The parser reads + // its lexer bits; the analyzer ONLY_FULL_GROUP_BY, the strict flags, REAL_AS_FLOAT and + // NO_UNSIGNED_SUBTRACTION. + SQLMode sqlmode.Mode + // LowerCaseTableNames is lower_case_table_names: 0 compares table and view names + // case-sensitively (the Linux default), 1 stores them lower-cased and compares + // case-insensitively (Windows), 2 stores them as declared and compares + // case-insensitively (macOS). + LowerCaseTableNames int + // MaxSPRecursionDepth is max_sp_recursion_depth (0..255, the server's default is 0): + // analyze/body.go's walkCall reads it to decide whether a routine's own direct + // recursion is certain to fail (1456) -- 0 (or undeclared) means every actual + // recursive invocation is refused, above 0 the depth reached is not something this + // checker proves statically, so it predicts nothing (measured: + // TestAdv3BodyRecursionDepthSettingIgnoredBy1456). + MaxSPRecursionDepth int +} + +// SettingNames are the server variables the loader reads. +var SettingNames = []string{"sql_mode", "lower_case_table_names", "max_sp_recursion_depth"} + +// ParseMode is the sql_mode as the parser takes it. +func (st Settings) ParseMode() mysqlparse.Mode { return mysqlparse.Mode(uint32(st.SQLMode)) } + +// Strict is whether either strict flag is set (THD::is_strict_mode). +func (st Settings) Strict() bool { return st.SQLMode.Strict() } + +// tableKey is the form a table or view name is stored and compared in. +func (s *Schema) tableKey(name string) string { + if s.Settings.LowerCaseTableNames == 1 { + return strings.ToLower(name) + } + return name +} + +// CanonicalName is the stored spelling of a table or view name written in a directive +// (`unfiltered Users`, `waive USERS ...`): the declared table's or view's name when one +// matches under lower_case_table_names, else the name in stored form. The facts name leaves +// by the stored spelling, so a waiver keyed by it meets its leaf. +func (s *Schema) CanonicalName(name string) string { + if t := s.Table(name); t != nil { + return t.Name + } + if v := s.View(name); v != nil { + return v.Name + } + return s.tableKey(name) +} + +// sameTable compares two table or view names as the server does under +// lower_case_table_names. +func (s *Schema) sameTable(a, b string) bool { + if s.Settings.LowerCaseTableNames == 0 { + return a == b + } + return strings.EqualFold(a, b) +} + +// Problem is a statement, or part of one, the loader could not apply. +type Problem struct { + Position int // byte offset in the schema text + Message string +} + +func (p Problem) String() string { return fmt.Sprintf("%d: %s", p.Position, p.Message) } + +// Table is a base table. +type Table struct { + Name string + Columns []*Column + Keys []*Key + ForeignKeys []*ForeignKey + Checks []*Check + Engine string + Charset string + Collation string + // RowFormat is the table's own `ROW_FORMAT=...` option ("DYNAMIC", "COMPRESSED", ...), + // "" when the statement declares none (the server's own default, not written back). + RowFormat string + // AutoIncrementStart is the table's own `AUTO_INCREMENT=` option as written, "" when + // the statement declares none. A live counter is data, not schema (dump.normalizeTable + // strips it from a canonicalized table read back from a server), but the value an + // author wrote for a table's first creation is a schema decision the loader still keeps + // here so a fresh CREATE TABLE can reproduce it (see dump.pinAutoIncrement). + AutoIncrementStart string + Comment string + Temporary bool + // Partitioning is the table's PARTITION BY clause, nil for an unpartitioned table. + Partitioning *Partitioning + // Definition is the CREATE TABLE text; Alters the ALTER TABLE texts applied after it. + Definition string + Alters []string + // Directives are the `-- sqlshape: ...` lines written above the CREATE, normalized: + // the obligations x/obligation parses. + Directives []string +} + +// Partitioning is a table's PARTITION BY clause. Every shape this package's grammar admits +// is broken down structurally -- RANGE/LIST COLUMNS, KEY, LINEAR HASH/KEY, subpartitions +// and a partition's own explicit SUBPARTITION name list included -- so there is no Text +// fallback: a clause this loader cannot break down (anything partitioning's own PartTypeDef +// switch does not name, or a per-partition option partitionDef does not read) is a problem +// up front, not a whole-clause equality/rewrite escape hatch a later stage discovers cannot +// be migrated incrementally. +type Partitioning struct { + // Kind is "RANGE", "LIST", "HASH" or "KEY". + Kind string + // Linear is LINEAR HASH / LINEAR KEY: the partition function spreads a value's own hash + // by bit-shifting instead of MOD, so a partition count change never reshuffles every row + // (a plain HASH/KEY does); this package writes the same ADD PARTITION PARTITIONS n / + // COALESCE PARTITION n DDL regardless (the server picks the spread), and only rewrites + // the whole clause if Linear itself flips. + Linear bool + // Columns is RANGE COLUMNS / LIST COLUMNS: the partitioning key is a column list (Cols), + // not a single expression (Expr); a partition's own Bound is then a value tuple, one + // literal per column, rather than a single scalar (see Partition's own doc comment). + Columns bool + // Expr is the partitioning key's own text, as written ("`id`", "(`id` + 1)"); set only + // when Kind is RANGE, HASH or LIST and Columns is false. + Expr string + // Cols is KEY's own column list (opt_columns -- empty when the statement wrote "KEY ()", + // the server's own cue to partition by the table's primary key; this package keeps that + // empty list as written rather than resolving it, since the DDL this package writes back + // spells it the same way), or RANGE/LIST COLUMNS' own column list; set only when Kind is + // KEY, or Columns is true. + Cols []string + // Algorithm is KEY's own ALGORITHM=1|2 (pre-5.5 / 5.5+ hashing; see mysqlast/hooks_ddl.go's + // own hand-written opt_key_algo rule), 0 for the default (not written). + Algorithm int + // Num is HASH/KEY's own partition count (PARTITIONS n); Parts is RANGE/LIST's ordered + // partition list. Exactly one of them holds anything, matching Kind. + Num int + Parts []Partition + // Sub is the table's own SUBPARTITION BY clause, nil for none. Only RANGE and LIST ever + // carry one (the grammar refuses it under HASH/KEY). + Sub *SubPartitioning +} + +// SubPartitioning is a RANGE or LIST Partitioning's own SUBPARTITION BY clause: every +// partition is split further by HASH or KEY, always to the same kind, expression/columns +// and count. A partition naming its own subpartitions explicitly (`PARTITION p ... +// (SUBPARTITION s0, SUBPARTITION s1)`) carries those names as its own Subs (see Partition), +// since SHOW CREATE TABLE spells them back and a whole-clause rewrite must reproduce them. +type SubPartitioning struct { + Kind string // "HASH" or "KEY" + Linear bool + Expr string // HASH's own expr text + Cols []string // KEY's own column list + Algorithm int + Num int // SUBPARTITIONS n +} + +// Partition is one partition of a RANGE or LIST Partitioning. +type Partition struct { + Name string + // MaxValue is a plain (non-COLUMNS) RANGE partition's own "VALUES LESS THAN MAXVALUE", + // or its own omitted VALUES clause (only the last partition of a RANGE clause may say + // either); Bound is the literal boundary's own text otherwise -- a single scalar for a + // plain RANGE/LIST, or a value tuple (RANGE COLUMNS, comma-joined) / one or more value + // tuples (LIST COLUMNS, `(a,b),(c,d)`, each parenthesized, comma-joined) for the COLUMNS + // variants, where MAXVALUE folds into the text itself instead (every column still needs + // its own literal, so there is no single "the whole partition is unbounded" case there). + MaxValue bool + Bound string + // Comment is this partition's own COMMENT option, "" for none: the one per-partition + // option (of TABLESPACE / ENGINE / NODEGROUP / MAX_ROWS / MIN_ROWS / DATA DIRECTORY / + // INDEX DIRECTORY / COMMENT) this package models -- ENGINE is always InnoDB in practice + // (SHOW CREATE TABLE writes it on every partition regardless, measured, so it is dropped + // rather than compared, see PartitioningProps) and the rest are rare enough that + // partitionDef raises a problem rather than reading them. + Comment string + // Subs is this partition's own explicit SUBPARTITION name list (`(SUBPARTITION s0, + // SUBPARTITION s1)`), nil when the parent's SUBPARTITION BY default names them: the + // names as written, in order. A subpartition's own option other than ENGINE (dropped + // the way a partition's own is) is not read -- partitionDef raises a problem instead. + Subs []string +} + +// Column is a table column. +type Column struct { + Name string + Type Type + NotNull bool + Default mysqlast.Value // nil when none; a literal, CURRENT_TIMESTAMP, or an (expression) + AutoIncrement bool + OnUpdate bool // ON UPDATE CURRENT_TIMESTAMP + Generated mysqlast.Value + Stored bool // a generated column that is STORED (else VIRTUAL) + Comment string + Invisible bool + Collation string + // Text is the column's definition as written in the statement that declared it + // (`\`id\` bigint unsigned NOT NULL AUTO_INCREMENT` in a CREATE TABLE), what an ALTER + // TABLE ... MODIFY COLUMN takes; "" when the column came from a statement the loader + // does not keep the text of + Text string +} + +// KeyKind is a MySQL index kind. +type KeyKind byte + +const ( + Primary KeyKind = iota + 1 + Unique + Index + Fulltext + Spatial +) + +func (k KeyKind) String() string { + return [...]string{"", "PRIMARY KEY", "UNIQUE", "INDEX", "FULLTEXT", "SPATIAL"}[k] +} + +// Key is an index or a PRIMARY / UNIQUE constraint. +type Key struct { + Name string + Kind KeyKind + Parts []KeyPart + Invisible bool + Comment string + Text string // the key's definition as written inline in its CREATE TABLE, "" otherwise +} + +// KeyPart is one key column, or a prefix of it, or an expression. +type KeyPart struct { + Column string + Length int // prefix length, 0 for the whole column + Desc bool + Expr mysqlast.Value // functional key part +} + +// ForeignKey is a REFERENCES constraint. +type ForeignKey struct { + Name string + Columns []string + RefTable string + RefColumns []string + OnDelete string // CASCADE, SET NULL, RESTRICT, NO ACTION, SET DEFAULT, or "" for the default + OnUpdate string + Text string // the constraint's definition as written inline in its CREATE TABLE, "" otherwise +} + +// Check is a CHECK constraint. +type Check struct { + Name string + Expr mysqlast.Value + Enforced bool + Text string // the constraint's definition as written inline in its CREATE TABLE, "" otherwise +} + +// View is a CREATE VIEW. +type View struct { + Name string + Columns []string // the declared column list, when given + Query mysqlast.Value + Algorithm string + CheckOption string + Definition string + // Directives are the `-- sqlshape: ...` lines written above the CREATE (obligations); + // Unfiltered / Waived are the view's own opt-outs (`unfiltered t`, `waive t ...`): the + // obligations its body does not owe, by table, carried on the body's leaves. + Directives []string + Unfiltered map[string]bool + Waived map[string][]string +} + +// Event is a CREATE EVENT: a body the server runs on its own schedule. Nothing a program's +// statement runs reaches it, so its body is read the way a routine's is (analyze.AnalyzeEvent) +// for the schema's own sake -- an event whose body names a table the schema does not have is +// accepted by the server at CREATE time and fails at every run -- and its definition is what +// diff / apply manage. +type Event struct { + Name string + // At is a one-time event's `AT `; Every a recurring one's `EVERY ` + // ("1 DAY"); Starts / Ends the recurring event's bounds, "" when not written. Each is + // the expression's own text. + At, Every, Starts, Ends string + // AtLiteral / StartsLiteral / EndsLiteral: the time was written as a string literal, + // a value the server stores as written. An expression (`CURRENT_TIMESTAMP`, `NOW() + + // INTERVAL 1 DAY`) and an omitted STARTS (the server fills in the creation time, + // measured) are evaluated when the event is created, so the time SHOW CREATE EVENT + // reads back is an accident of when: the canonical form of such an event carries a time + // that means nothing to compare (dump marks it from the source text; diff skips it). + AtLiteral, StartsLiteral, EndsLiteral bool + // Completion is PRESERVE or NOT PRESERVE (the default); Status ENABLE (the default), + // DISABLE or DISABLE ON REPLICA. + Completion string + Status string + Comment string + Body mysqlast.Value // the DO body (sp_block_content, or a single statement) + BodyText string // the body's own text, for comparison + Definition string // the CREATE EVENT text +} + +// Trigger is a CREATE TRIGGER. +type Trigger struct { + Name string + Table string // the trigger's table, in stored form (tableKey) + // Timing is BEFORE or AFTER; Event is INSERT, UPDATE or DELETE. + Timing string + Event string + // OrderClause is FOLLOWS or PRECEDES, "" when the trigger declares neither; OrderTrigger + // is the anchor trigger's name it names, "" when OrderClause is "". + OrderClause string + OrderTrigger string + Body mysqlast.Value // the trigger's body statement (sp_block_content, or a single statement) + Definition string // the CREATE TRIGGER text + // Directives are the `-- sqlshape: ...` lines written above the CREATE (currently only + // `error = ` is read here; a later stage parses it). + Directives []string +} + +// RoutineKind is whether a Routine is a PROCEDURE or a FUNCTION: they are separate +// namespaces (a schema may declare both p() and a PROCEDURE and a FUNCTION of that name). +type RoutineKind byte + +const ( + Procedure RoutineKind = iota + 1 + Function +) + +func (k RoutineKind) String() string { + return [...]string{"", "PROCEDURE", "FUNCTION"}[k] +} + +// Param is one parameter of a stored routine. +type Param struct { + Mode string // IN, OUT or INOUT (a stored function's parameters are all IN) + Name string + Type Type +} + +// Routine is a CREATE PROCEDURE or CREATE FUNCTION. +type Routine struct { + Name string + Kind RoutineKind + Params []Param + Returns Type // a stored function's RETURNS type; the zero Type for a procedure + // NotNull is the `-- sqlshape: not null` annotation above a CREATE FUNCTION: the + // function's result is never NULL, the same override postgres/schema.Function.NotNull + // documents for PostgreSQL (a stored function's RETURN can otherwise produce NULL + // regardless of the declared type, with no static proof otherwise). + NotNull bool + // Deterministic, DataAccess and Security are the routine's characteristics as declared + // (CREATE's defaults when not given: not deterministic, CONTAINS SQL, SQL SECURITY + // DEFINER); ALTER PROCEDURE/FUNCTION does not change them (it has no schema effect here). + Deterministic bool + DataAccess string // NO_SQL, CONTAINS_SQL, READS_SQL_DATA or MODIFIES_SQL_DATA + Security string // DEFINER or INVOKER + Body mysqlast.Value + Definition string // the CREATE PROCEDURE/FUNCTION text + Directives []string +} + +// Table returns the table named name, or nil. Table and view names compare as the +// declared lower_case_table_names says (case-sensitively at 0, the Linux default); column +// and key names never mind case. +func (s *Schema) Table(name string) *Table { + for _, t := range s.Tables { + if s.sameTable(t.Name, name) { + return t + } + } + return nil +} + +// View returns the view named name, or nil. +func (s *Schema) View(name string) *View { + for _, v := range s.Views { + if s.sameTable(v.Name, name) { + return v + } + } + return nil +} + +// Trigger returns the trigger named name, or nil. Trigger names are not affected by +// lower_case_table_names (that setting is a table/view naming convention only). +func (s *Schema) Trigger(name string) *Trigger { + for _, t := range s.Triggers { + if strings.EqualFold(t.Name, name) { + return t + } + } + return nil +} + +// Event returns the event named name, or nil. Event names are not affected by +// lower_case_table_names either. +func (s *Schema) Event(name string) *Event { + for _, e := range s.Events { + if strings.EqualFold(e.Name, name) { + return e + } + } + return nil +} + +// Routine returns the procedure or function named name, or nil. PROCEDURE and FUNCTION are +// separate namespaces in MySQL; when a schema declares both of the same name, the first one +// declared wins here (dump/diff/migrate, not written yet, will need the kind to tell them +// apart when that matters). +func (s *Schema) Routine(name string) *Routine { + for _, r := range s.Routines { + if strings.EqualFold(r.Name, name) { + return r + } + } + return nil +} + +// RoutineOf returns the procedure or function of the given kind named name, or nil: unlike +// Routine, it does not guess between a PROCEDURE and a FUNCTION of the same name (dump / +// diff / migrate need to tell them apart). +func (s *Schema) RoutineOf(kind RoutineKind, name string) *Routine { + return s.routine(kind, name) +} + +// routine returns the routine of the given kind named name, or nil. +func (s *Schema) routine(kind RoutineKind, name string) *Routine { + for _, r := range s.Routines { + if r.Kind == kind && strings.EqualFold(r.Name, name) { + return r + } + } + return nil +} + +// Column returns the column named name, or nil. +func (t *Table) Column(name string) *Column { + for _, c := range t.Columns { + if strings.EqualFold(c.Name, name) { + return c + } + } + return nil +} + +// PrimaryKey returns the table's primary key, or nil. +func (t *Table) PrimaryKey() *Key { + for _, k := range t.Keys { + if k.Kind == Primary { + return k + } + } + return nil +} + +var versionLine = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*sqlshape:[ \t]*mysql[ \t]+(\S+)[ \t]*$`) + +// Supported lists the MySQL versions with an embedded parser. +func Supported() []string { return []string{"8.4"} } + +// DeclaredVersion reads the `-- sqlshape: mysql ` declaration; "8.4" when +// there is none. +func DeclaredVersion(schemaSQL string) (string, error) { + v := "" + for _, m := range versionLine.FindAllStringSubmatch(schemaSQL, -1) { + ok := false + for _, s := range Supported() { + if s == m[1] { + ok = true + } + } + if !ok { + return "", fmt.Errorf("schema: `-- sqlshape: mysql %s`: sqlshape supports MySQL %s", m[1], strings.Join(Supported(), ", ")) + } + if v != "" && v != m[1] { + return "", fmt.Errorf("schema: `-- sqlshape: mysql` is declared twice, as %s and %s", v, m[1]) + } + v = m[1] + } + if v == "" { + v = Supported()[0] + } + return v, nil +} + +// Load parses schema.sql and applies its statements in order. +func Load(schemaSQL string) (*Schema, error) { + v, err := DeclaredVersion(schemaSQL) + if err != nil { + return nil, err + } + s := &Schema{Version: v, Settings: Settings{SQLMode: sqlmode.Default}} + settings, err := dialect.Settings(schemaSQL) + if err != nil { + return nil, err + } + for _, st := range settings { + s.setting(st) + } + for _, st := range mysqlparse.Split(schemaSQL) { + s.apply(st) + } + return s, nil +} + +// setting applies one declared server variable. +func (s *Schema) setting(st dialect.Setting) { + switch st.Name { + case "sql_mode": + m, err := sqlmode.Parse(st.Value) + if err != nil { + s.problem(st.Position, "server %s = '%s': %v", st.Name, st.Value, err) + return + } + s.Settings.SQLMode = m + case "lower_case_table_names": + n, err := strconv.Atoi(st.Value) + if err != nil || n < 0 || n > 2 { + s.problem(st.Position, "server %s = %s: want 0, 1 or 2", st.Name, st.Value) + return + } + s.Settings.LowerCaseTableNames = n + case "max_sp_recursion_depth": + n, err := strconv.Atoi(st.Value) + if err != nil || n < 0 || n > 255 { + s.problem(st.Position, "server %s = %s: want 0-255", st.Name, st.Value) + return + } + s.Settings.MaxSPRecursionDepth = n + default: + s.problem(st.Position, "server %s: not a variable sqlshape reads for MySQL (%s)", st.Name, strings.Join(SettingNames, ", ")) + } +} + +func (s *Schema) problem(pos int, format string, args ...any) { + s.Problems = append(s.Problems, Problem{Position: pos, Message: fmt.Sprintf(format, args...)}) +} + +// apply parses one statement and folds it into the schema. +func (s *Schema) apply(st mysqlparse.Statement) { + cst, err := mysqlparse.Parse(st.SQL, s.Settings.ParseMode()) + if err != nil { + if pe, ok := err.(*mysqlparse.Error); ok { + s.problem(st.Offset+pe.Offset, "%s", pe.Message) + return + } + s.problem(st.Offset, "%v", err) + return + } + v, err := mysqlast.BuildMode(st.SQL, cst, s.Settings.ParseMode()) + if err != nil { + if u, ok := err.(*mysqlast.Unsupported); ok { + s.problem(st.Offset+u.Start, "unsupported construct %s: %q", u.Rule, u.Text) + return + } + s.problem(st.Offset, "%v", err) + return + } + // DROP TRIGGER/PROCEDURE and ALTER PROCEDURE/FUNCTION have no NEW_PTN construction of + // their own in the server's grammar (the action fills LEX by hand); their generic fold + // is a by-value Struct (Lex's fields), not a Node. + if x, ok := v.(*mysqlast.Struct); ok { + s.applyStmt(x, st) + return + } + n, ok := v.(*mysqlast.Node) + if !ok { + s.problem(st.Offset, "not a statement the schema loader reads: %s", mysqlast.Sprint(v)) + return + } + s.cur = st.SQL + at := func(v mysqlast.Value) int { + if x, ok := v.(*mysqlast.Node); ok { + return st.Offset + x.Start + } + return st.Offset + } + switch n.Class { + case "PT_create_table_stmt": + s.createTable(n, st, at) + case "PT_create_index_stmt": + s.createIndex(n, st, at) + case "Sql_cmd_create_view": + s.createView(n, st) + case "Sql_cmd_alter_view": + s.alterView(n, st) + case "trigger_tail": + s.createTrigger(n, st, at) + case "event_tail": + s.createEvent(n, st, at) + case "alter_event_stmt": + s.alterEvent(n, st, at) + case "sp_tail": + s.createRoutine(n, Procedure, st, at) + case "sf_tail": + s.createRoutine(n, Function, st, at) + case "drop_function_stmt": + s.dropRoutine(Function, spName(n.Arg("spname")), isTrue(n.Arg("if_exists")), at(n)) + case "PT_alter_table_stmt": + s.alterTable(n, st, at) + case "PT_alter_table_standalone_stmt": + s.alterTable(n, st, at) // one action (ADD PARTITION ...), same shape for our purposes + case "PT_drop_index_stmt": + x, _ := mysqlast.AsPTDropIndexStmt(n) + t := s.needTable(tableName(x.Table()), at(n)) + if t != nil && !t.dropKey(str(x.IndexName())) { + s.problem(at(n), "DROP INDEX %s: no such index on %s", str(x.IndexName()), t.Name) + } + case "Sql_cmd_drop_table": + for _, ti := range list(n.Arg("tables")) { + name := tableName(ti) + if s.Table(name) == nil { + if n.Arg("if_exists") == nil || n.Arg("if_exists") == mysqlast.Const("0") || n.Arg("if_exists") == mysqlast.Const("false") { + s.problem(at(ti), "DROP TABLE %s: no such table", name) + } + continue + } + s.dropTable(name) + } + case "Sql_cmd_drop_view": + for _, ti := range list(n.Arg("views")) { + name := tableName(ti) + if s.View(name) == nil { + s.problem(at(ti), "DROP VIEW %s: no such view", name) + continue + } + s.dropView(name) + } + case "PT_truncate_table_stmt", "PT_set", "PT_option_value_list", "PT_start_option_value_list_no_type": + // no schema effect + default: + if n.Implicit && n.Class == "table_to_table_list" || n.Implicit && n.Class == "table_to_table" { + s.renameTables(n, at) + return + } + s.problem(st.Offset, "statement not applied to the schema: %s", n.Class) + } +} + +// applyStmt folds a statement whose generic fold is a by-value Struct (Lex's fields), not a +// Node: DROP TRIGGER, DROP PROCEDURE, ALTER PROCEDURE, ALTER FUNCTION. +func (s *Schema) applyStmt(x *mysqlast.Struct, st mysqlparse.Statement) { + switch str(x.Fields["sql_command"]) { + case "SQLCOM_DROP_TRIGGER": + s.dropTrigger(spName(x.Fields["spname"]), isTrue(x.Fields["drop_if_exists"]), st.Offset) + case "SQLCOM_DROP_EVENT": + s.dropEvent(spName(x.Fields["spname"]), isTrue(x.Fields["drop_if_exists"]), st.Offset) + case "SQLCOM_DROP_PROCEDURE": + s.dropRoutine(Procedure, spName(x.Fields["spname"]), isTrue(x.Fields["drop_if_exists"]), st.Offset) + case "SQLCOM_ALTER_PROCEDURE": + s.alterRoutine(Procedure, spName(x.Fields["spname"]), st.Offset) + case "SQLCOM_ALTER_FUNCTION": + s.alterRoutine(Function, spName(x.Fields["spname"]), st.Offset) + default: + s.problem(st.Offset, "statement not applied to the schema: %s", str(x.Fields["sql_command"])) + } +} + +// --- CREATE TABLE ------------------------------------------------------------------ + +func (s *Schema) createTable(n *mysqlast.Node, st mysqlparse.Statement, at func(mysqlast.Value) int) { + x, _ := mysqlast.AsPTCreateTableStmt(n) + name := s.tableKey(tableName(x.TableName())) + if s.Table(name) != nil { + if isTrue(x.OnlyIfNotExists()) { + return + } + s.problem(at(n), "CREATE TABLE %s: table already exists", name) + return + } + t := &Table{Name: name, Temporary: isTrue(x.IsTemporary()), Definition: st.SQL} + if like := x.OptLikeClause(); like != nil { + src := s.Table(tableName(like)) + if src == nil { + s.problem(at(n), "CREATE TABLE %s LIKE %s: no such table", name, tableName(like)) + return + } + copyTable(t, src) + s.Tables = append(s.Tables, t) + return + } + if x.OptQueryExpression() != nil { + s.problem(at(n), "CREATE TABLE %s AS SELECT: columns come from the query, which the loader does not evaluate", name) + } + for _, el := range list(x.OptTableElementList()) { + s.tableElement(t, el, at) + } + s.tableOptions(t, list(x.OptCreateTableOptions()), at) + if p := x.OptPartitioning(); p != nil { + t.Partitioning = s.partitioning(p, at) + } + s.tableDirectives(t, st.SQL, st.Offset) + s.Tables = append(s.Tables, t) +} + +// partitioning reads v, a PT_partition node (CREATE TABLE's own, or an ALTER TABLE +// PARTITION BY's), into the model, or records a problem and returns nil for a clause this +// package does not break down at all (see Partitioning's own doc comment: an explicit +// per-partition SUBPARTITION list, or a PartTypeDef this switch does not name). +func (s *Schema) partitioning(v mysqlast.Value, at func(mysqlast.Value) int) *Partitioning { + n, ok := v.(*mysqlast.Node) + if !ok { + s.problem(at(v), "PARTITION BY: clause not understood") + return nil + } + x, _ := mysqlast.AsPTPartition(n) + p := &Partitioning{} + switch d := x.PartTypeDef().(type) { + case *mysqlast.Node: + switch d.Class { + case "PT_part_type_def_range_expr": + rx, _ := mysqlast.AsPTPartTypeDefRangeExpr(d) + p.Kind, p.Expr = "RANGE", s.exprText(rx.Expr()) + case "PT_part_type_def_range_columns": + rx, _ := mysqlast.AsPTPartTypeDefRangeColumns(d) + p.Kind, p.Columns = "RANGE", true + p.Cols = names(rx.Columns()) + case "PT_part_type_def_list_expr": + lx, _ := mysqlast.AsPTPartTypeDefListExpr(d) + p.Kind, p.Expr = "LIST", s.exprText(lx.Expr()) + case "PT_part_type_def_list_columns": + lx, _ := mysqlast.AsPTPartTypeDefListColumns(d) + p.Kind, p.Columns = "LIST", true + p.Cols = names(lx.Columns()) + case "PT_part_type_def_hash": + hx, _ := mysqlast.AsPTPartTypeDefHash(d) + p.Kind, p.Linear, p.Expr = "HASH", isTrue(hx.IsLinear()), s.exprText(hx.Expr()) + case "PT_part_type_def_key": + kx, _ := mysqlast.AsPTPartTypeDefKey(d) + p.Kind, p.Linear = "KEY", isTrue(kx.IsLinear()) + p.Algorithm = keyAlgorithm(kx.KeyAlgo()) + p.Cols = names(kx.OptColumns()) + default: + s.problem(at(n), "PARTITION BY: clause not understood") + return nil + } + default: + s.problem(at(n), "PARTITION BY: clause not understood") + return nil + } + if num := x.OptNumParts(); num != nil { + p.Num = numOf(num) + } + if sub := x.OptSubPart(); sub != nil { + sn, ok := sub.(*mysqlast.Node) + if !ok { + s.problem(at(n), "SUBPARTITION BY: clause not understood") + return nil + } + switch sn.Class { + case "PT_sub_partition_by_hash": + sx, _ := mysqlast.AsPTSubPartitionByHash(sn) + p.Sub = &SubPartitioning{Kind: "HASH", Linear: isTrue(sx.IsLinear()), Expr: s.exprText(sx.Hash()), Num: numOf(sx.OptNumSubparts())} + case "PT_sub_partition_by_key": + sx, _ := mysqlast.AsPTSubPartitionByKey(sn) + p.Sub = &SubPartitioning{Kind: "KEY", Linear: isTrue(sx.IsLinear()), Algorithm: keyAlgorithm(sx.KeyAlgo()), Cols: names(sx.FieldNames()), Num: numOf(sx.OptNumSubparts())} + default: + s.problem(at(n), "SUBPARTITION BY: clause not understood") + return nil + } + } + defs := list(x.PartDefs()) + if p.Kind == "HASH" || p.Kind == "KEY" { + if len(defs) > 0 { + s.problem(at(n), "PARTITION BY %s: an explicit partition list is not supported", p.Kind) + return nil + } + return p + } + // RANGE: every partition definition names its own upper bound (or MAXVALUE); LIST: every + // partition definition names its own value list (partitionDef tells the two apart by the + // class OptPartValues() itself carries, guided by p.Columns). + for _, el := range defs { + pd, ok := el.(*mysqlast.Node) + if !ok { + s.problem(at(n), "PARTITION %s: definition not understood", p.Kind) + return nil + } + part, ok := s.partitionDef(pd, p.Columns) + if !ok { + s.problem(at(n), "PARTITION %s: definition not understood", p.Kind) + return nil + } + p.Parts = append(p.Parts, part) + } + return p +} + +// names renders a name_list (KEY's own column list, or RANGE/LIST COLUMNS' own) as plain +// identifier strings, in order. +func names(v mysqlast.Value) []string { + var out []string + for _, el := range list(v) { + out = append(out, str(el)) + } + return out +} + +// keyAlgorithm reads opt_key_algo's own constant (mysqlast/hooks_ddl.go's own hand-written +// rule folds ALGORITHM_SYM EQ real_ulong_num to one of these two) into KEY's own +// ALGORITHM=1|2, 0 for the default (not written). +func keyAlgorithm(v mysqlast.Value) int { + switch str(v) { + case "enum_key_algorithm::KEY_ALGORITHM_51": + return 1 + case "enum_key_algorithm::KEY_ALGORITHM_55": + return 2 + } + return 0 +} + +// partitionDef reads one PT_part_definition of a RANGE or LIST Partitioning (columns tells +// a plain clause apart from its COLUMNS variant, which admits a value tuple per column +// rather than a single scalar): a RANGE partition names its own upper bound (Bound) or none +// at all (MaxValue, VALUES LESS THAN MAXVALUE or no VALUES clause at all -- COLUMNS' own +// per-column MAXVALUE folds into Bound's own text instead, see Partition's own doc comment); +// a LIST partition names its own value list, joined into Bound the way SHOW CREATE spells it +// (a flat OR-set for a plain LIST, one or more parenthesized value tuples for COLUMNS). ok is +// false for anything this package does not break down (MAXVALUE inside a LIST partition's +// own list -- not valid SQL but the grammar admits it, or a per-partition or per-subpartition +// option other than COMMENT / ENGINE), the caller's cue to raise a problem. +func (s *Schema) partitionDef(n *mysqlast.Node, columns bool) (Partition, bool) { + x, _ := mysqlast.AsPTPartDefinition(n) + part := Partition{Name: str(x.Name())} + for _, el := range list(x.OptSubPartitions()) { + sn, ok := el.(*mysqlast.Node) + if !ok || sn.Class != "PT_subpartition" { + return Partition{}, false + } + for _, opt := range list(sn.Arg("options")) { + on, ok := opt.(*mysqlast.Node) + if !ok || on.Class != "PT_partition_engine" { // always InnoDB in practice; dropped + return Partition{}, false + } + } + part.Subs = append(part.Subs, str(sn.Arg("name"))) + } + for _, opt := range list(x.OptPartOptions()) { + on, ok := opt.(*mysqlast.Node) + if !ok { + return Partition{}, false + } + switch on.Class { + case "PT_partition_comment": + part.Comment = str(on.Arg("comment")) + case "PT_partition_engine": + // always InnoDB in practice (Partition.Comment's own doc comment says why); dropped. + default: + return Partition{}, false // TABLESPACE / NODEGROUP / MAX_ROWS / MIN_ROWS / DATA|INDEX DIRECTORY + } + } + values := x.OptPartValues() + if values == nil { + part.MaxValue = true // a RANGE partition with no VALUES clause: unbounded (MAXVALUE) + return part, true + } + vn, ok := values.(*mysqlast.Node) + if !ok { + return Partition{}, false + } + switch vn.Class { + case "PT_part_value_item_list_paren": + // RANGE's own VALUES LESS THAN (...): a single scalar (plain RANGE) or a value + // tuple, one literal per column (RANGE COLUMNS) -- part_func_max folds straight to + // this class (measured against the grammar's own shapes.go), not wrapped in + // anything naming it as RANGE's. + items := list(vn.Arg("values")) + texts := make([]string, len(items)) + for i, el := range items { + item, ok := el.(*mysqlast.Node) + if !ok { + return Partition{}, false + } + switch item.Class { + case "PT_part_value_item_expr": + texts[i] = s.exprText(item.Arg("expr")) + case "PT_part_value_item_max": + texts[i] = "MAXVALUE" + default: + return Partition{}, false + } + } + switch { + case len(texts) == 1 && !columns && texts[0] == "MAXVALUE": + part.MaxValue = true + case len(texts) == 1 && !columns: + part.Bound = texts[0] + case columns: + // RANGE COLUMNS spells a single-column bound the same way a plain RANGE does + // (measured), so len(texts) == 1 is not special-cased away from here. + part.Bound = strings.Join(texts, ", ") + default: + return Partition{}, false // a plain RANGE never carries more than one value + } + case "PT_part_values_in_item": + // LIST's own single-row VALUES IN (v1, v2, ...): the flat OR-set a plain LIST + // spells; never COLUMNS' own (see PT_part_values_in_list below). + if columns { + return Partition{}, false + } + ix, _ := mysqlast.AsPTPartValuesInItem(vn) + inner, ok := ix.Item().(*mysqlast.Node) + if !ok || inner.Class != "PT_part_value_item_list_paren" { + return Partition{}, false + } + var vals []string + for _, el := range list(inner.Arg("values")) { + item, ok := el.(*mysqlast.Node) + if !ok || item.Class != "PT_part_value_item_expr" { + return Partition{}, false // MAXVALUE inside a LIST partition, or anything else + } + vals = append(vals, s.exprText(item.Arg("expr"))) + } + part.Bound = strings.Join(vals, ", ") + case "PT_part_values_in_list": + // LIST COLUMNS' own value list: one or more rows, each its own value tuple + // (`(a,b), (c,d)`) -- always this class, even for a single row (measured: MySQL + // refuses the bare, unwrapped form its plain sibling accepts, "Inconsistency in + // usage of column lists for partitioning"). + if !columns { + return Partition{}, false + } + lx, _ := mysqlast.AsPTPartValuesInList(vn) + var rows []string + for _, row := range list(lx.List()) { + rn, ok := row.(*mysqlast.Node) + if !ok || rn.Class != "PT_part_value_item_list_paren" { + return Partition{}, false + } + var vals []string + for _, el := range list(rn.Arg("values")) { + item, ok := el.(*mysqlast.Node) + if !ok || item.Class != "PT_part_value_item_expr" { + return Partition{}, false // MAXVALUE inside a LIST partition, or anything else + } + vals = append(vals, s.exprText(item.Arg("expr"))) + } + rows = append(rows, "("+strings.Join(vals, ", ")+")") + } + part.Bound = strings.Join(rows, ", ") + default: + return Partition{}, false + } + return part, true +} + +// exprText is v's own text, verbatim from the source: a partitioning expression or a +// partition boundary literal, neither of which this package evaluates -- only compares +// and reproduces as written, the same way a CHECK constraint's expression is kept as Text +// rather than a value this package understands (schema.Check's own doc comment). +func (s *Schema) exprText(v mysqlast.Value) string { + n, ok := v.(*mysqlast.Node) + if !ok || n.Start < 0 || n.End > len(s.cur) || n.Start >= n.End { + return "" + } + return s.cur[n.Start:n.End] +} + +// closeVersionComment extends [start, end) over the ` */` that closes a versioned comment +// the span opened: SHOW CREATE TABLE writes `/*!80023 INVISIBLE */` and the parser reads +// the comment's body as code, so a node's span ends before the closing `*/` (measured: a +// MODIFY COLUMN from such a text is a syntax error). +func closeVersionComment(sql string, start, end int) int { + text := sql[start:end] + if strings.Count(text, "/*") <= strings.Count(text, "*/") { + return end + } + if i := strings.Index(sql[end:], "*/"); i >= 0 { + return end + i + 2 + } + return end +} + +// tableElement applies one element of a CREATE TABLE body or an ADD of ALTER TABLE. +func (s *Schema) tableElement(t *Table, el mysqlast.Value, at func(mysqlast.Value) int) { + n, ok := el.(*mysqlast.Node) + if !ok { + s.problem(at(el), "table element not understood: %s", mysqlast.Sprint(el)) + return + } + text := "" + if n.Start >= 0 && n.End <= len(s.cur) && n.Start < n.End { + text = s.cur[n.Start:closeVersionComment(s.cur, n.Start, n.End)] + } + switch n.Class { + case "PT_column_def": + x, _ := mysqlast.AsPTColumnDef(n) + col, keys, fk, check := s.column(str(x.FieldIdent()), x.FieldDef(), at) + col.Text = text + t.Columns = append(t.Columns, col) + t.Keys = append(t.Keys, keys...) + if fk != nil { + t.ForeignKeys = append(t.ForeignKeys, fk) + } + if check != nil { + t.Checks = append(t.Checks, check) + } + if x.OptColumnConstraint() != nil { + s.problem(at(n), "column %s: inline REFERENCES is parsed by MySQL but not enforced; declare a FOREIGN KEY", col.Name) + } + case "PT_inline_index_definition": + x, _ := mysqlast.AsPTInlineIndexDefinition(n) + k := &Key{Name: str(x.Name()), Kind: keyKind(str(x.TypePar())), Text: text} + for _, p := range list(x.Cols()) { + k.Parts = append(k.Parts, keyPart(p)) + } + s.indexOptions(k, list(x.Options())) + t.addKey(k) + case "PT_foreign_key_definition": + x, _ := mysqlast.AsPTForeignKeyDefinition(n) + fk := &ForeignKey{Name: str(x.ConstraintName()), RefTable: s.tableKey(tableName(x.ReferencedTable())), + OnDelete: fkOption(str(x.FkDeleteOpt())), OnUpdate: fkOption(str(x.FkUpdateOpt())), Text: text} + if fk.Name == "" { + fk.Name = str(x.KeyName()) + } + for _, p := range list(x.Columns()) { + fk.Columns = append(fk.Columns, keyPart(p).Column) + } + for _, p := range list(x.RefList()) { + fk.RefColumns = append(fk.RefColumns, keyPart(p).Column) + } + if len(fk.RefColumns) != len(fk.Columns) { + // the server refuses the definition at CREATE time -- a bare REFERENCES t with + // no column list the same as a count mismatch (1239 "Key reference and table + // reference don't match", measured on 8.4); it never falls back to t's primary key + name := fk.Name + if name == "" { + name = "foreign key without name" + } + s.problem(at(n), "Incorrect foreign key definition for '%s': Key reference and table reference don't match (1239)", name) + return + } + t.ForeignKeys = append(t.ForeignKeys, fk) + case "PT_check_constraint": + x, _ := mysqlast.AsPTCheckConstraint(n) + t.Checks = append(t.Checks, &Check{Name: str(x.Name()), Expr: x.Expr(), Enforced: !isFalse(x.IsEnforced()), Text: text}) + if fn, ok := s.exprCallsStoredFunction(x.Expr()); ok { + s.problem(at(n), "An expression of a check constraint '%s' contains disallowed function: %s", str(x.Name()), fn) + } + default: + s.problem(at(n), "table element not understood: %s", n.Class) + } +} + +// exprCallsStoredFunction walks v (a generated column's, a column DEFAULT's, or a CHECK +// constraint's own expression) looking for a call to a schema-declared FUNCTION anywhere +// inside it, and returns its name when found. Measured on mysqld 8.4: a stored FUNCTION in +// any of those three positions is refused at CREATE TABLE time regardless of whether the +// function itself is DETERMINISTIC (3763 for a generated column, 3770 for a column DEFAULT +// expression, 3814 for a CHECK constraint) -- unlike a routine's own body, where only a +// non-deterministic construct matters, here the position itself disallows every stored +// function outright. +func (s *Schema) exprCallsStoredFunction(v mysqlast.Value) (string, bool) { + switch x := v.(type) { + case *mysqlast.Node: + switch x.Class { + case "PTI_function_call_generic_2d": + if r := s.RoutineOf(Function, str(x.Arg("func"))); r != nil { + return r.Name, true + } + case "PTI_function_call_generic_ident_sys": + if r := s.RoutineOf(Function, str(x.Arg("ident"))); r != nil { + return r.Name, true + } + } + for _, a := range x.Args { + if fn, ok := s.exprCallsStoredFunction(a); ok { + return fn, true + } + } + case mysqlast.List: + for _, e := range x { + if fn, ok := s.exprCallsStoredFunction(e); ok { + return fn, true + } + } + } + return "", false +} + +// column reads a column definition: the type, then the attributes, some of which are keys +// or constraints of the table. +func (s *Schema) column(name string, fieldDef mysqlast.Value, at func(mysqlast.Value) int) (*Column, []*Key, *ForeignKey, *Check) { + col := &Column{Name: name} + var keys []*Key + var check *Check + fd, ok := fieldDef.(*mysqlast.Node) + if !ok { + s.problem(at(fieldDef), "column %s: definition not understood", name) + return col, nil, nil, nil + } + var attrs mysqlast.Value + switch fd.Class { + case "PT_field_def": + x, _ := mysqlast.AsPTFieldDef(fd) + col.Type = s.typeOf(x.TypeNode(), at) + attrs = x.OptAttrs() + case "PT_generated_field_def": + col.Type = s.typeOf(fd.Arg("type_node"), at) + col.Generated = fd.Arg("expr") + col.Stored = str(fd.Arg("virtual_or_stored")) == "Virtual_or_stored::STORED" + attrs = fd.Arg("opt_attrs") + if fn, ok := s.exprCallsStoredFunction(col.Generated); ok { + s.problem(at(fd), "Expression of generated column '%s' contains a disallowed function: `%s`", name, fn) + } + default: + s.problem(at(fd), "column %s: %s not understood", name, fd.Class) + } + for _, a := range list(attrs) { + an, ok := a.(*mysqlast.Node) + if !ok { + continue + } + switch an.Class { + case "PT_not_null_column_attr": + col.NotNull = true + case "PT_null_column_attr": + col.NotNull = false + case "PT_default_column_attr": + col.Default = an.Arg("item") + case "PT_generated_default_val_column_attr": + col.Default = an.Arg("expr") + if fn, ok := s.exprCallsStoredFunction(col.Default); ok { + s.problem(at(an), "Default value expression of column '%s' contains a disallowed function (%s)", name, fn) + } + case "PT_on_update_column_attr": + col.OnUpdate = true + case "PT_auto_increment_column_attr": + col.AutoIncrement = true + case "PT_serial_default_value_column_attr": // SERIAL DEFAULT VALUE = NOT NULL AUTO_INCREMENT UNIQUE + col.NotNull, col.AutoIncrement = true, true + keys = append(keys, &Key{Kind: Unique, Parts: []KeyPart{{Column: name}}}) + case "PT_primary_key_column_attr": + col.NotNull = true + keys = append(keys, &Key{Name: "PRIMARY", Kind: Primary, Parts: []KeyPart{{Column: name}}}) + case "PT_unique_key_column_attr": + keys = append(keys, &Key{Kind: Unique, Parts: []KeyPart{{Column: name}}}) + case "PT_comment_column_attr": + col.Comment = str(an.Arg("comment")) + case "PT_collate_column_attr": + col.Collation = str(an.Arg("collation")) + case "PT_check_constraint_column_attr": + check = &Check{Name: str(an.Arg("name")), Expr: an.Arg("expr"), Enforced: !isFalse(an.Arg("enforced"))} + if fn, ok := s.exprCallsStoredFunction(check.Expr); ok { + s.problem(at(an), "An expression of a check constraint '%s' contains disallowed function: %s", name, fn) + } + case "PT_column_visibility_attr": + col.Invisible = isFalse(an.Arg("is_visible")) + case "PT_srid_column_attr": + if v, ok := an.Arg("srid").(mysqlast.Number); ok { + col.Type.Srid = int(v) + } + case "PT_column_format_column_attr", "PT_storage_media_column_attr", "PT_secondary_column_attr", "PT_constraint_enforcement_attr": + // storage details, no shape + default: + s.problem(at(an), "column %s: attribute %s not understood", name, an.Class) + } + } + if col.Collation != "" { + // col.Type.Charset carries no information a collation does not already (a + // collation names its charset uniquely in MySQL), and whether the raw SQL spells + // CHARACTER SET explicitly on the type alongside a column-level COLLATE is not + // stable: an ENUM / SET column under a table with its own DEFAULT CHARSET / + // COLLATE freshly created from declarative SQL omits it, but SHOW CREATE TABLE + // read back from an existing table (canonicalizing a second time) always spells + // it (measured against mysqld 8.4) -- canonicalizing would otherwise not be + // idempotent, and a plan comparing the two spellings of the same column (Type is + // what ColumnProps' "type" compares) would see one as changed forever. + col.Type.Charset = "" + } + return col, keys, nil, check +} + +func (s *Schema) tableOptions(t *Table, opts []mysqlast.Value, at func(mysqlast.Value) int) { + for _, o := range opts { + n, ok := o.(*mysqlast.Node) + if !ok { + continue + } + switch n.Class { + case "PT_create_table_engine_option": + t.Engine = str(n.Arg("engine")) + case "PT_create_table_default_charset": + t.Charset = str(n.Arg("value")) + case "PT_create_table_default_collation": + t.Collation = str(n.Arg("value")) + case "PT_create_commen_option": + t.Comment = str(n.Arg("value")) + case "PT_create_auto_increment_option": + t.AutoIncrementStart = str(n.Arg("value")) + case "PT_create_row_format_option": + t.RowFormat = rowFormat(str(n.Arg("value"))) + } + // every other option (STATS_*, ...) has no shape + } +} + +// rowFormat spells a row_types constant (ROW_TYPE_DYNAMIC, ...) the way ROW_FORMAT=... in a +// CREATE / ALTER TABLE does; ROW_TYPE_DEFAULT is the server's own default, kept as "" the +// same way an undeclared option is (SHOW CREATE TABLE never writes ROW_FORMAT=DEFAULT). +func rowFormat(s string) string { + switch s { + case "ROW_TYPE_FIXED": + return "FIXED" + case "ROW_TYPE_DYNAMIC": + return "DYNAMIC" + case "ROW_TYPE_COMPRESSED": + return "COMPRESSED" + case "ROW_TYPE_REDUNDANT": + return "REDUNDANT" + case "ROW_TYPE_COMPACT": + return "COMPACT" + } + return "" +} + +func (s *Schema) indexOptions(k *Key, opts []mysqlast.Value) { + for _, o := range opts { + n, ok := o.(*mysqlast.Node) + if !ok { + continue + } + switch n.Class { + case "PT_index_comment": + k.Comment = str(n.Arg("option_value")) + case "PT_index_visibility": + k.Invisible = isFalse(n.Arg("option_value")) + } + } +} + +func keyPart(v mysqlast.Value) KeyPart { + n, ok := v.(*mysqlast.Node) + if !ok { + return KeyPart{Column: str(v)} + } + p := KeyPart{Column: str(n.Arg("column_name")), Expr: n.Arg("expression"), Desc: str(n.Arg("order")) == "ORDER_DESC"} + if l, ok := n.Arg("prefix_length").(mysqlast.Number); ok { + p.Length = int(l) + } + return p +} + +func keyKind(s string) KeyKind { + switch s { + case "KEYTYPE_PRIMARY": + return Primary + case "KEYTYPE_UNIQUE": + return Unique + case "KEYTYPE_FULLTEXT": + return Fulltext + case "KEYTYPE_SPATIAL": + return Spatial + } + return Index +} + +func fkOption(s string) string { + switch s { + case "FK_OPTION_CASCADE": + return "CASCADE" + case "FK_OPTION_SET_NULL": + return "SET NULL" + case "FK_OPTION_RESTRICT": + return "RESTRICT" + case "FK_OPTION_NO_ACTION": + return "NO ACTION" + case "FK_OPTION_DEFAULT": + return "SET DEFAULT" + } + return "" +} + +// addKey appends a key, naming it MySQL's way when unnamed: the first column's name, with +// _2, _3 ... on collision; PRIMARY replaces any existing primary key. +func (t *Table) addKey(k *Key) { + if k.Kind == Primary { + k.Name = "PRIMARY" + for _, p := range k.Parts { + if c := t.Column(p.Column); c != nil { + c.NotNull = true + } + } + } + if k.Name == "" && len(k.Parts) > 0 { + base := k.Parts[0].Column + if base == "" { + base = "functional_index" + } + k.Name = base + for i := 2; t.key(k.Name) != nil; i++ { + k.Name = base + "_" + strconv.Itoa(i) + } + } + t.Keys = append(t.Keys, k) +} + +func (t *Table) key(name string) *Key { + for _, k := range t.Keys { + if strings.EqualFold(k.Name, name) { + return k + } + } + return nil +} + +func (t *Table) dropKey(name string) bool { + for i, k := range t.Keys { + if strings.EqualFold(k.Name, name) { + t.Keys = append(t.Keys[:i], t.Keys[i+1:]...) + return true + } + } + return false +} + +func copyTable(dst, src *Table) { + for _, c := range src.Columns { + cc := *c + dst.Columns = append(dst.Columns, &cc) + } + for _, k := range src.Keys { + kk := *k + dst.Keys = append(dst.Keys, &kk) + } + for _, c := range src.Checks { + cc := *c + dst.Checks = append(dst.Checks, &cc) + } + dst.Engine, dst.Charset, dst.Collation, dst.RowFormat = src.Engine, src.Charset, src.Collation, src.RowFormat + // LIKE copies no foreign keys, and (measured against mysqld 8.4) no partitioning either +} + +// addPartitions applies an ADD PARTITION (...) def_list to t's RANGE or LIST Partitioning; +// a new definition this package does not break down (see partitionDef) is a problem, and +// leaves the table's Partitioning as it stood before the statement. +func (t *Table) addPartitions(defs []mysqlast.Value, s *Schema, at func(mysqlast.Value) int) { + if t.Partitioning == nil || (t.Partitioning.Kind != "RANGE" && t.Partitioning.Kind != "LIST") { + return + } + var added []Partition + for _, el := range defs { + pd, ok := el.(*mysqlast.Node) + if !ok { + s.problem(at(el), "ADD PARTITION: definition not understood") + return + } + part, ok := s.partitionDef(pd, t.Partitioning.Columns) + if !ok { + s.problem(at(el), "ADD PARTITION: definition not understood") + return + } + added = append(added, part) + } + t.Partitioning.Parts = append(t.Partitioning.Parts, added...) +} + +// --- CREATE INDEX / VIEW, DROP, RENAME ------------------------------------------------- + +func (s *Schema) createIndex(n *mysqlast.Node, st mysqlparse.Statement, at func(mysqlast.Value) int) { + x, _ := mysqlast.AsPTCreateIndexStmt(n) + t := s.needTable(tableName(x.TableIdent()), at(n)) + if t == nil { + return + } + k := &Key{Name: str(x.Name()), Kind: keyKind(str(x.TypePar()))} + for _, p := range list(x.Cols()) { + k.Parts = append(k.Parts, keyPart(p)) + } + s.indexOptions(k, list(x.Options())) + if t.key(k.Name) != nil { + s.problem(at(n), "CREATE INDEX %s: duplicate key name on %s", k.Name, t.Name) + return + } + t.addKey(k) + t.Alters = append(t.Alters, st.SQL) +} + +func (s *Schema) createView(n *mysqlast.Node, st mysqlparse.Statement) { + v, ok := s.viewOf(n, st, "CREATE VIEW") + if !ok { + return + } + if old := s.View(v.Name); old != nil { + if n.Arg("replace") == nil { + s.problem(st.Offset, "CREATE VIEW %s: view already exists", v.Name) + return + } + *old = *v + return + } + if s.Table(v.Name) != nil { + s.problem(st.Offset, "CREATE VIEW %s: a table of that name exists", v.Name) + return + } + s.Views = append(s.Views, v) +} + +// alterView applies an ALTER VIEW (Sql_cmd_alter_view, the CREATE's own shape): the view's +// definition is replaced whole, the way OR REPLACE replaces it. The view must exist (1146 +// "Table 'x' doesn't exist", measured) and be a view, not a table (1347 "'x' is not VIEW", +// measured); the CHECK OPTION rule is CREATE's (1368, measured for ALTER too). +func (s *Schema) alterView(n *mysqlast.Node, st mysqlparse.Statement) { + v, ok := s.viewOf(n, st, "ALTER VIEW") + if !ok { + return + } + if old := s.View(v.Name); old != nil { + *old = *v + return + } + if s.Table(v.Name) != nil { + s.problem(st.Offset, "ALTER VIEW %s: '%s' is not VIEW (1347)", v.Name, v.Name) + return + } + s.problem(st.Offset, "ALTER VIEW %s: Table '%s' doesn't exist (1146)", v.Name, v.Name) +} + +// viewOf reads a CREATE VIEW / ALTER VIEW node into a View: its name, column list, query, +// algorithm, check option and directives. what names the statement in a problem. +func (s *Schema) viewOf(n *mysqlast.Node, st mysqlparse.Statement, what string) (*View, bool) { + name := s.tableKey(tableName(n.Arg("name"))) + v := &View{Name: name, Query: n.Arg("query"), Definition: st.SQL, + Algorithm: strings.TrimPrefix(str(n.Arg("algorithm")), "VIEW_ALGORITHM_"), CheckOption: strings.TrimPrefix(str(n.Arg("check_option")), "VIEW_CHECK_")} + for _, c := range list(n.Arg("column_list")) { + v.Columns = append(v.Columns, str(c)) + } + if v.CheckOption != "" && v.CheckOption != "NONE" && (v.Algorithm == "TEMPTABLE" || !mysqlast.Mergeable(v.Query)) { + // the server refuses WITH CHECK OPTION on a view it cannot update -- one it would + // not merge (GROUP BY, DISTINCT, HAVING, LIMIT, a set operation, a subquery in the + // select list, ALGORITHM=TEMPTABLE) -- at CREATE time: 1368 "CHECK OPTION on + // non-updatable view" (measured on 8.4). The analyzer's own merge test is the + // server's is_mergeable, so the two agree on which views these are. + s.problem(st.Offset, "%s %s: CHECK OPTION on non-updatable view (1368)", what, name) + return nil, false + } + s.viewDirectives(v, st.SQL, st.Offset) + return v, true +} + +// dropTable drops the table, and with it every trigger declared on it (a trigger has no +// existence apart from its table on the server: DROP TABLE drops them silently, without a +// DROP TRIGGER of their own). +func (s *Schema) dropTable(name string) { + for i, t := range s.Tables { + if s.sameTable(t.Name, name) { + s.Tables = append(s.Tables[:i], s.Tables[i+1:]...) + var kept []*Trigger + for _, trg := range s.Triggers { + if !s.sameTable(trg.Table, t.Name) { + kept = append(kept, trg) + } + } + s.Triggers = kept + return + } + } +} + +func (s *Schema) dropView(name string) { + for i, v := range s.Views { + if s.sameTable(v.Name, name) { + s.Views = append(s.Views[:i], s.Views[i+1:]...) + return + } + } +} + +func (s *Schema) renameTables(n *mysqlast.Node, at func(mysqlast.Value) int) { + pairs := []*mysqlast.Node{n} + if n.Class == "table_to_table_list" { + pairs = nil + for _, p := range n.Args { + if pn, ok := p.(*mysqlast.Node); ok { + pairs = append(pairs, pn) + } + } + } + for _, p := range pairs { + if len(p.Args) != 2 { + s.problem(at(p), "RENAME TABLE: pair not understood") + continue + } + from, to := tableName(p.Args[0]), s.tableKey(tableName(p.Args[1])) + if t := s.Table(from); t != nil { + for _, trg := range s.Triggers { + if s.sameTable(trg.Table, t.Name) { + trg.Table = to + } + } + t.Name = to + } else if v := s.View(from); v != nil { + v.Name = to + } else { + s.problem(at(p), "RENAME TABLE %s: no such table", from) + } + } +} + +// needTable returns the table or records a problem. +func (s *Schema) needTable(name string, pos int) *Table { + t := s.Table(name) + if t == nil { + s.problem(pos, "no such table: %s", name) + } + return t +} + +// --- ALTER TABLE --------------------------------------------------------------------- + +func (s *Schema) alterTable(n *mysqlast.Node, st mysqlparse.Statement, at func(mysqlast.Value) int) { + t := s.needTable(tableName(n.Arg("table_name")), at(n)) + if t == nil { + return + } + t.Alters = append(t.Alters, st.SQL) + actions := list(n.Arg("opt_actions")) + if a := n.Arg("action"); a != nil { + actions = append(actions, a) + } + for _, a := range actions { + an, ok := a.(*mysqlast.Node) + if !ok { + continue + } + switch an.Class { + case "PT_alter_table_add_column": + x, _ := mysqlast.AsPTAlterTableAddColumn(an) + col, keys, _, check := s.column(str(x.FieldIdent()), x.FieldDef(), at) + if t.Column(col.Name) != nil { + s.problem(at(an), "ADD COLUMN %s: duplicate column", col.Name) + continue + } + t.insertColumn(col, x.OptPlace()) + t.Keys = append(t.Keys, keys...) + if check != nil { + t.Checks = append(t.Checks, check) + } + case "PT_alter_table_add_columns": + for _, el := range list(an.Arg("columns")) { + s.tableElement(t, el, at) + } + case "PT_alter_table_add_constraint": + s.tableElement(t, an.Arg("constraint"), at) + case "PT_alter_table_drop_column": + if !t.dropColumn(str(an.Arg("name"))) { + s.problem(at(an), "DROP COLUMN %s: no such column", str(an.Arg("name"))) + } + case "PT_alter_table_change_column": + x, _ := mysqlast.AsPTAlterTableChangeColumn(an) + old := str(x.OldName()) + if old == "" { + old = str(x.Name()) // MODIFY: one name + } + c := t.Column(old) + if c == nil { + s.problem(at(an), "CHANGE / MODIFY %s: no such column", old) + continue + } + newName := str(x.NewName()) + if newName == "" { + newName = old + } + col, keys, _, check := s.column(newName, x.FieldDef(), at) + *c = *col + t.Keys = append(t.Keys, keys...) + if check != nil { + t.Checks = append(t.Checks, check) + } + t.renameColumnRefs(old, newName) + if x.OptPlace() != nil { + t.dropColumn(newName) + t.insertColumn(c, x.OptPlace()) + } + case "PT_alter_table_rename_column": + from, to := str(an.Arg("from")), str(an.Arg("to")) + c := t.Column(from) + if c == nil { + s.problem(at(an), "RENAME COLUMN %s: no such column", from) + continue + } + c.Name = to + t.renameColumnRefs(from, to) + case "PT_alter_table_rename": + t.Name = tableName(an.Arg("ident")) + case "PT_alter_table_drop_key": + name := str(an.Arg("name")) + if name == "primary_key_name" { + name = "PRIMARY" + } + if !t.dropKey(name) { + s.problem(at(an), "DROP INDEX %s: no such index", name) + } + case "PT_alter_table_drop_foreign_key", "PT_alter_table_drop_constraint", "PT_alter_table_drop_check_constraint": + name := str(an.Arg("name")) + if !t.dropConstraint(name) { + s.problem(at(an), "DROP %s: no such constraint", name) + } + case "PT_alter_table_rename_key": + if k := t.key(str(an.Arg("from"))); k != nil { + k.Name = str(an.Arg("to")) + } else { + s.problem(at(an), "RENAME INDEX %s: no such index", str(an.Arg("from"))) + } + case "PT_alter_table_set_default": + if c := t.Column(str(an.Arg("col_name"))); c != nil { + c.Default = an.Arg("opt_default_expr") + } else { + s.problem(at(an), "ALTER COLUMN %s: no such column", str(an.Arg("col_name"))) + } + case "PT_alter_table_column_visibility": + if c := t.Column(str(an.Arg("col_name"))); c != nil { + c.Invisible = isFalse(an.Arg("is_visible")) + } + case "PT_alter_table_index_visible": + if k := t.key(str(an.Arg("name"))); k != nil { + k.Invisible = isFalse(an.Arg("visible")) + } + case "PT_alter_table_enforce_check_constraint": + for _, c := range t.Checks { + if strings.EqualFold(c.Name, str(an.Arg("name"))) { + c.Enforced = !isFalse(an.Arg("is_enforced")) + } + } + case "PT_alter_table_convert_to_charset": + t.Charset = str(an.Arg("charset")) + t.Collation = str(an.Arg("opt_collation")) + case "PT_create_table_engine_option", "PT_create_table_default_charset", "PT_create_table_default_collation", "PT_create_commen_option", "PT_create_row_format_option": + s.tableOptions(t, []mysqlast.Value{an}, at) + case "PT_alter_table_partition_by": + x, _ := mysqlast.AsPTAlterTablePartitionBy(an) + t.Partitioning = s.partitioning(x.Partition(), at) + case "PT_alter_table_remove_partitioning": + t.Partitioning = nil + case "PT_alter_table_add_partition_def_list": + x, _ := mysqlast.AsPTAlterTableAddPartitionDefList(an) + t.addPartitions(list(x.DefList()), s, at) + case "PT_alter_table_add_partition_num": + x, _ := mysqlast.AsPTAlterTableAddPartitionNum(an) + if t.Partitioning != nil && (t.Partitioning.Kind == "HASH" || t.Partitioning.Kind == "KEY") { + t.Partitioning.Num += numOf(x.NumParts()) + } + case "PT_alter_table_coalesce_partition": + x, _ := mysqlast.AsPTAlterTableCoalescePartition(an) + if t.Partitioning != nil && (t.Partitioning.Kind == "HASH" || t.Partitioning.Kind == "KEY") { + t.Partitioning.Num -= numOf(x.NumParts()) + } + case "PT_alter_table_drop_partition": + x, _ := mysqlast.AsPTAlterTableDropPartition(an) + if t.Partitioning != nil { + names := map[string]bool{} + for _, nm := range list(x.Partitions()) { + names[str(nm)] = true + } + var kept []Partition + for _, part := range t.Partitioning.Parts { + if !names[part.Name] { + kept = append(kept, part) + } + } + t.Partitioning.Parts = kept + } + case "PT_alter_table_reorganize_partition_into": + x, _ := mysqlast.AsPTAlterTableReorganizePartitionInto(an) + if t.Partitioning != nil && (t.Partitioning.Kind == "RANGE" || t.Partitioning.Kind == "LIST") { + names := map[string]bool{} + for _, nm := range list(x.PartitionNames()) { + names[str(nm)] = true + } + var into []Partition + ok := true + for _, el := range list(x.Into()) { + pd, isNode := el.(*mysqlast.Node) + if !isNode { + ok = false + break + } + part, partOK := s.partitionDef(pd, t.Partitioning.Columns) + if !partOK { + ok = false + break + } + into = append(into, part) + } + if !ok { + s.problem(at(an), "REORGANIZE PARTITION: definition not understood") + } else { + var kept []Partition + done := false + for _, part := range t.Partitioning.Parts { + if names[part.Name] { + if !done { + kept = append(kept, into...) + done = true + } + continue + } + kept = append(kept, part) + } + t.Partitioning.Parts = kept + } + } + case "PT_alter_table_order", "PT_alter_table_force", "PT_alter_table_enable_keys", "PT_alter_table_set_default_row_format", + "PT_alter_table_discard_tablespace", "PT_alter_table_import_tablespace", "PT_alter_table_secondary_load", "PT_alter_table_secondary_unload", + "PT_alter_table_add_partition": + // storage-level, no shape (PT_alter_table_add_partition: measured unreachable -- + // every ADD PARTITION this grammar accepts folds to _def_list or _num instead) + default: + if strings.HasPrefix(an.Class, "PT_create_") { // any other table option + continue + } + s.problem(at(an), "ALTER TABLE action not understood: %s", an.Class) + } + } +} + +// insertColumn adds col at the place ALTER TABLE names: FIRST, AFTER x, or the end. +func (t *Table) insertColumn(col *Column, place mysqlast.Value) { + switch p := place.(type) { + case nil: + t.Columns = append(t.Columns, col) + case mysqlast.Const: + if p == "first_keyword" { + t.Columns = append([]*Column{col}, t.Columns...) + return + } + t.Columns = append(t.Columns, col) + default: + after := str(place) + for i, c := range t.Columns { + if strings.EqualFold(c.Name, after) { + t.Columns = append(t.Columns[:i+1], append([]*Column{col}, t.Columns[i+1:]...)...) + return + } + } + t.Columns = append(t.Columns, col) + } +} + +func (t *Table) dropColumn(name string) bool { + for i, c := range t.Columns { + if strings.EqualFold(c.Name, name) { + t.Columns = append(t.Columns[:i], t.Columns[i+1:]...) + // keys over the column lose the part; an emptied key goes + var keys []*Key + for _, k := range t.Keys { + var parts []KeyPart + for _, p := range k.Parts { + if !strings.EqualFold(p.Column, name) { + parts = append(parts, p) + } + } + if len(parts) > 0 { + k.Parts = parts + keys = append(keys, k) + } + } + t.Keys = keys + return true + } + } + return false +} + +func (t *Table) dropConstraint(name string) bool { + t.nameUnnamed() // the server's generated names are what a DROP names + for i, fk := range t.ForeignKeys { + if strings.EqualFold(fk.Name, name) { + t.ForeignKeys = append(t.ForeignKeys[:i], t.ForeignKeys[i+1:]...) + return true + } + } + for i, c := range t.Checks { + if strings.EqualFold(c.Name, name) { + t.Checks = append(t.Checks[:i], t.Checks[i+1:]...) + return true + } + } + return t.dropKey(name) +} + +func (t *Table) renameColumnRefs(from, to string) { + for _, k := range t.Keys { + for i := range k.Parts { + if strings.EqualFold(k.Parts[i].Column, from) { + k.Parts[i].Column = to + } + } + } + for _, fk := range t.ForeignKeys { + for i := range fk.Columns { + if strings.EqualFold(fk.Columns[i], from) { + fk.Columns[i] = to + } + } + } +} + +// --- TRIGGER / PROCEDURE / FUNCTION ---------------------------------------------------- + +// createTrigger applies a CREATE TRIGGER (trigger_tail). Its args, in the grammar's order +// once terminals drop out: if_not_exists, sp_name, trg_action_time, trg_event, table_ident, +// trigger_follows_precedes_clause (a Struct), the body. +func (s *Schema) createTrigger(n *mysqlast.Node, st mysqlparse.Statement, at func(mysqlast.Value) int) { + if len(n.Args) != 7 { + // defensive: trigger_tail's own grammar always builds all 7 fields. + s.problem(at(n), "CREATE TRIGGER: statement not understood") + return + } + name := spName(n.Args[1]) + if s.Trigger(name) != nil { + if isTrue(n.Args[0]) { // IF NOT EXISTS + return + } + s.problem(at(n), "CREATE TRIGGER %s: trigger already exists", name) + return + } + table := s.tableKey(tableName(n.Args[4])) + if s.Table(table) == nil { + s.problem(at(n), "CREATE TRIGGER %s: no such table: %s", name, table) + return + } + trg := &Trigger{ + Name: name, + Table: table, + Timing: strings.TrimPrefix(str(n.Args[2]), "TRG_ACTION_"), + Event: strings.TrimPrefix(str(n.Args[3]), "TRG_EVENT_"), + Body: n.Args[6], + Definition: st.SQL, + } + if ord, ok := n.Args[5].(*mysqlast.Struct); ok { + if oc := str(ord.Fields["ordering_clause"]); oc != "" && oc != "TRG_ORDER_NONE" { + trg.OrderClause = strings.TrimPrefix(oc, "TRG_ORDER_") + trg.OrderTrigger = str(ord.Fields["anchor_trigger_name"]) + // the server rejects FOLLOWS/PRECEDES a trigger that does not exist (for that + // action time and event type) at CREATE TIME, 3011, measured against mysqld + if s.Trigger(trg.OrderTrigger) == nil { + s.problem(at(n), "CREATE TRIGGER %s: %s %s: no such trigger", name, trg.OrderClause, trg.OrderTrigger) + return + } + } + } + if msg := bodyRefusalProblem(trg.Body, true); msg != "" { + // the server refuses this CREATE TRIGGER outright (1336 dynamic SQL, 1331 a + // duplicate variable): a problem the same way an unknown table is, but the + // trigger is still loaded -- analyze.AnalyzeTrigger reads the very same body and + // raises the matching error for whatever statement's own check reaches it there. + s.problem(at(n), "CREATE TRIGGER %s: %s", name, msg) + } + trg.Directives, _ = s.spDirectives("trigger", name, st.SQL, st.Offset) + s.Triggers = append(s.Triggers, trg) +} + +// createEvent applies a CREATE EVENT (event_tail: if_not_exists, sp_name, ev_schedule, +// on_completion, status, comment, body). The server checks nothing of the body at CREATE +// time (measured: a DELETE from a table that does not exist is accepted, and fails at every +// run), only a RETURN (1313); the body's own reading is analyze.AnalyzeEvent's. +func (s *Schema) createEvent(n *mysqlast.Node, st mysqlparse.Statement, at func(mysqlast.Value) int) { + if len(n.Args) != 7 { + // defensive: event_tail's own grammar always builds all 7 fields. + s.problem(at(n), "CREATE EVENT: statement not understood") + return + } + name := spName(n.Args[1]) + if s.Event(name) != nil { + if isTrue(n.Args[0]) { // IF NOT EXISTS + return + } + s.problem(at(n), "CREATE EVENT %s: event already exists", name) + return + } + e := &Event{Name: name, Completion: "NOT PRESERVE", Status: "ENABLE", Definition: st.SQL} + e.setSchedule(n.Args[2], st.SQL) + e.setOptions(n.Args[3], n.Args[4], n.Args[5]) + e.setBody(n.Args[6], st.SQL) + s.Events = append(s.Events, e) +} + +// alterEvent applies an ALTER EVENT (alter_event_stmt: definer, sp_name, schedule and/or +// completion, RENAME TO, status, comment, DO body): each clause written replaces that part +// of the event, the rest stays (measured: SHOW CREATE EVENT after `ALTER EVENT e ON SCHEDULE +// EVERY 2 DAY` keeps the STARTS, completion, status and body as they were). The Definition +// stays the CREATE's text: the canonical form, which diff and apply read, comes from the +// server's own SHOW CREATE EVENT of the altered event. +func (s *Schema) alterEvent(n *mysqlast.Node, st mysqlparse.Statement, at func(mysqlast.Value) int) { + if len(n.Args) != 7 { + // defensive: alter_event_stmt's own grammar always builds all 7 fields. + s.problem(at(n), "ALTER EVENT: statement not understood") + return + } + name := spName(n.Args[1]) + e := s.Event(name) + if e == nil { + s.problem(at(n), "ALTER EVENT %s: no such event", name) + return + } + if sc, ok := n.Args[2].(*mysqlast.Node); ok && sc.Class == "ev_alter_schedule" { + if sch := sc.Arg("schedule"); sch != nil { + e.At, e.Every, e.Starts, e.Ends = "", "", "", "" + e.AtLiteral, e.StartsLiteral, e.EndsLiteral = false, false, false + e.setSchedule(sch, st.SQL) + } + if c := str(sc.Arg("completion")); c != "" { + e.Completion = c + } + } + if newName := spName(n.Args[3]); newName != "" && newName != "0" { + if s.Event(newName) != nil && !strings.EqualFold(newName, name) { + s.problem(at(n), "ALTER EVENT %s RENAME TO %s: event already exists", name, newName) + return + } + e.Name = newName + } + var comment mysqlast.Value = mysqlast.Const("0") + if str(n.Args[5]) != "0" { + comment = n.Args[5] + } + e.setOptions(mysqlast.Const("0"), n.Args[4], comment) + if str(n.Args[6]) != "0" && n.Args[6] != nil { + e.setBody(n.Args[6], st.SQL) + } +} + +// exprText is the text of an expression node in sql, trimmed (str(v) for a non-node). +func exprText(v mysqlast.Value, sql string) string { + if x, ok := v.(*mysqlast.Node); ok && x.End > x.Start && x.End <= len(sql) { + return strings.TrimSpace(sql[x.Start:x.End]) + } + return str(v) +} + +// isLiteral reports a string literal expression (a time the server stores as written). +func isLiteral(v mysqlast.Value) bool { + x, ok := v.(*mysqlast.Node) + return ok && strings.HasPrefix(x.Class, "PTI_text_literal") +} + +// setSchedule reads an ev_schedule node into e. +func (e *Event) setSchedule(v mysqlast.Value, sql string) { + sch, ok := v.(*mysqlast.Node) + if !ok || sch.Class != "ev_schedule" { + return + } + if v := sch.Arg("at"); v != nil { + e.At, e.AtLiteral = exprText(v, sql), isLiteral(v) + } + if v := sch.Arg("every"); v != nil { + e.Every = exprText(v, sql) + " " + strings.TrimPrefix(str(sch.Arg("interval")), "INTERVAL_") + } + if v := sch.Arg("starts"); v != nil { + e.Starts, e.StartsLiteral = exprText(v, sql), isLiteral(v) + } + if v := sch.Arg("ends"); v != nil { + e.Ends, e.EndsLiteral = exprText(v, sql), isLiteral(v) + } +} + +// setOptions reads the completion, status and comment values (each "0" or "" when not +// written, which leaves e's as it is). +func (e *Event) setOptions(completion, status, comment mysqlast.Value) { + if c := str(completion); c != "" && c != "0" { + e.Completion = c + } + if st := str(status); st != "" && st != "0" { + e.Status = st + } + if c := str(comment); c != "0" { + e.Comment = c + } +} + +// setBody reads the DO body. +func (e *Event) setBody(v mysqlast.Value, sql string) { + e.Body = v + e.BodyText = exprText(v, sql) +} + +// dropEvent applies a DROP EVENT (1539 "Unknown event" on the server when it does not +// exist, measured). +func (s *Schema) dropEvent(name string, ifExists bool, pos int) { + for i, e := range s.Events { + if strings.EqualFold(e.Name, name) { + s.Events = append(s.Events[:i], s.Events[i+1:]...) + return + } + } + if !ifExists { + s.problem(pos, "DROP EVENT %s: no such event", name) + } +} + +// dropTrigger applies a DROP TRIGGER. +func (s *Schema) dropTrigger(name string, ifExists bool, pos int) { + for i, t := range s.Triggers { + if strings.EqualFold(t.Name, name) { + s.Triggers = append(s.Triggers[:i], s.Triggers[i+1:]...) + return + } + } + if !ifExists { + s.problem(pos, "DROP TRIGGER %s: no such trigger", name) + } +} + +// createRoutine applies a CREATE PROCEDURE (sp_tail) or CREATE FUNCTION (sf_tail). sp_tail's +// args are if_not_exists, sp_name, params, characteristics, body; sf_tail's are +// if_not_exists, sp_name, params, returns type, opt_collate (on the return type, not kept), +// characteristics, body. +func (s *Schema) createRoutine(n *mysqlast.Node, kind RoutineKind, st mysqlparse.Statement, at func(mysqlast.Value) int) { + if kind == Function && len(n.Args) != 7 || kind == Procedure && len(n.Args) != 5 { + // defensive: sp_tail's and sf_tail's own grammar always build every field. + s.problem(at(n), "CREATE %s: statement not understood", kind) + return + } + name := spName(n.Args[1]) + if s.routine(kind, name) != nil { + if isTrue(n.Args[0]) { // IF NOT EXISTS + return + } + s.problem(at(n), "CREATE %s %s: %s already exists", kind, name, strings.ToLower(kind.String())) + return + } + r := &Routine{Name: name, Kind: kind, Params: s.paramsOf(n.Args[2], at), Definition: st.SQL, DataAccess: "CONTAINS_SQL", Security: "DEFINER"} + if kind == Function { + r.Returns = s.typeOf(n.Args[3], at) + s.applyChistics(r, list(n.Args[5])) + r.Body = n.Args[6] + } else { + s.applyChistics(r, list(n.Args[3])) + r.Body = n.Args[4] + } + if msg := bodyRefusalProblem(r.Body, kind == Function); msg != "" { + // the server refuses this CREATE outright (1336 dynamic SQL in a FUNCTION, 1331 a + // duplicate variable in either kind): a problem the same way an unknown table is, + // but the routine is still loaded -- analyze.AnalyzeRoutine reads the very same + // body and raises the matching error wherever a statement's own check reaches it. + s.problem(at(n), "CREATE %s %s: %s", kind, name, msg) + } else if kind == Function && !hasReturn(r.Body) { + // a FUNCTION with no RETURN anywhere in its body is refused at CREATE time (1320 + // "No RETURN found in FUNCTION", measured; found by the body probe, which saw the + // analyzer stop at an earlier run-time certainty of the same body and never say so) + s.problem(at(n), "CREATE FUNCTION %s: No RETURN found in FUNCTION %s", name, name) + } + r.Directives, r.NotNull = s.spDirectives(strings.ToLower(kind.String()), name, st.SQL, st.Offset) + s.Routines = append(s.Routines, r) +} + +// hasReturn reports whether a RETURN statement appears anywhere in a body (the server's +// own 1320 check is textual presence, not reachability). +func hasReturn(v mysqlast.Value) bool { + switch x := v.(type) { + case mysqlast.List: + for _, e := range x { + if hasReturn(e) { + return true + } + } + case *mysqlast.Struct: + for _, k := range x.Order { + if hasReturn(x.Fields[k]) { + return true + } + } + case *mysqlast.Node: + if x.Class == "sp_return" { + return true + } + for _, a := range x.Args { + if hasReturn(a) { + return true + } + } + } + return false +} + +// bodyRefusalProblem walks a trigger's or routine's body for a construct the server itself +// refuses at CREATE time that createTrigger/createRoutine cannot otherwise see: dynamic SQL +// (PREPARE / EXECUTE / DEALLOCATE PREPARE) inside a trigger or FUNCTION body (1336, measured +// -- a PROCEDURE is exempt, so dynamicSQL gates it), and two DECLAREs of the same variable +// name in the same block, any kind (1331, measured). Mirrors analyze/body.go's own +// commitCheck / walkDecl duplicate check, which this package cannot import (analyze imports +// schema, not the other way around) -- so analyze.AnalyzeTrigger / AnalyzeRoutine raise the +// matching error independently when a statement's own check reaches the same body. +func bodyRefusalProblem(v mysqlast.Value, dynamicSQL bool) string { + switch x := v.(type) { + case mysqlast.List: + for _, e := range x { + if msg := bodyRefusalProblem(e, dynamicSQL); msg != "" { + return msg + } + } + case *mysqlast.Struct: + if dynamicSQL { + if cmd, ok := x.Fields["sql_command"].(mysqlast.Const); ok { + switch cmd { + case "SQLCOM_PREPARE", "SQLCOM_DEALLOCATE_PREPARE": + return "Dynamic SQL is not allowed in stored function or trigger" + } + } + } + for _, k := range x.Order { + if msg := bodyRefusalProblem(x.Fields[k], dynamicSQL); msg != "" { + return msg + } + } + case *mysqlast.Node: + switch x.Class { + case "lock_tables", "unlock", "PT_load_table", "Sql_cmd_alter_view": + // refused by the grammar itself inside any body, a PROCEDURE included (1314 + // " is not allowed in stored procedures", measured for all three routine + // kinds); mirrors analyze/body.go's walkNode + return map[string]string{"lock_tables": "LOCK", "unlock": "UNLOCK", "PT_load_table": "LOAD DATA", "Sql_cmd_alter_view": "ALTER VIEW"}[x.Class] + " is not allowed in stored procedures" + } + if dynamicSQL && x.Class == "execute" { + return "Dynamic SQL is not allowed in stored function or trigger" + } + if dynamicSQL && x.Class == "flush" { + // mirrors analyze/body.go's own commitCheck: FLUSH folds to a bare "flush" + // Node, not a sql_command Struct (measured against the generated shapes), so + // it is checked here the same way "execute" is, not through the *Struct case + // above. + return "FLUSH is not allowed in stored function or trigger" + } + if x.Class == "sp_condition_value" { + // mirrors analyze/body.go's own classifyMysqlerrString: a SIGNAL/RESIGNAL/ + // HANDLER FOR/DECLARE ... CONDITION FOR naming a SQLSTATE literal that is not + // exactly 5 characters long is refused at CREATE time (1407 "Bad SQLSTATE: + // ''", measured; a well-formed 5-character one that happens to be + // non-numeric, e.g. 'ABCDE', is accepted). + if msg := malformedSQLStateProblem(x.Arg("_mysqlerr")); msg != "" { + return msg + } + } + if x.Class == "sp_block_content" { + if msg := duplicateVariableProblem(x.Args[0]); msg != "" { + return msg + } + } + for _, a := range x.Args { + if msg := bodyRefusalProblem(a, dynamicSQL); msg != "" { + return msg + } + } + } + return "" +} + +// malformedSQLStateProblem is bodyRefusalProblem's own check for a sp_condition_value's +// "_mysqlerr": a bare Go string only for the TEXT_STRING_literal alternative (a SQLSTATE +// literal; the three keyword conditions -- WARNING/NOT FOUND/EXCEPTION -- build a +// mysqlast.Const instead, and a bare number a mysqlast.Number, neither of which this ever +// sees), 1407 when it is not exactly 5 characters long. +func malformedSQLStateProblem(v mysqlast.Value) string { + x, ok := v.(string) + if !ok || len(x) == 5 { + return "" + } + return fmt.Sprintf("Bad SQLSTATE: '%s'", x) +} + +// duplicateVariableProblem is bodyRefusalProblem's own check for one block's own +// declarations (sp_block_content's first argument): two DECLAREs of the same name, +// case-insensitively, is 1331 ("Duplicate variable: x", measured); a nested block's own +// DECLARE of the same name is ordinary shadowing, not a duplicate, so this never looks past +// decls' own top level (bodyRefusalProblem's own recursion finds a nested block's decls in +// turn, as its own sp_block_content). +// +// The same block-level rule holds for the block's other declarations, each in its own +// namespace (measured on 8.4): two DECLARE ... CONDITIONs of one name are 1332 ("Duplicate +// condition: x"), two DECLARE ... CURSORs 1333 ("Duplicate cursor: x"), and two HANDLERs +// naming the same condition value -- a SQLSTATE, an error number, one of the three +// classes, or a named condition resolved to its value -- 1413 ("Duplicate handler declared +// in the same block"); a variable and a cursor of one name coexist. +func duplicateVariableProblem(decls mysqlast.Value) string { + names, _ := decls.(mysqlast.List) + seen := map[string]bool{} + conds := map[string]bool{} + condValues := map[string]string{} // named condition -> its value's key, this block's own + cursors := map[string]bool{} + var handled []string // every condition value key an earlier HANDLER of the block names + for _, d := range names { + dn, ok := d.(*mysqlast.Node) + if !ok { + continue + } + switch dn.Class { + case "sp_decl_var": + for _, nm := range list(dn.Arg("names")) { + name := str(nm) + key := strings.ToLower(name) + if seen[key] { + return fmt.Sprintf("Duplicate variable: %s", name) + } + seen[key] = true + } + case "sp_decl_condition": + name := str(dn.Arg("name")) + key := strings.ToLower(name) + if conds[key] { + return fmt.Sprintf("Duplicate condition: %s", name) + } + conds[key] = true + if vn, ok := dn.Arg("value").(*mysqlast.Node); ok { + condValues[key] = conditionKey(vn, condValues) + } + case "sp_decl_cursor": + name := str(dn.Arg("name")) + key := strings.ToLower(name) + if cursors[key] { + return fmt.Sprintf("Duplicate cursor: %s", name) + } + cursors[key] = true + case "sp_decl_handler": + var mine []string + for _, c := range list(dn.Arg("conditions")) { + cn, ok := c.(*mysqlast.Node) + if !ok { + continue + } + k := conditionKey(cn, condValues) + if k == "" { + continue + } + for _, h := range handled { + if h == k { + return "Duplicate handler declared in the same block" + } + } + mine = append(mine, k) + } + handled = append(handled, mine...) + } + } + return "" +} + +// conditionKey spells one HANDLER FOR / DECLARE CONDITION FOR value so that two values the +// server treats as the same compare equal: a SQLSTATE upper-cased, an error number, one of +// the three classes by its keyword, and a named condition by the value it was declared +// with in this block (an outer block's name resolves to nothing here, so it never collides +// -- analyze/body.go raises the exact error on the resolved chain). +func conditionKey(n *mysqlast.Node, named map[string]string) string { + switch n.Class { + case "sp_condition_value": + switch x := n.Arg("_mysqlerr").(type) { + case int: + return fmt.Sprintf("number:%d", x) + case mysqlast.Number: + return fmt.Sprintf("number:%d", int(x)) + case string: + return "value:" + strings.ToUpper(x) + case mysqlast.Const: + return "value:" + strings.ToUpper(string(x)) + } + case "sp_condition_name": + return named[strings.ToLower(str(n.Arg("name")))] + } + return "" +} + +// paramsOf reads a routine's parameter list (a List of sp_param nodes). +func (s *Schema) paramsOf(v mysqlast.Value, at func(mysqlast.Value) int) []Param { + var out []Param + for _, p := range list(v) { + pn, ok := p.(*mysqlast.Node) + if !ok || pn.Class != "sp_param" { + continue // defensive: a routine's own parameter list is always sp_param Nodes + } + out = append(out, Param{Mode: paramMode(str(pn.Arg("mode"))), Name: str(pn.Arg("name")), Type: s.typeOf(pn.Arg("type"), at)}) + } + return out +} + +func paramMode(s string) string { + switch s { + case "sp_variable::MODE_OUT": + return "OUT" + case "sp_variable::MODE_INOUT": + return "INOUT" + } + return "IN" +} + +// applyChistics reads a routine's characteristics (a List of the {kind, value} tags +// hooks_sp.go's sp_chistic hooks build): DETERMINISTIC, the SQL data access class, SQL +// SECURITY. COMMENT and LANGUAGE carry no field on Routine (nothing reads them yet). +func (s *Schema) applyChistics(r *Routine, items []mysqlast.Value) { + for _, it := range items { + cn, ok := it.(*mysqlast.Node) + if !ok || cn.Class != "sp_chistic" { + continue // defensive: a routine's own characteristics list is always sp_chistic Nodes + } + switch str(cn.Arg("kind")) { + case "DETERMINISTIC": + r.Deterministic = str(cn.Arg("value")) == "true" + case "SQL_DATA_ACCESS": + r.DataAccess = str(cn.Arg("value")) + case "SQL_SECURITY": + r.Security = str(cn.Arg("value")) + } + } +} + +// dropRoutine applies a DROP PROCEDURE / DROP FUNCTION. +func (s *Schema) dropRoutine(kind RoutineKind, name string, ifExists bool, pos int) { + for i, r := range s.Routines { + if r.Kind == kind && strings.EqualFold(r.Name, name) { + s.Routines = append(s.Routines[:i], s.Routines[i+1:]...) + return + } + } + if !ifExists { + s.problem(pos, "DROP %s %s: no such %s", kind, name, strings.ToLower(kind.String())) + } +} + +// alterRoutine applies an ALTER PROCEDURE / ALTER FUNCTION: MySQL's grammar only lets it +// change characteristics (COMMENT, SQL SECURITY, ...), none of which Routine models, so it +// has no schema effect beyond requiring the routine to exist. +func (s *Schema) alterRoutine(kind RoutineKind, name string, pos int) { + if s.routine(kind, name) == nil { + s.problem(pos, "ALTER %s %s: no such %s", kind, name, strings.ToLower(kind.String())) + } +} + +// spName is the unqualified name of a sp_name (the schema part is dropped, as tableName +// drops a Table_ident's: sqlshape works within one database). +func spName(v mysqlast.Value) string { + if n, ok := v.(*mysqlast.Node); ok && n.Class == "sp_name" { + return str(n.Arg("name")) + } + // defensive: every caller passes a CREATE/DROP TRIGGER/PROCEDURE/FUNCTION's own + // sp_name, always this Node class. + return str(v) +} + +// spDirectives applies the directives written above a CREATE TRIGGER / PROCEDURE / FUNCTION. +// `error = ` is read here (kept verbatim; a later analysis stage parses it and +// resolves against the routine's/trigger's own failure modes). `not null` (the same +// override postgres/schema.go's createFunction reads) is read too, but only above a +// FUNCTION: a stored function alone has a single result to mark never-NULL, the same +// reason docs/checks.md and docs/templates.md only document it there. Anything else is a +// problem. +func (s *Schema) spDirectives(kind, name, sql string, pos int) (out []string, notNull bool) { + for _, d := range leadingDirectives(sql) { + norm := strings.Join(strings.Fields(strings.ToLower(d)), " ") + switch { + case strings.HasPrefix(strings.ToLower(d), "error "): + for _, r := range ParseRaises([]string{d}) { + if !validErrorName(r.Name) { + s.problem(pos, "%s %s: directive %q: %q is not a valid name (a Go identifier)", kind, name, d, r.Name) + } + } + out = append(out, d) + case norm == "not null" && kind == "function": + notNull = true + default: + s.problem(pos, "%s %s: unknown directive %q", kind, name, d) + } + } + return out, notNull +} + +// --- values ---------------------------------------------------------------------------- + +// tableName is the unqualified name of a Table_ident (the schema part is dropped: sqlshape +// works within one database). +func tableName(v mysqlast.Value) string { + if n, ok := v.(*mysqlast.Node); ok && n.Class == "Table_ident" { + return str(n.Arg("table")) + } + return str(v) +} + +// str renders a token, constant or string value as text; nodes and lists give "". +func str(v mysqlast.Value) string { + switch x := v.(type) { + case mysqlast.Token: + if x.Value != "" || x.Kind.String() == "IDENT_QUOTED" || x.Kind.String() == "TEXT_STRING" { + return x.Value + } + return x.Text + case mysqlast.Const: + return string(x) + case string: + return x + case mysqlast.Number: + return strconv.FormatInt(int64(x), 10) + } + return "" +} + +func list(v mysqlast.Value) []mysqlast.Value { + l, _ := v.(mysqlast.List) + return l +} + +// numOf is v's own integer value (a Number literal); 0 for anything else. +func numOf(v mysqlast.Value) int { + if n, ok := v.(mysqlast.Number); ok { + return int(n) + } + return 0 +} + +func isTrue(v mysqlast.Value) bool { + switch x := v.(type) { + case mysqlast.Const: + return x == "true" || x == "1" + case mysqlast.Number: + return x != 0 + } + return false +} + +func isFalse(v mysqlast.Value) bool { + switch x := v.(type) { + case mysqlast.Const: + return x == "false" || x == "0" + case mysqlast.Number: + return x == 0 + } + return false +} diff --git a/check/mysql/internal/schema/schema_test.go b/check/mysql/internal/schema/schema_test.go new file mode 100644 index 0000000..73c7675 --- /dev/null +++ b/check/mysql/internal/schema/schema_test.go @@ -0,0 +1,200 @@ +package schema + +import ( + "strings" + "testing" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlast" +) + +const sample = `-- sqlshape: mysql 8.4 +CREATE TABLE customers ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL, + name VARCHAR(100) COLLATE utf8mb4_bin, + status ENUM('active', 'closed') NOT NULL DEFAULT 'active', + balance DECIMAL(12, 2) NOT NULL DEFAULT 0, + notes TEXT, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP, + full_name VARCHAR(200) GENERATED ALWAYS AS (concat(name, '!')) STORED, + UNIQUE KEY uq_email (email), + KEY ix_status_created (status, created_at DESC), + CHECK (balance >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='people'; + +CREATE TABLE orders ( + id INT NOT NULL AUTO_INCREMENT, + customer_id BIGINT UNSIGNED NOT NULL, + amount DECIMAL(12,2), + PRIMARY KEY (id), + CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers (id) ON DELETE CASCADE +); + +CREATE INDEX ix_orders_amount ON orders (amount); +CREATE VIEW big_orders (order_id, amt) AS SELECT id, amount FROM orders WHERE amount > 100; +ALTER TABLE orders ADD COLUMN note VARCHAR(20) NULL AFTER customer_id, MODIFY amount DECIMAL(14,2) NOT NULL, DROP INDEX ix_orders_amount; +ALTER TABLE customers RENAME COLUMN notes TO remarks; +RENAME TABLE orders TO purchases; +` + +func TestLoad(t *testing.T) { + s, err := Load(sample) + if err != nil { + t.Fatal(err) + } + for _, p := range s.Problems { + t.Errorf("problem: %s", p) + } + if s.Version != "8.4" { + t.Errorf("version %q", s.Version) + } + c := s.Table("customers") + if c == nil { + t.Fatal("no customers") + } + cols := map[string]*Column{} + var names []string + for _, col := range c.Columns { + cols[col.Name] = col + names = append(names, col.Name) + } + if got := strings.Join(names, ","); got != "id,email,name,status,balance,remarks,created_at,updated_at,full_name" { + t.Errorf("columns %s", got) + } + if id := cols["id"]; id.Type.String() != "bigint unsigned" || !id.NotNull || !id.AutoIncrement { + t.Errorf("id: %+v", id) + } + if e := cols["email"]; e.Type.String() != "varchar(255)" || !e.NotNull { + t.Errorf("email: %+v", e) + } + if n := cols["name"]; n.Collation != "utf8mb4_bin" || n.NotNull { + t.Errorf("name: %+v", n) + } + if st := cols["status"]; st.Type.String() != "enum('active','closed')" || mysqlast.Sprint(st.Default) != `PTI_text_literal_text_string(is_7bit=YYTHD->m_parser_state->m_lip.text_string_is_7bit(), literal=TEXT_STRING="active")` { + t.Errorf("status: %s default %s", st.Type, mysqlast.Sprint(st.Default)) + } + if b := cols["balance"]; b.Type.String() != "decimal(12,2)" { + t.Errorf("balance: %s", b.Type) + } + if r := cols["remarks"]; r.Type.String() != "text" { + t.Errorf("remarks: %s", r.Type) + } + if ca := cols["created_at"]; ca.Type.String() != "datetime(6)" || mysqlast.Sprint(ca.Default) != "Item_func_now_local(dec=6)" { + t.Errorf("created_at: %s default %s", ca.Type, mysqlast.Sprint(ca.Default)) + } + if u := cols["updated_at"]; u.Type.String() != "timestamp" || u.NotNull || !u.OnUpdate { + t.Errorf("updated_at: %+v", u) + } + if f := cols["full_name"]; f.Generated == nil || !f.Stored { + t.Errorf("full_name: %+v", f) + } + var keys []string + for _, k := range c.Keys { + var parts []string + for _, p := range k.Parts { + parts = append(parts, p.Column+map[bool]string{true: " DESC", false: ""}[p.Desc]) + } + keys = append(keys, k.Kind.String()+" "+k.Name+"("+strings.Join(parts, ",")+")") + } + if got := strings.Join(keys, "; "); got != "PRIMARY KEY PRIMARY(id); UNIQUE uq_email(email); INDEX ix_status_created(status,created_at DESC)" { + t.Errorf("keys: %s", got) + } + if len(c.Checks) != 1 || !c.Checks[0].Enforced { + t.Errorf("checks: %+v", c.Checks) + } + if c.Engine != "InnoDB" || c.Charset != "utf8mb4" || c.Comment != "people" { + t.Errorf("options: %s %s %s", c.Engine, c.Charset, c.Comment) + } + + p := s.Table("purchases") + if p == nil || s.Table("orders") != nil { + t.Fatal("RENAME TABLE did not take") + } + names = names[:0] + for _, col := range p.Columns { + names = append(names, col.Name) + } + if got := strings.Join(names, ","); got != "id,customer_id,note,amount" { + t.Errorf("purchases columns %s", got) + } + if a := p.Column("amount"); a.Type.String() != "decimal(14,2)" || !a.NotNull { + t.Errorf("amount after MODIFY: %+v", a) + } + if len(p.Keys) != 1 || p.Keys[0].Kind != Primary { + t.Errorf("purchases keys after DROP INDEX: %+v", p.Keys) + } + if len(p.ForeignKeys) != 1 || p.ForeignKeys[0].RefTable != "customers" || p.ForeignKeys[0].OnDelete != "CASCADE" || p.ForeignKeys[0].Columns[0] != "customer_id" || p.ForeignKeys[0].RefColumns[0] != "id" { + t.Errorf("fk: %+v", p.ForeignKeys) + } + v := s.View("big_orders") + if v == nil || strings.Join(v.Columns, ",") != "order_id,amt" || v.Query == nil || v.CheckOption != "NONE" { + t.Errorf("view: %+v", v) + } +} + +func TestProblems(t *testing.T) { + s, err := Load("CREATE TABLE t (a INT);\nCREATE TABLE t (b INT);\nALTER TABLE nope ADD COLUMN x INT;\nSELECT 1 +;\nCREATE TABLE u (a INT PRIMARY KEY) PARTITION BY HASH (a) PARTITIONS 2;\nDROP TABLE IF EXISTS gone;\n") + if err != nil { + t.Fatal(err) + } + var msgs []string + for _, p := range s.Problems { + msgs = append(msgs, p.Message) + } + want := []string{"CREATE TABLE t: table already exists", "no such table: nope", "syntax error"} + if len(msgs) != len(want) { + t.Fatalf("problems: %q", msgs) + } + for i := range want { + if !strings.HasPrefix(msgs[i], want[i]) { + t.Errorf("problem %d: %q, want %q", i, msgs[i], want[i]) + } + } + if u := s.Table("u"); u == nil || u.Partitioning == nil || u.Partitioning.Kind != "HASH" || u.Partitioning.Num != 2 { + t.Error("partitioned table") + } +} + +// A partition's own explicit SUBPARTITION name list (what SHOW CREATE TABLE spells back +// for a table declared with one) loads as the partition's Subs; a subpartition option +// other than ENGINE stays a problem. +func TestExplicitSubpartitions(t *testing.T) { + s, err := Load("CREATE TABLE t (a INT, b VARCHAR(10)) PARTITION BY RANGE (a) SUBPARTITION BY HASH (a) SUBPARTITIONS 2 " + + "(PARTITION pNeg VALUES LESS THAN (0) (SUBPARTITION subp0 ENGINE = InnoDB, SUBPARTITION subp1 ENGINE = InnoDB), " + + "PARTITION pPos VALUES LESS THAN MAXVALUE (SUBPARTITION subp2, SUBPARTITION subp3));\n") + if err != nil { + t.Fatal(err) + } + if len(s.Problems) > 0 { + t.Fatalf("problems: %+v", s.Problems) + } + p := s.Table("t").Partitioning + if p == nil || p.Kind != "RANGE" || p.Sub == nil || p.Sub.Kind != "HASH" || p.Sub.Num != 2 || len(p.Parts) != 2 { + t.Fatalf("partitioning: %+v", p) + } + if strings.Join(p.Parts[0].Subs, ",") != "subp0,subp1" || strings.Join(p.Parts[1].Subs, ",") != "subp2,subp3" { + t.Errorf("subs: %+v / %+v", p.Parts[0].Subs, p.Parts[1].Subs) + } + if !p.Parts[1].MaxValue { + t.Errorf("pPos: %+v", p.Parts[1]) + } + + s, err = Load("CREATE TABLE t (a INT) PARTITION BY RANGE (a) SUBPARTITION BY HASH (a) " + + "(PARTITION p0 VALUES LESS THAN (0) (SUBPARTITION s0 TABLESPACE ts));\n") + if err != nil { + t.Fatal(err) + } + if len(s.Problems) != 1 || !strings.Contains(s.Problems[0].Message, "PARTITION RANGE: definition not understood") { + t.Errorf("subpartition TABLESPACE: %+v", s.Problems) + } +} + +func TestDeclaredVersion(t *testing.T) { + if v, _ := DeclaredVersion("CREATE TABLE t (a INT)"); v != "8.4" { + t.Errorf("default %s", v) + } + if _, err := DeclaredVersion("-- sqlshape: mysql 9.1\n"); err == nil { + t.Error("9.1 should be refused for now") + } +} diff --git a/check/mysql/internal/schema/types.go b/check/mysql/internal/schema/types.go new file mode 100644 index 0000000..88a7111 --- /dev/null +++ b/check/mysql/internal/schema/types.go @@ -0,0 +1,253 @@ +package schema + +import ( + "fmt" + "strconv" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlast" +) + +// Type is a MySQL column type as declared. +type Type struct { + // Name is the MySQL type name in lower case: int, bigint, decimal, varchar, text, + // datetime, json, enum, set, point ... + Name string + Length int // display width / length / precision; -1 when not given + Dec int // scale / fractional seconds; -1 when not given + Unsigned bool + Zerofill bool + Binary bool // CHAR(n) BINARY, i.e. the binary collation + Charset string // CHARACTER SET on a string type + Collation string + Values []string // ENUM / SET members + Srid int // SRID on a spatial type; 0 when none +} + +// String renders the type the way SHOW CREATE TABLE would, modulo MySQL's own rewrites. +func (t Type) String() string { + var b strings.Builder + b.WriteString(t.Name) + switch { + case len(t.Values) > 0: + b.WriteByte('(') + for i, v := range t.Values { + if i > 0 { + b.WriteByte(',') + } + b.WriteString("'" + strings.ReplaceAll(v, "'", "''") + "'") + } + b.WriteByte(')') + case t.Length >= 0 && t.Dec >= 0: + b.WriteString("(" + strconv.Itoa(t.Length) + "," + strconv.Itoa(t.Dec) + ")") + case t.Length >= 0: + b.WriteString("(" + strconv.Itoa(t.Length) + ")") + case t.Dec >= 0: + b.WriteString("(" + strconv.Itoa(t.Dec) + ")") + } + if t.Unsigned { + b.WriteString(" unsigned") + } + if t.Zerofill { + b.WriteString(" zerofill") + } + if t.Charset != "" { + b.WriteString(" CHARACTER SET " + t.Charset) + } + if t.Binary { + b.WriteString(" BINARY") + } + if t.Collation != "" { + b.WriteString(" COLLATE " + t.Collation) + } + if t.Srid != 0 { + b.WriteString(" SRID " + strconv.Itoa(t.Srid)) + } + return b.String() +} + +// IsString reports a character string type (charset and collation apply). +func (t Type) IsString() bool { + switch t.Name { + case "char", "varchar", "tinytext", "text", "mediumtext", "longtext", "enum", "set": + return true + } + return false +} + +// IsInteger reports an integer type. +func (t Type) IsInteger() bool { + switch t.Name { + case "tinyint", "smallint", "mediumint", "int", "bigint": + return true + } + return false +} + +// typeOf reads a PT_*_type node, recording a problem at v's position when TypeOf cannot. +func (s *Schema) typeOf(v mysqlast.Value, at func(mysqlast.Value) int) Type { + t, err := TypeOf(v) + if err != nil { + s.problem(at(v), "%v", err) + } + return t +} + +// TypeOf converts a PT_*_type value to a Type: what a column's definition, a routine +// parameter's, a function's RETURNS, or (later) a DECLARE's all carry. Exported so that a +// later stage (the trigger/routine body analyzer, for a DECLARE's local variable) can share +// the same conversion column() uses, rather than reading the type shapes twice. +func TypeOf(v mysqlast.Value) (Type, error) { + t := Type{Length: -1, Dec: -1} + n, ok := v.(*mysqlast.Node) + if !ok { + return t, fmt.Errorf("type not understood: %s", mysqlast.Sprint(v)) + } + switch n.Class { + case "PT_numeric_type": + t.Name = enumName(str(n.Arg("type"))) + t.Length = intOr(n.Arg("length"), -1) + t.Dec = intOr(n.Arg("dec"), -1) + opts := str(n.Arg("options")) + t.Unsigned = strings.Contains(opts, "UNSIGNED_FLAG") + t.Zerofill = strings.Contains(opts, "ZEROFILL_FLAG") + if t.Zerofill { + t.Unsigned = true // ZEROFILL implies UNSIGNED + } + case "PT_char_type": + t.Name = enumName(str(n.Arg("char_type"))) + length, charset, forceBinary := n.Arg("length"), n.Arg("charset"), n.Arg("force_binary") + if str(forceBinary) == "my_charset_bin" { + // BINARY(n) / VARBINARY(n): the action calls the (type, length, charset) constructor, + // which parsegen named by the (type, charset, force_binary) one of the same arity + length, charset, forceBinary = charset, forceBinary, nil + } + t.Length = intOr(length, -1) + if t.Name == "char" && t.Length < 0 { + t.Length = 1 + } + t.Charset = charsetName(charset) + t.Binary = isTrue(forceBinary) + if t.Charset == "binary" { + t.Name = map[string]string{"char": "binary", "varchar": "varbinary"}[t.Name] + t.Charset = "" + } + case "PT_blob_type": + // the grammar tells the BLOB family from the TEXT one by the charset its action + // passes: my_charset_bin for TINYBLOB / MEDIUMBLOB / LONGBLOB / LONG VARBINARY, + // the written charset -- none for none -- for TINYTEXT / MEDIUMTEXT / LONGTEXT / + // LONG [VARCHAR]. A TEXT variant declared CHARACTER SET binary is the blob again, + // as the server stores it (measured: SHOW CREATE spells it tinyblob); plain + // BLOB [(n)] is the one alternative with no Blob_type at all. TINYTEXT BINARY + // stays text (the binary collation, not the binary charset). + cs := charsetName(n.Arg("charset")) + if bt := str(n.Arg("blob_type")); bt == "" { + t.Name = "blob" + } else { + t.Name = blobName(bt, cs != "binary") + } + if cs != "binary" { + t.Charset = cs + } + t.Length = intOr(n.Arg("length"), -1) + t.Binary = isTrue(n.Arg("force_binary")) + case "PT_time_type": + t.Name = enumName(str(n.Arg("time_type"))) + t.Dec = intOr(n.Arg("dec"), -1) + case "PT_timestamp_type": + t.Name = "timestamp" + t.Dec = intOr(n.Arg("dec"), -1) + case "PT_date_type": + t.Name = "date" + case "PT_year_type": + t.Name = "year" + case "PT_bit_type": + t.Name = "bit" + t.Length = intOr(n.Arg("length"), -1) + case "PT_boolean_type": + t.Name = "tinyint" + t.Length = 1 + case "PT_json_type": + t.Name = "json" + case "PT_serial_type": + t.Name = "bigint" + t.Unsigned = true + case "PT_enum_type", "PT_set_type": + t.Name = "enum" + if n.Class == "PT_set_type" { + t.Name = "set" + } + for _, m := range list(n.Arg("interval_list")) { + if mn, ok := m.(*mysqlast.Node); ok && mn.Class == "String" { + t.Values = append(t.Values, str(mn.Arg("str"))) + } else { + t.Values = append(t.Values, str(m)) + } + } + t.Charset = charsetName(n.Arg("charset")) + t.Binary = isTrue(n.Arg("force_binary")) + case "PT_spacial_type": + t.Name = strings.ToLower(strings.TrimPrefix(str(n.Arg("geo_type")), "Field::GEOM_")) + default: + return t, fmt.Errorf("type %s not understood", n.Class) + } + return t, nil +} + +// enumName lowers a server enum member (Int_type::INT, Char_type::VARCHAR, Numeric_type::DECIMAL, Time_type::DATETIME). +func enumName(s string) string { + if i := strings.LastIndex(s, "::"); i >= 0 { + s = s[i+2:] + } + return strings.ToLower(s) +} + +// blobName maps Blob_type and the presence of a charset to the MySQL type name: TEXT +// variants carry a charset, BLOB variants do not. The server's PT_blob_type uses the same +// node for both and tells them apart by the charset. +func blobName(blobType string, text bool) string { + prefix := "" + switch enumName(blobType) { + case "tiny": + prefix = "tiny" + case "medium": + prefix = "medium" + case "long": + prefix = "long" + } + if text { + return prefix + "text" + } + return prefix + "blob" +} + +// charsetName renders a charset argument: the name, or "" for none. The server passes +// &my_charset_bin for BINARY / VARBINARY / BLOB and national_charset_info for NCHAR. +func charsetName(v mysqlast.Value) string { + s := str(v) + switch s { + case "&my_charset_bin", "my_charset_bin": + return "binary" + case "national_charset_info": + return "utf8mb3" + case "national_charset_info.bin": + return "utf8mb3" + } + return s +} + +func intOr(v mysqlast.Value, def int) int { + switch x := v.(type) { + case mysqlast.Number: + return int(x) + case string: + if n, err := strconv.Atoi(x); err == nil { + return n + } + case mysqlast.Token: + if n, err := strconv.Atoi(str(x)); err == nil { + return n + } + } + return def +} diff --git a/check/mysql/internal/schema/types_blob_test.go b/check/mysql/internal/schema/types_blob_test.go new file mode 100644 index 0000000..b8f246a --- /dev/null +++ b/check/mysql/internal/schema/types_blob_test.go @@ -0,0 +1,74 @@ +package schema + +import ( + "strings" + "testing" +) + +// The BLOB family and the TEXT family share PT_blob_type, told apart by the charset the +// grammar's action passes. Every spelling below is pinned to what SHOW CREATE TABLE spells +// back on 8.4 (measured), the CHARACTER SET binary degradation to the blob included. +func TestBlobTextTypes(t *testing.T) { + s, err := Load(`CREATE TABLE b ( + a TINYTEXT, b MEDIUMTEXT, c LONGTEXT, + d TINYBLOB, e BLOB, f MEDIUMBLOB, g LONGBLOB, + h TEXT, i LONG VARCHAR, j LONG, k LONG VARBINARY, + l TINYTEXT CHARACTER SET binary, m TINYTEXT BINARY, n MEDIUMTEXT CHARACTER SET utf8mb4 +);`) + if err != nil { + t.Fatal(err) + } + for _, p := range s.Problems { + t.Errorf("problem: %s", p) + } + want := map[string]string{ + "a": "tinytext", "b": "mediumtext", "c": "longtext", + "d": "tinyblob", "e": "blob", "f": "mediumblob", "g": "longblob", + "h": "text", "i": "mediumtext", "j": "mediumtext", "k": "mediumblob", + "l": "tinyblob", "m": "tinytext", "n": "mediumtext", + } + tb := s.Table("b") + for name, typ := range want { + c := tb.Column(name) + if c.Type.Name != typ { + t.Errorf("%s: %q, want %q", name, c.Type.Name, typ) + } + } + if c := tb.Column("m"); !c.Type.Binary { + t.Error("TINYTEXT BINARY lost the binary collation flag") + } + if c := tb.Column("n"); c.Type.Charset != "utf8mb4" { + t.Errorf("n charset %q", c.Type.Charset) + } + if c := tb.Column("l"); c.Type.Charset != "" { + t.Errorf("l charset %q (the binary charset is the type, not a charset)", c.Type.Charset) + } +} + +// A foreign key whose reference list is missing or does not match its column count is the +// server's own CREATE-time refusal (1239, measured on 8.4); the loader never falls back +// to the parent's primary key. +func TestForeignKeyReferenceMismatch(t *testing.T) { + for _, sql := range []string{ + "CREATE TABLE p (id INT NOT NULL PRIMARY KEY);\nCREATE TABLE c (pid INT NOT NULL, FOREIGN KEY (pid) REFERENCES p);", + "CREATE TABLE p (a INT NOT NULL, b INT NOT NULL, PRIMARY KEY (a, b));\nCREATE TABLE c (x INT NOT NULL, y INT NOT NULL, FOREIGN KEY (x, y) REFERENCES p (a));", + "CREATE TABLE p (a INT NOT NULL, b INT NOT NULL, PRIMARY KEY (a, b));\nCREATE TABLE c (x INT NOT NULL, FOREIGN KEY (x) REFERENCES p (a, b));", + } { + s, err := Load(sql) + if err != nil { + t.Fatal(err) + } + found := false + for _, p := range s.Problems { + if strings.Contains(p.Message, "Key reference and table reference don't match (1239)") { + found = true + } + } + if !found { + t.Errorf("%s: no 1239 problem; problems: %v", sql, s.Problems) + } + if len(s.Table("c").ForeignKeys) != 0 { + t.Errorf("%s: the refused foreign key was kept", sql) + } + } +} diff --git a/check/mysql/migrate/adv2_migrate_test.go b/check/mysql/migrate/adv2_migrate_test.go new file mode 100644 index 0000000..817e409 --- /dev/null +++ b/check/mysql/migrate/adv2_migrate_test.go @@ -0,0 +1,354 @@ +package migrate + +// Adversarial round 2 for lane "migrate": dump / diff / migrate round trips, checked against a +// real mysqld 8.4 (nix-shell -p mysql84). See +// /tmp/claude-1000/-home-kr9ly-projects-sqlshape/3fb800a8-0c4b-48f3-83cd-e5fb3ffcc642/scratchpad/adv2/report-migrate.md +// for the write-up. Every test states the behavior a real server measurably has; the current +// planner disagrees with it (a DDL statement the server refuses, or Verify/verify-schema +// reporting "matches" when it silently does not), which is exactly why each test fails today. +// +// Common shape: mustCanonical(sql) builds a canonical schema+text (applies sql to a fresh +// mysqld and reads it back via SHOW CREATE ...); Plan(from, to, intents) is the planner under +// test; Verify replays the plan's DDL on a fresh server holding `from` and diffs the result +// against `to`. Every test here asserts what Verify *should* report for a schema edit that a +// real mysqld accepts perfectly well written any other way (as one CREATE TABLE, in the other +// statement order, combined into one ALTER, ...): no error and no leftover changes. Each +// assertion fails today because Verify's own Canonical call surfaces the real mysqld error the +// plan's DDL provokes, quoted in the doc comment and in the failure message. + +import ( + "strings" + "testing" + + "github.com/kr9ly/sqlshape/check/mysql/v2/dump" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// TestAdv2AddedGeneratedColumnDependsOnLaterColumn: adding a generated column and the column +// it reads together is fine as one CREATE TABLE (MySQL resolves a generated column's +// expression against the whole table definition, not declaration order), but the planner adds +// new columns one at a time in the target's column order via separate `ALTER TABLE ... ADD +// COLUMN ... AFTER` statements (addParts, migrate.go:550-566). When the target orders a +// generated column *before* the plain column its expression reads (a natural layout choice -- +// the computed field shown first), the planner faithfully reproduces that order in the ADD +// sequence, and the generated column's ADD runs while its dependency does not exist in the +// table yet. +// +// Measured against mysqld 8.4: the plan +// +// ALTER TABLE `t` ADD COLUMN `g` int GENERATED ALWAYS AS ((`x` + 1)) VIRTUAL AFTER `id`; +// ALTER TABLE `t` ADD COLUMN `x` int DEFAULT NULL AFTER `g`; +// +// fails on the first statement with Error 1054 (42S22): Unknown column 'x' in 'generated +// column function' -- the exact fragment `sqlshape diff` would print and `sqlshape apply` +// would try to run verbatim, on a schema change that is completely valid MySQL (as one CREATE +// TABLE, or as two ALTERs in the other order). sqlshape's current behavior: Plan returns this +// DDL with no error at all (nothing detects the dependency or reorders/regroups the ADDs), so +// the failure is only discovered when apply actually runs it against a live database. +func TestAdv2AddedGeneratedColumnDependsOnLaterColumn(t *testing.T) { + ctx := start(t) + baseSQL := "-- sqlshape: mysql 8.4\nCREATE TABLE t (id INT PRIMARY KEY);\n" + base := mustCanonical(t, ctx, baseSQL) + toSQL := "-- sqlshape: mysql 8.4\n" + + "CREATE TABLE t (id INT PRIMARY KEY, g INT GENERATED ALWAYS AS (x + 1) VIRTUAL, x INT);\n" + to := mustCanonical(t, ctx, toSQL) + + ddl, err := Plan(base.s, to.s, nil) + if err != nil { + t.Fatalf("plan: %v", err) + } + joined := strings.Join(ddl, "\n") + t.Logf("plan:\n%s", joined) + + // A generated column and the plain column it reads, added together, is a schema change a + // real server accepts (as one CREATE TABLE, or as these same two ADD COLUMNs the other way + // round): Verify should reach the target cleanly. + changes, verr := Verify(ctx, dump.Local{}, base.text, joined, to.s) + if verr != nil { + t.Fatalf("the plan's own DDL should apply cleanly to a real server, got: %v\nDDL:\n%s", verr, joined) + } + if len(changes) != 0 { + t.Errorf("changes=%v", changes) + } +} + +// TestAdv2DroppedColumnStillReadByGeneratedSibling is the drop-side mirror of +// TestAdv2AddedGeneratedColumnDependsOnLaterColumn: dropParts (migrate.go:212-261) collects +// goneCols and emits one `DROP COLUMN` per column in the *from* table's own declaration order +// (migrate.go:258-260), with no attempt to drop a generated column ahead of the plain column +// it reads. Both columns going together in one edit is unremarkable (the whole feature is +// removed), but the from-table happened to declare the plain column before the generated one, +// so the plan drops the plain column first. +// +// Measured against mysqld 8.4: the plan +// +// ALTER TABLE `t` DROP COLUMN `x`; +// ALTER TABLE `t` DROP COLUMN `g`; +// +// fails on the first statement with Error 3108 (HY000): Column 'x' has a generated column +// dependency -- MySQL refuses to drop a column any live generated column still reads, even +// though the plan (correctly, per its own `-- @migrate drop` declarations) intends to drop +// both. sqlshape's current behavior: Plan returns this DDL with no error and no reordering. +func TestAdv2DroppedColumnStillReadByGeneratedSibling(t *testing.T) { + ctx := start(t) + baseSQL := "-- sqlshape: mysql 8.4\n" + + "CREATE TABLE t (id INT PRIMARY KEY, x INT, g INT GENERATED ALWAYS AS (x + 1) VIRTUAL);\n" + base := mustCanonical(t, ctx, baseSQL) + toSQL := "-- sqlshape: mysql 8.4\n-- @migrate drop t.x\n-- @migrate drop t.g\n" + + "CREATE TABLE t (id INT PRIMARY KEY);\n" + to := mustCanonical(t, ctx, toSQL) + in, err := ParseIntents(toSQL) + if err != nil { + t.Fatalf("intents: %v", err) + } + + ddl, err := Plan(base.s, to.s, in) + if err != nil { + t.Fatalf("plan: %v", err) + } + joined := strings.Join(ddl, "\n") + t.Logf("plan:\n%s", joined) + + // Both columns declared droppable together (a whole feature removed): Verify should reach + // the target cleanly. + changes, verr := Verify(ctx, dump.Local{}, base.text, joined, to.s) + if verr != nil { + t.Fatalf("the plan's own DDL should apply cleanly to a real server, got: %v\nDDL:\n%s", verr, joined) + } + if len(changes) != 0 { + t.Errorf("changes=%v", changes) + } +} + +// TestAdv2WidenedForeignKeyColumnPairFailsMidPlan: widening a parent's primary key and the +// child column that references it (INT -> BIGINT on both sides of a foreign key) is a normal, +// reachable schema change, but the planner alters one table at a time (alters, migrate.go:386- +// 399) in the order the canonical schema lists tables -- alphabetical, since dump.Read lists +// `information_schema.TABLES ... ORDER BY TABLE_NAME` (dump.go:71). Nothing coordinates the two +// tables' MODIFY COLUMNs, so widening "acct" (parent) runs to completion while "txn" (child) +// still has the old, narrower type, even though both statements are in the very same plan. +// +// Measured against mysqld 8.4: the plan +// +// ALTER TABLE `acct` MODIFY COLUMN `id` bigint NOT NULL; +// ALTER TABLE `txn` MODIFY COLUMN `acct_id` bigint DEFAULT NULL; +// +// fails on the first statement with Error 3780 (HY000): Referencing column 'acct_id' and +// referenced column 'id' in foreign key constraint 'txn_ibfk_1' are incompatible -- MySQL +// checks FK type compatibility as soon as either side changes, so the parent's widening alone +// is rejected while the still-narrow child column is still attached by the foreign key. +// sqlshape's current behavior: Plan returns this DDL with no error and no note that the two +// statements must not be split (e.g. by dropping and re-adding the foreign key around them, as +// the plan already knows how to do for other reasons). +func TestAdv2WidenedForeignKeyColumnPairFailsMidPlan(t *testing.T) { + ctx := start(t) + baseSQL := `-- sqlshape: mysql 8.4 +CREATE TABLE acct (id INT PRIMARY KEY); +CREATE TABLE txn (id INT PRIMARY KEY, acct_id INT, FOREIGN KEY (acct_id) REFERENCES acct(id)); +` + base := mustCanonical(t, ctx, baseSQL) + toSQL := `-- sqlshape: mysql 8.4 +CREATE TABLE acct (id BIGINT PRIMARY KEY); +CREATE TABLE txn (id INT PRIMARY KEY, acct_id BIGINT, FOREIGN KEY (acct_id) REFERENCES acct(id)); +` + to := mustCanonical(t, ctx, toSQL) + + ddl, err := Plan(base.s, to.s, nil) + if err != nil { + t.Fatalf("plan: %v", err) + } + joined := strings.Join(ddl, "\n") + t.Logf("plan:\n%s", joined) + + // Widening a parent PK and its child FK column together is a normal edit: Verify should + // reach the target cleanly. + changes, verr := Verify(ctx, dump.Local{}, base.text, joined, to.s) + if verr != nil { + t.Fatalf("the plan's own DDL should apply cleanly to a real server, got: %v\nDDL:\n%s", verr, joined) + } + if len(changes) != 0 { + t.Errorf("changes=%v", changes) + } +} + +// TestAdv2PrimaryKeyReplacementBreaksAutoIncrementInvariant: MySQL requires an AUTO_INCREMENT +// column to be the leading column of *some* index at all times (Error 1075 the moment it isn't +// -- an invariant checked per-statement, not only at the end of a script). Moving AUTO_INCREMENT +// off the primary key (a new business key becomes PRIMARY, the old surrogate key keeps +// AUTO_INCREMENT under its own secondary index) is a legitimate, if unusual, edit. drops() +// emits `DROP PRIMARY KEY` for the table (dropParts, migrate.go:238-248) before adds() gets to +// emit the new secondary `KEY` on the auto_increment column (addParts, migrate.go:567-574) -- +// Plan never checks whether an intermediate statement would leave the auto_increment column +// without any index. +// +// Measured against mysqld 8.4: the plan +// +// ALTER TABLE `t` DROP PRIMARY KEY; +// ALTER TABLE `t` ADD PRIMARY KEY (`code`); +// ALTER TABLE `t` ADD KEY `t_id_idx` (`id`); +// +// fails with Error 1075 (42000): Incorrect table definition; there can be only one auto column +// and it must be defined as a key -- the DROP PRIMARY KEY statement itself is rejected, since +// `id` (AUTO_INCREMENT) has no other index at that point. sqlshape's current behavior: Plan +// returns this DDL with no error; the "auto_increment column must be indexed" invariant is +// never checked and the plan never orders the new secondary key ahead of the old PK's drop +// (or combines them into one ALTER TABLE, which MySQL does allow). +func TestAdv2PrimaryKeyReplacementBreaksAutoIncrementInvariant(t *testing.T) { + ctx := start(t) + baseSQL := "-- sqlshape: mysql 8.4\nCREATE TABLE t (id INT AUTO_INCREMENT PRIMARY KEY, code INT NOT NULL);\n" + base := mustCanonical(t, ctx, baseSQL) + toSQL := "-- sqlshape: mysql 8.4\n" + + "CREATE TABLE t (id INT AUTO_INCREMENT, code INT NOT NULL, PRIMARY KEY (code), KEY t_id_idx (id));\n" + to := mustCanonical(t, ctx, toSQL) + + ddl, err := Plan(base.s, to.s, nil) + if err != nil { + t.Fatalf("plan: %v", err) + } + joined := strings.Join(ddl, "\n") + t.Logf("plan:\n%s", joined) + + // Moving AUTO_INCREMENT off the primary key onto its own secondary index is a valid end + // state (mysqld accepts it as a single ALTER TABLE with both clauses together, measured + // separately): Verify should reach it cleanly. + changes, verr := Verify(ctx, dump.Local{}, base.text, joined, to.s) + if verr != nil { + t.Fatalf("the plan's own DDL should apply cleanly to a real server, got: %v\nDDL:\n%s", verr, joined) + } + if len(changes) != 0 { + t.Errorf("changes=%v", changes) + } +} + +// TestAdv2RenamedPrimaryKeyColumnDropsKeyNeedlessly: dropParts' handling of a foreign key +// translates both sides' columns through the declared renames before comparing +// (`p.renamedFK(f, fk)`, migrate.go:234), and addParts' handling of a *key* does the same +// (`renamedKey(p, f, fk)`, migrate.go:570) -- but dropParts' own key loop compares +// `diff.KeyProps(k)` (the from-side key, in the from column's own names) directly against +// `diff.KeyProps(tk)` (the target key, already in the target's post-rename names) with no +// translation at all (migrate.go:239-248). A `-- @migrate rename` of a primary-key column +// therefore makes the *same* key -- unchanged in every way but the column's declared name -- +// look "changed" to dropParts, and the plan drops the primary key it should be leaving alone. +// +// Measured against mysqld 8.4, renaming customers.id -> customers.cust_id (a primary key a +// child table's foreign key still references, unchanged), the plan +// +// ALTER TABLE `customers` DROP PRIMARY KEY; +// ALTER TABLE `customers` RENAME COLUMN `id` TO `cust_id`; +// +// fails on the first statement with Error 1553 (HY000): Cannot drop index 'PRIMARY': needed in +// a foreign key constraint -- the primary key was never supposed to be dropped at all (the +// rename alone reaches the target: RENAME COLUMN carries the primary key and the referencing +// foreign key along with it, measured separately). sqlshape's current behavior: Plan returns +// this DDL with no error; the spurious DROP PRIMARY KEY is emitted purely because dropParts' +// key comparison, unlike its own foreign-key comparison right above it, does not know about +// `-- @migrate rename`. +func TestAdv2RenamedPrimaryKeyColumnDropsKeyNeedlessly(t *testing.T) { + ctx := start(t) + baseSQL := `-- sqlshape: mysql 8.4 +CREATE TABLE customers (id INT PRIMARY KEY); +CREATE TABLE orders (id INT PRIMARY KEY, customer_id INT, FOREIGN KEY (customer_id) REFERENCES customers(id)); +` + base := mustCanonical(t, ctx, baseSQL) + toSQL := `-- sqlshape: mysql 8.4 +-- @migrate rename customers.id -> customers.cust_id +CREATE TABLE customers (cust_id INT PRIMARY KEY); +CREATE TABLE orders (id INT PRIMARY KEY, customer_id INT, FOREIGN KEY (customer_id) REFERENCES customers(cust_id)); +` + to := mustCanonical(t, ctx, toSQL) + in, err := ParseIntents(toSQL) + if err != nil { + t.Fatalf("intents: %v", err) + } + + ddl, err := Plan(base.s, to.s, in) + if err != nil { + t.Fatalf("plan: %v", err) + } + joined := strings.Join(ddl, "\n") + t.Logf("plan:\n%s", joined) + + // A bare column rename should not touch the primary key at all: RENAME COLUMN alone + // carries the primary key and the referencing foreign key along with it (measured + // separately: a schema whose only edit is the rename, applied as RENAME COLUMN with no + // DROP/ADD PRIMARY KEY around it, reaches the target with Verify reporting no changes). + if strings.Contains(joined, "DROP PRIMARY KEY") { + t.Errorf("a bare column rename must not drop and re-add the primary key it renames "+ + "(a needless statement a child foreign key refuses outright -- see Verify below), got:\n%s", joined) + } + + changes, verr := Verify(ctx, dump.Local{}, base.text, joined, to.s) + if verr != nil { + t.Errorf("consequence of the spurious DROP PRIMARY KEY above: the plan's own DDL is "+ + "refused by a real server (%v)\nDDL:\n%s", verr, joined) + } else if len(changes) != 0 { + t.Errorf("changes=%v", changes) + } +} + +// TestAdv2ExplicitAutoIncrementStartSilentlyDropped: dump.normalizeTable (dump.go:284-291) +// strips the `AUTO_INCREMENT=` table option from every canonicalized CREATE TABLE text -- +// including a table's own `Definition`, the text migrate.adds() emits verbatim for a brand new +// table (migrate.go:530-531). That is the right call for a table that already holds rows (the +// counter is data), but it also erases an author's explicitly declared starting value for a +// table that does not exist yet, which is a schema decision (avoiding overlapping IDs across +// shards or environments is a common reason to write `AUTO_INCREMENT=1000` on a brand new +// table). The value is not merely left out of the plan's CREATE TABLE -- it is invisible to +// every later comparison too: TableProps (diff.go:292-300) has no field for it, so +// diff.Compare and verify-schema report a table created this way as matching the schema.sql +// that asked for 1000 exactly as well as one that never mentioned an AUTO_INCREMENT start at +// all. +// +// Measured against mysqld 8.4: creating `t` from a schema.sql that declares `AUTO_INCREMENT = +// 1000` yields a table whose own canonical CREATE TABLE text (SHOW CREATE TABLE, read right +// back) already has no AUTO_INCREMENT clause -- the counter reads back as 1, the server's +// default, not 1000. sqlshape's current behavior: Plan's CREATE TABLE for `t` carries no +// AUTO_INCREMENT clause, Verify (and, doing the same comparison, verify-schema) reports zero +// changes, and there is no note anywhere that the declared starting value never took effect. +func TestAdv2ExplicitAutoIncrementStartSilentlyDropped(t *testing.T) { + ctx := start(t) + baseSQL := "-- sqlshape: mysql 8.4\nCREATE TABLE placeholder (id INT PRIMARY KEY);\n" + base := mustCanonical(t, ctx, baseSQL) + toSQL := "-- sqlshape: mysql 8.4\nCREATE TABLE placeholder (id INT PRIMARY KEY);\n" + + "CREATE TABLE t (id INT AUTO_INCREMENT PRIMARY KEY, v INT) AUTO_INCREMENT=1000;\n" + to := mustCanonical(t, ctx, toSQL) + + ddl, err := Plan(base.s, to.s, nil) + if err != nil { + t.Fatalf("plan: %v", err) + } + joined := strings.Join(ddl, "\n") + t.Logf("plan:\n%s", joined) + + changes, verr := Verify(ctx, dump.Local{}, base.text, joined, to.s) + if verr != nil { + t.Fatalf("verify: %v\nDDL:\n%s", verr, joined) + } + if len(changes) != 0 { + // If this ever starts failing because AUTO_INCREMENT becomes a compared property, + // that is the fix landing, not a new bug: update this test's expectation. + t.Fatalf("compare already reports the counter as unmatched (fix may have landed): %v", changes) + } + + // Ground truth: actually run the plan against a fresh mysqld holding base.text, insert a + // row into the freshly created table with no explicit id, and read back the id the server + // itself assigned. + srv, err := mysqltest.Start(ctx, base.text+"\n"+joined) + if err != nil { + t.Fatalf("apply: %v", err) + } + defer srv.Close() + if _, err := srv.Conn().ExecContext(ctx, "INSERT INTO t (v) VALUES (1)"); err != nil { + t.Fatalf("insert: %v", err) + } + var id int64 + if err := srv.Conn().QueryRowContext(ctx, "SELECT id FROM t").Scan(&id); err != nil { + t.Fatalf("read id: %v", err) + } + t.Logf("first row's assigned id: %d (schema.sql declared AUTO_INCREMENT=1000)", id) + if id != 1000 { + t.Errorf("expected the first row of a table created with AUTO_INCREMENT=1000 to get id "+ + "1000 (and for verify-schema to have said so, one way or the other, instead of "+ + "reporting a clean match), got %d -- verify already reported changes=%v (empty) above", id, changes) + } +} diff --git a/check/mysql/migrate/events_test.go b/check/mysql/migrate/events_test.go new file mode 100644 index 0000000..55a735b --- /dev/null +++ b/check/mysql/migrate/events_test.go @@ -0,0 +1,45 @@ +package migrate + +import ( + "strings" + "testing" +) + +// TestPlanEvents: an added, a changed and a dropped event plan as CREATE EVENT / DROP EVENT +// (a changed one both), and an event whose STARTS the schema leaves to the server plans +// nothing even though the two canonical forms were created at different times; the DDL +// reaches the target (plan's own Verify). +func TestPlanEvents(t *testing.T) { + ctx := start(t) + baseSQL := example(t) + from := mustCanonical(t, ctx, baseSQL+` +CREATE EVENT floating ON SCHEDULE EVERY 1 DAY DO DELETE FROM order_audit WHERE created_at < NOW() - INTERVAL 90 DAY; +CREATE EVENT changed ON SCHEDULE EVERY 1 DAY DO DELETE FROM order_audit WHERE created_at < NOW() - INTERVAL 30 DAY; +CREATE EVENT gone ON SCHEDULE EVERY 1 HOUR DO DELETE FROM order_audit WHERE id < 0; +`) + to := mustCanonical(t, ctx, baseSQL+` +CREATE EVENT floating ON SCHEDULE EVERY 1 DAY DO DELETE FROM order_audit WHERE created_at < NOW() - INTERVAL 90 DAY; +CREATE EVENT changed ON SCHEDULE EVERY 1 DAY DO DELETE FROM order_audit WHERE created_at < NOW() - INTERVAL 60 DAY; +CREATE EVENT added ON SCHEDULE EVERY 1 HOUR STARTS '2030-01-01 00:00:00' ON COMPLETION PRESERVE DISABLE DO DELETE FROM order_audit WHERE id < 0; +`) + if from.s.Event("floating").Starts == to.s.Event("floating").Starts { + t.Log("the two canonical forms were created within the same second; the floating STARTS is not exercised this run") + } + ddl := plan(t, ctx, "events", from, to) + joined := strings.Join(ddl, "\n") + for _, w := range []string{"DROP EVENT `gone`", "DROP EVENT `changed`", "INTERVAL 60 DAY", "CREATE EVENT `added` ON SCHEDULE EVERY 1 HOUR STARTS '2030-01-01 00:00:00' ON COMPLETION PRESERVE DISABLE"} { + if !strings.Contains(joined, w) { + t.Errorf("DDL lacks %q:\n%s", w, joined) + } + } + if strings.Contains(joined, "`floating`") { + t.Errorf("an event whose STARTS the schema leaves to the server must not be replanned:\n%s", joined) + } + if strings.Count(joined, "CREATE EVENT") != 2 { + t.Errorf("want exactly the changed and the added event created:\n%s", joined) + } + back := plan(t, ctx, "events back", to, from) + if j := strings.Join(back, "\n"); !strings.Contains(j, "DROP EVENT `added`") || !strings.Contains(j, "INTERVAL 30 DAY") { + t.Errorf("way back:\n%s", j) + } +} diff --git a/check/mysql/migrate/intents.go b/check/mysql/migrate/intents.go new file mode 100644 index 0000000..d3fe884 --- /dev/null +++ b/check/mysql/migrate/intents.go @@ -0,0 +1,306 @@ +package migrate + +import ( + "fmt" + "regexp" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" +) + +// Intent is one `-- @migrate` declaration in the schema source: what a diff of two +// schemas cannot decide on its own. Declarations describe the step from the database's +// current state to schema.sql, and go away once applied: a declaration the diff no +// longer bears out is an error, so stale ones cannot linger. The grammar is the +// PostgreSQL one's; `enum` names a column, since MySQL's ENUM is a column type. +// +// -- @migrate rename orders.state -> orders.status column (or table: rename old -> new); +// the left side names things as they are +// now, the right as they will be +// -- @migrate drop orders.legacy a column or table may go (data loss accepted) +// -- @migrate drop partition orders.p0 a RANGE / LIST partition may go the same way +// -- @migrate enum orders.status: drop 'canceled' using 'cancelled' +// -- @migrate backfill orders.status = 'pending' where status is null +type Intent struct { + Kind IntentKind + // Rename / Drop: dotted names (table.column or table). + From, To string + // EnumDrop: the ENUM column, the label removed and the label its values become. + Label, Using string + // EnumDrop / Backfill: the column; Backfill: the SQL expression and the optional WHERE; + // DropPartition: the partition (Column carries its name, reusing the field the same + // way EnumDrop's Table does). + Table, Column, Expr, Where string + Line int +} + +// IntentKind is the kind of declaration. +type IntentKind int + +const ( + Rename IntentKind = iota + 1 + Drop + EnumDrop + Backfill + DropPartition +) + +var ( + intentLine = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*@migrate[ \t]+(.+?)[ \t]*$`) + renameRe = regexp.MustCompile(`^rename\s+(\S+)\s*->\s*(\S+)$`) + dropPartRe = regexp.MustCompile(`^drop\s+partition\s+(\S+)$`) + dropRe = regexp.MustCompile(`^drop\s+(\S+)$`) + enumDropRe = regexp.MustCompile(`^enum\s+(\S+)\s*:\s*drop\s+'((?:[^']|'')*)'\s+using\s+'((?:[^']|'')*)'$`) + backfillRe = regexp.MustCompile(`^backfill\s+(\S+)\s*=\s*(.+)$`) + backfillWhen = regexp.MustCompile(`(?i)\s+where\s+`) +) + +// ParseIntents reads the `-- @migrate` lines of schema source text. +func ParseIntents(schemaSQL string) ([]Intent, error) { + var out []Intent + var errs []string + for _, m := range intentLine.FindAllStringSubmatchIndex(schemaSQL, -1) { + text := schemaSQL[m[2]:m[3]] + line := 1 + strings.Count(schemaSQL[:m[0]], "\n") + in := Intent{Line: line} + switch { + case renameRe.MatchString(text): + g := renameRe.FindStringSubmatch(text) + in.Kind, in.From, in.To = Rename, g[1], g[2] + case dropPartRe.MatchString(text): + g := dropPartRe.FindStringSubmatch(text) + t, c, ok := splitColumn(g[1]) + if !ok { + errs = append(errs, fmt.Sprintf("line %d: drop partition needs table.partition, got %q", line, g[1])) + continue + } + in.Kind, in.Table, in.Column = DropPartition, t, c + case dropRe.MatchString(text): + in.Kind, in.From = Drop, dropRe.FindStringSubmatch(text)[1] + case enumDropRe.MatchString(text): + g := enumDropRe.FindStringSubmatch(text) + t, c, ok := splitColumn(g[1]) + if !ok { + errs = append(errs, fmt.Sprintf("line %d: enum needs table.column, got %q", line, g[1])) + continue + } + in.Kind, in.Table, in.Column = EnumDrop, t, c + in.Label, in.Using = strings.ReplaceAll(g[2], "''", "'"), strings.ReplaceAll(g[3], "''", "'") + case backfillRe.MatchString(text): + g := backfillRe.FindStringSubmatch(text) + t, c, ok := splitColumn(g[1]) + if !ok { + errs = append(errs, fmt.Sprintf("line %d: backfill needs table.column, got %q", line, g[1])) + continue + } + in.Kind, in.Table, in.Column, in.Expr = Backfill, t, c, strings.TrimSpace(g[2]) + if w := backfillWhen.FindStringIndex(in.Expr); w != nil { + in.Where = strings.TrimSpace(in.Expr[w[1]:]) + in.Expr = strings.TrimSpace(in.Expr[:w[0]]) + } + default: + errs = append(errs, fmt.Sprintf("line %d: unknown @migrate declaration %q", line, text)) + continue + } + out = append(out, in) + } + if len(errs) > 0 { + return out, fmt.Errorf("%s", strings.Join(errs, "\n")) + } + return out, nil +} + +func splitColumn(dotted string) (table, column string, ok bool) { + i := strings.LastIndex(dotted, ".") + if i <= 0 || i == len(dotted)-1 { + return "", "", false + } + return dotted[:i], dotted[i+1:], true +} + +// String renders the declaration as it is written. +func (in Intent) String() string { + switch in.Kind { + case Rename: + return "rename " + in.From + " -> " + in.To + case Drop: + return "drop " + in.From + case DropPartition: + return "drop partition " + in.Table + "." + in.Column + case EnumDrop: + return "enum " + in.Table + "." + in.Column + ": drop " + lit(in.Label) + " using " + lit(in.Using) + case Backfill: + s := "backfill " + in.Table + "." + in.Column + " = " + in.Expr + if in.Where != "" { + s += " where " + in.Where + } + return s + } + return "" +} + +// readIntents checks each declaration against the two schemas and records what the plan +// does with it. +func (p *planner) readIntents(list []Intent) { + p.tableRename = map[string]string{} + p.colRename = map[string]map[string]string{} + p.droppable = map[string]bool{} + // table renames first, whatever line they are on: a column rename's right side names + // the table as it will be, which only the table's own declaration explains + isTableRename := func(in Intent) bool { + if in.Kind != Rename { + return false + } + _, _, col := splitColumn(in.From) + return !col + } + ordered := make([]Intent, 0, len(list)) + for _, in := range list { + if isTableRename(in) { + ordered = append(ordered, in) + } + } + for _, in := range list { + if !isTableRename(in) { + ordered = append(ordered, in) + } + } + for _, in := range ordered { + switch in.Kind { + case Rename: + ft, fc, fcol := splitColumn(in.From) + tt, tc, tcol := splitColumn(in.To) + if fcol != tcol { + p.problem("line %d: rename %s -> %s: both sides must be tables, or both columns", in.Line, in.From, in.To) + continue + } + if !fcol { + if p.from.Table(in.From) == nil { + p.problem("line %d: rename %s -> %s: %s is not in the current schema", in.Line, in.From, in.To, in.From) + continue + } + if p.to.Table(in.To) == nil { + p.problem("line %d: rename %s -> %s: %s is not in the target schema", in.Line, in.From, in.To, in.To) + continue + } + if p.from.Table(in.To) != nil || p.to.Table(in.From) != nil { + p.problem("line %d: rename %s -> %s: both names exist on both sides", in.Line, in.From, in.To) + continue + } + p.tableRename[in.From] = in.To + continue + } + if p.toName(ft) != tt && ft != tt { + p.problem("line %d: rename %s -> %s: a column rename stays within its table (rename the table separately)", in.Line, in.From, in.To) + continue + } + f := p.from.Table(ft) + t := p.to.Table(tt) + if f == nil || f.Column(fc) == nil { + p.problem("line %d: rename %s -> %s: %s is not in the current schema", in.Line, in.From, in.To, in.From) + continue + } + if t == nil || t.Column(tc) == nil { + p.problem("line %d: rename %s -> %s: %s is not in the target schema", in.Line, in.From, in.To, in.To) + continue + } + if f.Column(tc) != nil || t.Column(fc) != nil { + p.problem("line %d: rename %s -> %s: both names exist on both sides", in.Line, in.From, in.To) + continue + } + if p.colRename[ft] == nil { + p.colRename[ft] = map[string]string{} + } + p.colRename[ft][fc] = tc + case Drop: + t, c, isCol := splitColumn(in.From) + if !isCol { + if p.from.Table(in.From) == nil { + p.problem("line %d: drop %s: not in the current schema", in.Line, in.From) + continue + } + if p.to.Table(in.From) != nil { + p.problem("line %d: drop %s: %s still exists in the target schema", in.Line, in.From, in.From) + continue + } + p.droppable[in.From] = true + continue + } + f := p.from.Table(t) + if f == nil || f.Column(c) == nil { + p.problem("line %d: drop %s: not in the current schema", in.Line, in.From) + continue + } + if tt := p.to.Table(p.toName(t)); tt != nil && tt.Column(c) != nil { + p.problem("line %d: drop %s: %s still exists in the target schema", in.Line, in.From, in.From) + continue + } + p.droppable[t+"."+c] = true + case DropPartition: + f := p.from.Table(in.Table) + if f == nil || f.Partitioning == nil || !hasPartition(f.Partitioning, in.Column) { + p.problem("line %d: drop partition %s.%s: not in the current schema", in.Line, in.Table, in.Column) + continue + } + tt := p.to.Table(p.toName(in.Table)) + if tt != nil && tt.Partitioning != nil && hasPartition(tt.Partitioning, in.Column) { + p.problem("line %d: drop partition %s.%s: still exists in the target schema", in.Line, in.Table, in.Column) + continue + } + if p.droppablePartition == nil { + p.droppablePartition = map[string]bool{} + } + p.droppablePartition[in.Table+"."+in.Column] = true + case EnumDrop: + t := p.to.Table(in.Table) + if t == nil || t.Column(in.Column) == nil { + p.problem("line %d: enum %s.%s: not in the target schema", in.Line, in.Table, in.Column) + continue + } + if t.Column(in.Column).Type.Name != "enum" { + p.problem("line %d: enum %s.%s: not an ENUM column", in.Line, in.Table, in.Column) + continue + } + f := p.from.Table(p.fromName(in.Table)) + var fc *schemaColumn + if f != nil { + if c := f.Column(p.fromCol(f.Name, in.Column)); c != nil { + fc = &schemaColumn{c.Type.Values} + } + } + if fc == nil || !contains(fc.values, in.Label) { + p.problem("line %d: enum %s.%s: %s is not a label of the current column", in.Line, in.Table, in.Column, lit(in.Label)) + continue + } + if contains(t.Column(in.Column).Type.Values, in.Label) { + p.problem("line %d: enum %s.%s: %s is still a label in the target schema", in.Line, in.Table, in.Column, lit(in.Label)) + continue + } + p.enumDrops = append(p.enumDrops, in) + case Backfill: + p.backfillsOf = append(p.backfillsOf, in) + } + } +} + +// hasPartition reports whether p (a RANGE / LIST Partitioning; HASH/KEY's Parts is always +// empty, and its size changes by count, never by name) names a partition called name. +func hasPartition(p *schema.Partitioning, name string) bool { + for _, part := range p.Parts { + if part.Name == name { + return true + } + } + return false +} + +type schemaColumn struct{ values []string } + +func contains(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} diff --git a/check/mysql/migrate/main_test.go b/check/mysql/migrate/main_test.go new file mode 100644 index 0000000..271f42a --- /dev/null +++ b/check/mysql/migrate/main_test.go @@ -0,0 +1,12 @@ +package migrate + +import ( + "os" + "testing" + + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// One mysqld per set of server settings serves the whole package (mysqltest.Main); a test +// gets it with the database dropped, not a server of its own. +func TestMain(m *testing.M) { os.Exit(mysqltest.Main(m)) } diff --git a/check/mysql/migrate/migrate.go b/check/mysql/migrate/migrate.go new file mode 100644 index 0000000..8bf310c --- /dev/null +++ b/check/mysql/migrate/migrate.go @@ -0,0 +1,1766 @@ +// Package migrate turns the difference between two MySQL schemas into DDL, and checks DDL +// by its end state: applied to a database holding the current schema, does it read back +// as the target? +// +// Both schemas should be canonical (dump.Canonical / dump.Load): the target's element +// texts are then the server's own spelling, which the plan emits as they are (a CREATE +// TABLE from the canonical text, a column from its definition text in MODIFY COLUMN / ADD +// COLUMN, a key, foreign key or check from its inline definition). +// +// The plan is a proposal: it does not know how to rename, which value an ENUM label +// should map to when it goes, or what to backfill a new NOT NULL column with. Those show +// up as statements MySQL will refuse or as a leftover difference in Verify, and are the +// intent declarations' job to settle. MySQL's DDL commits implicitly, so the plan is a +// sequence of independent statements; apply runs them one by one and stops at the first +// the server refuses. +package migrate + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + "github.com/kr9ly/sqlshape/check/mysql/v2/diff" + "github.com/kr9ly/sqlshape/check/mysql/v2/dump" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/analyze" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/mysqlparse" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" +) + +// Plan lists the statements that turn from into to: drops (referencing foreign keys +// first, then views, tables, columns, keys, checks), the declared renames, then +// alterations (table options, columns by MODIFY COLUMN with their position, keys / +// foreign keys / checks by DROP and ADD, views by CREATE OR REPLACE), then additions in +// dependency order, then the backfills. The intents (ParseIntents of the target's source) +// decide what the diff cannot: a table or column that disappears must be declared dropped +// or renamed, an ENUM label that disappears must say which label its values become, and +// backfills fill new columns. The error lists every declaration the schemas do not bear +// out and every change no declaration explains; the DDL is still returned for reading. +func Plan(from, to *schema.Schema, list []Intent) ([]string, error) { + p := &planner{from: from, to: to} + p.readIntents(list) + p.drops() + p.renames() + p.coordinateForeignKeys() + p.alters() + p.adds() + p.backfills() + // partitioning: after every column add and backfill above, since a PARTITION BY may + // name a column only adds() just created and backfills() just gave every row a value + // (see partitionAlters' own doc comment). + p.alterPartitions() + // column MODIFYs alterTable deferred (deferredMods): after every row-touching + // statement above, so a column newly auto-updating does not fire on one of them. + for _, stmt := range p.deferredMods { + p.emit("%s", stmt) + } + // key visibility-only changes (keyVisAlters, dropParts): after the target's own + // PRIMARY KEY exists, not merely after the from-side's is dropped -- one of these may + // itself be turning invisible a UNIQUE key over what is now (after the MODIFYs above) + // only NOT NULL columns, InnoDB's own substitute clustering key candidate while no + // PRIMARY KEY is there to beat it to the role (Error 3522, the same as + // invisibleUniqueKeysOf guards against elsewhere, measured); by here, ADD PRIMARY KEY + // (if the plan has one) has already run. + for _, stmt := range p.keyVisAlters { + p.emit("%s", stmt) + } + // triggers are created last, after the backfills: a newly added trigger should not + // fire on the migration's own backfill UPDATEs (measured against mysqld: a trigger + // created after a table's rows already exist does not run for those existing rows, + // only for statements from here on; PostgreSQL's planner keeps the same order for the + // same reason, see check/postgres/migrate). + p.addTriggers() + // events last of all: nothing depends on an event, and an event's body is bound late + p.addEvents() + if len(p.problems) > 0 { + return p.out, errors.New(strings.Join(p.problems, "\n")) + } + return p.out, nil +} + +// Verify applies ddl to a database holding currentSQL (canonical text) and lists where the +// result still differs from target. An error is a statement MySQL refused (or the server +// failing); no changes and no error means the DDL reaches the target. +func Verify(ctx context.Context, c dump.Canonicalizer, currentSQL, ddl string, target *schema.Schema) ([]diff.Change, error) { + got, _, err := c.Canonical(ctx, currentSQL+"\nSET FOREIGN_KEY_CHECKS=1;\n"+ddl) + if err != nil { + return nil, err + } + return diff.Compare(got, target), nil +} + +type planner struct { + from, to *schema.Schema + out []string + problems []string + // renames: from-name -> to-name for tables, and per table for columns (by the + // from table's name) + tableRename map[string]string + colRename map[string]map[string]string + droppable map[string]bool // "table" or "table.column" declared droppable + droppablePartition map[string]bool // "table.partition" declared droppable + enumDrops []Intent + backfillsOf []Intent + droppedFKs map[string]bool // "table.fk" already dropped ahead of a table that goes + earlyKeys map[string]bool // "table.key" (target names) already added by dropKeysOf + earlyMods map[string]bool // "table.column" (target names) already MODIFYed by renames + backfilled map[int]bool // indexes into backfillsOf already emitted ahead of their statement + deferredMods []string // MODIFY COLUMN statements alterTable holds for the very end + keyVisAlters []string // "ALTER TABLE ... ALTER INDEX ... [NOT] VISIBLE" (dropParts) + visKeyHandled map[string]bool // "table.key" (from names) keyVisAlters covers, so dropParts' dropKeys and addParts' adds skip it + // partitionAlters: the (from, to) table pairs alterTable found a partitioning + // difference on, held for alterPartitions to write once every column the target's + // clause may read exists (adds()) and holds a value (backfills()) -- a partitioning + // column a mutation adds in the same step as partitioning it is otherwise Error 1054 + // "Unknown column ... in 'partition function'" against the plan's own earlier ALTER + // TABLE ... PARTITION BY, measured. + partitionAlters [][2]*schema.Table +} + +func (p *planner) emit(format string, args ...any) { + p.out = append(p.out, fmt.Sprintf(format, args...)) +} + +func (p *planner) problem(format string, args ...any) { + p.problems = append(p.problems, fmt.Sprintf(format, args...)) +} + +// check type-checks a data statement of the plan against the target schema. +func (p *planner) check(sql string) error { + _, err := analyze.Analyze(p.to, sql) + return err +} + +func q(name string) string { return "`" + strings.ReplaceAll(name, "`", "``") + "`" } + +func lit(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" } + +// toName is the target's name of a from table (its rename, or itself). +func (p *planner) toName(from string) string { + if to, ok := p.tableRename[from]; ok { + return to + } + return from +} + +// fromName is the from name of a target table (the source of its rename, or itself). +func (p *planner) fromName(to string) string { + for f, t := range p.tableRename { + if t == to { + return f + } + } + return to +} + +// toCol is the target's name of a from column. +func (p *planner) toCol(fromTable, col string) string { + if m, ok := p.colRename[fromTable]; ok { + if to, ok := m[col]; ok { + return to + } + } + return col +} + +// fromCol is the from name of a target column of a from table. +func (p *planner) fromCol(fromTable, toCol string) string { + for f, t := range p.colRename[fromTable] { + if t == toCol { + return f + } + } + return toCol +} + +// pair returns the from and to versions of a table that exists on both sides (renames +// followed), or nil for one that does not. +func (p *planner) pair(toTable *schema.Table) (*schema.Table, *schema.Table) { + f := p.from.Table(p.fromName(toTable.Name)) + return f, toTable +} + +// --- drops ----------------------------------------------------------------------- + +func (p *planner) drops() { + // tables that go, with the foreign keys of other tables that reference them + var gone []*schema.Table + for _, f := range p.from.Tables { + if p.to.Table(p.toName(f.Name)) == nil { + gone = append(gone, f) + } + } + goneNames := map[string]bool{} + for _, f := range gone { + goneNames[f.Name] = true + } + // triggers that go or change, ahead of the table drops below: a trigger dropped along + // with its own table (DROP TABLE takes it silently) needs no DROP TRIGGER of its own. + p.dropTriggers(goneNames) + p.dropEvents() + for _, f := range gone { + if !p.droppable[f.Name] { + p.problem("table %s is dropped, which no @migrate declares (`-- @migrate drop %s`, or a rename)", f.Name, f.Name) + } + for _, other := range p.from.Tables { + if other == f { + continue + } + for _, fk := range other.ForeignKeys { + if fk.RefTable == f.Name && p.to.Table(p.toName(other.Name)) != nil { + name := other.ForeignKeyName(fk) + p.emit("ALTER TABLE %s DROP FOREIGN KEY %s;", q(other.Name), q(name)) + if p.droppedFKs == nil { + p.droppedFKs = map[string]bool{} + } + p.droppedFKs[other.Name+"."+name] = true + } + } + } + } + // views that go, or whose definition changes (replaced in alters) + for _, v := range p.from.Views { + if p.to.View(v.Name) == nil { + p.emit("DROP VIEW %s;", q(v.Name)) + } + } + // routines that go or change, after the view drops above (a view may call a function) + // and before the table drops below (their bodies may still name a table that goes). + p.dropRoutines() + for _, f := range gone { + p.emit("DROP TABLE %s;", q(f.Name)) + } + // parts of tables that stay: first every table's foreign keys that go (one may rest on + // a key of another table dropped just below -- Error 1553 "Cannot drop index: needed + // in a foreign key constraint" when that table's keys went first, measured), then each + // table's keys, checks and columns + kept := map[*schema.Table][]*schema.ForeignKey{} + for _, t := range p.to.Tables { + if f, _ := p.pair(t); f != nil { + kept[t] = p.dropForeignKeys(f, t) + } + } + for _, t := range p.to.Tables { + if f, _ := p.pair(t); f != nil { + p.dropParts(f, t, kept[t]) + } + } +} + +// dropForeignKeys drops the foreign keys of a table that stays which the target lacks, or +// that a dropped / changed column takes with them; it returns the ones that stay. +func (p *planner) dropForeignKeys(f, t *schema.Table) []*schema.ForeignKey { + goneCols := goneColumns(p, f, t) + tfks := foreignKeysByName(t) + var keptFKs []*schema.ForeignKey + for _, fk := range f.ForeignKeys { + name := f.ForeignKeyName(fk) + if p.droppedFKs[f.Name+"."+name] { + continue + } + tf, ok := tfks[name] + if !ok || !sameProps(diff.ForeignKeyProps(p.renamedFK(f, fk)), diff.ForeignKeyProps(tf)) || touches(fk.Columns, goneCols) { + p.emit("ALTER TABLE %s DROP FOREIGN KEY %s;", q(f.Name), q(name)) + if p.droppedFKs == nil { + p.droppedFKs = map[string]bool{} + } + // dropForeignKeysOverRenamedColumn (renames, below) checks this before + // dropping again for the same foreign key. + p.droppedFKs[f.Name+"."+name] = true + } else { + keptFKs = append(keptFKs, fk) + } + } + return keptFKs +} + +func (p *planner) dropParts(f, t *schema.Table, keptFKs []*schema.ForeignKey) { + tcols := map[string]bool{} + for _, c := range t.Columns { + tcols[c.Name] = true + } + var goneCols []string + var goneColObjs []*schema.Column + for _, c := range f.Columns { + if !tcols[p.toCol(f.Name, c.Name)] { + goneCols = append(goneCols, c.Name) + goneColObjs = append(goneColObjs, c) + if !p.droppable[f.Name+"."+c.Name] { + p.problem("column %s.%s is dropped, which no @migrate declares (`-- @migrate drop %s.%s`, or a rename)", f.Name, c.Name, f.Name, c.Name) + } + } + } + tkeys := keysByName(t) + var dropKeys []*schema.Key + for _, k := range f.Keys { + name := keyName(k) + tk, ok := tkeys[name] + if !ok { + dropKeys = append(dropKeys, k) + continue + } + // the key's own columns are translated through any `-- @migrate rename` before the + // comparison, the same way renamedFK is above and renamedKey is in addParts: a + // rename of one of the key's columns must not, on its own, make the (otherwise + // unchanged) key look different and get dropped. + rk := renamedKey(p, f, k) + if sameProps(diff.KeyProps(rk), diff.KeyProps(tk)) && !touchesParts(k, goneCols) { + continue // unchanged + } + if !touchesParts(k, goneCols) && visibilityOnlyDiffers(rk, tk) { + // DROP INDEX + ADD (the recreate path every other key change below takes) + // silently keeps the old visibility on this server (measured: neither errors, + // but the result reads back visible either way) -- only ALTER INDEX ... [NOT] + // VISIBLE actually flips it. keyVisAlters carries this to alters(), after + // renames, so the table already bears its target name. + p.keyVisAlters = append(p.keyVisAlters, fmt.Sprintf("ALTER TABLE %s ALTER INDEX %s %s;", + q(p.toName(f.Name)), q(name), map[bool]string{true: "INVISIBLE", false: "VISIBLE"}[tk.Invisible])) + if p.visKeyHandled == nil { + p.visKeyHandled = map[string]bool{} + } + p.visKeyHandled[f.Name+"."+name] = true + continue + } + dropKeys = append(dropKeys, k) + } + p.dropKeysOf(f, t, dropKeys, keptFKs) + tchecks := checksByName(t) + for _, c := range f.Checks { + name := f.CheckName(c) + tc, ok := tchecks[name] + if !ok || !sameProps(diff.CheckProps(f, c), diff.CheckProps(t, tc)) { + p.emit("ALTER TABLE %s DROP CHECK %s;", q(f.Name), q(name)) + } + } + for _, c := range orderGoneColumns(goneColObjs) { + p.emit("ALTER TABLE %s DROP COLUMN %s;", q(f.Name), q(c.Name)) + } +} + +// dropKeysOf emits the DROP PRIMARY KEY / DROP INDEX statements for dropKeys (the from-side +// keys dropParts found changed or gone). MySQL requires an AUTO_INCREMENT column to be the +// leading column of some index at every statement boundary (Error 1075 the instant it is +// not, measured -- not only checked once at the end of a script): dropping dropKeys alone +// can leave such a column with no index at all, if the key that covered it is going and the +// target's replacement key for it has not been added yet (addParts only runs later, once +// every column exists). When that would happen, the drop and that replacement ADD are folded +// into one ALTER TABLE (measured against mysqld 8.4: DROP PRIMARY KEY and ADD KEY on the +// auto_increment column, as two clauses of the same statement, succeed where two separate +// statements do not -- the invariant is only checked once the whole ALTER TABLE has applied +// all of its clauses), and addParts is told (earlyKeys) not to add that key again. +// +// A foreign key that stays (keptFKs), and the columns other tables' foreign keys reference, +// have the same need: they must lead some index at every statement boundary (Error 1553 "Cannot drop index: needed in a foreign key +// constraint" on the DROP INDEX, measured -- the index the server created for the +// constraint goes when the target declares its own key over the same columns, so the +// dropped and the replacing key are the same fold). +func (p *planner) dropKeysOf(f, t *schema.Table, dropKeys []*schema.Key, keptFKs []*schema.ForeignKey) { + if len(dropKeys) == 0 { + return + } + dropping := map[string]bool{} + for _, k := range dropKeys { + dropping[keyName(k)] = true + } + fkeys := keysByName(f) + var adds []string + added := map[string]bool{} + // coverEarly folds the target's key leading with cols (the from side's names) into this + // ALTER when no surviving from-side key leads with them. + coverEarly := func(cols []string) { + for _, k := range f.Keys { + if !dropping[keyName(k)] && leadsWith(k, cols) { + return + } + } + toCols := make([]string, len(cols)) + for i, c := range cols { + toCols[i] = p.toCol(f.Name, c) + } + for _, tk := range t.Keys { + if !leadsWith(tk, toCols) || added[keyName(tk)] { + continue + } + fk, ok := fkeys[keyName(tk)] + if ok && !dropping[keyName(fk)] && sameProps(diff.KeyProps(renamedKey(p, f, fk)), diff.KeyProps(tk)) && !touchesParts(fk, goneColumns(p, f, t)) { + continue // this key of the target already exists unchanged, not being added + } + if tk.Invisible { + // this key is folded into the very statement that may drop f's PRIMARY + // KEY (below): adding it already invisible risks the same Error 3522 an + // invisible UNIQUE key over only NOT NULL columns hits while no PRIMARY + // KEY exists (invisibleUniqueKeysOf, measured) -- add it visible instead + // and let keyVisAlters turn it invisible once a real PRIMARY KEY is back. + adds = append(adds, "ADD "+stripInvisibleMarker(keyText(tk))) + p.keyVisAlters = append(p.keyVisAlters, fmt.Sprintf("ALTER TABLE %s ALTER INDEX %s INVISIBLE;", q(p.toName(f.Name)), q(keyName(tk)))) + } else { + adds = append(adds, "ADD "+keyText(tk)) + } + added[keyName(tk)] = true + if p.earlyKeys == nil { + p.earlyKeys = map[string]bool{} + } + p.earlyKeys[t.Name+"."+keyName(tk)] = true + return + } + } + for _, ai := range f.Columns { + if ai.AutoIncrement { + coverEarly([]string{ai.Name}) + } + } + for _, fk := range keptFKs { + coverEarly(fk.Columns) + } + // and the referenced side: a foreign key of another table pointing at f needs f's + // referenced columns to lead an index just the same (the same 1553 on DROP PRIMARY KEY + // when the primary key moves off a referenced column, measured) + for _, other := range p.from.Tables { + if other == f { + continue + } + for _, fk := range other.ForeignKeys { + if strings.EqualFold(fk.RefTable, f.Name) { + coverEarly(fk.RefColumns) + } + } + } + var clauses []string + for _, k := range dropKeys { + if k.Kind == schema.Primary { + // InnoDB picks a surviving UNIQUE key over only NOT NULL columns as the + // table's substitute clustering key the moment no PRIMARY KEY is left, and an + // invisible index cannot serve as one (Error 3522 "A primary key index cannot + // be invisible", measured) -- not only right on this DROP PRIMARY KEY, but at + // any later statement in the window before the target's own PRIMARY KEY goes + // back on: a column MODIFY turning NOT NULL that completes such a key's + // column list mid-plan hits the same error (measured), so every invisible + // UNIQUE key is covered here, not only ones already all NOT NULL now. Forcing + // each visible in this same statement (VISIBLE and DROP PRIMARY KEY both apply + // as the whole ALTER TABLE commits, the same reasoning coverEarly's fold above + // relies on) sidesteps the window entirely; deferredMods restores INVISIBLE, + // for whichever the target still declares it, once a real PRIMARY KEY exists + // and every row-touching statement in the plan is done. + for _, k := range invisibleUniqueKeysOf(f, dropping) { + clauses = append(clauses, "ALTER INDEX "+q(k.Name)+" VISIBLE") + if tk := keysByName(t)[keyName(k)]; tk != nil && tk.Invisible { + p.deferredMods = append(p.deferredMods, fmt.Sprintf("ALTER TABLE %s ALTER INDEX %s INVISIBLE;", q(p.toName(f.Name)), q(k.Name))) + } + } + clauses = append(clauses, "DROP PRIMARY KEY") + } else { + clauses = append(clauses, "DROP INDEX "+q(k.Name)) + } + } + clauses = append(clauses, adds...) + p.emit("ALTER TABLE %s %s;", q(f.Name), strings.Join(clauses, ", ")) +} + +// invisibleUniqueKeysOf are f's surviving (not among dropping) invisible UNIQUE keys: InnoDB +// candidates for substitute clustering key once f's PRIMARY KEY is gone, whether or not +// every column is NOT NULL yet now (a later MODIFY in the same plan may still make one so). +func invisibleUniqueKeysOf(f *schema.Table, dropping map[string]bool) []*schema.Key { + var out []*schema.Key + for _, k := range f.Keys { + if k.Kind == schema.Unique && k.Invisible && !dropping[keyName(k)] { + out = append(out, k) + } + } + return out +} + +// leadsWith reports whether k's leading parts are cols, in order (the index a foreign key +// or an AUTO_INCREMENT column needs). +func leadsWith(k *schema.Key, cols []string) bool { + if len(k.Parts) < len(cols) { + return false + } + for i, c := range cols { + if !strings.EqualFold(k.Parts[i].Column, c) { + return false + } + } + return true +} + +// dropTriggers drops every trigger that the target lacks or whose definition differs +// (MySQL has no CREATE OR REPLACE TRIGGER: a changed trigger is a DROP and a CREATE, the +// CREATE emitted by addTriggers). gone is the from tables the target no longer has: their +// triggers go with them (DROP TABLE takes them silently) and are not dropped here. +func (p *planner) dropTriggers(gone map[string]bool) { + for _, t := range p.from.Triggers { + if gone[t.Table] { + continue + } + tt := p.to.Trigger(t.Name) + if tt == nil || diff.TriggerProps(t)["definition"] != diff.TriggerProps(tt)["definition"] { + p.emit("DROP TRIGGER %s;", q(t.Name)) + } + } +} + +// dropRoutines drops every procedure/function that the target lacks or whose definition +// differs (MySQL's CREATE PROCEDURE/FUNCTION has no OR REPLACE either). +func (p *planner) dropRoutines() { + for _, r := range p.from.Routines { + tr := p.to.RoutineOf(r.Kind, r.Name) + if tr == nil || diff.RoutineProps(r)["definition"] != diff.RoutineProps(tr)["definition"] { + p.emit("DROP %s %s;", r.Kind, q(r.Name)) + } + } +} + +// addRoutines creates every procedure/function the from side lacks or whose definition +// differs, from the target's own CREATE text. +func (p *planner) addRoutines() { + for _, r := range p.to.Routines { + fr := p.from.RoutineOf(r.Kind, r.Name) + if fr != nil && diff.RoutineProps(fr)["definition"] == diff.RoutineProps(r)["definition"] { + continue + } + p.emit("%s;", strings.TrimSuffix(strings.TrimSpace(r.Definition), ";")) + } +} + +// addTriggers creates every trigger the from side lacks or whose definition differs, from +// the target's own CREATE text (which already carries the FOLLOWS clause dump.Read gives +// it, so the target's firing order is reproduced). +func (p *planner) addTriggers() { + for _, t := range p.to.Triggers { + ft := p.from.Trigger(t.Name) + if ft != nil && diff.TriggerProps(ft)["definition"] == diff.TriggerProps(t)["definition"] { + continue + } + p.emit("%s;", strings.TrimSuffix(strings.TrimSpace(t.Definition), ";")) + } +} + +// dropEvents drops every event the target lacks or whose definition differs (MySQL has no +// CREATE OR REPLACE EVENT: a changed event is a DROP and a CREATE, the CREATE emitted by +// addEvents). A time the target leaves to the server (an omitted STARTS) is not a +// difference (diff.EventChanged). +func (p *planner) dropEvents() { + for _, e := range p.from.Events { + te := p.to.Event(e.Name) + if te == nil || diff.EventChanged(e, te) { + p.emit("DROP EVENT %s;", q(e.Name)) + } + } +} + +// addEvents creates every event the from side lacks or whose definition differs, from the +// target's own CREATE text. +func (p *planner) addEvents() { + for _, e := range p.to.Events { + fe := p.from.Event(e.Name) + if fe != nil && !diff.EventChanged(fe, e) { + continue + } + p.emit("%s;", strings.TrimSuffix(strings.TrimSpace(e.Definition), ";")) + } +} + +// renamedFK is fk with its column and table names as the target spells them, for +// comparison with the target's. +func (p *planner) renamedFK(f *schema.Table, fk *schema.ForeignKey) *schema.ForeignKey { + r := *fk + r.Columns = nil + for _, c := range fk.Columns { + r.Columns = append(r.Columns, p.toCol(f.Name, c)) + } + r.RefTable = p.toName(fk.RefTable) + r.RefColumns = nil + for _, c := range fk.RefColumns { + r.RefColumns = append(r.RefColumns, p.toCol(fk.RefTable, c)) + } + return &r +} + +// --- renames --------------------------------------------------------------------- + +func (p *planner) renames() { + var froms []string + for f := range p.tableRename { + froms = append(froms, f) + } + sort.Strings(froms) + for _, f := range froms { + p.emit("RENAME TABLE %s TO %s;", q(f), q(p.tableRename[f])) + } + var tables []string + for t := range p.colRename { + tables = append(tables, t) + } + sort.Strings(tables) + for _, t := range tables { + var cols []string + for c := range p.colRename[t] { + cols = append(cols, c) + } + sort.Strings(cols) + f, tt := p.from.Table(t), p.to.Table(p.toName(t)) + for _, c := range cols { + clauses := []string{fmt.Sprintf("RENAME COLUMN %s TO %s", q(c), q(p.colRename[t][c]))} + // MySQL refuses to rename a column a generated column reads (Error 3108 "has a + // generated column dependency", measured against mysqld 8.4) unless the same + // ALTER TABLE also rewrites every such generated column with the new name: the + // target's definitions of them ride along, and alterTable skips them. + if f != nil && tt != nil { + for _, g := range f.Columns { + if !readsColumn(g, &schema.Column{Name: c}) { + continue + } + tg := tt.Column(p.toCol(t, g.Name)) + if tg == nil || tg.Text == "" { + continue + } + clauses = append(clauses, "MODIFY COLUMN "+tg.Text) + if p.earlyMods == nil { + p.earlyMods = map[string]bool{} + } + p.earlyMods[tt.Name+"."+tg.Name] = true + } + } + if len(clauses) > 1 && f != nil { + // The rename+MODIFY combination Error 3108 forces (above) has no + // algorithm when the renamed column also participates in a foreign key, + // this table's own or another table's reference: COPY is refused because + // an FK column is being renamed (Error 1846, "Try ALGORITHM=INPLACE", + // measured) and INPLACE is refused because a STORED generated column is + // being rewritten (Error 1845, "Try ALGORITHM=COPY", the other way, + // also measured) -- neither algorithm satisfies both clauses at once. + // Dropping the foreign key first (both sides: this table's own over the + // column, and any other table's referencing it) and letting + // coordinateForeignKeys' bookkeeping re-add it once the rename has run + // (addForeignKeys already re-adds anything droppedFKs marks, not only + // its own type-change case) sidesteps the conflict entirely. + p.dropForeignKeysOverRenamedColumn(f, c) + } + p.emit("ALTER TABLE %s %s;", q(p.toName(t)), strings.Join(clauses, ", ")) + } + } +} + +// changedColumns are the target names of the pair f, t's columns whose definition (type, +// nullability, default, ...) differs between them, ignoring position: the columns alterTable +// is about to MODIFY. +func changedColumns(p *planner, f, t *schema.Table) map[string]bool { + fcols := map[string]*schema.Column{} + for _, c := range f.Columns { + fcols[p.toCol(f.Name, c.Name)] = c + } + out := map[string]bool{} + for _, c := range t.Columns { + fc, ok := fcols[c.Name] + if ok && diff.Definition(fc.Text, fc.Name) != diff.Definition(c.Text, c.Name) { + out[c.Name] = true + } + } + return out +} + +// coordinateForeignKeys drops (ahead of alters, the MODIFY COLUMNs below) every foreign key +// that persists unchanged in the target but whose referencing or referenced column is about +// to change type, and marks it so addParts re-adds it once both sides are done: MySQL checks +// a foreign key's type compatibility the moment either side's ALTER runs, and the planner +// alters one table at a time (alphabetically, since dump.Read lists tables in that order), so +// a parent's MODIFY and the child's MODIFY are never in the same statement for the server to +// see together (measured against mysqld 8.4: Error 3780, Referencing column ... incompatible, +// on the very first MODIFY, parent or child, whichever runs first). Dropping the foreign key +// first and re-adding it after both MODIFYs is the minimal fix measured to work; combining a +// table's own MODIFY and its foreign key's maintenance into one ALTER TABLE does not help +// here since the two tables are always separate statements regardless. +func (p *planner) coordinateForeignKeys() { + changed := map[string]map[string]bool{} // from table name -> its changing target column names + for _, t := range p.to.Tables { + f, _ := p.pair(t) + if f == nil { + continue + } + changed[f.Name] = changedColumns(p, f, t) + } + for _, t := range p.to.Tables { + f, _ := p.pair(t) + if f == nil { + continue + } + tfks := foreignKeysByName(t) + for _, fk := range f.ForeignKeys { + name := f.ForeignKeyName(fk) + if p.droppedFKs[f.Name+"."+name] { + continue // already handled (e.g. a table it references is going) + } + tf, ok := tfks[name] + if !ok || !sameProps(diff.ForeignKeyProps(p.renamedFK(f, fk)), diff.ForeignKeyProps(tf)) { + continue // dropParts already drops a foreign key that changes or goes + } + touched := false + for _, c := range fk.Columns { + if changed[f.Name][p.toCol(f.Name, c)] { + touched = true + } + } + if refFrom := p.from.Table(fk.RefTable); refFrom != nil { + for _, c := range fk.RefColumns { + if changed[refFrom.Name][p.toCol(refFrom.Name, c)] { + touched = true + } + } + } + if !touched { + continue + } + // after renames: the table already bears its target name (measured: the from + // name is Error 1146 here when the table was renamed) + p.emit("ALTER TABLE %s DROP FOREIGN KEY %s;", q(p.toName(f.Name)), q(name)) + if p.droppedFKs == nil { + p.droppedFKs = map[string]bool{} + } + p.droppedFKs[f.Name+"."+name] = true + } + } +} + +// --- alters ---------------------------------------------------------------------- + +func (p *planner) alters() { + for _, t := range p.to.Tables { + f, _ := p.pair(t) + if f == nil { + continue + } + p.alterTable(f, t) + } + // views that change are replaced in adds, once every column they may read exists +} + +func (p *planner) alterTable(f, t *schema.Table) { + var opts []string + fp, tp := diff.TableProps(f), diff.TableProps(t) + if fp["engine"] != tp["engine"] && t.Engine != "" { + opts = append(opts, "ENGINE="+t.Engine) + } + tableCharsetChanged := fp["charset"] != tp["charset"] || fp["collation"] != tp["collation"] + if tableCharsetChanged { + if t.Charset != "" { + opts = append(opts, "DEFAULT CHARSET="+t.Charset) + } + if t.Collation != "" { + opts = append(opts, "COLLATE="+t.Collation) + } + } + if fp["rowFormat"] != tp["rowFormat"] { + rf := t.RowFormat + if rf == "" { + rf = "DEFAULT" // an undeclared ROW_FORMAT is "", but resetting one needs a clause + } + opts = append(opts, "ROW_FORMAT="+rf) + } + if fp["comment"] != tp["comment"] { + opts = append(opts, "COMMENT="+lit(t.Comment)) + } + if len(opts) > 0 { + p.emit("ALTER TABLE %s %s;", q(t.Name), strings.Join(opts, " ")) + } + if fp["partitioned"] != tp["partitioned"] { + p.partitionAlters = append(p.partitionAlters, [2]*schema.Table{f, t}) + } + // columns: the ENUM label drops first (an UPDATE the type change needs), then MODIFY + // for every column whose definition or position differs + fcols := map[string]*schema.Column{} + for _, c := range f.Columns { + fcols[p.toCol(f.Name, c.Name)] = c + } + for i, c := range t.Columns { + fc, ok := fcols[c.Name] + if !ok { + continue // added in adds + } + for _, in := range p.enumDrops { + if in.Table == t.Name && in.Column == c.Name { + sql := fmt.Sprintf("UPDATE %s SET %s = %s WHERE %s = %s", q(t.Name), q(c.Name), lit(in.Using), q(c.Name), lit(in.Label)) + if !hasLabel(c, in.Using) { + p.problem("line %d: enum %s.%s: %s is not a label of the target column", in.Line, in.Table, in.Column, lit(in.Using)) + } + p.emit("%s;", sql) + } + } + _ = i + // a plain (no explicit per-column COLLATE) string column's real encoding is the + // table's own default at the moment it was last touched, not whatever the table's + // default has since moved on to: the table option ALTER above never rewrites it + // (measured against mysqld 8.4 -- ALTER TABLE ... DEFAULT CHARSET= alone leaves every + // existing column exactly as encoded, only SHOW CREATE TABLE begins spelling it out + // explicitly once it no longer matches, which the target's own canonical -- authored + // fresh under the new default, so never explicit -- does not do either, so the two + // would otherwise never converge). A MODIFY COLUMN naming no charset of its own picks + // up the table's *current* default (also measured), so re-issuing the target's own + // text once the table option above has run converges it, the same as any other + // column definition change; it costs the same row rewrite CONVERT TO CHARACTER SET + // would (every byte of the column re-encoded), but only for the columns that actually + // need it -- an explicit-COLLATE column is unaffected either way, so CONVERT TO would + // rewrite it for nothing. + implicitCharsetChange := tableCharsetChanged && c.Type.IsString() && fc.Collation == "" && c.Collation == "" + if (diff.Definition(fc.Text, fc.Name) == diff.Definition(c.Text, c.Name) && !implicitCharsetChange) || p.earlyMods[t.Name+"."+c.Name] { + continue // the position, if it differs, is settled once every column exists (reorder) + } + if !fc.NotNull && c.NotNull { + // a column turning NOT NULL under rows holding NULL: its declared backfill runs + // first (Error 1138 "Invalid use of NULL value" on the MODIFY otherwise, measured) + p.backfill(t.Name, c.Name) + } + if !fc.OnUpdate && c.OnUpdate { + // a column newly gaining ON UPDATE CURRENT_TIMESTAMP fires on any later UPDATE + // against the row, including one this same plan runs for an unrelated column + // (a backfill), silently overwriting it -- measured to collide a UNIQUE key + // over it (Error 1062, "Duplicate entry", every touched row set to the same + // instant) even though neither statement names the column. deferredMods runs + // this MODIFY once every row-touching statement in the plan is done, the same + // reasoning addTriggers already carries for a newly created trigger. + p.deferredMods = append(p.deferredMods, fmt.Sprintf("ALTER TABLE %s MODIFY COLUMN %s;", q(t.Name), c.Text)) + continue + } + p.emit("ALTER TABLE %s MODIFY COLUMN %s;", q(t.Name), c.Text) + } +} + +// alterPartitioning writes the DDL for f's Partitioning becoming t's -- one of a whole +// rewrite (PARTITION BY / REMOVE PARTITIONING, when either side is absent, or the +// partitioning key itself changes: kind, LINEAR, COLUMNS-ness, expression / column list, +// KEY's own ALGORITHM, or the SUBPARTITION BY clause), a HASH/KEY partition count change +// (ADD PARTITION PARTITIONS n / COALESCE PARTITION n), or a RANGE/LIST change +// alterRangePartitioning / alterListPartitioning works out. Every pattern here keeps to the +// rest of the plan's own style: a change that can only lose rows (a partition dropped +// outright) is refused unless a `-- @migrate drop partition` declares it, the same +// requirement dropParts already carries for a column. +// alterPartitions writes the DDL for every table alterTable found a partitioning +// difference on (partitionAlters' own doc comment says why this runs as its own late +// phase, not inline in alterTable). +func (p *planner) alterPartitions() { + for _, pair := range p.partitionAlters { + p.alterPartitioning(pair[0], pair[1]) + } +} + +func (p *planner) alterPartitioning(f, t *schema.Table) { + from, to := f.Partitioning, t.Partitioning + switch { + case from == nil: + p.emit("ALTER TABLE %s %s;", q(t.Name), renderPartitioning(to)) + case to == nil: + p.emit("ALTER TABLE %s REMOVE PARTITIONING;", q(t.Name)) + case partitioningKeyChanged(from, to) || subPartitioningChanged(from.Sub, to.Sub): + p.emit("ALTER TABLE %s %s;", q(t.Name), renderPartitioning(to)) + case from.Kind == "HASH" || from.Kind == "KEY": + switch { + case to.Num > from.Num: + p.emit("ALTER TABLE %s ADD PARTITION PARTITIONS %d;", q(t.Name), to.Num-from.Num) + case to.Num < from.Num: + p.emit("ALTER TABLE %s COALESCE PARTITION %d;", q(t.Name), from.Num-to.Num) + } + case from.Kind == "LIST": + p.alterListPartitioning(f, t, from, to) + default: // RANGE + p.alterRangePartitioning(f, t, from, to) + } +} + +// partitioningKeyChanged reports whether the partitioning key itself changed between from +// and to -- kind, LINEAR, COLUMNS-ness, the expression or column list, or KEY's own +// ALGORITHM -- forcing alterPartitioning's whole rewrite rather than an incremental ADD / +// DROP / REORGANIZE PARTITION or a plain count change. +func partitioningKeyChanged(from, to *schema.Partitioning) bool { + if from.Kind != to.Kind || from.Linear != to.Linear || from.Columns != to.Columns || + from.Expr != to.Expr || from.Algorithm != to.Algorithm { + return true + } + return !equalCols(from.Cols, to.Cols) +} + +// subPartitioningChanged reports whether the table's own SUBPARTITION BY clause changed +// (added, dropped, or any of its own fields): always a whole rewrite of the parent clause, +// this package's own ADD PARTITION / REORGANIZE PARTITION never touching a subpartition's +// own definition (see schema.SubPartitioning's own doc comment). +func subPartitioningChanged(from, to *schema.SubPartitioning) bool { + switch { + case from == nil && to == nil: + return false + case from == nil || to == nil: + return true + } + return from.Kind != to.Kind || from.Linear != to.Linear || from.Expr != to.Expr || + from.Algorithm != to.Algorithm || from.Num != to.Num || !equalCols(from.Cols, to.Cols) +} + +func equalCols(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// qList renders a column list, backtick-quoted, comma-joined: KEY's own column list, or +// RANGE/LIST COLUMNS' own. +func qList(names []string) string { + parts := make([]string, len(names)) + for i, n := range names { + parts[i] = q(n) + } + return strings.Join(parts, ",") +} + +// renderPartitioning spells p's whole PARTITION BY clause (and its own SUBPARTITION BY, +// when it has one) the way this package's own generated DDL rewrites it whole: every shape +// schema.Partitioning accepts is rich enough to rebuild byte for byte in a form MySQL itself +// accepts, so there is no captured Text to fall back to (see schema.Partitioning's own doc +// comment). +func renderPartitioning(p *schema.Partitioning) string { + var b strings.Builder + b.WriteString("PARTITION BY ") + if p.Linear { + b.WriteString("LINEAR ") + } + switch p.Kind { + case "KEY": + b.WriteString("KEY ") + if p.Algorithm != 0 { + fmt.Fprintf(&b, "ALGORITHM=%d ", p.Algorithm) + } + fmt.Fprintf(&b, "(%s)\nPARTITIONS %d", qList(p.Cols), p.Num) + case "HASH": + fmt.Fprintf(&b, "HASH (%s)\nPARTITIONS %d", p.Expr, p.Num) + case "RANGE", "LIST": + b.WriteString(p.Kind) + if p.Columns { + fmt.Fprintf(&b, " COLUMNS (%s)", qList(p.Cols)) + } else { + fmt.Fprintf(&b, " (%s)", p.Expr) + } + } + if p.Sub != nil { + b.WriteString("\nSUBPARTITION BY ") + if p.Sub.Linear { + b.WriteString("LINEAR ") + } + switch p.Sub.Kind { + case "KEY": + b.WriteString("KEY ") + if p.Sub.Algorithm != 0 { + fmt.Fprintf(&b, "ALGORITHM=%d ", p.Sub.Algorithm) + } + fmt.Fprintf(&b, "(%s)", qList(p.Sub.Cols)) + case "HASH": + fmt.Fprintf(&b, "HASH (%s)", p.Sub.Expr) + } + fmt.Fprintf(&b, "\nSUBPARTITIONS %d", p.Sub.Num) + } + if p.Kind == "RANGE" { + fmt.Fprintf(&b, "\n(%s)", renderPartitionDefs(p.Parts)) + } else if p.Kind == "LIST" { + fmt.Fprintf(&b, "\n(%s)", renderListPartitionDefs(p.Parts)) + } + return b.String() +} + +// alterRangePartitioning writes the DDL for a RANGE Partitioning that stays RANGE over the +// same expression: the two partition lists' common leading run stays as it is (an earlier +// partition never moves once one after it does, this package's generator included), and +// what differs after it is one of: a plain tail extension (ADD PARTITION, only when the +// common run's own last partition is not already MAXVALUE -- nothing could follow it), +// a plain tail loss (DROP PARTITION, declared, every one of them), or both sides having a +// tail of their own (REORGANIZE PARTITION ... INTO, whether it only moves a boundary, +// splits one partition into several, or inserts one ahead of a trailing MAXVALUE) -- a +// partition named in the from side's tail that the to side's tail does not carry forward +// by name is still a loss REORGANIZE only papers over server-side, so it needs the same +// declaration a plain DROP PARTITION would. +func (p *planner) alterRangePartitioning(f, t *schema.Table, from, to *schema.Partitioning) { + k := 0 + for k < len(from.Parts) && k < len(to.Parts) && samePartition(from.Parts[k], to.Parts[k]) { + k++ + } + fromTail, toTail := from.Parts[k:], to.Parts[k:] + switch { + case len(fromTail) == 0 && len(toTail) == 0: + return // the props differed on something this package does not track per-partition + case len(fromTail) == 0 && !(k > 0 && from.Parts[k-1].MaxValue): + p.emit("ALTER TABLE %s ADD PARTITION (%s);", q(t.Name), renderPartitionDefs(toTail)) + case len(toTail) == 0: + p.dropNamedPartitions(f, t, fromTail) + default: + toNames := map[string]bool{} + for _, part := range toTail { + toNames[part.Name] = true + } + var gone []schema.Partition + for _, part := range fromTail { + if !toNames[part.Name] { + gone = append(gone, part) + } + } + if !p.checkPartitionsDroppable(f.Name, gone) { + return + } + p.emit("ALTER TABLE %s REORGANIZE PARTITION %s INTO (%s);", q(t.Name), partitionNameList(fromTail), renderPartitionDefs(toTail)) + } +} + +// dropNamedPartitions emits DROP PARTITION for parts (a RANGE table's whole tail lost, or +// any of a LIST table's own named partitions gone), once every one of them is declared. +func (p *planner) dropNamedPartitions(f, t *schema.Table, parts []schema.Partition) { + if !p.checkPartitionsDroppable(f.Name, parts) { + return + } + p.emit("ALTER TABLE %s DROP PARTITION %s;", q(t.Name), partitionNameList(parts)) +} + +// alterListPartitioning writes the DDL for a LIST Partitioning that stays LIST over the same +// expression. Unlike RANGE, a LIST partition's own name carries no order (nothing above or +// below it), so this compares the two partition lists by name rather than by a common +// leading run: a name only the from side carries is dropped (declared, the same requirement +// dropRangePartitions itself already carries), a name only the to side carries is added, and +// a name both sides carry whose own value list differs is reorganized -- every such name +// together in one REORGANIZE PARTITION ... INTO, so a value moving from one named partition +// to another (both keeping their name, each losing or gaining only that value) is one +// statement recreating both from their target definitions, not two that would fight over the +// value in between (a value in neither yet and both after, momentarily, is not a state VALUES +// IN accepts). +func (p *planner) alterListPartitioning(f, t *schema.Table, from, to *schema.Partitioning) { + toByName := map[string]schema.Partition{} + for _, part := range to.Parts { + toByName[part.Name] = part + } + var dropped, changed []schema.Partition + for _, part := range from.Parts { + tp, ok := toByName[part.Name] + switch { + case !ok: + dropped = append(dropped, part) + case tp.Bound != part.Bound || tp.Comment != part.Comment || !equalCols(tp.Subs, part.Subs): + changed = append(changed, part) + } + } + fromByName := map[string]bool{} + for _, part := range from.Parts { + fromByName[part.Name] = true + } + var added []schema.Partition + for _, part := range to.Parts { + if !fromByName[part.Name] { + added = append(added, part) + } + } + if len(dropped) > 0 { + p.dropNamedPartitions(f, t, dropped) + } + if len(changed) > 0 { + changedNames := map[string]bool{} + for _, part := range changed { + changedNames[part.Name] = true + } + var into []schema.Partition + for _, part := range to.Parts { // the to side's own order, for the changed names only + if changedNames[part.Name] { + into = append(into, part) + } + } + names := make([]string, len(changed)) + for i, part := range changed { + names[i] = q(part.Name) + } + p.emit("ALTER TABLE %s REORGANIZE PARTITION %s INTO (%s);", q(t.Name), strings.Join(names, ","), renderListPartitionDefs(into)) + } + if len(added) > 0 { + p.emit("ALTER TABLE %s ADD PARTITION (%s);", q(t.Name), renderListPartitionDefs(added)) + } +} + +// renderListPartitionDefs spells parts the way ADD PARTITION / REORGANIZE ... INTO takes a +// LIST partition: this package's own rendering (see renderPartitionDefs, RANGE's own), from +// Bound as partitionDef captured it (already a comma-separated list, verbatim). +func renderListPartitionDefs(parts []schema.Partition) string { + defs := make([]string, len(parts)) + for i, part := range parts { + defs[i] = fmt.Sprintf("PARTITION %s VALUES IN (%s)%s%s", q(part.Name), part.Bound, partitionOptions(part), renderSubs(part)) + } + return strings.Join(defs, ", ") +} + +// partitionOptions spells a partition's own COMMENT, when it has one (see schema.Partition's +// own doc comment for why COMMENT is the one per-partition option this package models). +func partitionOptions(part schema.Partition) string { + if part.Comment == "" { + return "" + } + return fmt.Sprintf(" COMMENT %s", lit(part.Comment)) +} + +// checkPartitionsDroppable reports whether every one of parts (fromTable's own) is +// declared droppable, raising a problem for each that is not. +func (p *planner) checkPartitionsDroppable(fromTable string, parts []schema.Partition) bool { + ok := true + for _, part := range parts { + if !p.droppablePartition[fromTable+"."+part.Name] { + p.problem("table %s: partition %s is dropped, which no @migrate declares (`-- @migrate drop partition %s.%s`)", + fromTable, part.Name, fromTable, part.Name) + ok = false + } + } + return ok +} + +func samePartition(a, b schema.Partition) bool { + return a.Name == b.Name && a.MaxValue == b.MaxValue && a.Bound == b.Bound && a.Comment == b.Comment && + equalCols(a.Subs, b.Subs) +} + +// renderSubs spells a partition's own explicit SUBPARTITION name list, when it has one +// (schema.Partition's own Subs; "" otherwise -- the parent's SUBPARTITION BY names them). +func renderSubs(part schema.Partition) string { + if len(part.Subs) == 0 { + return "" + } + names := make([]string, len(part.Subs)) + for i, s := range part.Subs { + names[i] = "SUBPARTITION " + q(s) + } + return " (" + strings.Join(names, ", ") + ")" +} + +func partitionNameList(parts []schema.Partition) string { + names := make([]string, len(parts)) + for i, part := range parts { + names[i] = q(part.Name) + } + return strings.Join(names, ",") +} + +// renderPartitionDefs spells parts the way ADD PARTITION / REORGANIZE ... INTO takes them: +// this package's own rendering, not any captured text (added or reorganized-in partitions +// have none), which is why RANGE's Kind is restricted to what this covers completely. +func renderPartitionDefs(parts []schema.Partition) string { + defs := make([]string, len(parts)) + for i, part := range parts { + bound := "MAXVALUE" + if !part.MaxValue { + bound = "(" + part.Bound + ")" + } + defs[i] = fmt.Sprintf("PARTITION %s VALUES LESS THAN %s%s%s", q(part.Name), bound, partitionOptions(part), renderSubs(part)) + } + return strings.Join(defs, ", ") +} + +// reorder moves the columns of a table that stays into the target's order, once the +// dropped ones are gone and the added ones are in: it follows the order the earlier +// statements leave and emits a MODIFY COLUMN ... FIRST / AFTER for each column out of +// place, fewest moves first to last. +func (p *planner) reorder(f, t *schema.Table) { + var cur []string + gone := map[string]bool{} + for _, c := range goneColumns(p, f, t) { + gone[c] = true + } + for _, c := range f.Columns { + if !gone[c.Name] { + cur = append(cur, p.toCol(f.Name, c.Name)) + } + } + have := map[string]bool{} + for _, c := range cur { + have[c] = true + } + newCols, positioned := newColumnsOrder(t, have) + if positioned { + // addParts placed each one directly at its target position (AFTER its target + // predecessor), the same as this loop assumed before dependency ordering existed. + for i, c := range t.Columns { + if have[c.Name] { + continue + } + at := 0 + if i > 0 { + at = indexOf(cur, t.Columns[i-1].Name) + 1 + } + cur = append(cur[:at], append([]string{c.Name}, cur[at:]...)...) + have[c.Name] = true + } + } else { + // a generated column among the new ones reads another new column out of the + // target's own order: addParts appended them all at the end of the table instead, + // in dependency-safe order, and the loop below reaches each one's real target + // position via an explicit MODIFY COLUMN. + for _, c := range newCols { + cur = append(cur, c.Name) + have[c.Name] = true + } + } + for i, c := range t.Columns { + if i < len(cur) && cur[i] == c.Name { + continue + } + clause := "MODIFY COLUMN " + c.Text + if i == 0 { + clause += " FIRST" + } else { + clause += " AFTER " + q(t.Columns[i-1].Name) + } + p.emit("ALTER TABLE %s %s;", q(t.Name), clause) + j := indexOf(cur, c.Name) + cur = append(cur[:j], cur[j+1:]...) + cur = append(cur[:i], append([]string{c.Name}, cur[i:]...)...) + } +} + +func indexOf(list []string, s string) int { + for i, x := range list { + if x == s { + return i + } + } + return -1 +} + +func hasLabel(c *schema.Column, label string) bool { + for _, v := range c.Type.Values { + if v == label { + return true + } + } + return false +} + +// --- adds ------------------------------------------------------------------------ + +// adds emits what the target has and the from side lacks, in an order every statement's +// dependencies allow (measured against mysqld 8.4, each the probe's finding): the columns, +// keys and checks of tables that stay first, since a brand new table's own CREATE TABLE may +// reference a column those tables are only now gaining (Error 3734 "Missing column ... in the +// referenced table" otherwise); then the new tables, parents before children; then the +// foreign keys of the tables that stay, which may reference a new table; then the tables' +// column order; then the views, new and replaced alike, in the target's dependency order, +// since a view's new definition may read a column added just above (Error 1054 otherwise). +func (p *planner) adds() { + // routines ahead of everything else: a view (below) may call one. + p.addRoutines() + staying := map[*schema.Table]*schema.Table{} + for _, t := range p.to.Tables { + if f, _ := p.pair(t); f != nil { + staying[t] = f + p.addParts(f, t) + } + } + // new tables, parents before children + var fresh []*schema.Table + for _, t := range p.to.Tables { + if p.from.Table(p.fromName(t.Name)) == nil { + fresh = append(fresh, t) + } + } + for _, t := range fkOrder(fresh) { + def := strings.TrimSuffix(strings.TrimSpace(t.Definition), ";") + if t.AutoIncrementStart != "" { + // t.Definition is the canonical CREATE TABLE text, which never carries + // AUTO_INCREMENT= (dump.normalizeTable strips a live counter from every + // canonicalized table); a brand new table's own declared starting value + // (dump.pinAutoIncrement) is a schema decision, not data, and belongs on the + // CREATE that brings the table into existence. + def += " AUTO_INCREMENT=" + t.AutoIncrementStart + } + p.emit("%s;", def) + } + for _, t := range p.to.Tables { + if f := staying[t]; f != nil { + p.addForeignKeys(f, t) + p.reorder(f, t) + } + } + for _, v := range p.to.Views { + if f := p.from.View(v.Name); f == nil { + p.emit("%s;", strings.TrimSuffix(strings.TrimSpace(v.Definition), ";")) + } else if !sameProps(diff.ViewProps(f), diff.ViewProps(v)) { + p.emit("CREATE OR REPLACE %s;", diff.ViewProps(v)["definition"]) + } + } +} + +func (p *planner) addParts(f, t *schema.Table) { + fcols := map[string]bool{} + for _, c := range f.Columns { + fcols[p.toCol(f.Name, c.Name)] = true + } + // Ordinarily a new column is added directly at its target position (AFTER its target + // predecessor, same as always). But a generated column reading a column added alongside + // it must not run before that column exists (MySQL resolves a generated expression + // against the table as it stands at that ADD, not the final target -- measured: Error + // 1054, Unknown column, otherwise); newColumnsOrder reports when the target's own order + // does not already satisfy every such dependency, and then the new columns go in at the + // end instead, in dependency-safe order, with no position clause -- reorder (below, + // after addParts for every table) moves every column into its final target position + // with its own MODIFY COLUMN FIRST/AFTER pass once all columns exist. + newCols, positioned := newColumnsOrder(t, fcols) + targetIndex := map[string]int{} + for i, c := range t.Columns { + targetIndex[c.Name] = i + } + fkeys := keysByName(f) + for _, c := range newCols { + clause := "ADD COLUMN " + c.Text + if positioned { + if i := targetIndex[c.Name]; i == 0 { + clause += " FIRST" + } else { + clause += " AFTER " + q(t.Columns[i-1].Name) + } + } + if c.AutoIncrement { + // a new AUTO_INCREMENT column must be a key by the end of the statement that + // creates it (Error 1075 otherwise, measured): the target's key leading with it + // rides along, and the key loop below skips it + for _, k := range t.Keys { + if len(k.Parts) > 0 && strings.EqualFold(k.Parts[0].Column, c.Name) && !p.earlyKeys[t.Name+"."+keyName(k)] { + clause += ", ADD " + keyText(k) + if p.earlyKeys == nil { + p.earlyKeys = map[string]bool{} + } + p.earlyKeys[t.Name+"."+keyName(k)] = true + break + } + } + } + p.emit("ALTER TABLE %s %s;", q(t.Name), clause) + // a new column's declared backfill right after it exists: the rows MySQL filled + // with the implicit default must hold their real values before a key or a foreign + // key over the column goes on (Error 1452 on the ADD CONSTRAINT otherwise, measured) + p.backfill(t.Name, c.Name) + } + for _, k := range t.Keys { + if p.earlyKeys[t.Name+"."+keyName(k)] { + continue // dropKeysOf already added this one, folded into its own DROP + } + if p.visKeyHandled[f.Name+"."+keyName(k)] { + continue // dropParts already scheduled a plain ALTER INDEX for this one + } + fk, ok := fkeys[keyName(k)] + if ok && sameProps(diff.KeyProps(renamedKey(p, f, fk)), diff.KeyProps(k)) && !touchesParts(fk, goneColumns(p, f, t)) { + continue + } + p.emit("ALTER TABLE %s ADD %s;", q(t.Name), keyText(k)) + } + fchecks := checksByName(f) + for _, c := range t.Checks { + fc, ok := fchecks[t.CheckName(c)] + if ok && sameProps(diff.CheckProps(f, fc), diff.CheckProps(t, c)) { + continue + } + p.emit("ALTER TABLE %s ADD %s;", q(t.Name), checkText(t, c)) + } +} + +// addForeignKeys adds the foreign keys of a table that stays, after every table and column +// they may reference exists. +func (p *planner) addForeignKeys(f, t *schema.Table) { + ffks := foreignKeysByName(f) + for _, fk := range t.ForeignKeys { + ffk, ok := ffks[t.ForeignKeyName(fk)] + // coordinateForeignKeys drops an otherwise-unchanged foreign key ahead of a type + // change on either of its sides (droppedFKs), which must still be re-added here. + if ok && sameProps(diff.ForeignKeyProps(p.renamedFK(f, ffk)), diff.ForeignKeyProps(fk)) && + !touches(ffk.Columns, goneColumns(p, f, t)) && !p.droppedFKs[f.Name+"."+t.ForeignKeyName(fk)] { + continue + } + p.emit("ALTER TABLE %s ADD %s;", q(t.Name), fkText(t, fk)) + } +} + +// goneColumns are the from columns the target lacks (by their from names). +func goneColumns(p *planner, f, t *schema.Table) []string { + tcols := map[string]bool{} + for _, c := range t.Columns { + tcols[c.Name] = true + } + var out []string + for _, c := range f.Columns { + if !tcols[p.toCol(f.Name, c.Name)] { + out = append(out, c.Name) + } + } + return out +} + +// newColumns are the target's columns (in target order) that have is missing (have keyed by +// target column name): the columns addParts is about to ADD, or reorder has already accounted +// for as added. +func newColumns(t *schema.Table, have map[string]bool) []*schema.Column { + var out []*schema.Column + for _, c := range t.Columns { + if !have[c.Name] { + out = append(out, c) + } + } + return out +} + +// newColumnsOrder is the columns a table is gaining (have keyed by target column name, as +// newColumns takes), and whether the target's own order already puts every generated column +// after every other new column it reads: when it does, addParts can add each one directly at +// its target position (AFTER its target predecessor) as it always could; when it does not, a +// real reordering is needed (orderNewColumns), and the caller adds them at the end instead, +// in that safe order, leaving reorder to place them. +func newColumnsOrder(t *schema.Table, have map[string]bool) (cols []*schema.Column, positioned bool) { + cols = newColumns(t, have) + ordered := orderNewColumns(cols) + for i, c := range ordered { + if cols[i] != c { + return ordered, false + } + } + return cols, true +} + +// readsColumn reports whether a generated column's own definition text reads another column +// of the same ADD/DROP batch, by whether its text names that column (quoted, as the server's +// own canonical spelling of a generated expression always does: GENERATED ALWAYS AS ((`x` + +// 1)) VIRTUAL). Only cols of the same table and the same batch (new columns together, or +// gone columns together) are checked, so this need not parse the expression itself. +func readsColumn(c, other *schema.Column) bool { + return c.Generated != nil && c.Name != other.Name && strings.Contains(c.Text, q(other.Name)) +} + +// dropForeignKeysOverRenamedColumn drops, ahead of the RENAME COLUMN + MODIFY COLUMN +// combination above, every foreign key touching f's column col: f's own constraint over it, +// and any other table's constraint referencing it. It marks each droppedFKs so +// coordinateForeignKeys leaves it alone and addForeignKeys re-adds it later, once the rename +// has run. +func (p *planner) dropForeignKeysOverRenamedColumn(f *schema.Table, col string) { + if p.droppedFKs == nil { + p.droppedFKs = map[string]bool{} + } + for _, fk := range f.ForeignKeys { + if indexOf(fk.Columns, col) < 0 { + continue + } + name := f.ForeignKeyName(fk) + if p.droppedFKs[f.Name+"."+name] { + continue + } + p.emit("ALTER TABLE %s DROP FOREIGN KEY %s;", q(p.toName(f.Name)), q(name)) + p.droppedFKs[f.Name+"."+name] = true + } + for _, other := range p.from.Tables { + for _, fk := range other.ForeignKeys { + if fk.RefTable != f.Name || indexOf(fk.RefColumns, col) < 0 { + continue + } + name := other.ForeignKeyName(fk) + if p.droppedFKs[other.Name+"."+name] { + continue + } + p.emit("ALTER TABLE %s DROP FOREIGN KEY %s;", q(p.toName(other.Name)), q(name)) + p.droppedFKs[other.Name+"."+name] = true + } + } +} + +// orderNewColumns orders cols (the columns a table is gaining, in the target's own order) so +// that a generated column comes after every other new column its own expression reads: +// MySQL resolves a generated column's expression against the table as it stands at the ADD +// that creates it, not the table's eventual final shape, so a column ADDed before one it +// reads fails (Error 1054, measured). The position each ADD ends up in the table does not +// depend on this order (reorder moves every column into its target position afterward), only +// which columns already exist when a given ADD runs. +func orderNewColumns(cols []*schema.Column) []*schema.Column { + var out []*schema.Column + done := map[string]bool{} + var visit func(c *schema.Column, stack map[string]bool) + visit = func(c *schema.Column, stack map[string]bool) { + if done[c.Name] || stack[c.Name] { + return + } + stack[c.Name] = true + for _, other := range cols { + if readsColumn(c, other) { + visit(other, stack) + } + } + done[c.Name] = true + out = append(out, c) + } + for _, c := range cols { + visit(c, map[string]bool{}) + } + return out +} + +// orderGoneColumns orders cols (the columns a table is losing) so that a generated column +// comes before every other gone column its own expression reads: MySQL refuses to drop a +// column a live generated column still reads (Error 3108, measured), even when the plan +// intends to drop both, so the generated column must go first. +func orderGoneColumns(cols []*schema.Column) []*schema.Column { + // orderNewColumns already computes a valid dependency order for this same graph (a + // generated column after every other column its own expression reads); reversing a + // topological order of a DAG is a topological order of the reverse graph, so this puts a + // generated column before every other column it reads, exactly what DROP COLUMN needs. + fwd := orderNewColumns(cols) + out := make([]*schema.Column, len(fwd)) + for i, c := range fwd { + out[len(fwd)-1-i] = c + } + return out +} + +// renamedKey is k with its columns as the target spells them. +func renamedKey(p *planner, f *schema.Table, k *schema.Key) *schema.Key { + r := *k + r.Parts = nil + for _, part := range k.Parts { + part.Column = p.toCol(f.Name, part.Column) + r.Parts = append(r.Parts, part) + } + if r.Text != "" { + for _, part := range k.Parts { + if to := p.toCol(f.Name, part.Column); to != part.Column { + r.Text = strings.ReplaceAll(r.Text, q(part.Column), q(to)) + } + } + } + return &r +} + +// keyText is the key as an ALTER TABLE ADD clause takes it. +func keyText(k *schema.Key) string { + if k.Text != "" { + return k.Text + } + return diff.RenderKey(k) +} + +// fkText is the foreign key as an ALTER TABLE ADD clause takes it. +func fkText(t *schema.Table, fk *schema.ForeignKey) string { + if fk.Text != "" { + return fk.Text + } + s := "CONSTRAINT " + q(t.ForeignKeyName(fk)) + " FOREIGN KEY (" + qlist(fk.Columns) + ") REFERENCES " + q(fk.RefTable) + " (" + qlist(fk.RefColumns) + ")" + if fk.OnDelete != "" { + s += " ON DELETE " + fk.OnDelete + } + if fk.OnUpdate != "" { + s += " ON UPDATE " + fk.OnUpdate + } + return s +} + +// checkText is the check as an ALTER TABLE ADD clause takes it. +func checkText(t *schema.Table, c *schema.Check) string { + if c.Text != "" { + return c.Text + } + props := diff.CheckProps(t, c) + s := "CONSTRAINT " + q(t.CheckName(c)) + " CHECK " + props["expression"] + if !c.Enforced { + s += " NOT ENFORCED" + } + return s +} + +func qlist(names []string) string { + var out []string + for _, n := range names { + out = append(out, q(n)) + } + return strings.Join(out, ", ") +} + +// --- backfills ------------------------------------------------------------------- + +// backfill emits the declared backfills of one target column now, ahead of the statement +// that needs the rows filled; backfills() at the end emits whatever is left. +func (p *planner) backfill(table, col string) { + for i, in := range p.backfillsOf { + if in.Table == table && in.Column == col && !p.backfilled[i] { + p.emitBackfill(in) + if p.backfilled == nil { + p.backfilled = map[int]bool{} + } + p.backfilled[i] = true + } + } +} + +func (p *planner) backfills() { + for i, in := range p.backfillsOf { + if p.backfilled[i] { + continue + } + p.emitBackfill(in) + } +} + +func (p *planner) emitBackfill(in Intent) { + { + t := p.to.Table(in.Table) + if t == nil || t.Column(in.Column) == nil { + p.problem("line %d: backfill %s.%s: no such column in the target schema", in.Line, in.Table, in.Column) + return + } + sql := fmt.Sprintf("UPDATE %s SET %s = %s", q(in.Table), q(in.Column), in.Expr) + if in.Where != "" { + sql += " WHERE " + in.Where + } + if err := p.check(sql); err != nil { + p.problem("line %d: backfill %s.%s: %v", in.Line, in.Table, in.Column, err) + } + p.emit("%s;", sql) + } +} + +// --- helpers --------------------------------------------------------------------- + +func keyName(k *schema.Key) string { + if k.Kind == schema.Primary { + return "PRIMARY" + } + return k.Name +} + +func keysByName(t *schema.Table) map[string]*schema.Key { + out := map[string]*schema.Key{} + for _, k := range t.Keys { + out[keyName(k)] = k + } + return out +} + +func foreignKeysByName(t *schema.Table) map[string]*schema.ForeignKey { + out := map[string]*schema.ForeignKey{} + for _, fk := range t.ForeignKeys { + out[t.ForeignKeyName(fk)] = fk + } + return out +} + +func checksByName(t *schema.Table) map[string]*schema.Check { + out := map[string]*schema.Check{} + for _, c := range t.Checks { + out[t.CheckName(c)] = c + } + return out +} + +// visibilityOnlyDiffers reports whether a and b are the same key definition apart from +// Invisible: DROP INDEX + ADD, the recreate every other key change takes, silently keeps +// the old visibility on this server (measured) rather than applying the new one, so this +// case alone must go through a plain ALTER INDEX ... [NOT] VISIBLE instead. +func visibilityOnlyDiffers(a, b *schema.Key) bool { + if a.Invisible == b.Invisible { + return false + } + pa, pb := diff.KeyProps(a), diff.KeyProps(b) + pa["definition"] = stripInvisibleMarker(pa["definition"]) + pb["definition"] = stripInvisibleMarker(pb["definition"]) + return sameProps(pa, pb) +} + +// stripInvisibleMarker removes a key definition's own INVISIBLE marker, the way SHOW CREATE +// TABLE spells it. +func stripInvisibleMarker(def string) string { + return strings.Replace(def, " /*!80000 INVISIBLE */", "", 1) +} + +func sameProps(a, b map[string]string) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if b[k] != v { + return false + } + } + return true +} + +func touches(cols, gone []string) bool { + for _, c := range cols { + for _, g := range gone { + if c == g { + return true + } + } + } + return false +} + +func touchesParts(k *schema.Key, gone []string) bool { + for _, p := range k.Parts { + for _, g := range gone { + if p.Column == g { + return true + } + } + } + return false +} + +// fkOrder sorts tables so that a table comes after the tables its foreign keys reference +// (among the given ones); a cycle is left in name order. +func fkOrder(tables []*schema.Table) []*schema.Table { + byName := map[string]*schema.Table{} + for _, t := range tables { + byName[t.Name] = t + } + var out []*schema.Table + done := map[string]bool{} + var visit func(t *schema.Table, stack map[string]bool) + visit = func(t *schema.Table, stack map[string]bool) { + if done[t.Name] || stack[t.Name] { + return + } + stack[t.Name] = true + var refs []string + for _, fk := range t.ForeignKeys { + if _, ok := byName[fk.RefTable]; ok && fk.RefTable != t.Name { + refs = append(refs, fk.RefTable) + } + } + sort.Strings(refs) + for _, r := range refs { + visit(byName[r], stack) + } + done[t.Name] = true + out = append(out, t) + } + for _, t := range tables { + visit(t, map[string]bool{}) + } + return out +} + +// Split cuts a DDL script into statements the way the mysql client does (at the `;` +// outside quotes and comments), for apply to run them one by one: MySQL's DDL commits +// implicitly, so a script is not a transaction, and the statement that fails is the one +// to report. +func Split(ddl string) []string { + var out []string + for _, st := range mysqlparse.Split(ddl) { + out = append(out, strings.TrimSpace(st.SQL)) + } + return out +} + +// SplitFor is Split under the sql_mode s declares: mysqlparse.Split already cuts a CREATE +// TRIGGER / PROCEDURE / FUNCTION / EVENT body as one statement regardless of mode (mode +// only matters to a candidate cut ambiguous enough that sql_mode decides whether it parses +// as a complete statement on its own), but a plan should still be split under the schema +// it targets rather than the parser's default. +func SplitFor(ddl string, s *schema.Schema) []string { + var out []string + for _, st := range mysqlparse.SplitMode(ddl, s.Settings.ParseMode()) { + out = append(out, strings.TrimSpace(st.SQL)) + } + return out +} diff --git a/check/mysql/migrate/migrate_test.go b/check/mysql/migrate/migrate_test.go new file mode 100644 index 0000000..dfd76a6 --- /dev/null +++ b/check/mysql/migrate/migrate_test.go @@ -0,0 +1,318 @@ +package migrate + +import ( + "context" + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/kr9ly/sqlshape/check/mysql/v2/diff" + "github.com/kr9ly/sqlshape/check/mysql/v2/dump" + "github.com/kr9ly/sqlshape/check/mysql/v2/internal/schema" +) + +type canonical struct { + s *schema.Schema + text string + intents []Intent +} + +func start(t *testing.T) context.Context { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + if _, _, err := (dump.Local{}).Canonical(ctx, "-- sqlshape: mysql 8.4\nCREATE TABLE t (a INT);"); errors.Is(err, dump.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } else if err != nil { + t.Fatal(err) + } + return ctx +} + +func mustCanonical(t *testing.T, ctx context.Context, sql string) canonical { + t.Helper() + s, text, err := (dump.Local{}).Canonical(ctx, sql) + if err != nil { + t.Fatalf("canonical: %v\n%s", err, sql) + } + if len(s.Problems) > 0 { + t.Fatalf("problems: %v", s.Problems) + } + in, err := ParseIntents(sql) + if err != nil { + t.Fatalf("intents: %v", err) + } + return canonical{s, text, in} +} + +func example(t *testing.T) string { + t.Helper() + b, err := os.ReadFile("../../../examples/5-mysql/schema.sql") + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// plan runs Plan from a to b and applies the DDL on a server holding a, expecting the +// result to read back as b. +func plan(t *testing.T, ctx context.Context, name string, from, to canonical) []string { + t.Helper() + ddl, err := Plan(from.s, to.s, to.intents) + if err != nil { + t.Fatalf("%s: plan: %v\n%s", name, err, strings.Join(ddl, "\n")) + } + changes, err := Verify(ctx, dump.Local{}, from.text, strings.Join(ddl, "\n"), to.s) + if err != nil { + t.Fatalf("%s: verify: %v\nDDL:\n%s", name, err, strings.Join(ddl, "\n")) + } + if len(changes) > 0 { + var b strings.Builder + for _, c := range changes { + b.WriteString(c.String() + "\n") + } + t.Errorf("%s: the DDL does not reach the target:\n%sDDL:\n%s", name, b.String(), strings.Join(ddl, "\n")) + } + return ddl +} + +func TestPlanEmptyForIdentical(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, example(t)) + ddl, err := Plan(base.s, base.s, nil) + if err != nil || len(ddl) != 0 { + t.Errorf("ddl %v, err %v", ddl, err) + } + if ch := diff.Compare(base.s, base.s); len(ch) != 0 { + t.Errorf("changes %v", ch) + } +} + +func TestPlan(t *testing.T) { + ctx := start(t) + baseSQL := example(t) + base := mustCanonical(t, ctx, baseSQL) + cases := []struct { + name, edit string + back string // declarations for the way back + oneWay bool + want []string // fragments the DDL must contain + }{ + {name: "columns", edit: ` +-- @migrate drop orders.note +ALTER TABLE customers ADD COLUMN nickname VARCHAR(50) DEFAULT 'anon'; +ALTER TABLE customers ADD COLUMN score INT NOT NULL DEFAULT 0 AFTER email; +ALTER TABLE orders MODIFY COLUMN total DECIMAL(14,2) NOT NULL DEFAULT 1; +ALTER TABLE orders DROP COLUMN note; +`, back: "-- @migrate drop customers.nickname\n-- @migrate drop customers.score\n", + want: []string{"ADD COLUMN `score` int NOT NULL DEFAULT '0' AFTER `email`", "MODIFY COLUMN `total` decimal(14,2) NOT NULL DEFAULT '1.00'", "DROP COLUMN `note`"}}, + {name: "keys and checks", edit: ` +ALTER TABLE orders ADD INDEX orders_status_idx (status, customer_id); +ALTER TABLE orders DROP CHECK orders_total_check; +ALTER TABLE orders ADD CONSTRAINT orders_total_check CHECK (total > 0); +ALTER TABLE customers DROP INDEX customers_email_key; +ALTER TABLE customers ADD UNIQUE KEY customers_email_key (email, name); +`, want: []string{"ADD KEY `orders_status_idx` (`status`,`customer_id`)", "DROP CHECK `orders_total_check`", "ADD CONSTRAINT `orders_total_check` CHECK ((`total` > 0))", "DROP INDEX `customers_email_key`", "ADD UNIQUE KEY `customers_email_key` (`email`,`name`)"}}, + {name: "foreign key and new table", edit: ` +CREATE TABLE regions (id INT PRIMARY KEY, name VARCHAR(50) NOT NULL); +ALTER TABLE customers ADD COLUMN region_id INT NULL; +ALTER TABLE customers ADD CONSTRAINT fk_customers_region FOREIGN KEY (region_id) REFERENCES regions (id) ON DELETE SET NULL; +ALTER TABLE orders DROP FOREIGN KEY fk_orders_customer; +ALTER TABLE orders ADD CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers (id) ON DELETE CASCADE; +`, back: "-- @migrate drop regions\n-- @migrate drop customers.region_id\n", + want: []string{"CREATE TABLE `regions`", "ADD CONSTRAINT `fk_customers_region` FOREIGN KEY (`region_id`) REFERENCES `regions` (`id`) ON DELETE SET NULL", "DROP FOREIGN KEY `fk_orders_customer`", "ON DELETE CASCADE"}}, + {name: "renames", edit: ` +-- @migrate rename orders.note -> orders.memo +-- @migrate rename customers -> clients +ALTER TABLE orders RENAME COLUMN note TO memo; +RENAME TABLE customers TO clients; +`, back: "-- @migrate rename orders.memo -> orders.note\n-- @migrate rename clients -> customers\n", + want: []string{"RENAME TABLE `customers` TO `clients`", "ALTER TABLE `orders` RENAME COLUMN `note` TO `memo`"}}, + {name: "table options and position", edit: ` +ALTER TABLE customers COMMENT='people who buy'; +ALTER TABLE orders MODIFY COLUMN note TEXT AFTER id; +`, want: []string{"ALTER TABLE `customers` COMMENT='people who buy'", "MODIFY COLUMN `note` text AFTER `id`"}}, + {name: "enum label and backfill", edit: ` +-- @migrate enum orders.status: drop 'cancelled' using 'pending' +-- @migrate backfill customers.tier = 'basic' +ALTER TABLE orders MODIFY COLUMN status ENUM('pending', 'paid') NOT NULL DEFAULT 'pending'; +ALTER TABLE customers ADD COLUMN tier VARCHAR(10) NOT NULL DEFAULT ''; +`, oneWay: true, + want: []string{"UPDATE `orders` SET `status` = 'pending' WHERE `status` = 'cancelled'", "MODIFY COLUMN `status` enum('pending','paid') NOT NULL DEFAULT 'pending'", "UPDATE `customers` SET `tier` = 'basic'"}}, + {name: "views", edit: ` +CREATE VIEW paid_orders AS SELECT id, customer_id, total FROM orders WHERE status = 'paid'; +`, want: []string{"CREATE ALGORITHM=UNDEFINED SQL SECURITY DEFINER VIEW `paid_orders`"}}, + {name: "trigger body change", edit: ` +DROP TRIGGER orders_before_insert; +CREATE TRIGGER orders_before_insert BEFORE INSERT ON orders FOR EACH ROW +BEGIN + SET NEW.total = NEW.total * 1; +END; +`, want: []string{"DROP TRIGGER `orders_before_insert`", "CREATE TRIGGER `orders_before_insert` BEFORE INSERT ON `orders` FOR EACH ROW"}}, + {name: "trigger drop", edit: ` +DROP TRIGGER orders_before_insert; +`, want: []string{"DROP TRIGGER `orders_before_insert`"}}, + {name: "function add", edit: ` +CREATE FUNCTION add_one(a INT) RETURNS INT DETERMINISTIC +BEGIN + RETURN a + 1; +END; +`, want: []string{"CREATE FUNCTION `add_one`(a INT) RETURNS int"}}, + {name: "function body change", edit: ` +DROP FUNCTION customer_order_total; +CREATE FUNCTION customer_order_total(cust_id BIGINT UNSIGNED) RETURNS DECIMAL(10,2) +READS SQL DATA +BEGIN + RETURN 0; +END; +`, want: []string{"DROP FUNCTION `customer_order_total`", "CREATE FUNCTION `customer_order_total`"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + to := mustCanonical(t, ctx, baseSQL+"\n"+c.edit) + // the declarations are in the edit, which the canonical text lacks: parse them + // from the edited source + ddl := plan(t, ctx, c.name, base, to) + joined := strings.Join(ddl, "\n") + for _, w := range c.want { + if !strings.Contains(joined, w) { + t.Errorf("%s: DDL lacks %q:\n%s", c.name, w, joined) + } + } + if c.oneWay { + return + } + backSQL := c.back + baseSQL + back := mustCanonical(t, ctx, backSQL) + back.intents, _ = ParseIntents(backSQL) + // the way back starts from the edited state + from := canonical{s: to.s, text: to.text} + plan(t, ctx, c.name+" (back)", from, back) + }) + } +} + +// A trigger's table renamed: the server keeps the trigger firing on the renamed table (its +// SHOW CREATE TRIGGER text reads `ON` the new name, measured against mysqld), so the +// trigger's definition text differs from the from side's even though nothing about the +// trigger itself changed; the plan drops and recreates it (there is no ALTER TRIGGER), the +// CREATE using the target's own (renamed) text. +func TestPlanTriggerTableRenamed(t *testing.T) { + ctx := start(t) + baseSQL := example(t) + base := mustCanonical(t, ctx, baseSQL) + editSQL := baseSQL + "\n-- @migrate rename orders -> orders2\nRENAME TABLE orders TO orders2;\n" + to := mustCanonical(t, ctx, editSQL) + ddl := plan(t, ctx, "trigger table renamed", base, to) + joined := strings.Join(ddl, "\n") + for _, w := range []string{"DROP TRIGGER `orders_before_insert`", "RENAME TABLE `orders` TO `orders2`", "CREATE TRIGGER `orders_before_insert` BEFORE INSERT ON `orders2` FOR EACH ROW"} { + if !strings.Contains(joined, w) { + t.Errorf("DDL lacks %q:\n%s", w, joined) + } + } + // the way back + backSQL := "-- @migrate rename orders2 -> orders\n" + baseSQL + back := mustCanonical(t, ctx, backSQL) + back.intents, _ = ParseIntents(backSQL) + from := canonical{s: to.s, text: to.text} + plan(t, ctx, "trigger table renamed (back)", from, back) +} + +// A view whose definition changes is replaced in place; one the target lacks is dropped. +func TestPlanViews(t *testing.T) { + ctx := start(t) + baseSQL := example(t) + from := mustCanonical(t, ctx, baseSQL+"\nCREATE VIEW paid_orders AS SELECT id FROM orders WHERE status = 'paid';\nCREATE VIEW gone AS SELECT 1 AS x;\n") + to := mustCanonical(t, ctx, baseSQL+"\nCREATE VIEW paid_orders AS SELECT id, total FROM orders WHERE status = 'paid';\n") + ddl := plan(t, ctx, "views", from, to) + joined := strings.Join(ddl, "\n") + for _, w := range []string{"DROP VIEW `gone`", "CREATE OR REPLACE ALGORITHM=UNDEFINED SQL SECURITY DEFINER VIEW `paid_orders`"} { + if !strings.Contains(joined, w) { + t.Errorf("DDL lacks %q:\n%s", w, joined) + } + } +} + +func TestPlanProblems(t *testing.T) { + ctx := start(t) + baseSQL := example(t) + base := mustCanonical(t, ctx, baseSQL) + cases := []struct{ name, edit, want string }{ + {"undeclared column drop", "ALTER TABLE orders DROP COLUMN note;", "column orders.note is dropped, which no @migrate declares"}, + {"undeclared table drop", "DROP TABLE orders;", "table orders is dropped, which no @migrate declares"}, + {"stale drop", "-- @migrate drop orders.note", "orders.note still exists in the target schema"}, + {"stale rename", "-- @migrate rename customers.name -> customers.full_name", "customers.full_name is not in the target schema"}, + {"enum label kept", "-- @migrate enum orders.status: drop 'paid' using 'pending'", "'paid' is still a label in the target schema"}, + {"backfill error", "-- @migrate backfill orders.total = nope\nALTER TABLE orders MODIFY COLUMN note TEXT COMMENT 'x';", "backfill orders.total: Unknown column 'nope'"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + text := baseSQL + "\n" + c.edit + s, _, err := (dump.Local{}).Canonical(ctx, text) + if err != nil { + t.Fatal(err) + } + in, _ := ParseIntents(text) + _, err = Plan(base.s, s, in) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: err = %v, want %q", c.name, err, c.want) + } + }) + } +} + +func TestParseIntents(t *testing.T) { + in, err := ParseIntents("-- @migrate rename a.b -> a.c\n-- @migrate drop t\n-- @migrate enum o.s: drop 'x' using 'y'\n-- @migrate backfill o.c = 1 where c is null\n") + if err != nil || len(in) != 4 { + t.Fatalf("%v %v", in, err) + } + want := []string{"rename a.b -> a.c", "drop t", "enum o.s: drop 'x' using 'y'", "backfill o.c = 1 where c is null"} + for i, w := range want { + if in[i].String() != w { + t.Errorf("%d: %q", i, in[i].String()) + } + } + if _, err := ParseIntents("-- @migrate enum s: drop 'x' using 'y'\n-- @migrate frobnicate x"); err == nil || !strings.Contains(err.Error(), "enum needs table.column") || !strings.Contains(err.Error(), "unknown @migrate") { + t.Errorf("%v", err) + } +} + +// TestSplit covers the package's own Split (mysqlparse.Split's own default sql_mode), +// including a compound CREATE TRIGGER body as one statement. +func TestSplit(t *testing.T) { + ddl := "CREATE TABLE t (id INT PRIMARY KEY);\n" + + "CREATE TRIGGER trg BEFORE INSERT ON t FOR EACH ROW\n" + + "BEGIN\n" + + " SET NEW.id = NEW.id;\n" + + "END;\n" + got := Split(ddl) + if len(got) != 2 { + t.Fatalf("got %d statements: %+v", len(got), got) + } + if !strings.HasPrefix(got[0], "CREATE TABLE") { + t.Errorf("got %q", got[0]) + } + if !strings.HasPrefix(got[1], "CREATE TRIGGER") || !strings.HasSuffix(got[1], "END") { + t.Errorf("got %q", got[1]) + } +} + +// TestSplitFor covers SplitFor, cutting under the schema's own declared sql_mode. +func TestSplitFor(t *testing.T) { + s, err := schema.Load(`-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY); +CREATE PROCEDURE p() BEGIN SELECT 1; END; +`) + if err != nil { + t.Fatal(err) + } + got := SplitFor("CREATE TABLE t (id INT PRIMARY KEY);\nCREATE PROCEDURE p() BEGIN SELECT 1; END;\n", s) + if len(got) != 2 { + t.Fatalf("got %d statements: %+v", len(got), got) + } + if !strings.HasPrefix(got[1], "CREATE PROCEDURE") || !strings.HasSuffix(got[1], "END") { + t.Errorf("got %q", got[1]) + } +} diff --git a/check/mysql/migrate/probe_alphabet_test.go b/check/mysql/migrate/probe_alphabet_test.go new file mode 100644 index 0000000..dc420ed --- /dev/null +++ b/check/mysql/migrate/probe_alphabet_test.go @@ -0,0 +1,36 @@ +package migrate + +// alphabetKnownUnreached is diff.Alphabet's entries the gate (seed 1, 200 pairs) does not +// need to hit, each with why -- the two reasons TestMigrateProbe's gate accepts as an +// escape from adding generator vocabulary (see the assertion at the end of +// TestMigrateProbe): the planner writes no DDL for it (a note or a problem, measured in +// migrate.go), or this probe's real server can never produce a pair carrying it in the +// first place. +var alphabetKnownUnreached = map[string]string{} + +// directedKnownUnusable is mutations directed coverage cannot ever apply completely alone +// (see TestMigrateProbe's own directed-coverage loop), each with why: every one of these +// edits a partitioning this package's generate() never produces on a fresh schema (only +// another mutation earlier in the same recipe -- "partition table by range" or "partition +// table by hash" -- creates one), so a schema fresh enough for these to have a candidate +// straight from generate() does not exist to draw. The random pairs above still reach every +// one of them in combination (a "partition table by ..." step ahead of it in the same +// recipe), which is what the alphabet coverage this file's TestMigrateProbe gate cares about +// actually depends on -- this map only accepts the standalone case as impossible instead of +// papering over a mutation the probe genuinely cannot exercise at all. +var directedKnownUnusable = map[string]string{ + "add partition": "needs a table already partitioned by RANGE with no MAXVALUE tail yet", + "drop partition": "needs a table already partitioned by RANGE with a tail an earlier step of the same recipe added", + "remove partitioning": "needs a table already partitioned (RANGE, HASH or LIST)", + "reorganize partition boundary": "needs a table already partitioned by RANGE with no MAXVALUE tail yet", + "reorganize partition insert before maxvalue": "needs a table already partitioned by RANGE with a MAXVALUE tail", + "change hash partition count": "needs a table already partitioned by HASH", + "add list partition": "needs a table already partitioned by LIST", + "drop list partition": "needs a table already partitioned by LIST with a partition an earlier step of the same recipe added", + "move list partition value": "needs a table already partitioned by LIST", + "change key partition count": "needs a table already partitioned by KEY", + "toggle key algorithm": "needs a table already partitioned by KEY", + "toggle partitioning linear": "needs a table already partitioned by HASH or KEY", + "change subpartition count": "needs a table already partitioned with a SUBPARTITION BY clause", + "remove subpartitioning": "needs a table already partitioned with a SUBPARTITION BY clause", +} diff --git a/check/mysql/migrate/probe_findings_test.go b/check/mysql/migrate/probe_findings_test.go new file mode 100644 index 0000000..ba05d56 --- /dev/null +++ b/check/mysql/migrate/probe_findings_test.go @@ -0,0 +1,604 @@ +package migrate + +// What the migrate probe (probe_test.go) found and the planner now does, each pinned as the +// minimized pair the probe reported, replayed against a real mysqld the way `plan` does: +// the DDL applies cleanly and reads back as the target. + +import ( + "strings" + "testing" + + "github.com/kr9ly/sqlshape/check/mysql/v2/diff" + "github.com/kr9ly/sqlshape/check/mysql/v2/dump" +) + +// A target that declares its own key over a foreign key's columns replaces the index the +// server created for the constraint (the canonical form of the target has only the declared +// key): the planner's DROP INDEX of the server's one and its ADD of the declared one must be +// one ALTER TABLE, since the foreign key needs an index at every statement boundary (Error +// 1553 "Cannot drop index: needed in a foreign key constraint" otherwise, measured). +func TestProbeForeignKeyIndexReplacedByDeclaredKey(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE parents (id INT PRIMARY KEY); +CREATE TABLE children (id INT PRIMARY KEY, parent_id INT NOT NULL, + CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES parents (id)); +`) + to := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE parents (id INT PRIMARY KEY); +CREATE TABLE children (id INT PRIMARY KEY, parent_id INT NOT NULL, + UNIQUE KEY children_parent_key (parent_id), + CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES parents (id)); +`) + plan(t, ctx, "declared key over a foreign key", base, to) +} + +// The primary key moving off a column another table's foreign key references: the DROP +// PRIMARY KEY needs the target's replacement key on that column in the same ALTER TABLE +// (the same Error 1553 on the referenced side, measured). +func TestProbePrimaryKeyMovesOffReferencedColumn(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE parents (id INT PRIMARY KEY, code BIGINT UNSIGNED); +CREATE TABLE children (id INT PRIMARY KEY, parent_id INT, + CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES parents (id) ON DELETE SET NULL); +`) + to := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE parents (id INT NOT NULL, code BIGINT UNSIGNED NOT NULL, PRIMARY KEY (code), UNIQUE KEY parents_id_key (id)); +CREATE TABLE children (id INT PRIMARY KEY, parent_id INT, + CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES parents (id) ON DELETE SET NULL); +`) + plan(t, ctx, "primary key off a referenced column", base, to) +} + +// Renaming a column a generated column reads: MySQL refuses the RENAME COLUMN on its own +// (Error 3108 "has a generated column dependency", measured) and accepts it with the +// dependent generated columns rewritten in the same ALTER TABLE. +func TestProbeRenamedColumnReadByGeneratedColumn(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY, qty INT NOT NULL DEFAULT 0, + qty_plus BIGINT GENERATED ALWAYS AS (qty + 1) STORED, + qty_twice BIGINT GENERATED ALWAYS AS (qty * 2) VIRTUAL, + KEY t_qty_plus (qty_plus)); +CREATE VIEW t_qty AS SELECT id, qty FROM t; +`) + toSQL := `-- sqlshape: mysql 8.4 +-- @migrate rename t.qty -> t.quantity +CREATE TABLE t (id INT PRIMARY KEY, quantity INT NOT NULL DEFAULT 0, + qty_plus BIGINT GENERATED ALWAYS AS (quantity + 1) STORED, + qty_twice BIGINT GENERATED ALWAYS AS (quantity * 2) VIRTUAL, + KEY t_qty_plus (qty_plus)); +CREATE VIEW t_qty AS SELECT id, quantity FROM t; +` + to := mustCanonical(t, ctx, toSQL) + ddl := plan(t, ctx, "renamed column read by generated columns", base, to) + for _, s := range ddl { + if len(s) > 12 && s[:12] == "ALTER TABLE " && strings.Contains(s, "RENAME COLUMN") && !strings.Contains(s, "MODIFY COLUMN `qty_plus`") { + t.Errorf("the RENAME COLUMN must carry its dependent generated columns:\n%s", s) + } + } +} + +// A view whose new definition reads a column the same plan adds: the CREATE OR REPLACE VIEW +// must come after the ADD COLUMN (Error 1054 "Unknown column" otherwise, measured). +func TestProbeReplacedViewReadsAddedColumn(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY, a INT); +CREATE VIEW v AS SELECT id, a FROM t; +`) + to := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY, a INT, b INT); +CREATE VIEW v AS SELECT id, a, b FROM t; +`) + plan(t, ctx, "replaced view reads an added column", base, to) +} + +// A brand new table whose foreign key references a column (and key) a table that stays is +// only now gaining: the CREATE TABLE must come after that ADD COLUMN / ADD KEY (Error 3734 +// "Missing column ... in the referenced table" and 6125 "Missing unique key" otherwise, +// measured), while a staying table's new foreign key to a new table must come after the +// CREATE TABLE. +func TestProbeNewTableReferencesAddedColumn(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE parents (id INT PRIMARY KEY, name VARCHAR(50)); +`) + to := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE regions (id INT PRIMARY KEY); +CREATE TABLE parents (id INT NOT NULL, code BIGINT UNSIGNED NOT NULL, name VARCHAR(50), region_id INT, + PRIMARY KEY (code), UNIQUE KEY parents_id_key (id), + CONSTRAINT fk_region FOREIGN KEY (region_id) REFERENCES regions (id)); +CREATE TABLE children (id INT PRIMARY KEY, parent_code BIGINT UNSIGNED, + CONSTRAINT fk_parent FOREIGN KEY (parent_code) REFERENCES parents (code)); +`) + plan(t, ctx, "new table references an added column", base, to) +} + +// A renamed table whose foreign key is dropped ahead of a type change on either side +// (coordinateForeignKeys): the DROP FOREIGN KEY runs after the RENAME TABLE and must name +// the table as it is by then (Error 1146 "Table doesn't exist" otherwise, measured). +func TestProbeRenamedTableForeignKeyCoordinated(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE parents (id INT PRIMARY KEY); +CREATE TABLE children (id INT PRIMARY KEY, parent_id INT, + CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES parents (id)); +`) + toSQL := `-- sqlshape: mysql 8.4 +-- @migrate rename children -> kids +CREATE TABLE parents (id BIGINT PRIMARY KEY); +CREATE TABLE kids (id INT PRIMARY KEY, parent_id BIGINT, + CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES parents (id)); +` + to := mustCanonical(t, ctx, toSQL) + plan(t, ctx, "renamed table with a coordinated foreign key", base, to) +} + +// With rows in the table: a column turning NOT NULL runs its declared backfill before the +// MODIFY (Error 1138 "Invalid use of NULL value" otherwise, measured); a new NOT NULL +// foreign key column runs its backfill before the constraint goes on (Error 1452 on the +// implicit-default rows otherwise, measured); a new AUTO_INCREMENT primary key column gets +// its key in the same statement (Error 1075 otherwise, measured). +func TestProbeBackfillsUnderRows(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE parents (id INT PRIMARY KEY); +CREATE TABLE t (id INT PRIMARY KEY, a INT, b INT); +`) + rows := "INSERT INTO parents VALUES (1), (2);\nINSERT INTO t VALUES (1, 1, 1), (2, NULL, 2);\n" + toSQL := `-- sqlshape: mysql 8.4 +-- @migrate backfill t.a = 9 where a is null +-- @migrate backfill t.parent_id = 1 +-- @migrate backfill t.seq = id +CREATE TABLE parents (id INT PRIMARY KEY); +CREATE TABLE t (id INT NOT NULL, a INT NOT NULL, b INT, parent_id INT NOT NULL, seq INT NOT NULL, + n BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + PRIMARY KEY (n), UNIQUE KEY t_id_key (id), UNIQUE KEY t_seq_key (seq), + CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES parents (id)); +` + to := mustCanonical(t, ctx, toSQL) + ddl, err := Plan(base.s, to.s, to.intents) + if err != nil { + t.Fatalf("plan: %v", err) + } + joined := strings.Join(ddl, "\n") + changes, err := Verify(ctx, dump.Local{}, base.text+"\n"+rows, joined, to.s) + if err != nil { + t.Fatalf("verify: %v\nDDL:\n%s", err, joined) + } + if len(changes) > 0 { + t.Errorf("changes: %v\nDDL:\n%s", changes, joined) + } +} + +// A renamed column that also participates in a foreign key (here, the parent side a child's +// constraint references) and is read by a generated column: the RENAME COLUMN + MODIFY COLUMN +// combination Error 3108 forces is itself refused by the server's default algorithm (Error +// 1846 "ALGORITHM=COPY is not supported ... Columns participating in a foreign key are +// renamed. Try ALGORITHM=INPLACE", measured); the same ALTER TABLE with ALGORITHM=INPLACE +// succeeds. +func TestProbeRenamedForeignKeyColumnReadByGeneratedColumn(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY, g BIGINT GENERATED ALWAYS AS (id + 1) STORED); +CREATE TABLE children (id INT PRIMARY KEY, parent_id INT, + CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES t (id)); +`) + toSQL := `-- sqlshape: mysql 8.4 +-- @migrate rename t.id -> t.id_new +CREATE TABLE t (id_new INT PRIMARY KEY, g BIGINT GENERATED ALWAYS AS (id_new + 1) STORED); +CREATE TABLE children (id INT PRIMARY KEY, parent_id INT, + CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES t (id_new)); +` + to := mustCanonical(t, ctx, toSQL) + plan(t, ctx, "renamed foreign-key column read by a generated column", base, to) +} + +// A column newly gaining ON UPDATE CURRENT_TIMESTAMP fires on any later UPDATE against the +// row, even one naming only an unrelated column (a fresh column's backfill): every row the +// backfill's UPDATE touches gets the same instant, which a UNIQUE key over the auto-updating +// column then refuses as a duplicate (Error 1062 "Duplicate entry", measured) if the MODIFY +// that adds it runs ahead of the backfill in the same plan. The MODIFY must wait until every +// row-touching statement in the plan is done (deferredMods). +func TestProbeOnUpdateModifyBeforeBackfill(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY, ts DATETIME(6), UNIQUE KEY t_ts (ts)); +`) + rows := "INSERT INTO t (id, ts) VALUES (1, '2024-01-01 00:00:00'), (2, '2024-01-02 00:00:00'), (3, NULL);\n" + toSQL := `-- sqlshape: mysql 8.4 +-- @migrate backfill t.a = id +CREATE TABLE t (id INT PRIMARY KEY, ts DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + a INT NOT NULL DEFAULT 0, UNIQUE KEY t_ts (ts)); +` + to := mustCanonical(t, ctx, toSQL) + ddl, err := Plan(base.s, to.s, to.intents) + if err != nil { + t.Fatalf("plan: %v", err) + } + joined := strings.Join(ddl, "\n") + changes, err := Verify(ctx, dump.Local{}, base.text+"\n"+rows, joined, to.s) + if err != nil { + t.Fatalf("verify: %v\nDDL:\n%s", err, joined) + } + if len(changes) > 0 { + t.Errorf("changes: %v\nDDL:\n%s", changes, joined) + } +} + +// The primary key going, with a surviving invisible UNIQUE key: InnoDB promotes a UNIQUE key +// over only NOT NULL columns to substitute clustering key the moment no PRIMARY KEY is left, +// and an invisible index cannot serve as one (Error 3522 "A primary key index cannot be +// invisible", measured) -- not only on the DROP PRIMARY KEY itself, but at a later MODIFY in +// the same window that completes such a key's column list (b here turns NOT NULL, under its +// declared backfill, after the DROP and before the new PRIMARY KEY goes back on). The +// planner forces every invisible UNIQUE key VISIBLE for that whole window and restores +// INVISIBLE, once the target's own PRIMARY KEY exists, at the very end of the plan +// (invisibleUniqueKeysOf, deferredMods). +func TestProbeInvisibleUniqueKeyBeforePrimaryKeyDrop(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL, a TEXT NOT NULL, b SMALLINT, PRIMARY KEY (id), + UNIQUE KEY t_ab (a(10), b) INVISIBLE); +`) + rows := "INSERT INTO t (id, a, b) VALUES (1, 'x', 1), (2, 'y', 2), (3, 'z', NULL);\n" + toSQL := `-- sqlshape: mysql 8.4 +-- @migrate backfill t.b = 9 where b is null +CREATE TABLE t (id INT, a TEXT NOT NULL, b SMALLINT NOT NULL, PRIMARY KEY (b), + UNIQUE KEY t_ab (a(10), b) INVISIBLE); +` + to := mustCanonical(t, ctx, toSQL) + ddl, err := Plan(base.s, to.s, to.intents) + if err != nil { + t.Fatalf("plan: %v", err) + } + joined := strings.Join(ddl, "\n") + changes, err := Verify(ctx, dump.Local{}, base.text+"\n"+rows, joined, to.s) + if err != nil { + t.Fatalf("verify: %v\nDDL:\n%s", err, joined) + } + if len(changes) > 0 { + t.Errorf("changes: %v\nDDL:\n%s", changes, joined) + } +} + +// A key whose only change is going invisible (or back): DROP INDEX + ADD, the recreate path +// every other key change takes, silently keeps the old visibility on this server (measured: +// neither statement errors, but the result reads back the same as before either way) rather +// than applying the new one -- only a plain ALTER INDEX ... [NOT] VISIBLE actually flips it. +func TestProbeKeyVisibilityOnlyChangeUsesAlterIndex(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE parents (id INT PRIMARY KEY, a INT NOT NULL, b INT NOT NULL, UNIQUE KEY parents_ab (a, b)); +CREATE TABLE children (id INT PRIMARY KEY, ra INT, rb INT, + CONSTRAINT fk_ab FOREIGN KEY (ra, rb) REFERENCES parents (a, b)); +`) + to := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE parents (id INT PRIMARY KEY, a INT NOT NULL, b INT NOT NULL, UNIQUE KEY parents_ab (a, b) INVISIBLE); +CREATE TABLE children (id INT PRIMARY KEY, ra INT, rb INT, + CONSTRAINT fk_ab FOREIGN KEY (ra, rb) REFERENCES parents (a, b)); +`) + plan(t, ctx, "a key turning invisible, needed by another table's foreign key", base, to) +} + +// A key the plan itself is turning invisible (keyVisAlters), not merely one already +// invisible and surviving: the same Error 3522 hits if that key leads with only NOT NULL +// columns and no PRIMARY KEY exists at the moment it goes invisible -- here the key is over +// the very column the PRIMARY KEY is moving onto, so it must wait until the target's own +// PRIMARY KEY exists (the very end of the plan), not run right after alters(). +func TestProbeKeyTurningInvisibleWaitsForPrimaryKey(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL, a TINYINT(1) NOT NULL, PRIMARY KEY (id), UNIQUE KEY t_a (a)); +`) + to := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT, a TINYINT(1) NOT NULL, PRIMARY KEY (a), UNIQUE KEY t_a (a) INVISIBLE); +`) + plan(t, ctx, "a key over the primary key's replacement turning invisible", base, to) +} + +// The primary key moving off an AUTO_INCREMENT column: dropKeysOf's own fold (coverEarly) +// adds the AUTO_INCREMENT column's replacement key in the same statement as DROP PRIMARY +// KEY (Error 1075 otherwise); when the target declares that replacement key itself +// INVISIBLE, adding it already invisible hits the same Error 3522 an already-invisible +// surviving key does (invisibleUniqueKeysOf) -- coverEarly must add it VISIBLE and let +// keyVisAlters turn it invisible once the target's own PRIMARY KEY exists. +func TestProbeCoverEarlyKeyInvisibleFromCreation(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL AUTO_INCREMENT, a TINYINT(1) NOT NULL, PRIMARY KEY (id)); +`) + to := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL AUTO_INCREMENT, a TINYINT(1) NOT NULL, PRIMARY KEY (a), + UNIQUE KEY t_id (id) INVISIBLE); +`) + plan(t, ctx, "the primary key moving off an auto_increment column onto an invisible replacement", base, to) +} + +// Canonicalizing must be idempotent: an ENUM column under a table with its own DEFAULT +// CHARSET / COLLATE, freshly created from raw declarative SQL, omits an explicit CHARACTER +// SET on the column (COLLATE alone, matching the table default); reading the same column +// back from an existing table (SHOW CREATE TABLE, canonicalizing a second time) always +// spells both (measured against mysqld 8.4). Without stripRedundantCharset (diff.go) and +// clearing Type.Charset once Column.Collation is set (schema.go's column()), a plan +// comparing the two spellings of the same column would see one as changed forever -- +// pinned two ways: canonicalizing the same schema twice must compare equal (diff.Compare +// empty both ways), and a plan between them must be empty. +func TestProbeCanonicalizeIdempotentUnderTableCharset(t *testing.T) { + ctx := start(t) + sql := `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY, c ENUM('a','b') NOT NULL DEFAULT 'a') + ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +` + first := mustCanonical(t, ctx, sql) + second := mustCanonical(t, ctx, first.text) + if changes := diff.Compare(first.s, second.s); len(changes) > 0 { + var d []string + for _, ch := range changes { + d = append(d, ch.String()) + } + t.Fatalf("canonicalizing twice does not compare equal:\n%s\nfirst:\n%s\nsecond:\n%s", + strings.Join(d, "\n"), first.text, second.text) + } + if changes := diff.Compare(second.s, first.s); len(changes) > 0 { + var d []string + for _, ch := range changes { + d = append(d, ch.String()) + } + t.Fatalf("canonicalizing twice does not compare equal (reversed):\n%s", strings.Join(d, "\n")) + } + ddl, err := Plan(first.s, second.s, nil) + if err != nil { + t.Fatalf("plan: %v", err) + } + if len(ddl) > 0 { + t.Errorf("plan between two canonicalizations of the same schema is not empty:\n%s", strings.Join(ddl, "\n")) + } +} + +// A column SHOW CREATE TABLE writes with a versioned comment (`/*!80023 INVISIBLE */`) +// keeps the comment's closing in its definition text, so MODIFY COLUMN from that text +// parses (a syntax error near ” otherwise, measured); and every table's foreign keys are +// dropped before any table's keys, since a child's foreign key may rest on the parent's +// key that goes (Error 1553 otherwise, measured). +func TestProbeInvisibleColumnAndForeignKeyBeforeKeyDrops(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE parents (id INT PRIMARY KEY, a INT NOT NULL, b INT NOT NULL, hidden INT INVISIBLE, + UNIQUE KEY parents_ab (a, b)); +CREATE TABLE children (id INT PRIMARY KEY, ra INT, rb INT, + CONSTRAINT fk_ab FOREIGN KEY (ra, rb) REFERENCES parents (a, b)); +`) + to := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +-- @migrate drop parents.b +CREATE TABLE parents (id INT PRIMARY KEY, a INT NOT NULL, hidden INT NOT NULL DEFAULT 0 INVISIBLE); +CREATE TABLE children (id INT PRIMARY KEY, ra INT, rb INT); +`) + plan(t, ctx, "invisible column modified, composite key dropped under a child's foreign key", base, to) +} + +// A table's primary key moving onto a new column that the same target also partitions by: +// alterTable used to write the ALTER TABLE ... PARTITION BY in the same early pass as the +// other table options, ahead of the ADD COLUMN and the backfill that give the row its +// value (Error 1054 "Unknown column 'c' in 'partition function'", measured) -- partitionAlters +// now holds every table's partitioning change for its own late phase, after adds() and +// backfills() have run. +func TestProbePartitionByNewPrimaryKeyColumn(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY, a INT NOT NULL); +`) + to := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +-- @migrate backfill t.c = id +CREATE TABLE t (id INT NOT NULL, a INT NOT NULL, c BIGINT UNSIGNED NOT NULL, PRIMARY KEY (c)) +PARTITION BY HASH (c) PARTITIONS 2; +`) + plan(t, ctx, "primary key onto a new column the target also partitions by", base, to) +} + +// A table's DEFAULT CHARSET / COLLATE changing while it still holds a plain (no explicit +// per-column COLLATE) varchar column: the column's real encoding is the table's own default +// at the moment it was last touched (ALTER TABLE ... DEFAULT CHARSET= alone never +// retroactively converts an existing column, measured), so alterTable's MODIFY COLUMN over +// it (implicitCharsetChange) is what makes the column actually converge on the new default, +// not only the table option itself -- without it a second Plan from the applied result is +// not empty (SHOW CREATE TABLE starts spelling the column's now-mismatched charset out +// explicitly, which the target's own canonical, authored fresh under the new default, never +// does). A column that already spells its own COLLATE (b) is untouched either way. +func TestProbeTableCharsetConvergesImplicitColumn(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY, a VARCHAR(20) NOT NULL, b VARCHAR(20) COLLATE utf8mb4_bin NOT NULL) + ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +`) + to := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT PRIMARY KEY, a VARCHAR(20) NOT NULL, b VARCHAR(20) COLLATE utf8mb4_bin NOT NULL) + ENGINE=InnoDB DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci; +`) + plan(t, ctx, "table charset over a plain column", base, to) +} + +// LIST partitioning, structured the same way RANGE already is (schema.Partitioning.Kind +// "LIST", Partition.Bound its own VALUES IN list): adding a partition, dropping one declared +// (`-- @migrate drop partition`), and a value moving from one named partition to another +// (both partitions kept, migrate.go's alterListPartitioning reorganizing them together in +// one REORGANIZE PARTITION so the row in between never has nowhere to go). +func TestProbeListPartitionAddDropMove(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL, a INT NOT NULL, PRIMARY KEY (id)) +PARTITION BY LIST (id) +(PARTITION p0 VALUES IN (1,2), PARTITION p1 VALUES IN (3)); +`) + rows := "\nINSERT INTO t (id, a) VALUES (1,1),(2,2),(3,3);\n" + + // add: a partition covering values no row holds yet + toAdd := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL, a INT NOT NULL, PRIMARY KEY (id)) +PARTITION BY LIST (id) +(PARTITION p0 VALUES IN (1,2), PARTITION p1 VALUES IN (3), PARTITION p2 VALUES IN (4,5)); +`) + plan(t, ctx, "list partition: add", canonical{s: base.s, text: base.text + rows, intents: base.intents}, toAdd) + + // drop: the added partition again, declared (no row of the source above ever lands there) + toDrop := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +-- @migrate drop partition t.p2 +CREATE TABLE t (id INT NOT NULL, a INT NOT NULL, PRIMARY KEY (id)) +PARTITION BY LIST (id) +(PARTITION p0 VALUES IN (1,2), PARTITION p1 VALUES IN (3)); +`) + addedRows := "\nINSERT INTO t (id, a) VALUES (1,1),(2,2),(3,3);\n" + plan(t, ctx, "list partition: drop", canonical{s: toAdd.s, text: toAdd.text + addedRows, intents: toAdd.intents}, toDrop) + + // move: id 2's own value moves from p0 to p1, both partitions kept, one REORGANIZE + toMove := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL, a INT NOT NULL, PRIMARY KEY (id)) +PARTITION BY LIST (id) +(PARTITION p0 VALUES IN (1), PARTITION p1 VALUES IN (2,3)); +`) + plan(t, ctx, "list partition: value moves between two named partitions", canonical{s: base.s, text: base.text + rows, intents: base.intents}, toMove) +} + +// KEY partitioning, structured (schema.Partitioning.Kind "KEY", Cols the column list, no +// Expr): PARTITIONS n changes the same ADD PARTITION PARTITIONS n / COALESCE PARTITION n as +// HASH's own (migrate.go's alterPartitioning treats the two kinds alike), and LINEAR or +// ALGORITHM changing is a whole rewrite (partitioningKeyChanged), not an incremental one. +func TestProbeKeyPartitionCountAndAlgorithm(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL, a INT NOT NULL, PRIMARY KEY (id)) +PARTITION BY KEY (id) PARTITIONS 2; +`) + rows := "\nINSERT INTO t (id, a) VALUES (1,1),(2,2),(3,3);\n" + base = canonical{s: base.s, text: base.text + rows, intents: base.intents} + + // count: ADD PARTITION PARTITIONS n, the server redistributing every row + toMore := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL, a INT NOT NULL, PRIMARY KEY (id)) +PARTITION BY KEY (id) PARTITIONS 4; +`) + plan(t, ctx, "key partition: count grows", base, toMore) + + // LINEAR flips on: the clause's own whole rewrite, not an ADD PARTITION + toLinear := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL, a INT NOT NULL, PRIMARY KEY (id)) +PARTITION BY LINEAR KEY (id) PARTITIONS 4; +`) + plan(t, ctx, "key partition: LINEAR", base, toLinear) + + // ALGORITHM changes: also a whole rewrite (KEY's own hashing function itself changes) + toAlgo := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL, a INT NOT NULL, PRIMARY KEY (id)) +PARTITION BY KEY ALGORITHM=1 (id) PARTITIONS 4; +`) + plan(t, ctx, "key partition: ALGORITHM", base, toAlgo) +} + +// RANGE COLUMNS partitioning (schema.Partitioning.Columns true, Cols the column list): +// spells VALUES LESS THAN a single-column bound the same way a plain RANGE does (measured), +// so alterRangePartitioning's own ADD / DROP / REORGANIZE reach it unchanged; this pins that +// the COLUMNS header itself round-trips and that an ADD PARTITION plans correctly over it. +func TestProbeRangeColumnsPartitionAdd(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL, a INT NOT NULL, PRIMARY KEY (id)) +PARTITION BY RANGE COLUMNS (id) +(PARTITION p0 VALUES LESS THAN (2), PARTITION p1 VALUES LESS THAN (100)); +`) + to := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL, a INT NOT NULL, PRIMARY KEY (id)) +PARTITION BY RANGE COLUMNS (id) +(PARTITION p0 VALUES LESS THAN (2), PARTITION p1 VALUES LESS THAN (100), PARTITION p2 VALUES LESS THAN MAXVALUE); +`) + plan(t, ctx, "range columns partition: add", base, to) +} + +// A RANGE Partitioning's own SUBPARTITION BY HASH: ADD PARTITION / REORGANIZE PARTITION +// take the same DDL this package already writes for a plain RANGE clause (the server splits +// the new partition into the default subpartition count on its own, measured), so this pins +// that alterRangePartitioning reaches a subpartitioned table unchanged, and that the +// SUBPARTITION BY clause's own count changing, or going away entirely, is a whole rewrite +// (subPartitioningChanged). +func TestProbeSubpartitionedRangeAddAndRewrite(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL, a INT NOT NULL, PRIMARY KEY (id)) +PARTITION BY RANGE (id) +SUBPARTITION BY HASH (id) +SUBPARTITIONS 2 +(PARTITION p0 VALUES LESS THAN (2), PARTITION p1 VALUES LESS THAN (100)); +`) + rows := "\nINSERT INTO t (id, a) VALUES (1,1),(2,2),(3,3);\n" + base = canonical{s: base.s, text: base.text + rows, intents: base.intents} + + // add: the new partition splits into 2 subpartitions by default, no explicit SUBPARTITION list + toAdd := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL, a INT NOT NULL, PRIMARY KEY (id)) +PARTITION BY RANGE (id) +SUBPARTITION BY HASH (id) +SUBPARTITIONS 2 +(PARTITION p0 VALUES LESS THAN (2), PARTITION p1 VALUES LESS THAN (100), PARTITION p2 VALUES LESS THAN MAXVALUE); +`) + plan(t, ctx, "subpartitioned range: add partition", base, toAdd) + + // the subpartition count itself changing: a whole rewrite, not an incremental one + toCount := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL, a INT NOT NULL, PRIMARY KEY (id)) +PARTITION BY RANGE (id) +SUBPARTITION BY HASH (id) +SUBPARTITIONS 4 +(PARTITION p0 VALUES LESS THAN (2), PARTITION p1 VALUES LESS THAN (100)); +`) + plan(t, ctx, "subpartitioned range: subpartition count changes", base, toCount) + + // SUBPARTITION BY dropped entirely: also a whole rewrite + toNone := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL, a INT NOT NULL, PRIMARY KEY (id)) +PARTITION BY RANGE (id) +(PARTITION p0 VALUES LESS THAN (2), PARTITION p1 VALUES LESS THAN (100)); +`) + plan(t, ctx, "subpartitioned range: SUBPARTITION BY removed", base, toNone) +} + +// A partition naming its own subpartitions explicitly loads as Partition.Subs and renders +// back in ADD PARTITION and the whole rewrite: this pins that an added partition carrying +// its own SUBPARTITION list writes DDL the server accepts, and that renaming a +// subpartition is a change (samePartition compares Subs) the plan carries out. +func TestProbeExplicitSubpartitions(t *testing.T) { + ctx := start(t) + base := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL, a INT NOT NULL, PRIMARY KEY (id)) +PARTITION BY RANGE (id) +SUBPARTITION BY HASH (id) +SUBPARTITIONS 2 +(PARTITION p0 VALUES LESS THAN (2) (SUBPARTITION s00, SUBPARTITION s01), + PARTITION p1 VALUES LESS THAN (100) (SUBPARTITION s10, SUBPARTITION s11)); +`) + rows := "\nINSERT INTO t (id, a) VALUES (1,1),(2,2),(3,3);\n" + base = canonical{s: base.s, text: base.text + rows, intents: base.intents} + + // add: the new partition names its own subpartitions too + toAdd := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL, a INT NOT NULL, PRIMARY KEY (id)) +PARTITION BY RANGE (id) +SUBPARTITION BY HASH (id) +SUBPARTITIONS 2 +(PARTITION p0 VALUES LESS THAN (2) (SUBPARTITION s00, SUBPARTITION s01), + PARTITION p1 VALUES LESS THAN (100) (SUBPARTITION s10, SUBPARTITION s11), + PARTITION p2 VALUES LESS THAN MAXVALUE (SUBPARTITION s20, SUBPARTITION s21)); +`) + plan(t, ctx, "explicit subpartitions: add partition", base, toAdd) + + // a subpartition renamed: the partitions differ, so the plan reorganizes / rewrites + toRename := mustCanonical(t, ctx, `-- sqlshape: mysql 8.4 +CREATE TABLE t (id INT NOT NULL, a INT NOT NULL, PRIMARY KEY (id)) +PARTITION BY RANGE (id) +SUBPARTITION BY HASH (id) +SUBPARTITIONS 2 +(PARTITION p0 VALUES LESS THAN (2) (SUBPARTITION s00, SUBPARTITION s01), + PARTITION p1 VALUES LESS THAN (100) (SUBPARTITION s10, SUBPARTITION sXX)); +`) + plan(t, ctx, "explicit subpartitions: subpartition renamed", base, toRename) +} diff --git a/check/mysql/migrate/probe_generate_test.go b/check/mysql/migrate/probe_generate_test.go new file mode 100644 index 0000000..d1c2f91 --- /dev/null +++ b/check/mysql/migrate/probe_generate_test.go @@ -0,0 +1,335 @@ +package migrate + +import ( + "fmt" + "math/rand" + "strings" +) + +// ---- generating a source schema ------------------------------------------------------- + +// ---- generating a source schema ------------------------------------------------------- + +func pick[T any](r *rand.Rand, list []T) T { return list[r.Intn(len(list))] } + +func (s *pSchema) newCol(r *rand.Rand) *pCol { + c := &pCol{name: s.next("c"), typ: pick(r, probeTypes)} + if c.typ == "enum" { + c.labels = []string{"a", "b", "c"}[:2+r.Intn(2)] + } + if c.typ == "point" { + c.notNull = true // a SPATIAL index (addSpatialKey) needs it; simplest to always have it + } else { + c.notNull = r.Intn(2) == 0 + } + if c.notNull || r.Intn(3) == 0 { + c.def = defaultFor(c) + } + if (c.typ == "text" || strings.HasPrefix(c.typ, "varchar")) && r.Intn(4) == 0 { + c.collate = true + } + if c.typ == "datetime(6)" && c.def != "" && r.Intn(2) == 0 { + c.onUpdate = true + } + if r.Intn(8) == 0 { + c.invisible = true + } + if r.Intn(5) == 0 { + c.comment = "note " + c.name + } + return c +} + +// numericCols: the plain (not generated, not AUTO_INCREMENT, not a foreign key's -- MySQL +// refuses a generated column over a referencing column with a referential action, 1215) +// numeric columns a generated column or a trigger body may read. +func (t *pTable) numericCols() []*pCol { + var out []*pCol + for _, c := range t.cols { + if isNumeric(c.typ) && c.gen == "" && !c.auto && !t.inFK(c.name) { + out = append(out, c) + } + } + return out +} + +func (s *pSchema) newTable(r *rand.Rand) *pTable { + t := &pTable{name: s.next("t")} + t.orig = t.name + id := &pCol{name: "id", typ: pick(r, []string{"int", "bigint unsigned"}), notNull: true, pk: true, auto: r.Intn(2) == 0} + t.cols = append(t.cols, id) + n := 2 + r.Intn(4) + for i := 0; i < n; i++ { + t.cols = append(t.cols, s.newCol(r)) + } + if src := t.numericCols(); len(src) > 0 && r.Intn(3) == 0 { + g := &pCol{name: s.next("g"), typ: "bigint", gen: pick(r, src).name, stored: r.Intn(2) == 0} + // before or after the column it reads: both are one CREATE TABLE + at := r.Intn(len(t.cols) + 1) + t.cols = append(t.cols[:at], append([]*pCol{g}, t.cols[at:]...)...) + } + if r.Intn(2) == 0 { + s.addKey(r, t) + } + if r.Intn(5) == 0 { + s.addFulltextKey(r, t) + } + if r.Intn(5) == 0 { + s.addSpatialKey(r, t) + } + if r.Intn(5) == 0 { + s.addFunctionalKey(r, t) + } + if r.Intn(3) == 0 { + s.addCheck(r, t) + } + if len(s.tables) > 0 && r.Intn(2) == 0 { + if r.Intn(4) == 0 { + s.addCompositeFK(r, t, pick(r, s.tables)) + } else { + s.addFK(r, t, pick(r, s.tables)) + } + } + if r.Intn(4) == 0 { + t.comment = "about " + t.name + } + if id.auto && r.Intn(3) == 0 { + t.autoInc = 100 + r.Intn(900) + } + if r.Intn(5) == 0 { + t.charset, t.collation = "utf8mb4", "utf8mb4_bin" + } + if r.Intn(5) == 0 { + t.rowFormat = pick(r, []string{"DYNAMIC", "COMPRESSED"}) + } + return t +} + +func (s *pSchema) addKey(r *rand.Rand, t *pTable) bool { + unique := r.Intn(2) == 0 + var cands []string + for _, c := range t.cols { + // (a column a mutation added holds one default in every row: no unique key over it; + // a text column takes a prefix length below, a json column no key at all; a point + // column takes only a SPATIAL index, addSpatialKey's job, never this plain kind) + // (an ON UPDATE timestamp is one value in every row a backfill touches: no unique key) + if c.typ != "json" && c.typ != "point" && !c.pk && (c.gen == "" || c.stored) && !(unique && (c.typ == "enum" || c.fresh || c.onUpdate)) { + cands = append(cands, c.name) + } + } + if len(cands) == 0 { + return false + } + r.Shuffle(len(cands), func(i, j int) { cands[i], cands[j] = cands[j], cands[i] }) + n := 1 + if len(cands) > 1 && r.Intn(2) == 0 { + n = 2 + } + k := &pKey{name: s.next("k"), unique: unique, cols: cands[:n], prefix: make([]int, n), desc: make([]bool, n)} + for i, name := range k.cols { + c := t.col(name) + if c.typ == "text" || (strings.HasPrefix(c.typ, "varchar") && r.Intn(3) == 0) { + k.prefix[i] = 10 + } + if r.Intn(4) == 0 { + k.desc[i] = true + } + } + if r.Intn(6) == 0 { + k.invisible = true + } + t.keys = append(t.keys, k) + return true +} + +// addFunctionalKey gives t an index over an expression, not a plain column: `(col + 1)` for +// a numeric source or `(lower(col))` for a text one (MySQL implements a functional key part +// as a hidden generated column reading the real one -- cols still names it, so every +// mutation that keeps a key's columns in step by name, rename included, already does). +func (s *pSchema) addFunctionalKey(r *rand.Rand, t *pTable) bool { + type cand struct { + name, kind string + } + var cands []cand + for _, c := range t.numericCols() { + cands = append(cands, cand{c.name, "plus1"}) + } + for _, c := range t.cols { + if strings.HasPrefix(c.typ, "varchar") && c.gen == "" && !t.inFK(c.name) { + cands = append(cands, cand{c.name, "lower"}) + } + } + if len(cands) == 0 { + return false + } + picked := pick(r, cands) + k := &pKey{name: s.next("k"), cols: []string{picked.name}, prefix: []int{0}, expr: []string{picked.kind}, desc: []bool{r.Intn(3) == 0}} + t.keys = append(t.keys, k) + return true +} + +// addFulltextKey gives t a FULLTEXT index over one of its text / varchar columns. +func (s *pSchema) addFulltextKey(r *rand.Rand, t *pTable) bool { + var cands []string + for _, c := range t.cols { + if (c.typ == "text" || strings.HasPrefix(c.typ, "varchar")) && (c.gen == "" || c.stored) { + cands = append(cands, c.name) + } + } + if len(cands) == 0 { + return false + } + name := pick(r, cands) + t.keys = append(t.keys, &pKey{name: s.next("k"), special: "FULLTEXT", cols: []string{name}, prefix: []int{0}}) + return true +} + +// addSpatialKey gives t a SPATIAL index over one of its point columns (always NOT NULL -- +// see newCol -- which a SPATIAL index requires). +func (s *pSchema) addSpatialKey(r *rand.Rand, t *pTable) bool { + var cands []string + for _, c := range t.cols { + if c.typ == "point" { + cands = append(cands, c.name) + } + } + if len(cands) == 0 { + return false + } + name := pick(r, cands) + t.keys = append(t.keys, &pKey{name: s.next("k"), special: "SPATIAL", cols: []string{name}, prefix: []int{0}}) + return true +} + +func (s *pSchema) addCheck(r *rand.Rand, t *pTable) bool { + var cands []string + for _, c := range t.cols { + if !c.auto && !c.pk && !t.inFK(c.name) && (isNumeric(c.typ) || strings.HasPrefix(c.typ, "varchar")) { + cands = append(cands, c.name) + } + } + if len(cands) == 0 { + return false + } + t.checks = append(t.checks, &pCheck{name: s.next("ck"), col: pick(r, cands), enforced: true}) + return true +} + +// addFK gives t a new column referencing parent's primary key. +func (s *pSchema) addFK(r *rand.Rand, t, parent *pTable) bool { + if parent == t { + return false + } + ppk := parent.pk() + c := &pCol{name: s.next("r"), typ: ppk.typ, notNull: r.Intn(2) == 0} + t.cols = append(t.cols, c) + fk := &pFK{name: s.next("fk"), cols: []string{c.name}, refTable: parent.name, refCols: []string{ppk.name}, + onDelete: pick(r, []string{"", "CASCADE", "SET NULL", "RESTRICT", "NO ACTION"}), + onUpdate: pick(r, []string{"", "CASCADE", "SET NULL", "RESTRICT", "NO ACTION"})} + t.fks = append(t.fks, fk) + // a column an action may SET NULL cannot be declared NOT NULL + if fk.onDelete == "SET NULL" || fk.onUpdate == "SET NULL" { + c.notNull = false + } + return true +} + +// addCompositeFK gives t two new columns referencing a two-column UNIQUE key that parent +// gains over two new NOT NULL integer columns of its own (rows carry the row number in +// every integer column, so every (i, i) pair exists in the parent). +func (s *pSchema) addCompositeFK(r *rand.Rand, t, parent *pTable) bool { + if parent == t { + return false + } + a := &pCol{name: s.next("a"), typ: "int", notNull: true, def: "0"} + b := &pCol{name: s.next("b"), typ: "int", notNull: true, def: "0"} + parent.cols = append(parent.cols, a, b) + if s.mutating { + // the parent holds rows: the new key columns need distinct values (the key's) + a.fresh, b.fresh = true, true + pk := parent.pk().name + s.intents = append(s.intents, fmt.Sprintf("-- @migrate backfill %s.%s = %s", parent.name, a.name, q(pk)), + fmt.Sprintf("-- @migrate backfill %s.%s = %s", parent.name, b.name, q(pk))) + } + parent.keys = append(parent.keys, &pKey{name: s.next("k"), unique: true, cols: []string{a.name, b.name}}) + ra := &pCol{name: s.next("r"), typ: "int", fresh: s.mutating} + rb := &pCol{name: s.next("r"), typ: "int", fresh: s.mutating} + t.cols = append(t.cols, ra, rb) + t.fks = append(t.fks, &pFK{name: s.next("fk"), cols: []string{ra.name, rb.name}, refTable: parent.name, refCols: []string{a.name, b.name}, + onDelete: pick(r, []string{"", "CASCADE", "SET NULL"}), onUpdate: pick(r, []string{"", "CASCADE", "RESTRICT"})}) + return true +} + +func (s *pSchema) addView(r *rand.Rand) bool { + t := pick(r, s.tables) + var cols []string + for _, c := range t.cols { + if r.Intn(2) == 0 { + cols = append(cols, c.name) + } + } + if len(cols) == 0 { + cols = []string{t.cols[0].name} + } + s.views = append(s.views, &pView{name: s.next("v"), table: t.name, cols: cols}) + return true +} + +func (s *pSchema) addTrigger(r *rand.Rand) bool { + t := pick(r, s.tables) + var src []*pCol + for _, c := range t.numericCols() { + // the body adds to the column: not a reference, not a key, not a referenced column + if !t.inFK(c.name) && !c.pk && len(referencedBy(s, t, c.name)) == 0 { + src = append(src, c) + } + } + if len(src) == 0 { + return false + } + s.triggers = append(s.triggers, &pTrigger{name: s.next("tr"), table: t.name, col: pick(r, src).name, n: 1 + r.Intn(9)}) + return true +} + +func generate(r *rand.Rand) *pSchema { + s := &pSchema{} + n := 1 + r.Intn(3) + for i := 0; i < n; i++ { + s.tables = append(s.tables, s.newTable(r)) + } + if r.Intn(2) == 0 { + s.addView(r) + } + if r.Intn(3) == 0 { + s.addTrigger(r) + } + if r.Intn(2) == 0 { + s.funcs = append(s.funcs, &pFunc{name: s.next("f"), n: 1 + r.Intn(9)}) + } + if r.Intn(2) == 0 { + s.procs = append(s.procs, &pProc{name: s.next("p"), n: 1 + r.Intn(9)}) + } + if r.Intn(3) == 0 { + s.events = append(s.events, s.newEvent(r)) + } + return s +} + +// newEvent gives about half its events some of the optional schedule / option vocabulary, +// so the source side already carries some of it for a mutation to toggle off, not only on. +func (s *pSchema) newEvent(r *rand.Rand) *pEvent { + e := &pEvent{name: s.next("ev"), n: 1 + r.Intn(9)} + if r.Intn(2) == 0 { + e.starts, e.ends = "2099-01-01 00:00:00", "2099-06-01 00:00:00" + } + if r.Intn(3) == 0 { + e.completion = "PRESERVE" + } + if r.Intn(2) == 0 { + e.status = "DISABLE" + } + if r.Intn(3) == 0 { + e.comment = "about " + e.name + } + return e +} diff --git a/check/mysql/migrate/probe_model_test.go b/check/mysql/migrate/probe_model_test.go new file mode 100644 index 0000000..87c1b6c --- /dev/null +++ b/check/mysql/migrate/probe_model_test.go @@ -0,0 +1,692 @@ +package migrate + +import ( + "fmt" + "strings" +) + +// ---- the model: a schema the generator can render and mutate -------------------------- + +// ---- the model: a schema the generator can render and mutate -------------------------- + +type pSchema struct { + tables []*pTable + views []*pView + triggers []*pTrigger + funcs []*pFunc + procs []*pProc + events []*pEvent + intents []string // `-- @migrate` lines the mutations declared (target side only) + seq int // name counter + mutating bool // set on a clone: the tables that exist are the source's, and hold rows +} + +type pTable struct { + name string + orig string // the name the source schema knows the table by (a declaration's left side) + cols []*pCol + keys []*pKey + fks []*pFK + checks []*pCheck + comment string + autoInc int // AUTO_INCREMENT=, 0 for none + // charset / collation: "" for the server's own default (utf8mb4 / utf8mb4_0900_ai_ci, + // not written); rowFormat: "" for none, else DYNAMIC / COMPRESSED. + charset, collation, rowFormat string + // engine: "" for InnoDB (the default everywhere else in this generator), "MyISAM" + // for the one mutation that exercises "~ table engine" (diff.Alphabet) -- guarded to + // tables with no foreign key on either side, since MyISAM does not support them. + engine string + // partition: nil for an unpartitioned table. Every partition mutation guards its + // candidates to a table with no foreign key on either side and no UNIQUE key besides + // its own PRIMARY KEY (every UNIQUE key, PRIMARY included, must carry the partitioning + // column -- Error 1503, measured -- and this generator always partitions by the id + // column, so a plain UNIQUE elsewhere would refuse it). + partition *pPartitioning +} + +// pPartitioning is a table's PARTITION BY clause: RANGE (id) [COLUMNS], an ordered list of +// parts, [LINEAR] HASH (id) PARTITIONS n, [LINEAR] KEY (id) [ALGORITHM=n] PARTITIONS n, or +// LIST (id), an unordered list of parts (each own value set, not a boundary). Always over +// the id column (see pTable.partition's own comment). columns is RANGE's own COLUMNS +// variant -- a single-column RANGE COLUMNS (id) spells VALUES LESS THAN the same way a +// plain RANGE (id) does (measured), so this reuses parts/render as they stand, only the +// clause's own header differs; sub is the table's own SUBPARTITION BY, RANGE only (this +// generator never gives HASH/KEY or LIST one). +type pPartitioning struct { + kind string // "RANGE", "HASH", "KEY" or "LIST" + linear bool // LINEAR HASH / LINEAR KEY + columns bool // RANGE COLUMNS + algorithm int // KEY's own ALGORITHM=1|2, 0 for none written + num int // HASH/KEY's PARTITIONS n + parts []pPart // RANGE's ordered partitions, or LIST's own (order is cosmetic there) + sub *pSubPartitioning +} + +// pSubPartitioning is a RANGE Partitioning's own SUBPARTITION BY clause: every partition +// split further by HASH, to the same count every time (this generator never names a single +// subpartition -- see schema.SubPartitioning's own doc comment for why the loader does not +// model that either). +type pSubPartitioning struct { + num int // SUBPARTITIONS n +} + +// pPart is one partition of a RANGE pPartitioning (`VALUES LESS THAN (bound)`, or +// `VALUES LESS THAN MAXVALUE` when maxValue -- only the last partition may say this) or of a +// LIST pPartitioning (`VALUES IN (values...)`, always over the id column's own domain -- +// rows 1..3, or a value this schema's rows never take, so a mutation adding or moving one +// never needs a declaration of its own the way dropping one still does). +type pPart struct { + name string + maxValue bool + bound int + values []int // LIST's own VALUES IN, nil for RANGE +} + +type pCol struct { + name string + typ string // as rendered: int, bigint unsigned, varchar(50), ... + labels []string + notNull bool + def string // default expression text, "" for none + pk bool + auto bool + gen string // the column this generated column reads ("" for a plain column) + stored bool + // collate: a varchar / text column declared COLLATE utf8mb4_bin; invisible: INVISIBLE; + // onUpdate: a datetime(6) column with ON UPDATE CURRENT_TIMESTAMP(6) + collate bool + invisible bool + onUpdate bool + comment string // COMMENT '...', "" for none + fresh bool // added by a mutation: not in the source, so its drop needs no declaration +} + +type pKey struct { + name string + unique bool + cols []string + prefix []int // one per column: a prefix length, 0 for the whole column + // special is "" for a plain (or unique) KEY, "FULLTEXT" or "SPATIAL" for one of those + // kinds; unique is meaningless when special is set (neither takes UNIQUE). + special string + desc []bool // one per column: DESC ordering + expr []string // one per column: "" for the plain column, "plus1" / "lower" for a + // functional part over it (keyCols renders the expression; cols still holds the real + // column it reads, for the bookkeeping every other mutation already does by name) + invisible bool +} + +type pFK struct { + name string + cols []string + refTable string + refCols []string + onDelete string + onUpdate string +} + +type pCheck struct { + name string + col string + // op is the comparison ("" defaults to ">"); enforced false renders NOT ENFORCED. + op string + enforced bool +} + +type pView struct { + name string + table string + cols []string +} + +type pTrigger struct { + name string + table string + col string // the column its body reads and sets (NEW.col) + n int // the constant the body adds (a body change is a different n) +} + +type pFunc struct { + name string + n int +} + +// pProc is a CREATE PROCEDURE, the same shape as pFunc (a single SELECT body a mutation +// changes by n) but its own Kind ("procedure", never "function") in diff.Compare. +type pProc struct { + name string + n int +} + +// pEvent is a CREATE EVENT with a schedule of n hours (or, at.Set, a one-time AT) and a +// body that sets a user variable. starts / ends / at hold a literal datetime text (so the +// server stores it as written, AtLiteral / StartsLiteral / EndsLiteral true -- see +// schema.Event's own doc comment for why that is what diff.Compare ever looks at), "" for +// none; completion is "" (the default, NOT PRESERVE) or "PRESERVE"; status is "" (the +// default, ENABLE) or "DISABLE". +type pEvent struct { + name string + n int + at, starts, ends string + completion, status string + comment string + // fresh: added by a mutation within the same recipe, not the source -- a step that + // only sets one of the fields above on it produces a whole "+ event", never the + // per-field "~ event ..." the toggle mutations below exist to exercise, so they skip it. + fresh bool +} + +func (s *pSchema) next(prefix string) string { + s.seq++ + return fmt.Sprintf("%s%d", prefix, s.seq) +} + +func (s *pSchema) clone() *pSchema { + c := &pSchema{seq: s.seq} + for _, t := range s.tables { + nt := &pTable{name: t.name, orig: t.orig, comment: t.comment, autoInc: t.autoInc, + charset: t.charset, collation: t.collation, rowFormat: t.rowFormat, engine: t.engine} + if t.partition != nil { + np := *t.partition + np.parts = make([]pPart, len(t.partition.parts)) + for i, part := range t.partition.parts { + np.parts[i] = part + np.parts[i].values = append([]int(nil), part.values...) + } + if t.partition.sub != nil { + nsub := *t.partition.sub + np.sub = &nsub + } + nt.partition = &np + } + for _, col := range t.cols { + nc := *col + nc.labels = append([]string(nil), col.labels...) + nt.cols = append(nt.cols, &nc) + } + for _, k := range t.keys { + nk := *k + nk.cols = append([]string(nil), k.cols...) + nk.prefix = append([]int(nil), k.prefix...) + nk.desc = append([]bool(nil), k.desc...) + nk.expr = append([]string(nil), k.expr...) + nt.keys = append(nt.keys, &nk) + } + for _, fk := range t.fks { + nfk := *fk + nfk.cols = append([]string(nil), fk.cols...) + nfk.refCols = append([]string(nil), fk.refCols...) + nt.fks = append(nt.fks, &nfk) + } + for _, ck := range t.checks { + nck := *ck + nt.checks = append(nt.checks, &nck) + } + c.tables = append(c.tables, nt) + } + for _, v := range s.views { + nv := *v + nv.cols = append([]string(nil), v.cols...) + c.views = append(c.views, &nv) + } + for _, tr := range s.triggers { + ntr := *tr + c.triggers = append(c.triggers, &ntr) + } + for _, f := range s.funcs { + nf := *f + c.funcs = append(c.funcs, &nf) + } + for _, p := range s.procs { + np := *p + c.procs = append(c.procs, &np) + } + for _, e := range s.events { + ne := *e + c.events = append(c.events, &ne) + } + return c +} + +func (s *pSchema) table(name string) *pTable { + for _, t := range s.tables { + if t.name == name { + return t + } + } + return nil +} + +func (t *pTable) col(name string) *pCol { + for _, c := range t.cols { + if c.name == name { + return c + } + } + return nil +} + +// inFK: a CHECK may not read a column a foreign key's referential action writes (Error 3823). +func (t *pTable) inFK(col string) bool { + for _, fk := range t.fks { + if indexOf(fk.cols, col) >= 0 { + return true + } + } + return false +} + +func (t *pTable) pk() *pCol { + for _, c := range t.cols { + if c.pk { + return c + } + } + return nil +} + +// ---- the type vocabulary -------------------------------------------------------------- + +var probeTypes = []string{"int", "bigint unsigned", "varchar(50)", "decimal(10,2)", "datetime(6)", "date", "text", "enum", "json", "tinyint(1)", "smallint", "double", "point"} + +func isNumeric(typ string) bool { + switch { + case strings.HasPrefix(typ, "int"), strings.HasPrefix(typ, "bigint"), strings.HasPrefix(typ, "decimal"), + strings.HasPrefix(typ, "tinyint"), strings.HasPrefix(typ, "smallint"), typ == "double": + return true + } + return false +} + +func isInteger(typ string) bool { + return isNumeric(typ) && !strings.HasPrefix(typ, "decimal") && typ != "double" +} + +// keyable: a type a plain (non-SPATIAL) index takes without a prefix length. json takes no +// index at all; point takes only a SPATIAL index (addSpatialKey), never a plain one. +func keyable(typ string) bool { return typ != "text" && typ != "json" && typ != "point" } + +func defaultFor(c *pCol) string { + switch { + case isInteger(c.typ): + return "'1'" + case strings.HasPrefix(c.typ, "decimal"): + return "'1.00'" + case c.typ == "double": + return "'1'" + case strings.HasPrefix(c.typ, "varchar"): + return "'x'" + case c.typ == "datetime(6)": + return "CURRENT_TIMESTAMP(6)" + case c.typ == "date": + return "'2020-01-01'" + case c.typ == "enum": + return "'" + c.labels[0] + "'" + case c.typ == "point": + // unlike the other types here, point has no implicit "zero value" MySQL can fill + // existing rows with when a fresh NOT NULL point column is added (Error 1138 + // "Invalid use of NULL value", measured): the others (including text / json, which + // take no literal default either) all do, so only point needs an explicit one, an + // expression default (MySQL 8.0.13+, DEFAULT (expr)) since it takes no literal. + return "(ST_GeomFromText('POINT(0 0)', 4326))" + } + return "" // text / json take no literal default +} + +func widen(typ string) string { + switch typ { + case "int": + return "bigint" + case "smallint", "tinyint(1)": + return "int" + case "varchar(50)": + return "varchar(100)" + case "decimal(10,2)": + return "decimal(14,2)" + case "bigint unsigned": + return "decimal(20,0)" + } + return "" +} + +// ---- rendering ------------------------------------------------------------------------ + +func (c *pCol) typeText() string { + if c.typ == "enum" { + quoted := make([]string, len(c.labels)) + for i, l := range c.labels { + quoted[i] = "'" + l + "'" + } + return "enum(" + strings.Join(quoted, ",") + ")" + } + return c.typ +} + +func (c *pCol) render() string { + var b strings.Builder + b.WriteString(q(c.name) + " " + c.typeText()) + if c.typ == "point" { + // SRID is part of the spatial type's own clause, between the type and NOT NULL. + b.WriteString(" SRID 4326") + } + if c.collate { + b.WriteString(" COLLATE utf8mb4_bin") + } + if c.gen != "" { + b.WriteString(" GENERATED ALWAYS AS ((" + q(c.gen) + " + 1))") + if c.stored { + b.WriteString(" STORED") + } else { + b.WriteString(" VIRTUAL") + } + if c.comment != "" { + b.WriteString(" COMMENT " + lit(c.comment)) + } + return b.String() + } + if c.notNull { + b.WriteString(" NOT NULL") + } + if c.auto { + b.WriteString(" AUTO_INCREMENT") + } else if c.def != "" { + b.WriteString(" DEFAULT " + c.def) + } + if c.onUpdate { + b.WriteString(" ON UPDATE CURRENT_TIMESTAMP(6)") + } + if c.comment != "" { + b.WriteString(" COMMENT " + lit(c.comment)) + } + if c.invisible { + b.WriteString(" INVISIBLE") + } + return b.String() +} + +// keyCols renders a key's column list with its prefix lengths. +func (k *pKey) keyCols() string { + parts := make([]string, len(k.cols)) + for i, c := range k.cols { + kind := "" + if i < len(k.expr) { + kind = k.expr[i] + } + switch kind { + case "plus1": + parts[i] = "(" + q(c) + " + 1)" + case "lower": + parts[i] = "(lower(" + q(c) + "))" + default: + parts[i] = q(c) + if i < len(k.prefix) && k.prefix[i] > 0 { + parts[i] += fmt.Sprintf("(%d)", k.prefix[i]) + } + } + if i < len(k.desc) && k.desc[i] { + parts[i] += " DESC" + } + } + return strings.Join(parts, ",") +} + +func (t *pTable) render() string { + var parts []string + for _, c := range t.cols { + parts = append(parts, " "+c.render()) + } + if pk := t.pk(); pk != nil { + parts = append(parts, " PRIMARY KEY ("+q(pk.name)+")") + } + for _, k := range t.keys { + kind := "KEY" + switch { + case k.special == "FULLTEXT": + kind = "FULLTEXT KEY" + case k.special == "SPATIAL": + kind = "SPATIAL KEY" + case k.unique: + kind = "UNIQUE KEY" + } + s := " " + kind + " " + q(k.name) + " (" + k.keyCols() + ")" + if k.invisible { + s += " INVISIBLE" + } + parts = append(parts, s) + } + for _, fk := range t.fks { + s := " CONSTRAINT " + q(fk.name) + " FOREIGN KEY (" + qlist(fk.cols) + ") REFERENCES " + q(fk.refTable) + " (" + qlist(fk.refCols) + ")" + if fk.onDelete != "" { + s += " ON DELETE " + fk.onDelete + } + if fk.onUpdate != "" { + s += " ON UPDATE " + fk.onUpdate + } + parts = append(parts, s) + } + for _, ck := range t.checks { + c := t.col(ck.col) + op := ck.op + if op == "" { + op = ">" + } + expr := "(" + q(ck.col) + " " + op + " 0)" + if !isNumeric(c.typ) { + expr = "(char_length(" + q(ck.col) + ") " + op + " 0)" + } + s := " CONSTRAINT " + q(ck.name) + " CHECK " + expr + if !ck.enforced { + s += " NOT ENFORCED" + } + parts = append(parts, s) + } + var b strings.Builder + eng := t.engine + if eng == "" { + eng = "InnoDB" + } + b.WriteString("CREATE TABLE " + q(t.name) + " (\n" + strings.Join(parts, ",\n") + "\n) ENGINE=" + eng) + if t.autoInc > 0 { + fmt.Fprintf(&b, " AUTO_INCREMENT=%d", t.autoInc) + } + if t.charset != "" { + b.WriteString(" DEFAULT CHARSET=" + t.charset) + } + if t.collation != "" { + b.WriteString(" COLLATE=" + t.collation) + } + if t.rowFormat != "" { + b.WriteString(" ROW_FORMAT=" + t.rowFormat) + } + if t.comment != "" { + b.WriteString(" COMMENT=" + lit(t.comment)) + } + if t.partition != nil { + b.WriteString("\n" + t.partition.render(t.pk().name)) + } + b.WriteString(";\n") + return b.String() +} + +// render spells p's clause, over col (always the table's id column -- see pTable.partition +// and pPartitioning's own doc comments for why). +func (p *pPartitioning) render(col string) string { + linear := "" + if p.linear { + linear = "LINEAR " + } + if p.kind == "KEY" { + algo := "" + if p.algorithm != 0 { + algo = fmt.Sprintf("ALGORITHM=%d ", p.algorithm) + } + return fmt.Sprintf("PARTITION BY %sKEY %s(%s)\nPARTITIONS %d", linear, algo, q(col), p.num) + } + if p.kind == "HASH" { + return fmt.Sprintf("PARTITION BY %sHASH (%s)\nPARTITIONS %d", linear, q(col), p.num) + } + if p.kind == "LIST" { + defs := make([]string, len(p.parts)) + for i, part := range p.parts { + defs[i] = fmt.Sprintf("PARTITION %s VALUES IN (%s)", q(part.name), intList(part.values)) + } + return fmt.Sprintf("PARTITION BY LIST (%s)\n(%s)", q(col), strings.Join(defs, ",\n ")) + } + columns := "" + if p.columns { + columns = "COLUMNS " + } + defs := make([]string, len(p.parts)) + for i, part := range p.parts { + if part.maxValue { + defs[i] = "PARTITION " + q(part.name) + " VALUES LESS THAN MAXVALUE" + } else { + defs[i] = fmt.Sprintf("PARTITION %s VALUES LESS THAN (%d)", q(part.name), part.bound) + } + } + var sub strings.Builder + if p.sub != nil { + fmt.Fprintf(&sub, "SUBPARTITION BY HASH (%s)\nSUBPARTITIONS %d\n", q(col), p.sub.num) + } + return fmt.Sprintf("PARTITION BY RANGE %s(%s)\n%s(%s)", columns, q(col), sub.String(), strings.Join(defs, ",\n ")) +} + +// intList renders a LIST partition's own value set, comma-separated. +func intList(values []int) string { + strs := make([]string, len(values)) + for i, v := range values { + strs[i] = fmt.Sprint(v) + } + return strings.Join(strs, ", ") +} + +func (s *pSchema) render() string { + var b strings.Builder + b.WriteString("-- sqlshape: mysql 8.4\n") + for _, in := range s.intents { + b.WriteString(in + "\n") + } + for _, t := range s.tables { + b.WriteString(t.render()) + } + for _, v := range s.views { + b.WriteString("CREATE VIEW " + q(v.name) + " AS SELECT " + qlist(v.cols) + " FROM " + q(v.table) + ";\n") + } + for _, f := range s.funcs { + fmt.Fprintf(&b, "CREATE FUNCTION %s(a INT) RETURNS INT DETERMINISTIC RETURN a + %d;\n", q(f.name), f.n) + } + for _, p := range s.procs { + fmt.Fprintf(&b, "CREATE PROCEDURE %s(IN a INT) BEGIN SELECT a + %d; END;\n", q(p.name), p.n) + } + for _, tr := range s.triggers { + fmt.Fprintf(&b, "CREATE TRIGGER %s BEFORE INSERT ON %s FOR EACH ROW SET NEW.%s = COALESCE(NEW.%s, 0) + %d;\n", + q(tr.name), q(tr.table), q(tr.col), q(tr.col), tr.n) + } + for _, e := range s.events { + b.WriteString(e.render()) + } + return b.String() +} + +// render spells e's CREATE EVENT: a one-time AT schedule if e.at is set, else a recurring +// EVERY with an optional STARTS / ENDS, then the options every mutation here can toggle. +func (e *pEvent) render() string { + var sched string + if e.at != "" { + sched = "AT '" + e.at + "'" + } else { + sched = fmt.Sprintf("EVERY %d HOUR", e.n) + if e.starts != "" { + sched += " STARTS '" + e.starts + "'" + } + if e.ends != "" { + sched += " ENDS '" + e.ends + "'" + } + } + var opts strings.Builder + if e.completion != "" { + opts.WriteString(" ON COMPLETION " + e.completion) + } + if e.status != "" { + opts.WriteString(" " + e.status) + } + if e.comment != "" { + opts.WriteString(" COMMENT " + lit(e.comment)) + } + return fmt.Sprintf("CREATE EVENT %s ON SCHEDULE %s%s DO SET @probe = %d;\n", q(e.name), sched, opts.String(), e.n) +} + +// rows renders three rows per table, parents first (the tables are declared in that order): +// integer columns carry the row number (distinct, positive, a valid reference to a parent's +// id), a nullable column is NULL in the third row, an AUTO_INCREMENT id is set explicitly so +// children can reference it, generated columns are left to the server. +func (s *pSchema) rows() string { + var b strings.Builder + for _, t := range s.tables { + var names []string + var cols []*pCol + for _, c := range t.cols { + if c.gen == "" { + names = append(names, q(c.name)) + cols = append(cols, c) + } + } + var rows []string + for i := 1; i <= 3; i++ { + var vals []string + for _, c := range cols { + if i == 3 && !c.notNull && !c.pk { + vals = append(vals, "NULL") + continue + } + vals = append(vals, value(c, i)) + } + rows = append(rows, "("+strings.Join(vals, ", ")+")") + } + fmt.Fprintf(&b, "INSERT INTO %s (%s) VALUES %s;\n", q(t.name), strings.Join(names, ", "), strings.Join(rows, ", ")) + } + return b.String() +} + +// value is row i's value for column c. +func value(c *pCol, i int) string { + switch { + case isInteger(c.typ), c.typ == "double", strings.HasPrefix(c.typ, "decimal"): + return fmt.Sprint(i) + case strings.HasPrefix(c.typ, "varchar"), c.typ == "text": + return fmt.Sprintf("'v%d'", i) + case c.typ == "datetime(6)": + return fmt.Sprintf("'2024-01-%02d 00:00:00'", i) + case c.typ == "date": + return fmt.Sprintf("'2024-01-%02d'", i) + case c.typ == "enum": + return "'" + c.labels[(i-1)%len(c.labels)] + "'" + case c.typ == "json": + return fmt.Sprintf(`'{"i": %d}'`, i) + case c.typ == "point": + return fmt.Sprintf("ST_GeomFromText('POINT(%d %d)', 4326)", i, i) + } + return "NULL" +} + +// fill is the literal a backfill gives a column that turns NOT NULL under existing rows. +// (point is always NOT NULL from the start -- see newCol -- so this case is never reached +// by a mutation today; it is here so fill stays total over the type vocabulary.) +func fill(c *pCol) string { + switch { + case c.typ == "point": + return "ST_GeomFromText('POINT(9 9)', 4326)" + case isNumeric(c.typ): + return "9" + case c.typ == "enum": + return "'" + c.labels[0] + "'" + case c.typ == "json": + return "'{}'" + case c.typ == "datetime(6)": + return "'2024-02-01 00:00:00'" + case c.typ == "date": + return "'2024-02-01'" + } + return "'fill'" +} diff --git a/check/mysql/migrate/probe_mutations_test.go b/check/mysql/migrate/probe_mutations_test.go new file mode 100644 index 0000000..fa128b3 --- /dev/null +++ b/check/mysql/migrate/probe_mutations_test.go @@ -0,0 +1,1598 @@ +package migrate + +import ( + "fmt" + "math/rand" + "strings" +) + +// ---- mutations ------------------------------------------------------------------------ + +// ---- mutations ------------------------------------------------------------------------ + +// A mutation edits the schema toward the target and declares what the diff cannot see; it +// reports false when the schema offers nothing it applies to. touched is the set of names +// ("t", "t.c") a mutation already moved: a second mutation keeps off them so every pair's +// declarations describe one step per object (a column renamed and then dropped is a +// declaration the planner is right to refuse, not a probe of its DDL). +type mutation struct { + name string + apply func(r *rand.Rand, s *pSchema, touched map[string]bool) bool +} + +func (t *pTable) plainCols(touched map[string]bool) []*pCol { + var out []*pCol + for _, c := range t.cols { + if !c.pk && c.gen == "" && !c.fresh && !touched[t.name+"."+c.name] { + out = append(out, c) + } + } + return out +} + +func untouched(s *pSchema, touched map[string]bool) []*pTable { + var out []*pTable + for _, t := range s.tables { + if !touched[t.name] { + out = append(out, t) + } + } + return out +} + +// referencedByAny: the foreign keys (in any table) that reference table t at all, on any +// of its columns. +// nonFreshEvents are s's events that already existed in the source (not one an earlier +// step in the same recipe just added): a step that only sets one of their optional +// fields, on one of those, produces a whole "+ event" (diff.Compare never sees the from +// side at all), not the per-field "~ event ..." the event mutations below exist for. +func nonFreshEvents(s *pSchema) []*pEvent { + var out []*pEvent + for _, e := range s.events { + if !e.fresh { + out = append(out, e) + } + } + return out +} + +func referencedByAny(s *pSchema, t *pTable) []*pFK { + var out []*pFK + for _, other := range s.tables { + for _, fk := range other.fks { + if fk.refTable == t.name { + out = append(out, fk) + } + } + } + return out +} + +// referencedBy: the foreign keys (in any table) that reference table t's column col. +func referencedBy(s *pSchema, t *pTable, col string) []*pFK { + var out []*pFK + for _, other := range s.tables { + for _, fk := range other.fks { + if fk.refTable == t.name && indexOf(fk.refCols, col) >= 0 { + out = append(out, fk) + } + } + } + return out +} + +// detach removes every part of the schema that reads t's column col: keys and checks over +// it, foreign keys from it, generated columns reading it (dropped, declared), views' +// projections of it (the view goes when it projected nothing else), triggers on it. +func detach(s *pSchema, t *pTable, col string) { + var keys []*pKey + for _, k := range t.keys { + if indexOf(k.cols, col) < 0 { + keys = append(keys, k) + } + } + t.keys = keys + var checks []*pCheck + for _, ck := range t.checks { + if ck.col != col { + checks = append(checks, ck) + } + } + t.checks = checks + var fks []*pFK + for _, fk := range t.fks { + if indexOf(fk.cols, col) < 0 { + fks = append(fks, fk) + } + } + t.fks = fks + var cols []*pCol + for _, c := range t.cols { + if c.gen == col { + if !c.fresh { + s.intents = append(s.intents, "-- @migrate drop "+t.orig+"."+c.name) + } + detach(s, t, c.name) + continue + } + cols = append(cols, c) + } + t.cols = cols + var views []*pView + for _, v := range s.views { + if v.table == t.name { + var keep []string + for _, c := range v.cols { + if c != col { + keep = append(keep, c) + } + } + if len(keep) == 0 { + continue + } + v.cols = keep + } + views = append(views, v) + } + s.views = views + var triggers []*pTrigger + for _, tr := range s.triggers { + if tr.table != t.name || tr.col != col { + triggers = append(triggers, tr) + } + } + s.triggers = triggers +} + +// renameRefs spells t's column from as to everywhere it is read. +func renameRefs(s *pSchema, t *pTable, from, to string) { + for _, k := range t.keys { + for i, c := range k.cols { + if c == from { + k.cols[i] = to + } + } + } + for _, ck := range t.checks { + if ck.col == from { + ck.col = to + } + } + for _, fk := range t.fks { + if i := indexOf(fk.cols, from); i >= 0 { + fk.cols[i] = to + } + } + for _, c := range t.cols { + if c.gen == from { + c.gen = to + } + } + for _, fk := range referencedBy(s, t, from) { + fk.refCols[indexOf(fk.refCols, from)] = to + } + for _, v := range s.views { + if v.table == t.name { + for i, c := range v.cols { + if c == from { + v.cols[i] = to + } + } + } + } + for _, tr := range s.triggers { + if tr.table == t.name && tr.col == from { + tr.col = to + } + } +} + +func dropTable(s *pSchema, t *pTable) { + var tables []*pTable + for _, other := range s.tables { + if other == t { + continue + } + var fks []*pFK + for _, fk := range other.fks { + if fk.refTable != t.name { + fks = append(fks, fk) + } + } + other.fks = fks + tables = append(tables, other) + } + s.tables = tables + var views []*pView + for _, v := range s.views { + if v.table != t.name { + views = append(views, v) + } + } + s.views = views + var triggers []*pTrigger + for _, tr := range s.triggers { + if tr.table != t.name { + triggers = append(triggers, tr) + } + } + s.triggers = triggers +} + +var mutations = []mutation{ + {"add column", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + var c *pCol + if src := t.numericCols(); len(src) > 0 && r.Intn(3) == 0 { + c = &pCol{name: s.next("g"), typ: "bigint", gen: pick(r, src).name, stored: r.Intn(2) == 0} + } else { + c = s.newCol(r) + } + if c.typ == "point" && s.mutating { + // t already holds rows (this is a mutation, not a fresh table): unlike every + // other type here, MySQL has no implicit "zero value" for a NOT NULL geometry + // column, and even an expression DEFAULT does not save it (Error 1138, + // "Invalid use of NULL value", measured both ways) -- a point column added + // under rows can only be nullable. + c.notNull, c.def = false, "" + } + c.fresh = true + at := r.Intn(len(t.cols) + 1) + t.cols = append(t.cols[:at], append([]*pCol{c}, t.cols[at:]...)...) + touched[t.name+"."+c.name] = true + return true + }}, + {"add generated column reading a new column", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + x := &pCol{name: s.next("c"), typ: pick(r, []string{"int", "decimal(10,2)", "smallint"})} + g := &pCol{name: s.next("g"), typ: "bigint", gen: x.name, stored: r.Intn(2) == 0} + x.fresh, g.fresh = true, true + // the generated column first or last, before or after its source + at := r.Intn(len(t.cols) + 1) + t.cols = append(t.cols[:at], append([]*pCol{g}, t.cols[at:]...)...) + at = r.Intn(len(t.cols) + 1) + t.cols = append(t.cols[:at], append([]*pCol{x}, t.cols[at:]...)...) + touched[t.name+"."+x.name], touched[t.name+"."+g.name] = true, true + return true + }}, + {"drop column", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pTable + for _, t := range untouched(s, touched) { + if len(t.plainCols(touched)) > 0 { + cands = append(cands, t) + } + } + if len(cands) == 0 { + return false + } + t := pick(r, cands) + c := pick(r, t.plainCols(touched)) + for _, g := range t.cols { + if g.gen == c.name && touched[t.name+"."+g.name] { + return false // the generated column reading it was moved by another step already + } + } + for _, fk := range referencedBy(s, t, c.name) { + owner := s.table(fkOwner(s, fk)) + var fks []*pFK + for _, f := range owner.fks { + if f != fk { + fks = append(fks, f) + } + } + owner.fks = fks + } + s.intents = append(s.intents, "-- @migrate drop "+t.orig+"."+c.name) + detach(s, t, c.name) + t.cols = append(t.cols[:indexOfCol(t, c.name)], t.cols[indexOfCol(t, c.name)+1:]...) + touched[t.name+"."+c.name] = true + return true + }}, + {"drop generated column", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + for _, c := range t.cols { + if c.gen != "" && !touched[t.name+"."+c.name] { + s.intents = append(s.intents, "-- @migrate drop "+t.orig+"."+c.name) + detach(s, t, c.name) + t.cols = append(t.cols[:indexOfCol(t, c.name)], t.cols[indexOfCol(t, c.name)+1:]...) + touched[t.name+"."+c.name] = true + return true + } + } + } + return false + }}, + {"rename column", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + var cands []*pCol + for _, c := range t.cols { + if !touched[t.name+"."+c.name] { + cands = append(cands, c) + } + } + if len(cands) == 0 { + return false + } + c := pick(r, cands) + to := c.name + "_new" + s.intents = append(s.intents, "-- @migrate rename "+t.orig+"."+c.name+" -> "+t.name+"."+to) + // a backfill expression written earlier names the column as the target spells it + for i, in := range s.intents { + if strings.HasPrefix(in, "-- @migrate backfill "+t.name+".") { + s.intents[i] = strings.ReplaceAll(in, " = "+q(c.name), " = "+q(to)) + } + } + renameRefs(s, t, c.name, to) + touched[t.name+"."+c.name], touched[t.name+"."+to] = true, true + c.name = to + return true + }}, + {"widen column", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + var cands []*pCol + for _, c := range t.cols { + if widen(c.typ) != "" && c.gen == "" && !touched[t.name+"."+c.name] { + cands = append(cands, c) + } + } + if len(cands) == 0 { + return false + } + c := pick(r, cands) + w := widen(c.typ) + if c.auto && !isInteger(w) { + return false + } + if t.inFK(c.name) { + return false // a referencing column follows its parent's key (widened below) + } + // a referenced key and every column referencing it widen together + for _, fk := range referencedBy(s, t, c.name) { + child := s.table(fkOwner(s, fk)) + cc := child.col(fk.cols[indexOf(fk.refCols, c.name)]) + if touched[child.name+"."+cc.name] { + return false + } + cc.typ = w + touched[child.name+"."+cc.name] = true + } + c.typ = w + if c.def != "" { + c.def = defaultFor(c) + } + touched[t.name+"."+c.name] = true + return true + }}, + {"move column", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + if len(t.cols) < 2 { + return false + } + i := r.Intn(len(t.cols)) + c := t.cols[i] + if touched[t.name+"."+c.name] { + return false + } + t.cols = append(t.cols[:i], t.cols[i+1:]...) + j := r.Intn(len(t.cols) + 1) + t.cols = append(t.cols[:j], append([]*pCol{c}, t.cols[j:]...)...) + touched[t.name+"."+c.name] = true + return true + }}, + {"change nullability and default", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + var cands []*pCol + for _, c := range t.plainCols(touched) { + // point is always NOT NULL (a SPATIAL index needs it, and it takes no plain + // literal DEFAULT the way defaultFor spells one) + if c.typ != "point" { + cands = append(cands, c) + } + } + if len(cands) == 0 { + return false + } + c := pick(r, cands) + for _, fk := range t.fks { + if indexOf(fk.cols, c.name) >= 0 && fk.onDelete == "SET NULL" { + return false + } + } + c.notNull = !c.notNull + if c.notNull || r.Intn(2) == 0 { + c.def = defaultFor(c) + } else { + c.def = "" + } + if c.notNull { + f := fill(c) + if t.inFK(c.name) { + f = "1" // a parent every table has + } + s.intents = append(s.intents, fmt.Sprintf("-- @migrate backfill %s.%s = %s where %s is null", t.name, c.name, f, q(c.name))) + } + touched[t.name+"."+c.name] = true + return true + }}, + {"add key", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + return s.addKey(r, pick(r, ts)) + }}, + {"drop key", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + if len(t.keys) > 0 { + i := r.Intn(len(t.keys)) + // the index an AUTO_INCREMENT column or a foreign key (this table's, or one + // referencing this table) needs stays: without it the target is not a schema + lead := t.keys[i].cols[0] + if c := t.col(lead); c.auto || len(referencedBy(s, t, lead)) > 0 { + return false + } + for _, fk := range t.fks { + if lead == fk.cols[0] { + return false + } + } + t.keys = append(t.keys[:i], t.keys[i+1:]...) + return true + } + } + return false + }}, + {"add fulltext key", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + return s.addFulltextKey(r, pick(r, ts)) + }}, + {"add spatial key", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + return s.addSpatialKey(r, pick(r, ts)) + }}, + {"add functional key", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + return s.addFunctionalKey(r, pick(r, ts)) + }}, + {"toggle key invisible", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + if len(t.keys) > 0 { + k := pick(r, t.keys) + k.invisible = !k.invisible + return true + } + } + return false + }}, + {"move primary key", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + old := t.pk() + var cands []*pCol + for _, c := range t.cols { + if !c.pk && c.gen == "" && isInteger(c.typ) && !touched[t.name+"."+c.name] { + cands = append(cands, c) + } + } + if len(cands) == 0 || touched[t.name+"."+old.name] { + return false + } + c := pick(r, cands) + if t.inFK(c.name) { + return false + } + if !c.notNull { + s.intents = append(s.intents, fmt.Sprintf("-- @migrate backfill %s.%s = 9 where %s is null", t.name, c.name, q(c.name))) + } + c.pk, c.notNull, c.def = true, true, "" + old.pk = false + // the old key column: an AUTO_INCREMENT column must stay a key, and so must a + // referenced one + if old.auto || len(referencedBy(s, t, old.name)) > 0 { + t.keys = append(t.keys, &pKey{name: s.next("k"), unique: true, cols: []string{old.name}}) + } else if r.Intn(2) == 0 { + old.notNull = false + } + touched[t.name+"."+old.name], touched[t.name+"."+c.name] = true, true + return true + }}, + {"add foreign key", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) < 2 { + return false + } + i, j := r.Intn(len(ts)), r.Intn(len(ts)) + if i == j { + return false + } + if i < j { + i, j = j, i + } + t, parent := ts[i], ts[j] // the parent is created first in the rendered schema + if !s.addFK(r, t, parent) { + return false + } + c := t.cols[len(t.cols)-1] + if c.notNull { + // existing rows need a parent before the column turns NOT NULL + s.intents = append(s.intents, fmt.Sprintf("-- @migrate backfill %s.%s = 1", t.name, c.name)) + } + touched[t.name+"."+c.name] = true + return true + }}, + {"add composite foreign key", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) < 2 { + return false + } + i, j := r.Intn(len(ts)), r.Intn(len(ts)) + if i == j { + return false + } + if i < j { + i, j = j, i + } + t, parent := ts[i], ts[j] + if !s.addCompositeFK(r, t, parent) { + return false + } + for _, c := range parent.cols[len(parent.cols)-2:] { + c.fresh = true + touched[parent.name+"."+c.name] = true + } + for _, c := range t.cols[len(t.cols)-2:] { + c.fresh = true + touched[t.name+"."+c.name] = true + } + return true + }}, + {"drop foreign key", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + if len(t.fks) > 0 { + i := r.Intn(len(t.fks)) + t.fks = append(t.fks[:i], t.fks[i+1:]...) + return true + } + } + return false + }}, + {"change foreign key actions", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + for _, fk := range t.fks { + key := t.name + ".fk:" + fk.name + if touched[key] { + continue + } + fk.onDelete = pick(r, []string{"", "CASCADE", "SET NULL", "RESTRICT", "NO ACTION"}) + fk.onUpdate = pick(r, []string{"", "CASCADE", "SET NULL", "RESTRICT", "NO ACTION"}) + // SET NULL on either action refuses a NOT NULL referencing column (1216-class) + if fk.onDelete == "SET NULL" || fk.onUpdate == "SET NULL" { + for _, name := range fk.cols { + t.col(name).notNull = false + } + } + touched[key] = true + return true + } + } + return false + }}, + {"add check", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + return s.addCheck(r, pick(r, ts)) + }}, + {"drop check", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + if len(t.checks) > 0 { + i := r.Intn(len(t.checks)) + t.checks = append(t.checks[:i], t.checks[i+1:]...) + return true + } + } + return false + }}, + {"change check expression", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + for _, ck := range t.checks { + key := t.name + ".check:" + ck.name + if touched[key] { + continue + } + if ck.op == ">=" { + ck.op = ">" + } else { + ck.op = ">=" + } + touched[key] = true + return true + } + } + return false + }}, + // CheckProps compares expression and enforced together (diff.CheckProps), so the planner + // redoes the constraint as a DROP + ADD rather than ALTER TABLE ... ALTER CHECK ... + // [NOT] ENFORCED; both a real mysqld accepts, and this exercises the DROP + ADD path. + {"toggle check enforced", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + for _, ck := range t.checks { + key := t.name + ".check:" + ck.name + if touched[key] { + continue + } + ck.enforced = !ck.enforced + touched[key] = true + return true + } + } + return false + }}, + {"add table", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + t := s.newTable(r) + s.tables = append(s.tables, t) + touched[t.name] = true + return true + }}, + {"drop table", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) < 2 { + return false + } + t := pick(r, ts) + for k := range touched { + if strings.HasPrefix(k, t.name+".") { + return false // a step already moved one of its columns: one declaration per object + } + } + s.intents = append(s.intents, "-- @migrate drop "+t.orig) + dropTable(s, t) + touched[t.name] = true + return true + }}, + {"rename table", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + to := t.name + "_new" + s.intents = append(s.intents, "-- @migrate rename "+t.orig+" -> "+to) + // declarations written before this one name the table on their right side as it + // will be: the new name + for i, in := range s.intents { + in = strings.ReplaceAll(in, " -> "+t.name+".", " -> "+to+".") + in = strings.ReplaceAll(in, "enum "+t.name+".", "enum "+to+".") + in = strings.ReplaceAll(in, "backfill "+t.name+".", "backfill "+to+".") + s.intents[i] = in + } + for _, other := range s.tables { + for _, fk := range other.fks { + if fk.refTable == t.name { + fk.refTable = to + } + } + } + for _, v := range s.views { + if v.table == t.name { + v.table = to + } + } + for _, tr := range s.triggers { + if tr.table == t.name { + tr.table = to + } + } + touched[t.name], touched[to] = true, true + t.name = to + return true + }}, + {"add enum label", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + for _, c := range t.cols { + if c.typ == "enum" && !touched[t.name+"."+c.name] { + c.labels = append(c.labels, "z") + touched[t.name+"."+c.name] = true + return true + } + } + } + return false + }}, + {"drop enum label", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + for _, c := range t.cols { + if c.typ == "enum" && len(c.labels) > 1 && !touched[t.name+"."+c.name] { + gone := c.labels[len(c.labels)-1] + c.labels = c.labels[:len(c.labels)-1] + s.intents = append(s.intents, fmt.Sprintf("-- @migrate enum %s.%s: drop '%s' using '%s'", t.name, c.name, gone, c.labels[0])) + touched[t.name+"."+c.name] = true + return true + } + } + } + return false + }}, + {"change generated expression", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + for _, c := range t.cols { + if c.gen == "" || touched[t.name+"."+c.name] { + continue + } + for _, src := range t.numericCols() { + if src.name != c.gen && !touched[t.name+"."+src.name] { + c.gen = src.name + touched[t.name+"."+c.name] = true + return true + } + } + } + } + return false + }}, + {"move primary key onto a new column", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + old := t.pk() + if touched[t.name+"."+old.name] { + return false + } + c := &pCol{name: s.next("c"), typ: pick(r, []string{"int", "bigint unsigned"}), notNull: true, pk: true} + at := r.Intn(len(t.cols) + 1) + t.cols = append(t.cols[:at], append([]*pCol{c}, t.cols[at:]...)...) + if !old.auto && r.Intn(2) == 0 { + c.auto = true // the server numbers the existing rows + } else { + // the rows need distinct values before the key goes on: the old key's + s.intents = append(s.intents, fmt.Sprintf("-- @migrate backfill %s.%s = %s", t.name, c.name, q(old.name))) + } + old.pk = false + if old.auto || len(referencedBy(s, t, old.name)) > 0 { + t.keys = append(t.keys, &pKey{name: s.next("k"), unique: true, cols: []string{old.name}}) + } + touched[t.name+"."+old.name], touched[t.name+"."+c.name] = true, true + return true + }}, + {"toggle collation", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + for _, c := range t.cols { + if (c.typ == "text" || strings.HasPrefix(c.typ, "varchar")) && c.gen == "" && !touched[t.name+"."+c.name] { + c.collate = !c.collate + touched[t.name+"."+c.name] = true + return true + } + } + } + return false + }}, + {"toggle invisible", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + for _, c := range t.cols { + if !c.pk && c.gen == "" && !touched[t.name+"."+c.name] { + c.invisible = !c.invisible + touched[t.name+"."+c.name] = true + return true + } + } + } + return false + }}, + {"toggle on update", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + for _, c := range t.cols { + if c.typ == "datetime(6)" && c.gen == "" && !touched[t.name+"."+c.name] { + c.onUpdate = !c.onUpdate + if c.onUpdate && c.def == "" { + c.def = defaultFor(c) + } + touched[t.name+"."+c.name] = true + return true + } + } + } + return false + }}, + {"add event", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + s.events = append(s.events, &pEvent{name: s.next("ev"), n: 1 + r.Intn(9), fresh: true}) + return true + }}, + {"drop event", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + if len(s.events) == 0 { + return false + } + i := r.Intn(len(s.events)) + s.events = append(s.events[:i], s.events[i+1:]...) + return true + }}, + {"change event", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + if len(s.events) == 0 { + return false + } + pick(r, s.events).n += 10 + return true + }}, + {"table comment", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + if t.comment == "" { + t.comment = "now about " + t.name + } else { + t.comment = "" + } + return true + }}, + {"table charset", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // a plain (no explicit per-column COLLATE) text / varchar / enum column implicitly + // takes the table's own default charset at CREATE time; migrate.go's alterTable + // re-issues such a column's own MODIFY COLUMN once the table's own default has + // moved out from under it (see its own doc comment for why: ALTER TABLE ... DEFAULT + // CHARSET= alone never retroactively converts it), so both a table where every + // string column already spells its own COLLATE explicitly (this generator's + // `collate` flag) and one where none do are candidates now. + cands := untouched(s, touched) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + if t.charset == "latin1" { + // back to undeclared (the server default, utf8mb4 family): toggling between + // "" and "utf8mb4"/"utf8mb4_bin" alone never actually changes the charset + // (only the collation -- undeclared still canonicalizes to the utf8mb4 + // family, measured), so this needs a genuinely different charset to ever + // exercise "~ table charset" itself, not only "~ table collation" + t.charset, t.collation = "", "" + } else { + t.charset, t.collation = "latin1", "latin1_swedish_ci" + } + return true + }}, + {"table row format", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + switch t.rowFormat { + case "": + t.rowFormat = "DYNAMIC" + case "DYNAMIC": + t.rowFormat = "COMPRESSED" + default: + t.rowFormat = "" + } + return true + }}, + {"table engine", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pTable + for _, t := range untouched(s, touched) { + // MyISAM takes neither this table's own foreign keys nor another's + // referencing it (Error 1215, "Cannot add foreign key constraint", + // measured); touched below keeps a later step from adding either. + if len(t.fks) > 0 || len(referencedByAny(s, t)) > 0 { + continue + } + // nor a SRID-bound spatial column (Error 1178, "The storage engine for + // the table doesn't support geographic spatial reference systems", + // measured): only InnoDB does. Nor a DESC key part (same Error 1178, + // "...doesn't support descending indexes", measured): MyISAM key parts are + // always ascending. + unfit := false + for _, c := range t.cols { + if c.typ == "point" { + unfit = true + } + } + for _, k := range t.keys { + for _, d := range k.desc { + if d { + unfit = true + } + } + for _, e := range k.expr { + if e != "" { + unfit = true // a functional key part: not risking MyISAM support + } + } + } + if unfit { + continue + } + cands = append(cands, t) + } + if len(cands) == 0 { + return false + } + t := pick(r, cands) + if t.engine == "MyISAM" { + t.engine = "" + } else { + t.engine = "MyISAM" + } + touched[t.name] = true + return true + }}, + {"column comment", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + cands := t.plainCols(touched) + if len(cands) == 0 { + return false + } + c := pick(r, cands) + if c.comment == "" { + c.comment = "now about " + c.name + } else { + c.comment = "" + } + touched[t.name+"."+c.name] = true + return true + }}, + // table auto_increment: a live counter, not data the diff reports -- the design keeps + // dump.normalizeTable from seeing an existing table's AUTO_INCREMENT at all, so a plan + // touching only this is empty and a second Plan is empty too (the server may coerce a + // requested value below the rows' own counter upward; that is not a difference either). + {"table auto_increment", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pTable + for _, t := range untouched(s, touched) { + if pk := t.pk(); pk != nil && pk.auto { + cands = append(cands, t) + } + } + if len(cands) == 0 { + return false + } + t := pick(r, cands) + if t.autoInc > 0 && r.Intn(2) == 0 { + t.autoInc = 0 + } else { + t.autoInc = 100 + r.Intn(900) + } + return true + }}, + {"add view", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { return s.addView(r) }}, + {"drop view", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + if len(s.views) == 0 { + return false + } + i := r.Intn(len(s.views)) + s.views = append(s.views[:i], s.views[i+1:]...) + return true + }}, + {"change view", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + if len(s.views) == 0 { + return false + } + v := pick(r, s.views) + t := s.table(v.table) + for _, c := range t.cols { + if indexOf(v.cols, c.name) < 0 { + v.cols = append(v.cols, c.name) + return true + } + } + return false + }}, + {"add trigger", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { return s.addTrigger(r) }}, + {"drop trigger", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + if len(s.triggers) == 0 { + return false + } + i := r.Intn(len(s.triggers)) + s.triggers = append(s.triggers[:i], s.triggers[i+1:]...) + return true + }}, + {"change trigger body", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + if len(s.triggers) == 0 { + return false + } + tr := pick(r, s.triggers) + tr.n += 10 + return true + }}, + {"add function", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + s.funcs = append(s.funcs, &pFunc{name: s.next("f"), n: 1 + r.Intn(9)}) + return true + }}, + {"drop function", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + if len(s.funcs) == 0 { + return false + } + i := r.Intn(len(s.funcs)) + s.funcs = append(s.funcs[:i], s.funcs[i+1:]...) + return true + }}, + {"change function body", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + if len(s.funcs) == 0 { + return false + } + pick(r, s.funcs).n += 10 + return true + }}, + {"add procedure", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + s.procs = append(s.procs, &pProc{name: s.next("p"), n: 1 + r.Intn(9)}) + return true + }}, + {"drop procedure", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + if len(s.procs) == 0 { + return false + } + i := r.Intn(len(s.procs)) + s.procs = append(s.procs[:i], s.procs[i+1:]...) + return true + }}, + {"change procedure body", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + if len(s.procs) == 0 { + return false + } + pick(r, s.procs).n += 10 + return true + }}, + {"toggle event at", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := nonFreshEvents(s) + if len(cands) == 0 { + return false + } + e := pick(r, cands) + if e.at != "" { + e.at = "" + } else { + e.at, e.starts, e.ends = "2099-03-01 00:00:00", "", "" // AT excludes STARTS/ENDS + } + return true + }}, + // "toggle event bounds" used to cover both directions in one mutation, deterministic on + // each candidate event's own current state; directed coverage only asks that a mutation + // applies at all, not that it lands on a particular direction, so it never guaranteed + // this alphabet gate's own "~ event starts" / "~ event ends" the way it looked like it + // did (measured: adding this package's own new partitioning mutations shifted the random + // pairs' draws enough that neither direction landed at seed 1 anymore). Split in two, so + // each direction is its own candidate set directed coverage (and the random pairs) can + // draw independently. + {"set event bounds", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, e := range nonFreshEvents(s) { + if e.at != "" || e.starts != "" { + continue // STARTS/ENDS belong to the EVERY form only, and only when unset + } + e.starts, e.ends = "2099-01-01 00:00:00", "2099-06-01 00:00:00" + return true + } + return false + }}, + {"clear event bounds", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, e := range nonFreshEvents(s) { + if e.starts == "" { + continue + } + e.starts, e.ends = "", "" + return true + } + return false + }}, + {"toggle event completion", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := nonFreshEvents(s) + if len(cands) == 0 { + return false + } + e := pick(r, cands) + if e.completion == "" { + e.completion = "PRESERVE" + } else { + e.completion = "" + } + // status rides along: this mutation is the only reliable way seed 1's specific + // draws exercise "~ event status" (a dedicated "toggle event status" mutation + // exists but this recipe's random walk never happens to select its own index in + // 200 pairs, measured -- an artifact of a fixed seed, not a real gap). + if e.status == "" { + e.status = "DISABLE" + } else { + e.status = "" + } + return true + }}, + {"toggle event status", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := nonFreshEvents(s) + if len(cands) == 0 { + return false + } + e := pick(r, cands) + if e.status == "" { + e.status = "DISABLE" + } else { + e.status = "" + } + return true + }}, + {"event comment", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := nonFreshEvents(s) + if len(cands) == 0 { + return false + } + e := pick(r, cands) + if e.comment == "" { + e.comment = "now about " + e.name + } else { + e.comment = "" + } + return true + }}, + {"reorder foreign key columns", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + for _, fk := range t.fks { + if len(fk.cols) < 2 || touched[t.name+".fk:"+fk.name] || touched[fk.refTable] { + continue + } + // the referenced side must still match some key on the parent, in the + // same order (Error 6125, "Missing unique key for constraint", + // measured): find the parent's key over exactly these columns, in this + // order, and swap it the same way, so the constraint means the same + // thing throughout, spelled with its column list in the other order + // (ForeignKeyProps' "columns" -- the referencing side, which this + // itself does not need a matching parent key for). + parent := s.table(fk.refTable) + if parent == nil { + continue + } + var pk *pKey + for _, k := range parent.keys { + if len(k.cols) == 2 && k.cols[0] == fk.refCols[0] && k.cols[1] == fk.refCols[1] { + pk = k + break + } + } + if pk == nil { + continue + } + fk.cols[0], fk.cols[1] = fk.cols[1], fk.cols[0] + fk.refCols[0], fk.refCols[1] = fk.refCols[1], fk.refCols[0] + pk.cols[0], pk.cols[1] = pk.cols[1], pk.cols[0] + touched[t.name+".fk:"+fk.name] = true + touched[parent.name] = true + return true + } + } + return false + }}, + {"partition table by range", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := partitionCandidates(s, touched, func(t *pTable) bool { return t.partition == nil }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + // two partitions covering every row (id 1..3): p0 < 2, p1 < 100 -- no MAXVALUE, so + // a later "add partition" mutation can still extend the tail (migrate.go's ADD + // PARTITION path refuses once the last partition is already unbounded). + t.partition = &pPartitioning{kind: "RANGE", parts: []pPart{ + {name: s.next("p"), bound: 2}, + {name: s.next("p"), bound: 100}, + }} + touched[t.name] = true + return true + }}, + {"partition table by hash", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := partitionCandidates(s, touched, func(t *pTable) bool { return t.partition == nil }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + t.partition = &pPartitioning{kind: "HASH", num: 2} + touched[t.name] = true + return true + }}, + {"remove partitioning", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := partitionCandidates(s, touched, func(t *pTable) bool { return t.partition != nil }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + t.partition = nil + touched[t.name] = true + return true + }}, + {"add partition", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := partitionCandidates(s, touched, func(t *pTable) bool { + return t.partition != nil && t.partition.kind == "RANGE" && !t.partition.parts[len(t.partition.parts)-1].maxValue + }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + last := t.partition.parts[len(t.partition.parts)-1] + if r.Intn(2) == 0 { + // close the range off: nothing above it belongs to any lower partition + t.partition.parts = append(t.partition.parts, pPart{name: s.next("p"), maxValue: true}) + } else { + t.partition.parts = append(t.partition.parts, pPart{name: s.next("p"), bound: last.bound + 100}) + } + touched[t.name] = true + return true + }}, + {"drop partition", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // only a partition an earlier "add partition" step in this same recipe appended + // (parts[2:]): the generator's own first two partitions are what every row of a + // freshly partitioned table depends on to have somewhere to go (id 1..3 always + // fits in the second, VALUES LESS THAN (100)); dropping the table's own tail + // beyond that never takes a row down with it. + cands := partitionCandidates(s, touched, func(t *pTable) bool { + return t.partition != nil && t.partition.kind == "RANGE" && len(t.partition.parts) >= 3 + }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + gone := t.partition.parts[len(t.partition.parts)-1] + t.partition.parts = t.partition.parts[:len(t.partition.parts)-1] + s.intents = append(s.intents, fmt.Sprintf("-- @migrate drop partition %s.%s", t.orig, gone.name)) + touched[t.name] = true + return true + }}, + {"reorganize partition boundary", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := partitionCandidates(s, touched, func(t *pTable) bool { + return t.partition != nil && t.partition.kind == "RANGE" && !t.partition.parts[len(t.partition.parts)-1].maxValue + }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + last := &t.partition.parts[len(t.partition.parts)-1] + last.bound += 50 // still above every row's id, and above the partition before it + touched[t.name] = true + return true + }}, + {"reorganize partition insert before maxvalue", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := partitionCandidates(s, touched, func(t *pTable) bool { + return t.partition != nil && t.partition.kind == "RANGE" && t.partition.parts[len(t.partition.parts)-1].maxValue + }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + parts := t.partition.parts + prev := 0 + if len(parts) > 1 { + prev = parts[len(parts)-2].bound + } + mid := pPart{name: s.next("p"), bound: prev + 50} + t.partition.parts = append(parts[:len(parts)-1], mid, parts[len(parts)-1]) + touched[t.name] = true + return true + }}, + {"change hash partition count", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := partitionCandidates(s, touched, func(t *pTable) bool { return t.partition != nil && t.partition.kind == "HASH" }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + if t.partition.num > 1 && r.Intn(2) == 0 { + t.partition.num-- + } else { + t.partition.num++ + } + touched[t.name] = true + return true + }}, + {"partition table by list", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := partitionCandidates(s, touched, func(t *pTable) bool { + // the partitioning key's own domain must still be exactly 1..3 (every row): + // an earlier "move primary key" in this same recipe can give this table's + // current pk() a backfilled value (fill's own "9", measured -- Error 1526 + // "Table has no partition for value 9") that RANGE's own wide bounds absorb + // but LIST's exact VALUES IN enumeration does not, so this generator only + // partitions by an untouched column. + return t.partition == nil && !touched[t.name+"."+t.pk().name] + }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + // every row (id 1..3) split across the two partitions, so a later "add list + // partition" / "move list partition value" step never needs a declaration of its + // own the way dropping one still does. + t.partition = &pPartitioning{kind: "LIST", parts: []pPart{ + {name: s.next("p"), values: []int{1, 2}}, + {name: s.next("p"), values: []int{3}}, + }} + touched[t.name] = true + return true + }}, + {"add list partition", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := partitionCandidates(s, touched, func(t *pTable) bool { return t.partition != nil && t.partition.kind == "LIST" }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + // s.seq is already a name counter unique across the whole schema; borrowing it as + // the value too keeps this partition's own VALUES IN clear of every row's id (1..3) + // and of any other "add list partition" step in the same recipe. + name := s.next("p") + t.partition.parts = append(t.partition.parts, pPart{name: name, values: []int{1000 + s.seq}}) + touched[t.name] = true + return true + }}, + {"drop list partition", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // only a partition an earlier "add list partition" step in this same recipe + // appended (parts[2:]): the generator's own first two partitions are what every row + // depends on to have somewhere to go, the same restriction "drop partition" (RANGE's + // own) already carries. + cands := partitionCandidates(s, touched, func(t *pTable) bool { + return t.partition != nil && t.partition.kind == "LIST" && len(t.partition.parts) >= 3 + }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + gone := t.partition.parts[len(t.partition.parts)-1] + t.partition.parts = t.partition.parts[:len(t.partition.parts)-1] + s.intents = append(s.intents, fmt.Sprintf("-- @migrate drop partition %s.%s", t.orig, gone.name)) + touched[t.name] = true + return true + }}, + {"move list partition value", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // moves one of the generator's own two base partitions' values to the other: both + // keep their name, so migrate.go's alterListPartitioning reorganizes them together, + // and no row's id ever goes missing (it only ever changes which named partition + // holds it). + cands := partitionCandidates(s, touched, func(t *pTable) bool { + return t.partition != nil && t.partition.kind == "LIST" && len(t.partition.parts) >= 2 && + (len(t.partition.parts[0].values) > 0 || len(t.partition.parts[1].values) > 0) + }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + src := 0 + if len(t.partition.parts[0].values) == 0 { + src = 1 + } + dst := 1 - src + v := t.partition.parts[src].values[0] + t.partition.parts[src].values = t.partition.parts[src].values[1:] + t.partition.parts[dst].values = append(t.partition.parts[dst].values, v) + touched[t.name] = true + return true + }}, + {"partition table by key", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := partitionCandidates(s, touched, func(t *pTable) bool { return t.partition == nil }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + t.partition = &pPartitioning{kind: "KEY", num: 2} + touched[t.name] = true + return true + }}, + {"partition table by linear hash", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := partitionCandidates(s, touched, func(t *pTable) bool { return t.partition == nil }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + t.partition = &pPartitioning{kind: "HASH", linear: true, num: 2} + touched[t.name] = true + return true + }}, + {"partition table by range columns", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := partitionCandidates(s, touched, func(t *pTable) bool { return t.partition == nil }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + // the same two-partition RANGE shape "partition table by range" starts from + // (COLUMNS spells a single-column VALUES LESS THAN identically to a plain RANGE, + // measured -- see pPartitioning's own doc comment), so a later "add partition" / + // "drop partition" / "reorganize ..." step still has a candidate to draw. + t.partition = &pPartitioning{kind: "RANGE", columns: true, parts: []pPart{ + {name: s.next("p"), bound: 2}, + {name: s.next("p"), bound: 100}, + }} + touched[t.name] = true + return true + }}, + {"partition table by range with subpartitions", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := partitionCandidates(s, touched, func(t *pTable) bool { return t.partition == nil }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + t.partition = &pPartitioning{kind: "RANGE", sub: &pSubPartitioning{num: 2}, parts: []pPart{ + {name: s.next("p"), bound: 2}, + {name: s.next("p"), bound: 100}, + }} + touched[t.name] = true + return true + }}, + {"change key partition count", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := partitionCandidates(s, touched, func(t *pTable) bool { return t.partition != nil && t.partition.kind == "KEY" }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + if t.partition.num > 1 && r.Intn(2) == 0 { + t.partition.num-- + } else { + t.partition.num++ + } + touched[t.name] = true + return true + }}, + {"toggle key algorithm", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := partitionCandidates(s, touched, func(t *pTable) bool { return t.partition != nil && t.partition.kind == "KEY" }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + t.partition.algorithm = [3]int{1, 2, 0}[t.partition.algorithm] + touched[t.name] = true + return true + }}, + {"toggle partitioning linear", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := partitionCandidates(s, touched, func(t *pTable) bool { + return t.partition != nil && (t.partition.kind == "HASH" || t.partition.kind == "KEY") + }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + t.partition.linear = !t.partition.linear + touched[t.name] = true + return true + }}, + {"change subpartition count", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := partitionCandidates(s, touched, func(t *pTable) bool { return t.partition != nil && t.partition.sub != nil }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + if t.partition.sub.num > 1 && r.Intn(2) == 0 { + t.partition.sub.num-- + } else { + t.partition.sub.num++ + } + touched[t.name] = true + return true + }}, + {"remove subpartitioning", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + cands := partitionCandidates(s, touched, func(t *pTable) bool { return t.partition != nil && t.partition.sub != nil }) + if len(cands) == 0 { + return false + } + t := pick(r, cands) + t.partition.sub = nil + touched[t.name] = true + return true + }}, +} + +// partitionCandidates: untouched tables a partitioning mutation may act on that also +// satisfy pred (already, or not yet, partitioned, in whatever shape the mutation needs). +// Every partitioning mutation guards to this set: no foreign key either side (Error 1506), +// no UNIQUE key besides PRIMARY (Error 1503 -- every UNIQUE key must carry the +// partitioning column, and this generator always partitions by id, which a plain UNIQUE +// elsewhere never does), and no spatial (point) column at all -- InnoDB refuses to +// partition a table carrying one anywhere, not only as the partitioning key itself +// (Error 1178, "The storage engine for the table doesn't support GEOMETRY", measured). +func partitionCandidates(s *pSchema, touched map[string]bool, pred func(*pTable) bool) []*pTable { + var out []*pTable + for _, t := range untouched(s, touched) { + if !pred(t) || len(t.fks) > 0 || len(referencedByAny(s, t)) > 0 { + continue + } + unique, fulltext := false, false + for _, k := range t.keys { + if k.unique { + unique = true + } + // InnoDB refuses FULLTEXT on a partitioned table (Error 1214, "The used table + // type doesn't support FULLTEXT indexes", measured); directed coverage is what + // actually surfaced this one, applying "partition table by hash" against a table + // a random draw had already given a FULLTEXT key. + if k.special == "FULLTEXT" { + fulltext = true + } + } + if unique || fulltext { + continue + } + spatial := false + for _, c := range t.cols { + if c.typ == "point" { + spatial = true + break + } + } + if spatial { + continue + } + out = append(out, t) + } + return out +} + +func indexOfCol(t *pTable, name string) int { + for i, c := range t.cols { + if c.name == name { + return i + } + } + return -1 +} + +func fkOwner(s *pSchema, fk *pFK) string { + for _, t := range s.tables { + for _, f := range t.fks { + if f == fk { + return t.name + } + } + } + return "" +} + +// step is one mutation with the seed it draws from, so a recipe replays deterministically +// with steps removed. +type step struct { + m int + seed int64 +} + +// mutate applies the recipe to a copy of src; a step whose mutation finds nothing to apply +// to is skipped. It returns the target and the names of the steps that applied. +func mutate(src *pSchema, recipe []step) (*pSchema, []string) { + s := src.clone() + s.mutating = true + touched := map[string]bool{} + var applied []string + for _, st := range recipe { + r := rand.New(rand.NewSource(st.seed)) + if mutations[st.m].apply(r, s, touched) { + applied = append(applied, mutations[st.m].name) + } + } + return s, applied +} diff --git a/check/mysql/migrate/probe_test.go b/check/mysql/migrate/probe_test.go new file mode 100644 index 0000000..c35e4f9 --- /dev/null +++ b/check/mysql/migrate/probe_test.go @@ -0,0 +1,431 @@ +package migrate + +// The migrate probe: an oracle for Plan that a hand-written adversarial round cannot be. The +// second adversarial round's migrate lane found six problems, all of one class -- the order +// the plan's DDL runs in against a real server -- and a hand-picked case only ever catches +// the one instance its author thought of. This probe generates schemas from the vocabulary +// the planner handles (column types, generated columns, keys, foreign keys, AUTO_INCREMENT, +// CHECKs, views, triggers, functions), mutates each into a target (columns added / dropped / +// renamed / retyped / moved, keys and foreign keys attached and detached, the primary key +// moved, tables added / dropped / renamed, ENUM labels, views, triggers, functions), and +// judges the plan the way `sqlshape apply` would live: the DDL runs on a server holding the +// source, the result must read back as the target's canonical form, and a second Plan from +// there must be empty. Every pair is a change a real mysqld accepts written as one schema +// (the target canonicalizes cleanly before the plan is judged), so a refused statement or a +// leftover difference is the planner's. +// +// go test ./migrate -run TestMigrateProbe [-migrate-probe-n 200] [-migrate-probe-seed 1] \ +// [-migrate-probe-report /path/report.md] +// +// A failing pair is minimized (mutations removed while the failure stands) and written to +// the report with its source, target, DDL and what went wrong; the test fails on any finding. +// Skipped without a mysqld on PATH (nix-shell -p mysql84). + +import ( + "context" + "errors" + "flag" + "fmt" + "math/rand" + "os" + "runtime" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/kr9ly/sqlshape/check/mysql/v2/diff" + "github.com/kr9ly/sqlshape/check/mysql/v2/dump" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +var ( + probeN = flag.Int("migrate-probe-n", 200, "schema pairs the migrate probe judges") + probeSeed = flag.Int64("migrate-probe-seed", 1, "seed of the migrate probe's generator") + probeReport = flag.String("migrate-probe-report", "", "write the migrate probe's findings here (default: the test log)") +) + +// ---- judging -------------------------------------------------------------------------- + +type verdict struct { + kind string // "" when the pair passes + detail string + ddl []string + aSQL string + bSQL string + applied []string + // changes is diff.Compare(a, b): the planner's actual input, kept so the probe can + // tally which (Op, Kind, Field) triples (diff.Alphabet) the pair exercised, whatever + // judge makes of it afterward. + changes []diff.Change +} + +// judge plans src -> target and runs the plan on a server holding src. The generator's own +// mistakes (a target the server refuses as one schema) are "generator" verdicts, kept apart +// from the planner's. +func judge(ctx context.Context, c dump.Canonicalizer, src, target *pSchema, applied []string) verdict { + v := verdict{aSQL: src.render(), bSQL: target.render(), applied: applied} + a, aText, err := c.Canonical(ctx, v.aSQL) + if err != nil { + v.kind, v.detail = "generator (source)", err.Error() + return v + } + b, _, err := c.Canonical(ctx, v.bSQL) + if err != nil { + v.kind, v.detail = "generator (target)", err.Error() + return v + } + if len(a.Problems) > 0 || len(b.Problems) > 0 { + v.kind, v.detail = "generator (problems)", fmt.Sprint(a.Problems, b.Problems) + return v + } + v.changes = diff.Compare(a, b) + intents, err := ParseIntents(v.bSQL) + if err != nil { + v.kind, v.detail = "generator (intents)", err.Error() + return v + } + ddl, err := Plan(a, b, intents) + v.ddl = ddl + if err != nil { + v.kind, v.detail = "plan refused", err.Error() + return v + } + // the source database holds rows: what apply runs against is never empty + got, _, err := c.Canonical(ctx, aText+"\n"+src.rows()+"\nSET FOREIGN_KEY_CHECKS=1;\n"+strings.Join(ddl, "\n")) + if err != nil { + if strings.Contains(err.Error(), "Error 1062") || strings.Contains(err.Error(), "Error 1452") || strings.Contains(err.Error(), "Error 3819") { + // the rows themselves (a duplicate, a dangling reference, a CHECK): a rows() + // mistake unless the plan's own statement raised it -- told apart below by + // loading the rows alone + if _, _, rerr := c.Canonical(ctx, aText+"\n"+src.rows()); rerr != nil { + v.kind, v.detail = "generator (rows)", rerr.Error()+"\n"+src.rows() + return v + } + } + v.kind, v.detail = "server refused the DDL", err.Error() + return v + } + if changes := diff.Compare(got, b); len(changes) > 0 { + var d []string + for _, ch := range changes { + d = append(d, ch.String()) + } + v.kind, v.detail = "DDL does not reach the target", strings.Join(d, "\n") + return v + } + again, err := Plan(got, b, nil) + if err != nil || len(again) > 0 { + v.kind, v.detail = "a second plan is not empty", fmt.Sprintf("%v\n%s", err, strings.Join(again, "\n")) + return v + } + return v +} + +// probeWorkers is how many pairs judge at once. Every Canonical runs in a scratch database +// of its own on the one server, so the draw stays sequential -- the PRNG's stream, and with +// it the report, is what it was when the pairs were judged one after another -- and only +// the judging fans out. +var probeWorkers = min(8, runtime.GOMAXPROCS(0)) + +// parallel runs fn over items on probeWorkers goroutines and returns when all are done. +func parallel[T any](items []T, fn func(T)) { + var wg sync.WaitGroup + next := make(chan T) + for w := 0; w < probeWorkers; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for it := range next { + fn(it) + } + }() + } + for _, it := range items { + next <- it + } + close(next) + wg.Wait() +} + +// judgePair applies recipe to src and judges the pair; a real finding is minimized (steps +// dropped while the failure stands). applied is what the recipe changed before minimizing, +// nil when no step found anything to apply -- the caller's cue that this attempt did not +// exercise its mutation(s) at all and, for a directed attempt, should be retried against a +// freshly generated schema. +func judgePair(ctx context.Context, scratch dump.Canonicalizer, src *pSchema, recipe []step) (v verdict, applied []string) { + target, applied := mutate(src, recipe) + if len(applied) == 0 { + return verdict{}, nil + } + v = judge(ctx, scratch, src, target, applied) + if v.kind == "" || strings.HasPrefix(v.kind, "generator") { + return v, applied + } + for j := 0; j < len(recipe); { + shorter := append(append([]step(nil), recipe[:j]...), recipe[j+1:]...) + tgt, app := mutate(src, shorter) + if len(app) == 0 { + j++ + continue + } + if w := judge(ctx, scratch, src, tgt, app); w.kind == v.kind { + recipe, v = shorter, w + continue + } + j++ + } + return v, applied +} + +// tally records a judged pair: byMutation by what it applied, hit by which (Op, Kind, +// Field) triples (diff.Alphabet) it exercised whatever judge made of it afterward, counts by +// the verdict; a failure the generator itself produced is logged (the first three) and +// dropped, a real finding kept. +func tally(t *testing.T, label string, v verdict, applied []string, counts, byMutation map[string]int, hit map[string]bool, findings *[]verdict) { + for _, a := range applied { + byMutation[a]++ + } + for _, ch := range v.changes { + if ch.Op == diff.Alter { + for _, f := range ch.Fields { + hit[diff.AlphabetEntry{Op: ch.Op, Kind: ch.Kind, Field: f.Name}.String()] = true + } + continue + } + hit[diff.AlphabetEntry{Op: ch.Op, Kind: ch.Kind}.String()] = true + } + switch { + case v.kind == "": + counts["pass"]++ + case strings.HasPrefix(v.kind, "generator"): + counts[v.kind]++ + if counts[v.kind] <= 3 { + t.Logf("%s (%s, %s): %s\n%s", v.kind, label, strings.Join(applied, "; "), v.detail, v.aSQL) + } + default: + counts["finding"]++ + *findings = append(*findings, v) + } +} + +// runPair is judgePair followed by tally; the zero verdict, untallied, when nothing applied. +func runPair(ctx context.Context, scratch dump.Canonicalizer, label string, src *pSchema, recipe []step, + counts map[string]int, byMutation map[string]int, hit map[string]bool, findings *[]verdict, t *testing.T) verdict { + v, applied := judgePair(ctx, scratch, src, recipe) + if applied == nil { + return verdict{} + } + tally(t, label, v, applied, counts, byMutation, hit, findings) + return v +} + +func TestMigrateProbe(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + srv, err := mysqltest.Start(ctx, "-- sqlshape: mysql 8.4\n") + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } else if err != nil { + t.Fatal(err) + } + defer srv.Close() + scratch, err := dump.NewScratch(srv.DSN()) + if err != nil { + t.Fatal(err) + } + + r := rand.New(rand.NewSource(*probeSeed)) + counts := map[string]int{} + byMutation := map[string]int{} + hit := map[string]bool{} + var findings []verdict + type pair struct { + i int + src *pSchema + recipe []step + applied []string + v verdict + } + var pairs []*pair + for i := 0; i < *probeN; i++ { + src := generate(r) + n := 1 + r.Intn(5) + var recipe []step + for j := 0; j < n; j++ { + recipe = append(recipe, step{m: r.Intn(len(mutations)), seed: r.Int63()}) + } + pairs = append(pairs, &pair{i: i, src: src, recipe: recipe}) + } + parallel(pairs, func(p *pair) { p.v, p.applied = judgePair(ctx, scratch, p.src, p.recipe) }) + for _, p := range pairs { + if p.applied == nil { + continue + } + tally(t, fmt.Sprintf("pair %d", p.i), p.v, p.applied, counts, byMutation, hit, &findings) + } + hitRandom := len(hit) + + // directed coverage: the random draw above is what an earlier round's gate relied on + // entirely (200 pairs of seed 1 "happening" to reach every alphabet entry), which breaks + // the moment a new mutation shifts the PRNG's draws out from under an existing one (measured: + // adding a mutation here once made an existing "~ domain check " stop being hit at + // seed 1). This applies each mutation completely alone, against a schema fresh enough for + // it, at least once, regardless of what the random pairs above happened to draw -- so the + // gate's coverage no longer depends on chance. A mutation that finds nothing to apply to a + // given fresh schema (untouched candidates the generator did not happen to produce) just + // draws another; one that still finds nothing after directedTries schemas is reported as + // unable to run standalone and fails the gate below (as opposed to reachable only combined + // with another mutation first, which this loop does not claim to rule out). + const directedTries = 30 + var unusable []string + for m, mut := range mutations { + ok := false + for try := 0; try < directedTries && !ok; try++ { + src := generate(r) + recipe := []step{{m: m, seed: r.Int63()}} + label := fmt.Sprintf("directed %q try %d", mut.name, try) + v := runPair(ctx, scratch, label, src, recipe, counts, byMutation, hit, &findings, t) + if v.applied == nil { + continue // this schema had no candidate for the mutation: draw another + } + if strings.HasPrefix(v.kind, "generator") { + continue // a bad pairing, not this mutation's fault: draw another + } + ok = true + } + if !ok { + unusable = append(unusable, mut.name) + } + } + sort.Strings(unusable) + + var keys []string + for k := range counts { + keys = append(keys, k) + } + sort.Strings(keys) + var summary strings.Builder + for _, k := range keys { + fmt.Fprintf(&summary, "%s: %d\n", k, counts[k]) + } + var ms []string + for m := range byMutation { + ms = append(ms, m) + } + sort.Strings(ms) + for _, m := range ms { + fmt.Fprintf(&summary, " %-45s %d\n", m, byMutation[m]) + } + t.Logf("migrate probe, seed %d, %d pairs:\n%s", *probeSeed, *probeN, summary.String()) + + // the same failure minimized from different pairs reads the same: dedupe by kind + DDL + seen := map[string]bool{} + var report strings.Builder + fmt.Fprintf(&report, "# migrate probe, seed %d, %d pairs\n\n%s\n", *probeSeed, *probeN, summary.String()) + distinct := 0 + for _, v := range findings { + key := v.kind + "\n" + v.detail + if seen[key] { + continue + } + seen[key] = true + distinct++ + fmt.Fprintf(&report, "## %d. %s\n\nmutations: %s\n\n%s\n\n### source\n\n```sql\n%s```\n\n### target\n\n```sql\n%s```\n\n### plan\n\n```sql\n%s\n```\n\n", + distinct, v.kind, strings.Join(v.applied, "; "), v.detail, v.aSQL, v.bSQL, strings.Join(v.ddl, "\n")) + } + + alphabet, err := diff.Alphabet() + if err != nil { + t.Fatalf("diff.Alphabet: %v", err) + } + var missed []string + fmt.Fprintf(&report, "## alphabet coverage\n\n%d entries, %d hit by the random pairs alone, %d hit once directed is added\n\n", len(alphabet), hitRandom, len(hit)) + var newlyUnusable []string + fmt.Fprintf(&report, "### directed coverage: applying every mutation alone\n\n") + for _, name := range unusable { + if reason := directedKnownUnusable[name]; reason != "" { + fmt.Fprintf(&report, "- [ ] %s -- known unusable standalone: %s\n", name, reason) + continue + } + fmt.Fprintf(&report, "- [ ] %s -- MISSED (could not apply alone after %d fresh schemas)\n", name, directedTries) + newlyUnusable = append(newlyUnusable, name) + } + if len(unusable) == 0 { + fmt.Fprintf(&report, "every mutation applied standalone at least once\n") + } + fmt.Fprintln(&report) + for _, e := range alphabet { + s := e.String() + switch { + case hit[s]: + fmt.Fprintf(&report, "- [x] %s\n", s) + case alphabetKnownUnreached[s] != "": + fmt.Fprintf(&report, "- [ ] %s -- known unreached: %s\n", s, alphabetKnownUnreached[s]) + default: + fmt.Fprintf(&report, "- [ ] %s -- MISSED\n", s) + missed = append(missed, s) + } + } + // a known-unreached entry diff.Alphabet no longer lists is stale (the field moved, was + // renamed, or the planner learned to emit DDL for it): flag it so the list stays + // honest instead of silently over-forgiving forever. + known := map[string]bool{} + for _, e := range alphabet { + known[e.String()] = true + } + var stale []string + for s := range alphabetKnownUnreached { + if !known[s] { + stale = append(stale, s) + } + } + sort.Strings(stale) + for _, s := range stale { + fmt.Fprintf(&report, "- stale known-unreached entry (not in the current alphabet): %s\n", s) + } + // a directedKnownUnusable entry this run's directed pass did apply standalone is stale + // the same way: the mutation (or a companion one earlier in mutations) changed and the + // allowance no longer describes reality. + unusableNow := map[string]bool{} + for _, name := range unusable { + unusableNow[name] = true + } + var staleUnusable []string + for name := range directedKnownUnusable { + if !unusableNow[name] { + staleUnusable = append(staleUnusable, name) + } + } + sort.Strings(staleUnusable) + for _, s := range staleUnusable { + fmt.Fprintf(&report, "- stale directedKnownUnusable entry (applied standalone this run): %s\n", s) + } + + if *probeReport != "" { + if err := os.WriteFile(*probeReport, []byte(report.String()), 0o644); err != nil { + t.Fatal(err) + } + t.Logf("report: %s", *probeReport) + } else if distinct > 0 { + t.Log(report.String()) + } + if distinct > 0 { + t.Errorf("%d distinct findings (%d pairs)", distinct, counts["finding"]) + } + sort.Strings(missed) + if len(missed) > 0 { + t.Errorf("%d/%d alphabet entries neither hit nor known-unreached:\n%s", len(missed), len(alphabet), strings.Join(missed, "\n")) + } + if len(stale) > 0 { + t.Errorf("%d stale alphabetKnownUnreached entries (not in diff.Alphabet): %s", len(stale), strings.Join(stale, ", ")) + } + sort.Strings(newlyUnusable) + if len(newlyUnusable) > 0 { + t.Errorf("%d mutation(s) could not be applied standalone by directed coverage and are not in directedKnownUnusable: %s", len(newlyUnusable), strings.Join(newlyUnusable, ", ")) + } + if len(staleUnusable) > 0 { + t.Errorf("%d stale directedKnownUnusable entries (applied standalone this run): %s", len(staleUnusable), strings.Join(staleUnusable, ", ")) + } +} diff --git a/check/postgres/LICENSE b/check/postgres/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/check/postgres/LICENSE @@ -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 [yyyy] [name of copyright owner] + + 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. diff --git a/check/postgres/NOTICE b/check/postgres/NOTICE new file mode 100644 index 0000000..4789871 --- /dev/null +++ b/check/postgres/NOTICE @@ -0,0 +1,35 @@ +sqlshape +Copyright 2026 kr9ly + +Licensed under the Apache License, Version 2.0. + +This product includes data and logic derived from PostgreSQL +(https://www.postgresql.org/), which is distributed under the PostgreSQL +License: + + catalog/data the pg_catalog contents of PostgreSQL 17 and of + its contrib extensions, dumped as TSV + analyze input-function validation and type-conversion + rules ported from the PostgreSQL source + +PostgreSQL Database Management System +(formerly known as Postgres, then as Postgres95) + +Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group +Portions Copyright (c) 1994, The Regents of the University of California + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose, without fee, and without a written agreement is +hereby granted, provided that the above copyright notice and this paragraph and +the following two paragraphs appear in all copies. + +IN NO EVENT SHALL THE UNIVERSITY OF CALIFORNIA BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST +PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF +THE UNIVERSITY OF CALIFORNIA HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +THE UNIVERSITY OF CALIFORNIA SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, AND +THE UNIVERSITY OF CALIFORNIA HAS NO OBLIGATIONS TO PROVIDE MAINTENANCE, SUPPORT, +UPDATES, ENHANCEMENTS, OR MODIFICATIONS. diff --git a/internal/analyze/README.md b/check/postgres/analyze/README.md similarity index 98% rename from internal/analyze/README.md rename to check/postgres/analyze/README.md index 267a232..15020d5 100644 --- a/internal/analyze/README.md +++ b/check/postgres/analyze/README.md @@ -14,7 +14,7 @@ The column list is the only part PG really freezes; NOT NULL is not — a view r column's attnotnull live, on every query, the same as any other read of the table. `analyze.Load` also installs a `NotNullHook`, fired whenever an `ALTER TABLE ... ALTER COLUMN SET/DROP NOT NULL` (or a `PRIMARY KEY` added after the fact) changes a table's column -(`schema.Schema.notifyNotNullChange`, `internal/schema/schema.go`). The hook +(`schema.Schema.notifyNotNullChange`, `check/postgres/schema/schema.go`). The hook (`refreezeDependentNullability`, `analyze.go`) finds every (plain, not materialized) view that depends on that table, transitively, via `schema.DependentViews`, and re-analyzes each one fresh in declaration order (`freshViewColumns`, `scope.go` — it bypasses `viewColumns`' shortcut of trusting whatever is @@ -90,7 +90,7 @@ SET / SHOW / transaction control / DO / VACUUM / ANALYZE / COPY / DECLARE CURSOR names follow FigureColname (a cast names the column only when what it casts has no name of its own; SQL/JSON and XML constructors, merge_action(); a function in FROM names its one column by its single OUT parameter, then the alias, then the function) -- `testdata/queries/*.sql` + `.golden`: goldens come from the real PG (`go test ./internal/analyze -update`); +- `testdata/queries/*.sql` + `.golden`: goldens come from the real PG (`go test ./check/postgres/analyze -update`); the default run compares the analyzer to them without starting PG - `regress_test.go`: `-regress /path/to/postgres/src/test/regress` replays PG's own regression corpus against a live PG and the analyzer side by side and writes every disagreement to `-regress-report`, grouped by kind. diff --git a/check/postgres/analyze/adv_card_test.go b/check/postgres/analyze/adv_card_test.go new file mode 100644 index 0000000..fbe35b4 --- /dev/null +++ b/check/postgres/analyze/adv_card_test.go @@ -0,0 +1,190 @@ +package analyze + +import ( + "context" + "os" + "testing" + "time" + + "github.com/kr9ly/sqlshape/check/postgres/v2/oracle" +) + +// readSchema reads the shared testdata/schema.sql (users / orders / order_items, with the +// partial unique index orders_uid_active) that card_test.go and this file both analyze. +func readSchema() (string, error) { + b, err := os.ReadFile("testdata/schema.sql") + return string(b), err +} + +// Adversarial round 3 (PostgreSQL), lane "card": the soundness of x/cardinality.AtMostOne +// over the facts check/postgres/analyze produces (keyFacts, groupTerm, predFacts). Every +// test below is reproduced against a real, running PostgreSQL (embedded-postgres, via +// oracle.Start) and states the *correct* expected outcome, so it fails today. +// +// All findings here are the "conservative" direction the lane brief calls mid severity: +// sqlshape refuses to prove AtMostOne for a statement that a real PostgreSQL always +// answers with at most one row (it never wrongly claims AtMostOne for a statement that +// can return two). Each test pins that down two ways: the analyzer's own verdict (which +// is wrong) and a live query against real data (which shows the true, single-row answer). + +// checkOneRow loads schemaSQL, analyzes sql, asserts the analyzer proves AtMostOne (the +// documented "correct" outcome), then runs inserts and the query itself (with args) against +// a real PostgreSQL and asserts it returns at most one row -- the independent, ground-truth +// half of the claim. +func checkOneRow(t *testing.T, schemaSQL string, inserts []string, sql string, args ...any) { + t.Helper() + s, err := Load(schemaSQL) + if err != nil { + t.Fatalf("schema: %v", err) + } + for _, p := range s.Problems { + t.Fatalf("schema problem: %s", p) + } + r, aerr := Analyze(s, sql) + if aerr != nil { + t.Fatalf("analyze: %v", aerr) + } + if !r.AtMostOne { + t.Errorf("AtMostOne: want true, got false (%s)", r.ManyRowsWhy) + } else if r.ManyRowsWhy != "" { + t.Errorf("why should be empty when AtMostOne: %q", r.ManyRowsWhy) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + o, err := oracle.Start(ctx, schemaSQL) + if err != nil { + t.Fatalf("oracle start: %v", err) + } + defer o.Close() + conn := o.Conn() + for _, ins := range inserts { + if _, err := conn.Exec(ctx, ins); err != nil { + t.Fatalf("insert %q: %v", ins, err) + } + } + rows, err := conn.Query(ctx, sql, args...) + if err != nil { + t.Fatalf("query: %v", err) + } + defer rows.Close() + n := 0 + for rows.Next() { + n++ + } + if err := rows.Err(); err != nil { + t.Fatalf("rows: %v", err) + } + if n == 0 { + t.Fatalf("query returned no rows; cannot demonstrate the single-row answer") + } + if n > 1 { + t.Errorf("real PostgreSQL returned %d rows, not at most one -- the finding above is not what we thought", n) + } +} + +// TestAdvCardGroupByCastOfPinnedColumn: a column fixed to a known value by WHERE equality +// still identifies a single group when GROUP BY names a cast of it (or an alias for that +// cast), not just the bare column. sqlshape's groupTerm resolves a GROUP BY item to the +// underlying column only for a bare ColumnRef (card.go prover.resolve); wrapped in a +// TypeCast it falls through to an opaque Expr term, which the proof never treats as +// pinned even though the column beneath it is. Reproduced: one order with status = +// 'paid'; grouping by status::text (equivalently, by its alias) still yields the single +// group PostgreSQL actually returns. +func TestAdvCardGroupByCastOfPinnedColumn(t *testing.T) { + schemaSQL, err := readSchema() + if err != nil { + t.Fatal(err) + } + checkOneRow(t, schemaSQL, []string{ + "INSERT INTO users (id, email) OVERRIDING SYSTEM VALUE VALUES (1, 'a@x')", + "INSERT INTO orders (user_id, status, total) VALUES (1, 'paid', 10)", + }, "SELECT status::text, count(*) FROM orders WHERE status = 'paid' GROUP BY status::text") +} + +// TestAdvCardGroupByAliasOfCastOfPinnedColumn: the same gap, reached through a SELECT-list +// alias that names the cast expression rather than the cast written out again in GROUP BY. +func TestAdvCardGroupByAliasOfCastOfPinnedColumn(t *testing.T) { + schemaSQL, err := readSchema() + if err != nil { + t.Fatal(err) + } + checkOneRow(t, schemaSQL, []string{ + "INSERT INTO users (id, email) OVERRIDING SYSTEM VALUE VALUES (1, 'a@x')", + "INSERT INTO orders (id, user_id, status, total) OVERRIDING SYSTEM VALUE VALUES (1, 1, 'paid', 10)", + }, "SELECT id::int AS x, count(*) FROM orders WHERE id = $1 GROUP BY x", int64(1)) +} + +// TestAdvCardSingletonArrayAny: `col = ANY(ARRAY[$1])`, an array literal with exactly one +// (known) element, is exactly as pinning as `col = $1` -- PostgreSQL never returns more +// than one row for it against a unique key. sqlshape's alternatives()/predFacts only +// special-cases `IN (...)` and `x = a OR x = b OR ...`, not `= ANY(ARRAY[...])`, so it +// falls back to treating the whole comparison as unknown and refuses the proof, the same +// way it (correctly) refuses `id = ANY($1)` where the whole array is an opaque parameter. +func TestAdvCardSingletonArrayAny(t *testing.T) { + schemaSQL, err := readSchema() + if err != nil { + t.Fatal(err) + } + checkOneRow(t, schemaSQL, []string{ + "INSERT INTO users (id, email) OVERRIDING SYSTEM VALUE VALUES (1, 'a@x')", + }, "SELECT id FROM users WHERE id = ANY(ARRAY[$1::bigint])", int64(1)) +} + +// TestAdvCardIsNotDistinctFromKnownValue: `col IS NOT DISTINCT FROM $1` pins col exactly +// like `col = $1` whenever the bound value is not NULL (the two predicates agree on every +// row for a non-NULL right-hand side). sqlshape's equalitySides only recognizes plain `=` +// (and a one-item IN), so a lookup written with IS NOT DISTINCT FROM -- a common pattern +// for a nullable filter column -- is never treated as pinning a unique key. +func TestAdvCardIsNotDistinctFromKnownValue(t *testing.T) { + schemaSQL, err := readSchema() + if err != nil { + t.Fatal(err) + } + checkOneRow(t, schemaSQL, []string{ + "INSERT INTO users (id, email) OVERRIDING SYSTEM VALUE VALUES (1, 'a@x')", + }, "SELECT id FROM users WHERE email IS NOT DISTINCT FROM $1", "a@x") +} + +// TestAdvCardPartialIndexPredicateExplicitCast: a partial unique index's predicate and the +// statement's own WHERE conjunct restrict exactly the same rows even when the statement +// spells its enum literal with an explicit cast the index's predicate text does not carry +// (`status <> 'cancelled'::order_status` vs. the index's `status <> 'cancelled'`). +// sqlshape's samePred (x/cardinality via check/postgres/analyze's Opaque predicate) compares +// the two predicates by their deparsed text, which differs with the cast present, so the +// partial index `orders_uid_active ON orders (uid) WHERE status <> 'cancelled'` is not +// recognized as repeated and the proof is refused even though PostgreSQL enforces -- and +// this statement restricts to -- exactly the same one row. +func TestAdvCardPartialIndexPredicateExplicitCast(t *testing.T) { + schemaSQL, err := readSchema() + if err != nil { + t.Fatal(err) + } + checkOneRow(t, schemaSQL, []string{ + "INSERT INTO users (id, email) OVERRIDING SYSTEM VALUE VALUES (1, 'a@x')", + "INSERT INTO orders (id, user_id, status, total, uid) OVERRIDING SYSTEM VALUE " + + "VALUES (1, 1, 'pending', 10, '00000000-0000-0000-0000-000000000001')", + }, "SELECT id FROM orders WHERE uid = $1::uuid AND status <> 'cancelled'::order_status", + "00000000-0000-0000-0000-000000000001") +} + +// TestAdvCardSingleElementInFoldsToEq: not a finding from either adversarial round, and not +// a mysqld/PostgreSQL measurement either -- it needs none. `col IN (x)` with exactly one +// alternative fixes col to x for exactly the reason `col = x` does (SQL's IN takes a list of +// alternatives, and a one-item list is one value), so x/cardinality's One proof should treat +// it the same as an explicit `=`. check/postgres/analyze's alternatives() (facts.go) used to +// require at least two items (`x.Rexpr.GetList().GetItems(); len(items) >= 2`) before it +// built anything at all -- a singleton IN list fell all the way through predFacts to the +// opaque fallback, so id IN ($1) never fixed id the way id = $1 does. Mirrored on the MySQL +// side by TestAdv2SingleElementInFoldsToEq (check/mysql/dialect), so both producers treat +// the same SQL shape identically. +func TestAdvCardSingleElementInFoldsToEq(t *testing.T) { + schemaSQL, err := readSchema() + if err != nil { + t.Fatal(err) + } + checkOneRow(t, schemaSQL, []string{ + "INSERT INTO users (id, email) OVERRIDING SYSTEM VALUE VALUES (1, 'a@x')", + "INSERT INTO users (id, email) OVERRIDING SYSTEM VALUE VALUES (2, 'b@x')", + }, "SELECT id FROM users WHERE id IN ($1)", 1) +} diff --git a/internal/analyze/adv_facts_test.go b/check/postgres/analyze/adv_facts_test.go similarity index 95% rename from internal/analyze/adv_facts_test.go rename to check/postgres/analyze/adv_facts_test.go index 64e3973..132c7ac 100644 --- a/internal/analyze/adv_facts_test.go +++ b/check/postgres/analyze/adv_facts_test.go @@ -7,15 +7,15 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/facts" - "github.com/kr9ly/sqlshape/internal/obligation" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" + "github.com/kr9ly/sqlshape/v2/x/facts" + "github.com/kr9ly/sqlshape/v2/x/obligation" ) type advLowerer struct{ s *schema.Schema } -func (l advLowerer) Lower(expr string, rel *schema.Relation) ([]facts.Pred, error) { - return Lower(l.s, expr, rel) +func (l advLowerer) Lower(expr string, rel obligation.Relation) ([]facts.Pred, error) { + return Lower(l.s, expr, l.s.ByFullName(rel.FullName())) } func advPathName(p obligation.Path) string { @@ -37,7 +37,7 @@ func advPathName(p obligation.Path) string { func advCheckLines(t *testing.T, s *schema.Schema, decls []obligation.Obligation, f *facts.Facts) string { t.Helper() var lines []string - for _, d := range obligation.Check(s, decls, f, advLowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, f, advLowerer{s}) { line := d.Obligation.Source + " " + advPathName(d.Path) if d.Message != "" { line += " " + d.Message @@ -137,7 +137,7 @@ CREATE TABLE staging_orders ( if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -224,7 +224,7 @@ WHEN MATCHED THEN UPDATE SET status = s.status` if aerr != nil { t.Fatal(aerr) } - if !r.Facts.AtMostOne { + if !r.AtMostOne { t.Fatalf("expected AtMostOne (ON pins orders.id by equality to a parameter, same as an equivalent UPDATE's WHERE), got false:\n%s", r.Facts.String()) } } @@ -272,7 +272,7 @@ func TestAdvUsesAssignedNotHole(t *testing.T) { // facts.Use.Position is documented as "the 0-based byte offset of the reference in the // statement text" (internal/facts/facts.go). A column named only in GROUP BY still gets -// its real byte offset, not a sentinel -- Position feeds internal/obligation/check.go's +// its real byte offset, not a sentinel -- Position feeds x/obligation/check.go's // sensitive() diagnostics, so a `context X: may read
. (it has no constraint name to report), but a +// NOT NULL that comes from a domain instead is reported by the domain's own name -- PG's +// error there carries no table/column at all, so
. could not be built even if +// wanted (a domain's DataTypeName is all pgconn.PgError gives the runtime; see +// postgres/runtime.go). +func (a *analyzer) notNullViolation(tableName string, c *schema.Column) Violation { + v := Violation{Code: codeNotNullViolation, Table: tableName, Columns: []string{c.Name}} + if !c.NotNull { + v.Constraint = a.domainNotNullName(c.Type.OID) + } + return v +} + +// domainNotNullName returns the key of the first NOT NULL domain in oid's domain chain, +// schema-qualified unless the domain lives in "public" (the same rule every other +// constraint name in this package follows: schema.Relation.FullName, Source.Table, ...). +// "" when oid is not a domain, or none of its layers is declared NOT NULL. +func (a *analyzer) domainNotNullName(oid catalog.OID) string { + for { + t := a.s.Types.ByOID(oid) + if t == nil || t.Kind != 'd' { + return "" + } + if d := a.s.Types.Domains[oid]; d != nil && d.NotNull { + if t.Schema == "" || t.Schema == "public" { + return t.Name + } + return t.Schema + "." + t.Name + } + oid = t.BaseType + } +} + // referencingViolations lists foreign keys of other tables that point at rel and would // reject the change: any on DELETE, those referencing a changed column on UPDATE. // CASCADE / SET NULL / SET DEFAULT never fail on rel itself, but the cascaded change they @@ -370,14 +437,14 @@ func (a *analyzer) cascadingViolations(rel *schema.Relation, changed map[string] case 'n': // SET NULL: the FK columns are set to NULL for _, cn := range con.Columns { if c := other.Column(cn); c != nil && (c.NotNull || a.domainNotNull(c.Type.OID)) { - out = append(out, Violation{Code: codeNotNullViolation, Table: other.Name, Columns: []string{cn}}) + out = append(out, a.notNullViolation(other.Name, c)) } } case 'd': // SET DEFAULT: the FK columns take their DEFAULT, which the parent may not have out = append(out, Violation{Code: codeForeignKeyViolation, Constraint: con.Name, Table: other.Name, Columns: con.Columns, RefTable: rel.Name}) for _, cn := range con.Columns { if c := other.Column(cn); c != nil && (c.NotNull || a.domainNotNull(c.Type.OID)) && c.Default == nil { - out = append(out, Violation{Code: codeNotNullViolation, Table: other.Name, Columns: []string{cn}}) + out = append(out, a.notNullViolation(other.Name, c)) } } } @@ -386,6 +453,46 @@ func (a *analyzer) cascadingViolations(rel *schema.Relation, changed map[string] return out } +// truncateNotes flags a TRUNCATE that can never succeed: PostgreSQL refuses to truncate a +// table still referenced, by an enforced foreign key, from a table that is neither named in +// the same TRUNCATE list nor reached by CASCADE (SQLSTATE 0A000). This is a purely +// structural, row-independent check -- like a NOT NULL column left out of an INSERT, it +// fails on every execution, so it is reported as a Note rather than a maybe-Violation. +func (a *analyzer) truncateNotes(tr *pgparse.TruncateStmt) { + if tr.Behavior == pgparse.DropBehavior_DROP_CASCADE { + return // CASCADE truncates every referencing table along with the named ones + } + targets := map[*schema.Relation]bool{} + type target struct { + rel *schema.Relation + loc int32 + } + var rels []target + for _, rv := range tr.Relations { + r := rv.GetRangeVar() + rel := a.s.Relation(r.Schemaname, r.Relname) + if rel == nil { + continue + } + targets[rel] = true + rels = append(rels, target{rel: rel, loc: r.Location}) + } + for _, t := range rels { + for _, other := range a.s.Relations { + if targets[other] { + continue + } + for _, con := range other.Constraints { + if con.Kind == schema.ForeignKey && !con.NotEnforced && a.relByFullName(con.RefTable) == t.rel { + a.note(noteAlwaysFails, t.loc, "TRUNCATE "+t.rel.Name+": every execution fails (SQLSTATE 0A000): "+ + other.Name+" references "+t.rel.Name+" and is not included in the same TRUNCATE (add it, or use TRUNCATE ... CASCADE)") + return + } + } + } + } +} + func colSet(cols []string) map[string]bool { m := make(map[string]bool, len(cols)) for _, c := range cols { @@ -482,6 +589,17 @@ func anyIn(cols []string, set map[string]bool) bool { return false } +// predicateMatches reports whether a candidate unique/exclusion constraint's own predicate +// (nil for a non-partial one) is one the ON CONFLICT inference specification's WHERE clause +// satisfies as an arbiter: PostgreSQL only infers a partial index when its predicate is +// exactly the specification's WHERE clause (a non-partial index needs none). +func predicateMatches(conPredicate schema.Expr, whereClause *pgparse.Node) bool { + if conPredicate == nil { + return true + } + return whereClause != nil && equalIgnoringLocation(conPredicate, whereClause) +} + func sameColumns(x, y []string) bool { if len(x) != len(y) { return false diff --git a/check/postgres/analyze/violation_onconflict_test.go b/check/postgres/analyze/violation_onconflict_test.go new file mode 100644 index 0000000..e49a33f --- /dev/null +++ b/check/postgres/analyze/violation_onconflict_test.go @@ -0,0 +1,91 @@ +package analyze + +import ( + "context" + "strings" + "testing" + "time" +) + +// ON CONFLICT DO NOTHING without a conflict target takes every unique constraint, unique +// index and exclusion constraint of the table as an arbiter: a conflict on any of them is +// absorbed. A DEFERRABLE one among them makes the statement fail whether or not a row +// conflicts (SQLSTATE 55000). Both measured on PostgreSQL 17 (the server run is the +// demonstration; the assertions on the analyzer are the verdict). +func TestOnConflictDoNothingWithoutTarget(t *testing.T) { + const schemaSQL = `-- sqlshape: postgres 17 +CREATE EXTENSION IF NOT EXISTS btree_gist; +CREATE TABLE t (id int PRIMARY KEY, room int NOT NULL, during int4range NOT NULL, + CONSTRAINT t_room_excl EXCLUDE USING gist (room WITH =, during WITH &&)); +CREATE TABLE d (id int PRIMARY KEY, email text, CONSTRAINT d_email_key UNIQUE (email) DEFERRABLE INITIALLY IMMEDIATE); +CREATE TABLE p (id int PRIMARY KEY, email text, active bool NOT NULL DEFAULT true); +CREATE UNIQUE INDEX p_email_active ON p (email) WHERE active; +` + // the keys and the exclusion are absorbed; the NOT NULL violations of the parameters stay + keys := func(sql string) []string { + var out []string + for _, k := range advViolationKeys(t, schemaSQL, sql) { + if !strings.HasPrefix(k, "23502 ") { + out = append(out, k) + } + } + return out + } + if got := keys("INSERT INTO t (id, room, during) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING"); len(got) != 0 { + t.Errorf("exclusion absorbed: violations %v, want none", got) + } + if got := keys("INSERT INTO p (id, email) VALUES ($1, $2) ON CONFLICT DO NOTHING"); len(got) != 0 { + t.Errorf("partial unique index absorbed: violations %v, want none", got) + } + if got := keys("INSERT INTO t (id, room, during) VALUES ($1, $2, $3)"); len(got) != 2 { + t.Errorf("without ON CONFLICT: violations %v, want the key and the exclusion", got) + } + s, err := Load(schemaSQL) + if err != nil { + t.Fatal(err) + } + r, err := Analyze(s, "INSERT INTO d (id, email) VALUES ($1, $2) ON CONFLICT DO NOTHING") + if err != nil { + t.Fatal(err) + } + found := false + for _, n := range r.Notes { + found = found || strings.Contains(n.Message, "SQLSTATE 55000") && strings.Contains(n.Message, "d_email_key is DEFERRABLE") + } + if !found { + t.Errorf("deferrable arbiter: notes %+v, want the always-fails note", r.Notes) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + o := advStartOracle(t, ctx, schemaSQL) + defer o.Close() + conn := o.Conn() + for _, q := range []string{ + "INSERT INTO t (id, room, during) VALUES (1, 1, int4range(1,5))", + "INSERT INTO d (id, email) VALUES (1, 'a')", + "INSERT INTO p (id, email) VALUES (1, 'a')", + } { + if _, err := conn.Exec(ctx, q); err != nil { + t.Fatal(err) + } + } + for _, c := range []struct { + sql string + rows int64 + code string + }{ + {"INSERT INTO t (id, room, during) VALUES (2, 1, int4range(3,8)) ON CONFLICT DO NOTHING", 0, ""}, + {"INSERT INTO p (id, email) VALUES (2, 'a') ON CONFLICT DO NOTHING", 0, ""}, + {"INSERT INTO d (id, email) VALUES (2, 'a') ON CONFLICT DO NOTHING", 0, "55000"}, + {"INSERT INTO d (id, email) VALUES (3, 'b') ON CONFLICT DO NOTHING", 0, "55000"}, + } { + tag, err := conn.Exec(ctx, c.sql) + switch { + case c.code == "" && (err != nil || tag.RowsAffected() != c.rows): + t.Errorf("%s: %v rows, %v", c.sql, tag.RowsAffected(), err) + case c.code != "" && (err == nil || !strings.Contains(err.Error(), "SQLSTATE "+c.code)): + t.Errorf("%s: %v, want SQLSTATE %s", c.sql, err, c.code) + } + } +} diff --git a/internal/analyze/violation_test.go b/check/postgres/analyze/violation_test.go similarity index 94% rename from internal/analyze/violation_test.go rename to check/postgres/analyze/violation_test.go index d51822b..59d6faf 100644 --- a/internal/analyze/violation_test.go +++ b/check/postgres/analyze/violation_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" ) // TestViolations covers the failure-mode enumeration (violation.go) against testdata/schema.sql. @@ -24,12 +24,15 @@ func TestViolations(t *testing.T) { want string // sorted "code key[@param]" entries joined by ", " notes string }{ + // email's NOT NULL is the domain's, not the column's (`email email UNIQUE`, no + // column-level NOT NULL): PG's own error carries no table/column for it, so the + // key is the domain's name, "email", not "users.email" (docs/checks.md). {sql: "INSERT INTO users (email, name) VALUES ($1, $2)", - want: "23502 users.email@1, 23505 users_email_key, 23514 email_check"}, + want: "23502 email@1, 23505 users_email_key, 23514 email_check"}, {sql: "INSERT INTO users (email, name) VALUES ('a@x', $1)", want: "23505 users_email_key, 23514 email_check"}, {sql: "INSERT INTO users (email, balance) VALUES ($1, $2)", - want: "23502 users.balance@2, 23502 users.email@1, 23505 users_email_key, 23514 email_check, 23514 yen_check"}, + want: "23502 email@1, 23502 users.balance@2, 23505 users_email_key, 23514 email_check, 23514 yen_check"}, {sql: "INSERT INTO orders (user_id, total) VALUES ($1, $2)", want: "23502 orders.total@2, 23502 orders.user_id@1, 23503 orders_user_id_fkey, 23505 orders_pkey, 23505 orders_user_note_key, 23514 orders_total_check, P0401 P0401"}, {sql: "INSERT INTO orders (user_id, total) VALUES ($1, 10) ON CONFLICT (user_id, note) DO NOTHING", diff --git a/internal/analyze/zz_pg18_probe_test.go b/check/postgres/analyze/zz_pg18_probe_test.go similarity index 94% rename from internal/analyze/zz_pg18_probe_test.go rename to check/postgres/analyze/zz_pg18_probe_test.go index 6261a2a..16896b5 100644 --- a/internal/analyze/zz_pg18_probe_test.go +++ b/check/postgres/analyze/zz_pg18_probe_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "github.com/kr9ly/sqlshape/internal/oracle" - "github.com/kr9ly/sqlshape/internal/pgparse" + "github.com/kr9ly/sqlshape/check/postgres/v2/oracle" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" ) func TestZZPG18Probe(t *testing.T) { diff --git a/internal/catalog/README.md b/check/postgres/catalog/README.md similarity index 95% rename from internal/catalog/README.md rename to check/postgres/catalog/README.md index 85887bf..79e605c 100644 --- a/internal/catalog/README.md +++ b/check/postgres/catalog/README.md @@ -22,7 +22,7 @@ hstore, intarray, isn, ltree, pg_trgm, pgcrypto, seg, tablefunc, unaccent, uuid- - One directory per PostgreSQL major version: `data//` holds `VERSION`, the bootstrap TSVs and `ext/`. A schema's `-- sqlshape: postgres ` picks the directory (`catalog.Load(major)`). -- Regenerate: `go run ./internal/catalog/gen -pg 18` (boots the oracle PG of that major, +- Regenerate: `go run ./check/postgres/catalog/gen -pg 18` (boots the oracle PG of that major, `COPY ... TO STDOUT`, writes `data/18/*.tsv` + `data/18/VERSION`); `-ext ` (repeatable) dumps an extension, `-all-ext` every extension the default version has, `-list` shows what the oracle binary ships diff --git a/internal/catalog/catalog.go b/check/postgres/catalog/catalog.go similarity index 98% rename from internal/catalog/catalog.go rename to check/postgres/catalog/catalog.go index 420296d..9e46369 100644 --- a/internal/catalog/catalog.go +++ b/check/postgres/catalog/catalog.go @@ -240,7 +240,6 @@ type Catalog struct { typeByName map[string]*Type funcByOID map[OID]*Func funcByName map[string][]*Func - opByOID map[OID]*Operator opByName map[string][]*Operator castByPair map[[2]OID]*Cast aggByFn map[OID]*Aggregate @@ -299,9 +298,6 @@ func (c *Catalog) FuncByOID(oid OID) *Func { return c.funcByOID[oid] } // FuncsByName returns all pg_catalog functions named name (overloads), in oid order. func (c *Catalog) FuncsByName(name string) []*Func { return c.funcByName[name] } -// OperatorByOID returns the operator with the given oid, or nil. -func (c *Catalog) OperatorByOID(oid OID) *Operator { return c.opByOID[oid] } - // OperatorsByName returns all pg_catalog operators with the given symbol (e.g. "="), in oid order. func (c *Catalog) OperatorsByName(name string) []*Operator { return c.opByName[name] } @@ -335,7 +331,6 @@ func newCatalog() *Catalog { typeByName: map[string]*Type{}, funcByOID: map[OID]*Func{}, funcByName: map[string][]*Func{}, - opByOID: map[OID]*Operator{}, opByName: map[string][]*Operator{}, castByPair: map[[2]OID]*Cast{}, aggByFn: map[OID]*Aggregate{}, @@ -450,7 +445,6 @@ func (c *Catalog) index() { } for i := range c.Operators { op := &c.Operators[i] - c.opByOID[op.OID] = op c.opByName[op.Name] = append(c.opByName[op.Name], op) } for i := range c.Casts { diff --git a/internal/catalog/catalog_test.go b/check/postgres/catalog/catalog_test.go similarity index 100% rename from internal/catalog/catalog_test.go rename to check/postgres/catalog/catalog_test.go diff --git a/internal/catalog/data/17/VERSION b/check/postgres/catalog/data/17/VERSION similarity index 100% rename from internal/catalog/data/17/VERSION rename to check/postgres/catalog/data/17/VERSION diff --git a/internal/catalog/data/17/ext/btree_gin/META b/check/postgres/catalog/data/17/ext/btree_gin/META similarity index 100% rename from internal/catalog/data/17/ext/btree_gin/META rename to check/postgres/catalog/data/17/ext/btree_gin/META diff --git a/internal/catalog/data/17/ext/btree_gin/pg_aggregate.tsv b/check/postgres/catalog/data/17/ext/btree_gin/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/17/ext/btree_gin/pg_aggregate.tsv rename to check/postgres/catalog/data/17/ext/btree_gin/pg_aggregate.tsv diff --git a/internal/catalog/data/17/ext/btree_gin/pg_cast.tsv b/check/postgres/catalog/data/17/ext/btree_gin/pg_cast.tsv similarity index 100% rename from internal/catalog/data/17/ext/btree_gin/pg_cast.tsv rename to check/postgres/catalog/data/17/ext/btree_gin/pg_cast.tsv diff --git a/internal/catalog/data/17/ext/btree_gin/pg_operator.tsv b/check/postgres/catalog/data/17/ext/btree_gin/pg_operator.tsv similarity index 100% rename from internal/catalog/data/17/ext/btree_gin/pg_operator.tsv rename to check/postgres/catalog/data/17/ext/btree_gin/pg_operator.tsv diff --git a/internal/catalog/data/17/ext/btree_gin/pg_proc.tsv b/check/postgres/catalog/data/17/ext/btree_gin/pg_proc.tsv similarity index 100% rename from internal/catalog/data/17/ext/btree_gin/pg_proc.tsv rename to check/postgres/catalog/data/17/ext/btree_gin/pg_proc.tsv diff --git a/internal/catalog/data/17/ext/btree_gin/pg_range.tsv b/check/postgres/catalog/data/17/ext/btree_gin/pg_range.tsv similarity index 100% rename from internal/catalog/data/17/ext/btree_gin/pg_range.tsv rename to check/postgres/catalog/data/17/ext/btree_gin/pg_range.tsv diff --git a/internal/catalog/data/17/ext/btree_gin/pg_type.tsv b/check/postgres/catalog/data/17/ext/btree_gin/pg_type.tsv similarity index 100% rename from internal/catalog/data/17/ext/btree_gin/pg_type.tsv rename to check/postgres/catalog/data/17/ext/btree_gin/pg_type.tsv diff --git a/internal/catalog/data/17/ext/btree_gist/META b/check/postgres/catalog/data/17/ext/btree_gist/META similarity index 100% rename from internal/catalog/data/17/ext/btree_gist/META rename to check/postgres/catalog/data/17/ext/btree_gist/META diff --git a/internal/catalog/data/17/ext/btree_gist/pg_aggregate.tsv b/check/postgres/catalog/data/17/ext/btree_gist/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/17/ext/btree_gist/pg_aggregate.tsv rename to check/postgres/catalog/data/17/ext/btree_gist/pg_aggregate.tsv diff --git a/internal/catalog/data/17/ext/btree_gist/pg_cast.tsv b/check/postgres/catalog/data/17/ext/btree_gist/pg_cast.tsv similarity index 100% rename from internal/catalog/data/17/ext/btree_gist/pg_cast.tsv rename to check/postgres/catalog/data/17/ext/btree_gist/pg_cast.tsv diff --git a/internal/catalog/data/17/ext/btree_gist/pg_operator.tsv b/check/postgres/catalog/data/17/ext/btree_gist/pg_operator.tsv similarity index 100% rename from internal/catalog/data/17/ext/btree_gist/pg_operator.tsv rename to check/postgres/catalog/data/17/ext/btree_gist/pg_operator.tsv diff --git a/internal/catalog/data/17/ext/btree_gist/pg_proc.tsv b/check/postgres/catalog/data/17/ext/btree_gist/pg_proc.tsv similarity index 100% rename from internal/catalog/data/17/ext/btree_gist/pg_proc.tsv rename to check/postgres/catalog/data/17/ext/btree_gist/pg_proc.tsv diff --git a/internal/catalog/data/17/ext/btree_gist/pg_range.tsv b/check/postgres/catalog/data/17/ext/btree_gist/pg_range.tsv similarity index 100% rename from internal/catalog/data/17/ext/btree_gist/pg_range.tsv rename to check/postgres/catalog/data/17/ext/btree_gist/pg_range.tsv diff --git a/internal/catalog/data/17/ext/btree_gist/pg_type.tsv b/check/postgres/catalog/data/17/ext/btree_gist/pg_type.tsv similarity index 100% rename from internal/catalog/data/17/ext/btree_gist/pg_type.tsv rename to check/postgres/catalog/data/17/ext/btree_gist/pg_type.tsv diff --git a/internal/catalog/data/17/ext/citext/META b/check/postgres/catalog/data/17/ext/citext/META similarity index 100% rename from internal/catalog/data/17/ext/citext/META rename to check/postgres/catalog/data/17/ext/citext/META diff --git a/internal/catalog/data/17/ext/citext/pg_aggregate.tsv b/check/postgres/catalog/data/17/ext/citext/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/17/ext/citext/pg_aggregate.tsv rename to check/postgres/catalog/data/17/ext/citext/pg_aggregate.tsv diff --git a/internal/catalog/data/17/ext/citext/pg_cast.tsv b/check/postgres/catalog/data/17/ext/citext/pg_cast.tsv similarity index 100% rename from internal/catalog/data/17/ext/citext/pg_cast.tsv rename to check/postgres/catalog/data/17/ext/citext/pg_cast.tsv diff --git a/internal/catalog/data/17/ext/citext/pg_operator.tsv b/check/postgres/catalog/data/17/ext/citext/pg_operator.tsv similarity index 100% rename from internal/catalog/data/17/ext/citext/pg_operator.tsv rename to check/postgres/catalog/data/17/ext/citext/pg_operator.tsv diff --git a/internal/catalog/data/17/ext/citext/pg_proc.tsv b/check/postgres/catalog/data/17/ext/citext/pg_proc.tsv similarity index 100% rename from internal/catalog/data/17/ext/citext/pg_proc.tsv rename to check/postgres/catalog/data/17/ext/citext/pg_proc.tsv diff --git a/internal/catalog/data/17/ext/citext/pg_range.tsv b/check/postgres/catalog/data/17/ext/citext/pg_range.tsv similarity index 100% rename from internal/catalog/data/17/ext/citext/pg_range.tsv rename to check/postgres/catalog/data/17/ext/citext/pg_range.tsv diff --git a/internal/catalog/data/17/ext/citext/pg_type.tsv b/check/postgres/catalog/data/17/ext/citext/pg_type.tsv similarity index 100% rename from internal/catalog/data/17/ext/citext/pg_type.tsv rename to check/postgres/catalog/data/17/ext/citext/pg_type.tsv diff --git a/internal/catalog/data/17/ext/cube/META b/check/postgres/catalog/data/17/ext/cube/META similarity index 100% rename from internal/catalog/data/17/ext/cube/META rename to check/postgres/catalog/data/17/ext/cube/META diff --git a/internal/catalog/data/17/ext/cube/pg_aggregate.tsv b/check/postgres/catalog/data/17/ext/cube/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/17/ext/cube/pg_aggregate.tsv rename to check/postgres/catalog/data/17/ext/cube/pg_aggregate.tsv diff --git a/internal/catalog/data/17/ext/cube/pg_cast.tsv b/check/postgres/catalog/data/17/ext/cube/pg_cast.tsv similarity index 100% rename from internal/catalog/data/17/ext/cube/pg_cast.tsv rename to check/postgres/catalog/data/17/ext/cube/pg_cast.tsv diff --git a/internal/catalog/data/17/ext/cube/pg_operator.tsv b/check/postgres/catalog/data/17/ext/cube/pg_operator.tsv similarity index 100% rename from internal/catalog/data/17/ext/cube/pg_operator.tsv rename to check/postgres/catalog/data/17/ext/cube/pg_operator.tsv diff --git a/internal/catalog/data/17/ext/cube/pg_proc.tsv b/check/postgres/catalog/data/17/ext/cube/pg_proc.tsv similarity index 100% rename from internal/catalog/data/17/ext/cube/pg_proc.tsv rename to check/postgres/catalog/data/17/ext/cube/pg_proc.tsv diff --git a/internal/catalog/data/17/ext/cube/pg_range.tsv b/check/postgres/catalog/data/17/ext/cube/pg_range.tsv similarity index 100% rename from internal/catalog/data/17/ext/cube/pg_range.tsv rename to check/postgres/catalog/data/17/ext/cube/pg_range.tsv diff --git a/internal/catalog/data/17/ext/cube/pg_type.tsv b/check/postgres/catalog/data/17/ext/cube/pg_type.tsv similarity index 100% rename from internal/catalog/data/17/ext/cube/pg_type.tsv rename to check/postgres/catalog/data/17/ext/cube/pg_type.tsv diff --git a/internal/catalog/data/17/ext/earthdistance/META b/check/postgres/catalog/data/17/ext/earthdistance/META similarity index 100% rename from internal/catalog/data/17/ext/earthdistance/META rename to check/postgres/catalog/data/17/ext/earthdistance/META diff --git a/internal/catalog/data/17/ext/earthdistance/pg_aggregate.tsv b/check/postgres/catalog/data/17/ext/earthdistance/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/17/ext/earthdistance/pg_aggregate.tsv rename to check/postgres/catalog/data/17/ext/earthdistance/pg_aggregate.tsv diff --git a/internal/catalog/data/17/ext/earthdistance/pg_cast.tsv b/check/postgres/catalog/data/17/ext/earthdistance/pg_cast.tsv similarity index 100% rename from internal/catalog/data/17/ext/earthdistance/pg_cast.tsv rename to check/postgres/catalog/data/17/ext/earthdistance/pg_cast.tsv diff --git a/internal/catalog/data/17/ext/earthdistance/pg_operator.tsv b/check/postgres/catalog/data/17/ext/earthdistance/pg_operator.tsv similarity index 100% rename from internal/catalog/data/17/ext/earthdistance/pg_operator.tsv rename to check/postgres/catalog/data/17/ext/earthdistance/pg_operator.tsv diff --git a/internal/catalog/data/17/ext/earthdistance/pg_proc.tsv b/check/postgres/catalog/data/17/ext/earthdistance/pg_proc.tsv similarity index 100% rename from internal/catalog/data/17/ext/earthdistance/pg_proc.tsv rename to check/postgres/catalog/data/17/ext/earthdistance/pg_proc.tsv diff --git a/internal/catalog/data/17/ext/earthdistance/pg_range.tsv b/check/postgres/catalog/data/17/ext/earthdistance/pg_range.tsv similarity index 100% rename from internal/catalog/data/17/ext/earthdistance/pg_range.tsv rename to check/postgres/catalog/data/17/ext/earthdistance/pg_range.tsv diff --git a/internal/catalog/data/17/ext/earthdistance/pg_type.tsv b/check/postgres/catalog/data/17/ext/earthdistance/pg_type.tsv similarity index 100% rename from internal/catalog/data/17/ext/earthdistance/pg_type.tsv rename to check/postgres/catalog/data/17/ext/earthdistance/pg_type.tsv diff --git a/internal/catalog/data/17/ext/fuzzystrmatch/META b/check/postgres/catalog/data/17/ext/fuzzystrmatch/META similarity index 100% rename from internal/catalog/data/17/ext/fuzzystrmatch/META rename to check/postgres/catalog/data/17/ext/fuzzystrmatch/META diff --git a/internal/catalog/data/17/ext/fuzzystrmatch/pg_aggregate.tsv b/check/postgres/catalog/data/17/ext/fuzzystrmatch/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/17/ext/fuzzystrmatch/pg_aggregate.tsv rename to check/postgres/catalog/data/17/ext/fuzzystrmatch/pg_aggregate.tsv diff --git a/internal/catalog/data/17/ext/fuzzystrmatch/pg_cast.tsv b/check/postgres/catalog/data/17/ext/fuzzystrmatch/pg_cast.tsv similarity index 100% rename from internal/catalog/data/17/ext/fuzzystrmatch/pg_cast.tsv rename to check/postgres/catalog/data/17/ext/fuzzystrmatch/pg_cast.tsv diff --git a/internal/catalog/data/17/ext/fuzzystrmatch/pg_operator.tsv b/check/postgres/catalog/data/17/ext/fuzzystrmatch/pg_operator.tsv similarity index 100% rename from internal/catalog/data/17/ext/fuzzystrmatch/pg_operator.tsv rename to check/postgres/catalog/data/17/ext/fuzzystrmatch/pg_operator.tsv diff --git a/internal/catalog/data/17/ext/fuzzystrmatch/pg_proc.tsv b/check/postgres/catalog/data/17/ext/fuzzystrmatch/pg_proc.tsv similarity index 100% rename from internal/catalog/data/17/ext/fuzzystrmatch/pg_proc.tsv rename to check/postgres/catalog/data/17/ext/fuzzystrmatch/pg_proc.tsv diff --git a/internal/catalog/data/17/ext/fuzzystrmatch/pg_range.tsv b/check/postgres/catalog/data/17/ext/fuzzystrmatch/pg_range.tsv similarity index 100% rename from internal/catalog/data/17/ext/fuzzystrmatch/pg_range.tsv rename to check/postgres/catalog/data/17/ext/fuzzystrmatch/pg_range.tsv diff --git a/internal/catalog/data/17/ext/fuzzystrmatch/pg_type.tsv b/check/postgres/catalog/data/17/ext/fuzzystrmatch/pg_type.tsv similarity index 100% rename from internal/catalog/data/17/ext/fuzzystrmatch/pg_type.tsv rename to check/postgres/catalog/data/17/ext/fuzzystrmatch/pg_type.tsv diff --git a/internal/catalog/data/17/ext/hstore/META b/check/postgres/catalog/data/17/ext/hstore/META similarity index 100% rename from internal/catalog/data/17/ext/hstore/META rename to check/postgres/catalog/data/17/ext/hstore/META diff --git a/internal/catalog/data/17/ext/hstore/pg_aggregate.tsv b/check/postgres/catalog/data/17/ext/hstore/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/17/ext/hstore/pg_aggregate.tsv rename to check/postgres/catalog/data/17/ext/hstore/pg_aggregate.tsv diff --git a/internal/catalog/data/17/ext/hstore/pg_cast.tsv b/check/postgres/catalog/data/17/ext/hstore/pg_cast.tsv similarity index 100% rename from internal/catalog/data/17/ext/hstore/pg_cast.tsv rename to check/postgres/catalog/data/17/ext/hstore/pg_cast.tsv diff --git a/internal/catalog/data/17/ext/hstore/pg_operator.tsv b/check/postgres/catalog/data/17/ext/hstore/pg_operator.tsv similarity index 100% rename from internal/catalog/data/17/ext/hstore/pg_operator.tsv rename to check/postgres/catalog/data/17/ext/hstore/pg_operator.tsv diff --git a/internal/catalog/data/17/ext/hstore/pg_proc.tsv b/check/postgres/catalog/data/17/ext/hstore/pg_proc.tsv similarity index 100% rename from internal/catalog/data/17/ext/hstore/pg_proc.tsv rename to check/postgres/catalog/data/17/ext/hstore/pg_proc.tsv diff --git a/internal/catalog/data/17/ext/hstore/pg_range.tsv b/check/postgres/catalog/data/17/ext/hstore/pg_range.tsv similarity index 100% rename from internal/catalog/data/17/ext/hstore/pg_range.tsv rename to check/postgres/catalog/data/17/ext/hstore/pg_range.tsv diff --git a/internal/catalog/data/17/ext/hstore/pg_type.tsv b/check/postgres/catalog/data/17/ext/hstore/pg_type.tsv similarity index 100% rename from internal/catalog/data/17/ext/hstore/pg_type.tsv rename to check/postgres/catalog/data/17/ext/hstore/pg_type.tsv diff --git a/internal/catalog/data/17/ext/intarray/META b/check/postgres/catalog/data/17/ext/intarray/META similarity index 100% rename from internal/catalog/data/17/ext/intarray/META rename to check/postgres/catalog/data/17/ext/intarray/META diff --git a/internal/catalog/data/17/ext/intarray/pg_aggregate.tsv b/check/postgres/catalog/data/17/ext/intarray/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/17/ext/intarray/pg_aggregate.tsv rename to check/postgres/catalog/data/17/ext/intarray/pg_aggregate.tsv diff --git a/internal/catalog/data/17/ext/intarray/pg_cast.tsv b/check/postgres/catalog/data/17/ext/intarray/pg_cast.tsv similarity index 100% rename from internal/catalog/data/17/ext/intarray/pg_cast.tsv rename to check/postgres/catalog/data/17/ext/intarray/pg_cast.tsv diff --git a/internal/catalog/data/17/ext/intarray/pg_operator.tsv b/check/postgres/catalog/data/17/ext/intarray/pg_operator.tsv similarity index 100% rename from internal/catalog/data/17/ext/intarray/pg_operator.tsv rename to check/postgres/catalog/data/17/ext/intarray/pg_operator.tsv diff --git a/internal/catalog/data/17/ext/intarray/pg_proc.tsv b/check/postgres/catalog/data/17/ext/intarray/pg_proc.tsv similarity index 100% rename from internal/catalog/data/17/ext/intarray/pg_proc.tsv rename to check/postgres/catalog/data/17/ext/intarray/pg_proc.tsv diff --git a/internal/catalog/data/17/ext/intarray/pg_range.tsv b/check/postgres/catalog/data/17/ext/intarray/pg_range.tsv similarity index 100% rename from internal/catalog/data/17/ext/intarray/pg_range.tsv rename to check/postgres/catalog/data/17/ext/intarray/pg_range.tsv diff --git a/internal/catalog/data/17/ext/intarray/pg_type.tsv b/check/postgres/catalog/data/17/ext/intarray/pg_type.tsv similarity index 100% rename from internal/catalog/data/17/ext/intarray/pg_type.tsv rename to check/postgres/catalog/data/17/ext/intarray/pg_type.tsv diff --git a/internal/catalog/data/17/ext/isn/META b/check/postgres/catalog/data/17/ext/isn/META similarity index 100% rename from internal/catalog/data/17/ext/isn/META rename to check/postgres/catalog/data/17/ext/isn/META diff --git a/internal/catalog/data/17/ext/isn/pg_aggregate.tsv b/check/postgres/catalog/data/17/ext/isn/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/17/ext/isn/pg_aggregate.tsv rename to check/postgres/catalog/data/17/ext/isn/pg_aggregate.tsv diff --git a/internal/catalog/data/17/ext/isn/pg_cast.tsv b/check/postgres/catalog/data/17/ext/isn/pg_cast.tsv similarity index 100% rename from internal/catalog/data/17/ext/isn/pg_cast.tsv rename to check/postgres/catalog/data/17/ext/isn/pg_cast.tsv diff --git a/internal/catalog/data/17/ext/isn/pg_operator.tsv b/check/postgres/catalog/data/17/ext/isn/pg_operator.tsv similarity index 100% rename from internal/catalog/data/17/ext/isn/pg_operator.tsv rename to check/postgres/catalog/data/17/ext/isn/pg_operator.tsv diff --git a/internal/catalog/data/17/ext/isn/pg_proc.tsv b/check/postgres/catalog/data/17/ext/isn/pg_proc.tsv similarity index 100% rename from internal/catalog/data/17/ext/isn/pg_proc.tsv rename to check/postgres/catalog/data/17/ext/isn/pg_proc.tsv diff --git a/internal/catalog/data/17/ext/isn/pg_range.tsv b/check/postgres/catalog/data/17/ext/isn/pg_range.tsv similarity index 100% rename from internal/catalog/data/17/ext/isn/pg_range.tsv rename to check/postgres/catalog/data/17/ext/isn/pg_range.tsv diff --git a/internal/catalog/data/17/ext/isn/pg_type.tsv b/check/postgres/catalog/data/17/ext/isn/pg_type.tsv similarity index 100% rename from internal/catalog/data/17/ext/isn/pg_type.tsv rename to check/postgres/catalog/data/17/ext/isn/pg_type.tsv diff --git a/internal/catalog/data/17/ext/ltree/META b/check/postgres/catalog/data/17/ext/ltree/META similarity index 100% rename from internal/catalog/data/17/ext/ltree/META rename to check/postgres/catalog/data/17/ext/ltree/META diff --git a/internal/catalog/data/17/ext/ltree/pg_aggregate.tsv b/check/postgres/catalog/data/17/ext/ltree/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/17/ext/ltree/pg_aggregate.tsv rename to check/postgres/catalog/data/17/ext/ltree/pg_aggregate.tsv diff --git a/internal/catalog/data/17/ext/ltree/pg_cast.tsv b/check/postgres/catalog/data/17/ext/ltree/pg_cast.tsv similarity index 100% rename from internal/catalog/data/17/ext/ltree/pg_cast.tsv rename to check/postgres/catalog/data/17/ext/ltree/pg_cast.tsv diff --git a/internal/catalog/data/17/ext/ltree/pg_operator.tsv b/check/postgres/catalog/data/17/ext/ltree/pg_operator.tsv similarity index 100% rename from internal/catalog/data/17/ext/ltree/pg_operator.tsv rename to check/postgres/catalog/data/17/ext/ltree/pg_operator.tsv diff --git a/internal/catalog/data/17/ext/ltree/pg_proc.tsv b/check/postgres/catalog/data/17/ext/ltree/pg_proc.tsv similarity index 100% rename from internal/catalog/data/17/ext/ltree/pg_proc.tsv rename to check/postgres/catalog/data/17/ext/ltree/pg_proc.tsv diff --git a/internal/catalog/data/17/ext/ltree/pg_range.tsv b/check/postgres/catalog/data/17/ext/ltree/pg_range.tsv similarity index 100% rename from internal/catalog/data/17/ext/ltree/pg_range.tsv rename to check/postgres/catalog/data/17/ext/ltree/pg_range.tsv diff --git a/internal/catalog/data/17/ext/ltree/pg_type.tsv b/check/postgres/catalog/data/17/ext/ltree/pg_type.tsv similarity index 100% rename from internal/catalog/data/17/ext/ltree/pg_type.tsv rename to check/postgres/catalog/data/17/ext/ltree/pg_type.tsv diff --git a/internal/catalog/data/17/ext/pg_trgm/META b/check/postgres/catalog/data/17/ext/pg_trgm/META similarity index 100% rename from internal/catalog/data/17/ext/pg_trgm/META rename to check/postgres/catalog/data/17/ext/pg_trgm/META diff --git a/internal/catalog/data/17/ext/pg_trgm/pg_aggregate.tsv b/check/postgres/catalog/data/17/ext/pg_trgm/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/17/ext/pg_trgm/pg_aggregate.tsv rename to check/postgres/catalog/data/17/ext/pg_trgm/pg_aggregate.tsv diff --git a/internal/catalog/data/17/ext/pg_trgm/pg_cast.tsv b/check/postgres/catalog/data/17/ext/pg_trgm/pg_cast.tsv similarity index 100% rename from internal/catalog/data/17/ext/pg_trgm/pg_cast.tsv rename to check/postgres/catalog/data/17/ext/pg_trgm/pg_cast.tsv diff --git a/internal/catalog/data/17/ext/pg_trgm/pg_operator.tsv b/check/postgres/catalog/data/17/ext/pg_trgm/pg_operator.tsv similarity index 100% rename from internal/catalog/data/17/ext/pg_trgm/pg_operator.tsv rename to check/postgres/catalog/data/17/ext/pg_trgm/pg_operator.tsv diff --git a/internal/catalog/data/17/ext/pg_trgm/pg_proc.tsv b/check/postgres/catalog/data/17/ext/pg_trgm/pg_proc.tsv similarity index 100% rename from internal/catalog/data/17/ext/pg_trgm/pg_proc.tsv rename to check/postgres/catalog/data/17/ext/pg_trgm/pg_proc.tsv diff --git a/internal/catalog/data/17/ext/pg_trgm/pg_range.tsv b/check/postgres/catalog/data/17/ext/pg_trgm/pg_range.tsv similarity index 100% rename from internal/catalog/data/17/ext/pg_trgm/pg_range.tsv rename to check/postgres/catalog/data/17/ext/pg_trgm/pg_range.tsv diff --git a/internal/catalog/data/17/ext/pg_trgm/pg_type.tsv b/check/postgres/catalog/data/17/ext/pg_trgm/pg_type.tsv similarity index 100% rename from internal/catalog/data/17/ext/pg_trgm/pg_type.tsv rename to check/postgres/catalog/data/17/ext/pg_trgm/pg_type.tsv diff --git a/internal/catalog/data/17/ext/pgcrypto/META b/check/postgres/catalog/data/17/ext/pgcrypto/META similarity index 100% rename from internal/catalog/data/17/ext/pgcrypto/META rename to check/postgres/catalog/data/17/ext/pgcrypto/META diff --git a/internal/catalog/data/17/ext/pgcrypto/pg_aggregate.tsv b/check/postgres/catalog/data/17/ext/pgcrypto/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/17/ext/pgcrypto/pg_aggregate.tsv rename to check/postgres/catalog/data/17/ext/pgcrypto/pg_aggregate.tsv diff --git a/internal/catalog/data/17/ext/pgcrypto/pg_cast.tsv b/check/postgres/catalog/data/17/ext/pgcrypto/pg_cast.tsv similarity index 100% rename from internal/catalog/data/17/ext/pgcrypto/pg_cast.tsv rename to check/postgres/catalog/data/17/ext/pgcrypto/pg_cast.tsv diff --git a/internal/catalog/data/17/ext/pgcrypto/pg_operator.tsv b/check/postgres/catalog/data/17/ext/pgcrypto/pg_operator.tsv similarity index 100% rename from internal/catalog/data/17/ext/pgcrypto/pg_operator.tsv rename to check/postgres/catalog/data/17/ext/pgcrypto/pg_operator.tsv diff --git a/internal/catalog/data/17/ext/pgcrypto/pg_proc.tsv b/check/postgres/catalog/data/17/ext/pgcrypto/pg_proc.tsv similarity index 100% rename from internal/catalog/data/17/ext/pgcrypto/pg_proc.tsv rename to check/postgres/catalog/data/17/ext/pgcrypto/pg_proc.tsv diff --git a/internal/catalog/data/17/ext/pgcrypto/pg_range.tsv b/check/postgres/catalog/data/17/ext/pgcrypto/pg_range.tsv similarity index 100% rename from internal/catalog/data/17/ext/pgcrypto/pg_range.tsv rename to check/postgres/catalog/data/17/ext/pgcrypto/pg_range.tsv diff --git a/internal/catalog/data/17/ext/pgcrypto/pg_type.tsv b/check/postgres/catalog/data/17/ext/pgcrypto/pg_type.tsv similarity index 100% rename from internal/catalog/data/17/ext/pgcrypto/pg_type.tsv rename to check/postgres/catalog/data/17/ext/pgcrypto/pg_type.tsv diff --git a/internal/catalog/data/17/ext/seg/META b/check/postgres/catalog/data/17/ext/seg/META similarity index 100% rename from internal/catalog/data/17/ext/seg/META rename to check/postgres/catalog/data/17/ext/seg/META diff --git a/internal/catalog/data/17/ext/seg/pg_aggregate.tsv b/check/postgres/catalog/data/17/ext/seg/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/17/ext/seg/pg_aggregate.tsv rename to check/postgres/catalog/data/17/ext/seg/pg_aggregate.tsv diff --git a/internal/catalog/data/17/ext/seg/pg_cast.tsv b/check/postgres/catalog/data/17/ext/seg/pg_cast.tsv similarity index 100% rename from internal/catalog/data/17/ext/seg/pg_cast.tsv rename to check/postgres/catalog/data/17/ext/seg/pg_cast.tsv diff --git a/internal/catalog/data/17/ext/seg/pg_operator.tsv b/check/postgres/catalog/data/17/ext/seg/pg_operator.tsv similarity index 100% rename from internal/catalog/data/17/ext/seg/pg_operator.tsv rename to check/postgres/catalog/data/17/ext/seg/pg_operator.tsv diff --git a/internal/catalog/data/17/ext/seg/pg_proc.tsv b/check/postgres/catalog/data/17/ext/seg/pg_proc.tsv similarity index 100% rename from internal/catalog/data/17/ext/seg/pg_proc.tsv rename to check/postgres/catalog/data/17/ext/seg/pg_proc.tsv diff --git a/internal/catalog/data/17/ext/seg/pg_range.tsv b/check/postgres/catalog/data/17/ext/seg/pg_range.tsv similarity index 100% rename from internal/catalog/data/17/ext/seg/pg_range.tsv rename to check/postgres/catalog/data/17/ext/seg/pg_range.tsv diff --git a/internal/catalog/data/17/ext/seg/pg_type.tsv b/check/postgres/catalog/data/17/ext/seg/pg_type.tsv similarity index 100% rename from internal/catalog/data/17/ext/seg/pg_type.tsv rename to check/postgres/catalog/data/17/ext/seg/pg_type.tsv diff --git a/internal/catalog/data/17/ext/tablefunc/META b/check/postgres/catalog/data/17/ext/tablefunc/META similarity index 100% rename from internal/catalog/data/17/ext/tablefunc/META rename to check/postgres/catalog/data/17/ext/tablefunc/META diff --git a/internal/catalog/data/17/ext/tablefunc/pg_aggregate.tsv b/check/postgres/catalog/data/17/ext/tablefunc/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/17/ext/tablefunc/pg_aggregate.tsv rename to check/postgres/catalog/data/17/ext/tablefunc/pg_aggregate.tsv diff --git a/internal/catalog/data/17/ext/tablefunc/pg_cast.tsv b/check/postgres/catalog/data/17/ext/tablefunc/pg_cast.tsv similarity index 100% rename from internal/catalog/data/17/ext/tablefunc/pg_cast.tsv rename to check/postgres/catalog/data/17/ext/tablefunc/pg_cast.tsv diff --git a/internal/catalog/data/17/ext/tablefunc/pg_operator.tsv b/check/postgres/catalog/data/17/ext/tablefunc/pg_operator.tsv similarity index 100% rename from internal/catalog/data/17/ext/tablefunc/pg_operator.tsv rename to check/postgres/catalog/data/17/ext/tablefunc/pg_operator.tsv diff --git a/internal/catalog/data/17/ext/tablefunc/pg_proc.tsv b/check/postgres/catalog/data/17/ext/tablefunc/pg_proc.tsv similarity index 100% rename from internal/catalog/data/17/ext/tablefunc/pg_proc.tsv rename to check/postgres/catalog/data/17/ext/tablefunc/pg_proc.tsv diff --git a/internal/catalog/data/17/ext/tablefunc/pg_range.tsv b/check/postgres/catalog/data/17/ext/tablefunc/pg_range.tsv similarity index 100% rename from internal/catalog/data/17/ext/tablefunc/pg_range.tsv rename to check/postgres/catalog/data/17/ext/tablefunc/pg_range.tsv diff --git a/internal/catalog/data/17/ext/tablefunc/pg_type.tsv b/check/postgres/catalog/data/17/ext/tablefunc/pg_type.tsv similarity index 100% rename from internal/catalog/data/17/ext/tablefunc/pg_type.tsv rename to check/postgres/catalog/data/17/ext/tablefunc/pg_type.tsv diff --git a/internal/catalog/data/17/ext/unaccent/META b/check/postgres/catalog/data/17/ext/unaccent/META similarity index 100% rename from internal/catalog/data/17/ext/unaccent/META rename to check/postgres/catalog/data/17/ext/unaccent/META diff --git a/internal/catalog/data/17/ext/unaccent/pg_aggregate.tsv b/check/postgres/catalog/data/17/ext/unaccent/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/17/ext/unaccent/pg_aggregate.tsv rename to check/postgres/catalog/data/17/ext/unaccent/pg_aggregate.tsv diff --git a/internal/catalog/data/17/ext/unaccent/pg_cast.tsv b/check/postgres/catalog/data/17/ext/unaccent/pg_cast.tsv similarity index 100% rename from internal/catalog/data/17/ext/unaccent/pg_cast.tsv rename to check/postgres/catalog/data/17/ext/unaccent/pg_cast.tsv diff --git a/internal/catalog/data/17/ext/unaccent/pg_operator.tsv b/check/postgres/catalog/data/17/ext/unaccent/pg_operator.tsv similarity index 100% rename from internal/catalog/data/17/ext/unaccent/pg_operator.tsv rename to check/postgres/catalog/data/17/ext/unaccent/pg_operator.tsv diff --git a/internal/catalog/data/17/ext/unaccent/pg_proc.tsv b/check/postgres/catalog/data/17/ext/unaccent/pg_proc.tsv similarity index 100% rename from internal/catalog/data/17/ext/unaccent/pg_proc.tsv rename to check/postgres/catalog/data/17/ext/unaccent/pg_proc.tsv diff --git a/internal/catalog/data/17/ext/unaccent/pg_range.tsv b/check/postgres/catalog/data/17/ext/unaccent/pg_range.tsv similarity index 100% rename from internal/catalog/data/17/ext/unaccent/pg_range.tsv rename to check/postgres/catalog/data/17/ext/unaccent/pg_range.tsv diff --git a/internal/catalog/data/17/ext/unaccent/pg_type.tsv b/check/postgres/catalog/data/17/ext/unaccent/pg_type.tsv similarity index 100% rename from internal/catalog/data/17/ext/unaccent/pg_type.tsv rename to check/postgres/catalog/data/17/ext/unaccent/pg_type.tsv diff --git a/internal/catalog/data/17/ext/uuid-ossp/META b/check/postgres/catalog/data/17/ext/uuid-ossp/META similarity index 100% rename from internal/catalog/data/17/ext/uuid-ossp/META rename to check/postgres/catalog/data/17/ext/uuid-ossp/META diff --git a/internal/catalog/data/17/ext/uuid-ossp/pg_aggregate.tsv b/check/postgres/catalog/data/17/ext/uuid-ossp/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/17/ext/uuid-ossp/pg_aggregate.tsv rename to check/postgres/catalog/data/17/ext/uuid-ossp/pg_aggregate.tsv diff --git a/internal/catalog/data/17/ext/uuid-ossp/pg_cast.tsv b/check/postgres/catalog/data/17/ext/uuid-ossp/pg_cast.tsv similarity index 100% rename from internal/catalog/data/17/ext/uuid-ossp/pg_cast.tsv rename to check/postgres/catalog/data/17/ext/uuid-ossp/pg_cast.tsv diff --git a/internal/catalog/data/17/ext/uuid-ossp/pg_operator.tsv b/check/postgres/catalog/data/17/ext/uuid-ossp/pg_operator.tsv similarity index 100% rename from internal/catalog/data/17/ext/uuid-ossp/pg_operator.tsv rename to check/postgres/catalog/data/17/ext/uuid-ossp/pg_operator.tsv diff --git a/internal/catalog/data/17/ext/uuid-ossp/pg_proc.tsv b/check/postgres/catalog/data/17/ext/uuid-ossp/pg_proc.tsv similarity index 100% rename from internal/catalog/data/17/ext/uuid-ossp/pg_proc.tsv rename to check/postgres/catalog/data/17/ext/uuid-ossp/pg_proc.tsv diff --git a/internal/catalog/data/17/ext/uuid-ossp/pg_range.tsv b/check/postgres/catalog/data/17/ext/uuid-ossp/pg_range.tsv similarity index 100% rename from internal/catalog/data/17/ext/uuid-ossp/pg_range.tsv rename to check/postgres/catalog/data/17/ext/uuid-ossp/pg_range.tsv diff --git a/internal/catalog/data/17/ext/uuid-ossp/pg_type.tsv b/check/postgres/catalog/data/17/ext/uuid-ossp/pg_type.tsv similarity index 100% rename from internal/catalog/data/17/ext/uuid-ossp/pg_type.tsv rename to check/postgres/catalog/data/17/ext/uuid-ossp/pg_type.tsv diff --git a/internal/catalog/data/17/pg_aggregate.tsv b/check/postgres/catalog/data/17/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/17/pg_aggregate.tsv rename to check/postgres/catalog/data/17/pg_aggregate.tsv diff --git a/internal/catalog/data/17/pg_cast.tsv b/check/postgres/catalog/data/17/pg_cast.tsv similarity index 100% rename from internal/catalog/data/17/pg_cast.tsv rename to check/postgres/catalog/data/17/pg_cast.tsv diff --git a/internal/catalog/data/17/pg_class.tsv b/check/postgres/catalog/data/17/pg_class.tsv similarity index 100% rename from internal/catalog/data/17/pg_class.tsv rename to check/postgres/catalog/data/17/pg_class.tsv diff --git a/internal/catalog/data/17/pg_operator.tsv b/check/postgres/catalog/data/17/pg_operator.tsv similarity index 100% rename from internal/catalog/data/17/pg_operator.tsv rename to check/postgres/catalog/data/17/pg_operator.tsv diff --git a/internal/catalog/data/17/pg_proc.tsv b/check/postgres/catalog/data/17/pg_proc.tsv similarity index 100% rename from internal/catalog/data/17/pg_proc.tsv rename to check/postgres/catalog/data/17/pg_proc.tsv diff --git a/internal/catalog/data/17/pg_range.tsv b/check/postgres/catalog/data/17/pg_range.tsv similarity index 100% rename from internal/catalog/data/17/pg_range.tsv rename to check/postgres/catalog/data/17/pg_range.tsv diff --git a/internal/catalog/data/17/pg_type.tsv b/check/postgres/catalog/data/17/pg_type.tsv similarity index 100% rename from internal/catalog/data/17/pg_type.tsv rename to check/postgres/catalog/data/17/pg_type.tsv diff --git a/internal/catalog/data/18/VERSION b/check/postgres/catalog/data/18/VERSION similarity index 100% rename from internal/catalog/data/18/VERSION rename to check/postgres/catalog/data/18/VERSION diff --git a/internal/catalog/data/18/ext/btree_gin/META b/check/postgres/catalog/data/18/ext/btree_gin/META similarity index 100% rename from internal/catalog/data/18/ext/btree_gin/META rename to check/postgres/catalog/data/18/ext/btree_gin/META diff --git a/internal/catalog/data/18/ext/btree_gin/pg_aggregate.tsv b/check/postgres/catalog/data/18/ext/btree_gin/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/18/ext/btree_gin/pg_aggregate.tsv rename to check/postgres/catalog/data/18/ext/btree_gin/pg_aggregate.tsv diff --git a/internal/catalog/data/18/ext/btree_gin/pg_cast.tsv b/check/postgres/catalog/data/18/ext/btree_gin/pg_cast.tsv similarity index 100% rename from internal/catalog/data/18/ext/btree_gin/pg_cast.tsv rename to check/postgres/catalog/data/18/ext/btree_gin/pg_cast.tsv diff --git a/internal/catalog/data/18/ext/btree_gin/pg_class.tsv b/check/postgres/catalog/data/18/ext/btree_gin/pg_class.tsv similarity index 100% rename from internal/catalog/data/18/ext/btree_gin/pg_class.tsv rename to check/postgres/catalog/data/18/ext/btree_gin/pg_class.tsv diff --git a/internal/catalog/data/18/ext/btree_gin/pg_operator.tsv b/check/postgres/catalog/data/18/ext/btree_gin/pg_operator.tsv similarity index 100% rename from internal/catalog/data/18/ext/btree_gin/pg_operator.tsv rename to check/postgres/catalog/data/18/ext/btree_gin/pg_operator.tsv diff --git a/internal/catalog/data/18/ext/btree_gin/pg_proc.tsv b/check/postgres/catalog/data/18/ext/btree_gin/pg_proc.tsv similarity index 100% rename from internal/catalog/data/18/ext/btree_gin/pg_proc.tsv rename to check/postgres/catalog/data/18/ext/btree_gin/pg_proc.tsv diff --git a/internal/catalog/data/18/ext/btree_gin/pg_range.tsv b/check/postgres/catalog/data/18/ext/btree_gin/pg_range.tsv similarity index 100% rename from internal/catalog/data/18/ext/btree_gin/pg_range.tsv rename to check/postgres/catalog/data/18/ext/btree_gin/pg_range.tsv diff --git a/internal/catalog/data/18/ext/btree_gin/pg_type.tsv b/check/postgres/catalog/data/18/ext/btree_gin/pg_type.tsv similarity index 100% rename from internal/catalog/data/18/ext/btree_gin/pg_type.tsv rename to check/postgres/catalog/data/18/ext/btree_gin/pg_type.tsv diff --git a/internal/catalog/data/18/ext/btree_gist/META b/check/postgres/catalog/data/18/ext/btree_gist/META similarity index 100% rename from internal/catalog/data/18/ext/btree_gist/META rename to check/postgres/catalog/data/18/ext/btree_gist/META diff --git a/internal/catalog/data/18/ext/btree_gist/pg_aggregate.tsv b/check/postgres/catalog/data/18/ext/btree_gist/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/18/ext/btree_gist/pg_aggregate.tsv rename to check/postgres/catalog/data/18/ext/btree_gist/pg_aggregate.tsv diff --git a/internal/catalog/data/18/ext/btree_gist/pg_cast.tsv b/check/postgres/catalog/data/18/ext/btree_gist/pg_cast.tsv similarity index 100% rename from internal/catalog/data/18/ext/btree_gist/pg_cast.tsv rename to check/postgres/catalog/data/18/ext/btree_gist/pg_cast.tsv diff --git a/internal/catalog/data/18/ext/btree_gist/pg_class.tsv b/check/postgres/catalog/data/18/ext/btree_gist/pg_class.tsv similarity index 100% rename from internal/catalog/data/18/ext/btree_gist/pg_class.tsv rename to check/postgres/catalog/data/18/ext/btree_gist/pg_class.tsv diff --git a/internal/catalog/data/18/ext/btree_gist/pg_operator.tsv b/check/postgres/catalog/data/18/ext/btree_gist/pg_operator.tsv similarity index 100% rename from internal/catalog/data/18/ext/btree_gist/pg_operator.tsv rename to check/postgres/catalog/data/18/ext/btree_gist/pg_operator.tsv diff --git a/internal/catalog/data/18/ext/btree_gist/pg_proc.tsv b/check/postgres/catalog/data/18/ext/btree_gist/pg_proc.tsv similarity index 100% rename from internal/catalog/data/18/ext/btree_gist/pg_proc.tsv rename to check/postgres/catalog/data/18/ext/btree_gist/pg_proc.tsv diff --git a/internal/catalog/data/18/ext/btree_gist/pg_range.tsv b/check/postgres/catalog/data/18/ext/btree_gist/pg_range.tsv similarity index 100% rename from internal/catalog/data/18/ext/btree_gist/pg_range.tsv rename to check/postgres/catalog/data/18/ext/btree_gist/pg_range.tsv diff --git a/internal/catalog/data/18/ext/btree_gist/pg_type.tsv b/check/postgres/catalog/data/18/ext/btree_gist/pg_type.tsv similarity index 100% rename from internal/catalog/data/18/ext/btree_gist/pg_type.tsv rename to check/postgres/catalog/data/18/ext/btree_gist/pg_type.tsv diff --git a/internal/catalog/data/18/ext/citext/META b/check/postgres/catalog/data/18/ext/citext/META similarity index 100% rename from internal/catalog/data/18/ext/citext/META rename to check/postgres/catalog/data/18/ext/citext/META diff --git a/internal/catalog/data/18/ext/citext/pg_aggregate.tsv b/check/postgres/catalog/data/18/ext/citext/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/18/ext/citext/pg_aggregate.tsv rename to check/postgres/catalog/data/18/ext/citext/pg_aggregate.tsv diff --git a/internal/catalog/data/18/ext/citext/pg_cast.tsv b/check/postgres/catalog/data/18/ext/citext/pg_cast.tsv similarity index 100% rename from internal/catalog/data/18/ext/citext/pg_cast.tsv rename to check/postgres/catalog/data/18/ext/citext/pg_cast.tsv diff --git a/internal/catalog/data/18/ext/citext/pg_class.tsv b/check/postgres/catalog/data/18/ext/citext/pg_class.tsv similarity index 100% rename from internal/catalog/data/18/ext/citext/pg_class.tsv rename to check/postgres/catalog/data/18/ext/citext/pg_class.tsv diff --git a/internal/catalog/data/18/ext/citext/pg_operator.tsv b/check/postgres/catalog/data/18/ext/citext/pg_operator.tsv similarity index 100% rename from internal/catalog/data/18/ext/citext/pg_operator.tsv rename to check/postgres/catalog/data/18/ext/citext/pg_operator.tsv diff --git a/internal/catalog/data/18/ext/citext/pg_proc.tsv b/check/postgres/catalog/data/18/ext/citext/pg_proc.tsv similarity index 100% rename from internal/catalog/data/18/ext/citext/pg_proc.tsv rename to check/postgres/catalog/data/18/ext/citext/pg_proc.tsv diff --git a/internal/catalog/data/18/ext/citext/pg_range.tsv b/check/postgres/catalog/data/18/ext/citext/pg_range.tsv similarity index 100% rename from internal/catalog/data/18/ext/citext/pg_range.tsv rename to check/postgres/catalog/data/18/ext/citext/pg_range.tsv diff --git a/internal/catalog/data/18/ext/citext/pg_type.tsv b/check/postgres/catalog/data/18/ext/citext/pg_type.tsv similarity index 100% rename from internal/catalog/data/18/ext/citext/pg_type.tsv rename to check/postgres/catalog/data/18/ext/citext/pg_type.tsv diff --git a/internal/catalog/data/18/ext/cube/META b/check/postgres/catalog/data/18/ext/cube/META similarity index 100% rename from internal/catalog/data/18/ext/cube/META rename to check/postgres/catalog/data/18/ext/cube/META diff --git a/internal/catalog/data/18/ext/cube/pg_aggregate.tsv b/check/postgres/catalog/data/18/ext/cube/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/18/ext/cube/pg_aggregate.tsv rename to check/postgres/catalog/data/18/ext/cube/pg_aggregate.tsv diff --git a/internal/catalog/data/18/ext/cube/pg_cast.tsv b/check/postgres/catalog/data/18/ext/cube/pg_cast.tsv similarity index 100% rename from internal/catalog/data/18/ext/cube/pg_cast.tsv rename to check/postgres/catalog/data/18/ext/cube/pg_cast.tsv diff --git a/internal/catalog/data/18/ext/cube/pg_class.tsv b/check/postgres/catalog/data/18/ext/cube/pg_class.tsv similarity index 100% rename from internal/catalog/data/18/ext/cube/pg_class.tsv rename to check/postgres/catalog/data/18/ext/cube/pg_class.tsv diff --git a/internal/catalog/data/18/ext/cube/pg_operator.tsv b/check/postgres/catalog/data/18/ext/cube/pg_operator.tsv similarity index 100% rename from internal/catalog/data/18/ext/cube/pg_operator.tsv rename to check/postgres/catalog/data/18/ext/cube/pg_operator.tsv diff --git a/internal/catalog/data/18/ext/cube/pg_proc.tsv b/check/postgres/catalog/data/18/ext/cube/pg_proc.tsv similarity index 100% rename from internal/catalog/data/18/ext/cube/pg_proc.tsv rename to check/postgres/catalog/data/18/ext/cube/pg_proc.tsv diff --git a/internal/catalog/data/18/ext/cube/pg_range.tsv b/check/postgres/catalog/data/18/ext/cube/pg_range.tsv similarity index 100% rename from internal/catalog/data/18/ext/cube/pg_range.tsv rename to check/postgres/catalog/data/18/ext/cube/pg_range.tsv diff --git a/internal/catalog/data/18/ext/cube/pg_type.tsv b/check/postgres/catalog/data/18/ext/cube/pg_type.tsv similarity index 100% rename from internal/catalog/data/18/ext/cube/pg_type.tsv rename to check/postgres/catalog/data/18/ext/cube/pg_type.tsv diff --git a/internal/catalog/data/18/ext/earthdistance/META b/check/postgres/catalog/data/18/ext/earthdistance/META similarity index 100% rename from internal/catalog/data/18/ext/earthdistance/META rename to check/postgres/catalog/data/18/ext/earthdistance/META diff --git a/internal/catalog/data/18/ext/earthdistance/pg_aggregate.tsv b/check/postgres/catalog/data/18/ext/earthdistance/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/18/ext/earthdistance/pg_aggregate.tsv rename to check/postgres/catalog/data/18/ext/earthdistance/pg_aggregate.tsv diff --git a/internal/catalog/data/18/ext/earthdistance/pg_cast.tsv b/check/postgres/catalog/data/18/ext/earthdistance/pg_cast.tsv similarity index 100% rename from internal/catalog/data/18/ext/earthdistance/pg_cast.tsv rename to check/postgres/catalog/data/18/ext/earthdistance/pg_cast.tsv diff --git a/internal/catalog/data/18/ext/earthdistance/pg_class.tsv b/check/postgres/catalog/data/18/ext/earthdistance/pg_class.tsv similarity index 100% rename from internal/catalog/data/18/ext/earthdistance/pg_class.tsv rename to check/postgres/catalog/data/18/ext/earthdistance/pg_class.tsv diff --git a/internal/catalog/data/18/ext/earthdistance/pg_operator.tsv b/check/postgres/catalog/data/18/ext/earthdistance/pg_operator.tsv similarity index 100% rename from internal/catalog/data/18/ext/earthdistance/pg_operator.tsv rename to check/postgres/catalog/data/18/ext/earthdistance/pg_operator.tsv diff --git a/internal/catalog/data/18/ext/earthdistance/pg_proc.tsv b/check/postgres/catalog/data/18/ext/earthdistance/pg_proc.tsv similarity index 100% rename from internal/catalog/data/18/ext/earthdistance/pg_proc.tsv rename to check/postgres/catalog/data/18/ext/earthdistance/pg_proc.tsv diff --git a/internal/catalog/data/18/ext/earthdistance/pg_range.tsv b/check/postgres/catalog/data/18/ext/earthdistance/pg_range.tsv similarity index 100% rename from internal/catalog/data/18/ext/earthdistance/pg_range.tsv rename to check/postgres/catalog/data/18/ext/earthdistance/pg_range.tsv diff --git a/internal/catalog/data/18/ext/earthdistance/pg_type.tsv b/check/postgres/catalog/data/18/ext/earthdistance/pg_type.tsv similarity index 100% rename from internal/catalog/data/18/ext/earthdistance/pg_type.tsv rename to check/postgres/catalog/data/18/ext/earthdistance/pg_type.tsv diff --git a/internal/catalog/data/18/ext/fuzzystrmatch/META b/check/postgres/catalog/data/18/ext/fuzzystrmatch/META similarity index 100% rename from internal/catalog/data/18/ext/fuzzystrmatch/META rename to check/postgres/catalog/data/18/ext/fuzzystrmatch/META diff --git a/internal/catalog/data/18/ext/fuzzystrmatch/pg_aggregate.tsv b/check/postgres/catalog/data/18/ext/fuzzystrmatch/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/18/ext/fuzzystrmatch/pg_aggregate.tsv rename to check/postgres/catalog/data/18/ext/fuzzystrmatch/pg_aggregate.tsv diff --git a/internal/catalog/data/18/ext/fuzzystrmatch/pg_cast.tsv b/check/postgres/catalog/data/18/ext/fuzzystrmatch/pg_cast.tsv similarity index 100% rename from internal/catalog/data/18/ext/fuzzystrmatch/pg_cast.tsv rename to check/postgres/catalog/data/18/ext/fuzzystrmatch/pg_cast.tsv diff --git a/internal/catalog/data/18/ext/fuzzystrmatch/pg_class.tsv b/check/postgres/catalog/data/18/ext/fuzzystrmatch/pg_class.tsv similarity index 100% rename from internal/catalog/data/18/ext/fuzzystrmatch/pg_class.tsv rename to check/postgres/catalog/data/18/ext/fuzzystrmatch/pg_class.tsv diff --git a/internal/catalog/data/18/ext/fuzzystrmatch/pg_operator.tsv b/check/postgres/catalog/data/18/ext/fuzzystrmatch/pg_operator.tsv similarity index 100% rename from internal/catalog/data/18/ext/fuzzystrmatch/pg_operator.tsv rename to check/postgres/catalog/data/18/ext/fuzzystrmatch/pg_operator.tsv diff --git a/internal/catalog/data/18/ext/fuzzystrmatch/pg_proc.tsv b/check/postgres/catalog/data/18/ext/fuzzystrmatch/pg_proc.tsv similarity index 100% rename from internal/catalog/data/18/ext/fuzzystrmatch/pg_proc.tsv rename to check/postgres/catalog/data/18/ext/fuzzystrmatch/pg_proc.tsv diff --git a/internal/catalog/data/18/ext/fuzzystrmatch/pg_range.tsv b/check/postgres/catalog/data/18/ext/fuzzystrmatch/pg_range.tsv similarity index 100% rename from internal/catalog/data/18/ext/fuzzystrmatch/pg_range.tsv rename to check/postgres/catalog/data/18/ext/fuzzystrmatch/pg_range.tsv diff --git a/internal/catalog/data/18/ext/fuzzystrmatch/pg_type.tsv b/check/postgres/catalog/data/18/ext/fuzzystrmatch/pg_type.tsv similarity index 100% rename from internal/catalog/data/18/ext/fuzzystrmatch/pg_type.tsv rename to check/postgres/catalog/data/18/ext/fuzzystrmatch/pg_type.tsv diff --git a/internal/catalog/data/18/ext/hstore/META b/check/postgres/catalog/data/18/ext/hstore/META similarity index 100% rename from internal/catalog/data/18/ext/hstore/META rename to check/postgres/catalog/data/18/ext/hstore/META diff --git a/internal/catalog/data/18/ext/hstore/pg_aggregate.tsv b/check/postgres/catalog/data/18/ext/hstore/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/18/ext/hstore/pg_aggregate.tsv rename to check/postgres/catalog/data/18/ext/hstore/pg_aggregate.tsv diff --git a/internal/catalog/data/18/ext/hstore/pg_cast.tsv b/check/postgres/catalog/data/18/ext/hstore/pg_cast.tsv similarity index 100% rename from internal/catalog/data/18/ext/hstore/pg_cast.tsv rename to check/postgres/catalog/data/18/ext/hstore/pg_cast.tsv diff --git a/internal/catalog/data/18/ext/hstore/pg_class.tsv b/check/postgres/catalog/data/18/ext/hstore/pg_class.tsv similarity index 100% rename from internal/catalog/data/18/ext/hstore/pg_class.tsv rename to check/postgres/catalog/data/18/ext/hstore/pg_class.tsv diff --git a/internal/catalog/data/18/ext/hstore/pg_operator.tsv b/check/postgres/catalog/data/18/ext/hstore/pg_operator.tsv similarity index 100% rename from internal/catalog/data/18/ext/hstore/pg_operator.tsv rename to check/postgres/catalog/data/18/ext/hstore/pg_operator.tsv diff --git a/internal/catalog/data/18/ext/hstore/pg_proc.tsv b/check/postgres/catalog/data/18/ext/hstore/pg_proc.tsv similarity index 100% rename from internal/catalog/data/18/ext/hstore/pg_proc.tsv rename to check/postgres/catalog/data/18/ext/hstore/pg_proc.tsv diff --git a/internal/catalog/data/18/ext/hstore/pg_range.tsv b/check/postgres/catalog/data/18/ext/hstore/pg_range.tsv similarity index 100% rename from internal/catalog/data/18/ext/hstore/pg_range.tsv rename to check/postgres/catalog/data/18/ext/hstore/pg_range.tsv diff --git a/internal/catalog/data/18/ext/hstore/pg_type.tsv b/check/postgres/catalog/data/18/ext/hstore/pg_type.tsv similarity index 100% rename from internal/catalog/data/18/ext/hstore/pg_type.tsv rename to check/postgres/catalog/data/18/ext/hstore/pg_type.tsv diff --git a/internal/catalog/data/18/ext/intarray/META b/check/postgres/catalog/data/18/ext/intarray/META similarity index 100% rename from internal/catalog/data/18/ext/intarray/META rename to check/postgres/catalog/data/18/ext/intarray/META diff --git a/internal/catalog/data/18/ext/intarray/pg_aggregate.tsv b/check/postgres/catalog/data/18/ext/intarray/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/18/ext/intarray/pg_aggregate.tsv rename to check/postgres/catalog/data/18/ext/intarray/pg_aggregate.tsv diff --git a/internal/catalog/data/18/ext/intarray/pg_cast.tsv b/check/postgres/catalog/data/18/ext/intarray/pg_cast.tsv similarity index 100% rename from internal/catalog/data/18/ext/intarray/pg_cast.tsv rename to check/postgres/catalog/data/18/ext/intarray/pg_cast.tsv diff --git a/internal/catalog/data/18/ext/intarray/pg_class.tsv b/check/postgres/catalog/data/18/ext/intarray/pg_class.tsv similarity index 100% rename from internal/catalog/data/18/ext/intarray/pg_class.tsv rename to check/postgres/catalog/data/18/ext/intarray/pg_class.tsv diff --git a/internal/catalog/data/18/ext/intarray/pg_operator.tsv b/check/postgres/catalog/data/18/ext/intarray/pg_operator.tsv similarity index 100% rename from internal/catalog/data/18/ext/intarray/pg_operator.tsv rename to check/postgres/catalog/data/18/ext/intarray/pg_operator.tsv diff --git a/internal/catalog/data/18/ext/intarray/pg_proc.tsv b/check/postgres/catalog/data/18/ext/intarray/pg_proc.tsv similarity index 100% rename from internal/catalog/data/18/ext/intarray/pg_proc.tsv rename to check/postgres/catalog/data/18/ext/intarray/pg_proc.tsv diff --git a/internal/catalog/data/18/ext/intarray/pg_range.tsv b/check/postgres/catalog/data/18/ext/intarray/pg_range.tsv similarity index 100% rename from internal/catalog/data/18/ext/intarray/pg_range.tsv rename to check/postgres/catalog/data/18/ext/intarray/pg_range.tsv diff --git a/internal/catalog/data/18/ext/intarray/pg_type.tsv b/check/postgres/catalog/data/18/ext/intarray/pg_type.tsv similarity index 100% rename from internal/catalog/data/18/ext/intarray/pg_type.tsv rename to check/postgres/catalog/data/18/ext/intarray/pg_type.tsv diff --git a/internal/catalog/data/18/ext/isn/META b/check/postgres/catalog/data/18/ext/isn/META similarity index 100% rename from internal/catalog/data/18/ext/isn/META rename to check/postgres/catalog/data/18/ext/isn/META diff --git a/internal/catalog/data/18/ext/isn/pg_aggregate.tsv b/check/postgres/catalog/data/18/ext/isn/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/18/ext/isn/pg_aggregate.tsv rename to check/postgres/catalog/data/18/ext/isn/pg_aggregate.tsv diff --git a/internal/catalog/data/18/ext/isn/pg_cast.tsv b/check/postgres/catalog/data/18/ext/isn/pg_cast.tsv similarity index 100% rename from internal/catalog/data/18/ext/isn/pg_cast.tsv rename to check/postgres/catalog/data/18/ext/isn/pg_cast.tsv diff --git a/internal/catalog/data/18/ext/isn/pg_class.tsv b/check/postgres/catalog/data/18/ext/isn/pg_class.tsv similarity index 100% rename from internal/catalog/data/18/ext/isn/pg_class.tsv rename to check/postgres/catalog/data/18/ext/isn/pg_class.tsv diff --git a/internal/catalog/data/18/ext/isn/pg_operator.tsv b/check/postgres/catalog/data/18/ext/isn/pg_operator.tsv similarity index 100% rename from internal/catalog/data/18/ext/isn/pg_operator.tsv rename to check/postgres/catalog/data/18/ext/isn/pg_operator.tsv diff --git a/internal/catalog/data/18/ext/isn/pg_proc.tsv b/check/postgres/catalog/data/18/ext/isn/pg_proc.tsv similarity index 100% rename from internal/catalog/data/18/ext/isn/pg_proc.tsv rename to check/postgres/catalog/data/18/ext/isn/pg_proc.tsv diff --git a/internal/catalog/data/18/ext/isn/pg_range.tsv b/check/postgres/catalog/data/18/ext/isn/pg_range.tsv similarity index 100% rename from internal/catalog/data/18/ext/isn/pg_range.tsv rename to check/postgres/catalog/data/18/ext/isn/pg_range.tsv diff --git a/internal/catalog/data/18/ext/isn/pg_type.tsv b/check/postgres/catalog/data/18/ext/isn/pg_type.tsv similarity index 100% rename from internal/catalog/data/18/ext/isn/pg_type.tsv rename to check/postgres/catalog/data/18/ext/isn/pg_type.tsv diff --git a/internal/catalog/data/18/ext/ltree/META b/check/postgres/catalog/data/18/ext/ltree/META similarity index 100% rename from internal/catalog/data/18/ext/ltree/META rename to check/postgres/catalog/data/18/ext/ltree/META diff --git a/internal/catalog/data/18/ext/ltree/pg_aggregate.tsv b/check/postgres/catalog/data/18/ext/ltree/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/18/ext/ltree/pg_aggregate.tsv rename to check/postgres/catalog/data/18/ext/ltree/pg_aggregate.tsv diff --git a/internal/catalog/data/18/ext/ltree/pg_cast.tsv b/check/postgres/catalog/data/18/ext/ltree/pg_cast.tsv similarity index 100% rename from internal/catalog/data/18/ext/ltree/pg_cast.tsv rename to check/postgres/catalog/data/18/ext/ltree/pg_cast.tsv diff --git a/internal/catalog/data/18/ext/ltree/pg_class.tsv b/check/postgres/catalog/data/18/ext/ltree/pg_class.tsv similarity index 100% rename from internal/catalog/data/18/ext/ltree/pg_class.tsv rename to check/postgres/catalog/data/18/ext/ltree/pg_class.tsv diff --git a/internal/catalog/data/18/ext/ltree/pg_operator.tsv b/check/postgres/catalog/data/18/ext/ltree/pg_operator.tsv similarity index 100% rename from internal/catalog/data/18/ext/ltree/pg_operator.tsv rename to check/postgres/catalog/data/18/ext/ltree/pg_operator.tsv diff --git a/internal/catalog/data/18/ext/ltree/pg_proc.tsv b/check/postgres/catalog/data/18/ext/ltree/pg_proc.tsv similarity index 100% rename from internal/catalog/data/18/ext/ltree/pg_proc.tsv rename to check/postgres/catalog/data/18/ext/ltree/pg_proc.tsv diff --git a/internal/catalog/data/18/ext/ltree/pg_range.tsv b/check/postgres/catalog/data/18/ext/ltree/pg_range.tsv similarity index 100% rename from internal/catalog/data/18/ext/ltree/pg_range.tsv rename to check/postgres/catalog/data/18/ext/ltree/pg_range.tsv diff --git a/internal/catalog/data/18/ext/ltree/pg_type.tsv b/check/postgres/catalog/data/18/ext/ltree/pg_type.tsv similarity index 100% rename from internal/catalog/data/18/ext/ltree/pg_type.tsv rename to check/postgres/catalog/data/18/ext/ltree/pg_type.tsv diff --git a/internal/catalog/data/18/ext/pg_trgm/META b/check/postgres/catalog/data/18/ext/pg_trgm/META similarity index 100% rename from internal/catalog/data/18/ext/pg_trgm/META rename to check/postgres/catalog/data/18/ext/pg_trgm/META diff --git a/internal/catalog/data/18/ext/pg_trgm/pg_aggregate.tsv b/check/postgres/catalog/data/18/ext/pg_trgm/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/18/ext/pg_trgm/pg_aggregate.tsv rename to check/postgres/catalog/data/18/ext/pg_trgm/pg_aggregate.tsv diff --git a/internal/catalog/data/18/ext/pg_trgm/pg_cast.tsv b/check/postgres/catalog/data/18/ext/pg_trgm/pg_cast.tsv similarity index 100% rename from internal/catalog/data/18/ext/pg_trgm/pg_cast.tsv rename to check/postgres/catalog/data/18/ext/pg_trgm/pg_cast.tsv diff --git a/internal/catalog/data/18/ext/pg_trgm/pg_class.tsv b/check/postgres/catalog/data/18/ext/pg_trgm/pg_class.tsv similarity index 100% rename from internal/catalog/data/18/ext/pg_trgm/pg_class.tsv rename to check/postgres/catalog/data/18/ext/pg_trgm/pg_class.tsv diff --git a/internal/catalog/data/18/ext/pg_trgm/pg_operator.tsv b/check/postgres/catalog/data/18/ext/pg_trgm/pg_operator.tsv similarity index 100% rename from internal/catalog/data/18/ext/pg_trgm/pg_operator.tsv rename to check/postgres/catalog/data/18/ext/pg_trgm/pg_operator.tsv diff --git a/internal/catalog/data/18/ext/pg_trgm/pg_proc.tsv b/check/postgres/catalog/data/18/ext/pg_trgm/pg_proc.tsv similarity index 100% rename from internal/catalog/data/18/ext/pg_trgm/pg_proc.tsv rename to check/postgres/catalog/data/18/ext/pg_trgm/pg_proc.tsv diff --git a/internal/catalog/data/18/ext/pg_trgm/pg_range.tsv b/check/postgres/catalog/data/18/ext/pg_trgm/pg_range.tsv similarity index 100% rename from internal/catalog/data/18/ext/pg_trgm/pg_range.tsv rename to check/postgres/catalog/data/18/ext/pg_trgm/pg_range.tsv diff --git a/internal/catalog/data/18/ext/pg_trgm/pg_type.tsv b/check/postgres/catalog/data/18/ext/pg_trgm/pg_type.tsv similarity index 100% rename from internal/catalog/data/18/ext/pg_trgm/pg_type.tsv rename to check/postgres/catalog/data/18/ext/pg_trgm/pg_type.tsv diff --git a/internal/catalog/data/18/ext/pgcrypto/META b/check/postgres/catalog/data/18/ext/pgcrypto/META similarity index 100% rename from internal/catalog/data/18/ext/pgcrypto/META rename to check/postgres/catalog/data/18/ext/pgcrypto/META diff --git a/internal/catalog/data/18/ext/pgcrypto/pg_aggregate.tsv b/check/postgres/catalog/data/18/ext/pgcrypto/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/18/ext/pgcrypto/pg_aggregate.tsv rename to check/postgres/catalog/data/18/ext/pgcrypto/pg_aggregate.tsv diff --git a/internal/catalog/data/18/ext/pgcrypto/pg_cast.tsv b/check/postgres/catalog/data/18/ext/pgcrypto/pg_cast.tsv similarity index 100% rename from internal/catalog/data/18/ext/pgcrypto/pg_cast.tsv rename to check/postgres/catalog/data/18/ext/pgcrypto/pg_cast.tsv diff --git a/internal/catalog/data/18/ext/pgcrypto/pg_class.tsv b/check/postgres/catalog/data/18/ext/pgcrypto/pg_class.tsv similarity index 100% rename from internal/catalog/data/18/ext/pgcrypto/pg_class.tsv rename to check/postgres/catalog/data/18/ext/pgcrypto/pg_class.tsv diff --git a/internal/catalog/data/18/ext/pgcrypto/pg_operator.tsv b/check/postgres/catalog/data/18/ext/pgcrypto/pg_operator.tsv similarity index 100% rename from internal/catalog/data/18/ext/pgcrypto/pg_operator.tsv rename to check/postgres/catalog/data/18/ext/pgcrypto/pg_operator.tsv diff --git a/internal/catalog/data/18/ext/pgcrypto/pg_proc.tsv b/check/postgres/catalog/data/18/ext/pgcrypto/pg_proc.tsv similarity index 100% rename from internal/catalog/data/18/ext/pgcrypto/pg_proc.tsv rename to check/postgres/catalog/data/18/ext/pgcrypto/pg_proc.tsv diff --git a/internal/catalog/data/18/ext/pgcrypto/pg_range.tsv b/check/postgres/catalog/data/18/ext/pgcrypto/pg_range.tsv similarity index 100% rename from internal/catalog/data/18/ext/pgcrypto/pg_range.tsv rename to check/postgres/catalog/data/18/ext/pgcrypto/pg_range.tsv diff --git a/internal/catalog/data/18/ext/pgcrypto/pg_type.tsv b/check/postgres/catalog/data/18/ext/pgcrypto/pg_type.tsv similarity index 100% rename from internal/catalog/data/18/ext/pgcrypto/pg_type.tsv rename to check/postgres/catalog/data/18/ext/pgcrypto/pg_type.tsv diff --git a/internal/catalog/data/18/ext/seg/META b/check/postgres/catalog/data/18/ext/seg/META similarity index 100% rename from internal/catalog/data/18/ext/seg/META rename to check/postgres/catalog/data/18/ext/seg/META diff --git a/internal/catalog/data/18/ext/seg/pg_aggregate.tsv b/check/postgres/catalog/data/18/ext/seg/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/18/ext/seg/pg_aggregate.tsv rename to check/postgres/catalog/data/18/ext/seg/pg_aggregate.tsv diff --git a/internal/catalog/data/18/ext/seg/pg_cast.tsv b/check/postgres/catalog/data/18/ext/seg/pg_cast.tsv similarity index 100% rename from internal/catalog/data/18/ext/seg/pg_cast.tsv rename to check/postgres/catalog/data/18/ext/seg/pg_cast.tsv diff --git a/internal/catalog/data/18/ext/seg/pg_class.tsv b/check/postgres/catalog/data/18/ext/seg/pg_class.tsv similarity index 100% rename from internal/catalog/data/18/ext/seg/pg_class.tsv rename to check/postgres/catalog/data/18/ext/seg/pg_class.tsv diff --git a/internal/catalog/data/18/ext/seg/pg_operator.tsv b/check/postgres/catalog/data/18/ext/seg/pg_operator.tsv similarity index 100% rename from internal/catalog/data/18/ext/seg/pg_operator.tsv rename to check/postgres/catalog/data/18/ext/seg/pg_operator.tsv diff --git a/internal/catalog/data/18/ext/seg/pg_proc.tsv b/check/postgres/catalog/data/18/ext/seg/pg_proc.tsv similarity index 100% rename from internal/catalog/data/18/ext/seg/pg_proc.tsv rename to check/postgres/catalog/data/18/ext/seg/pg_proc.tsv diff --git a/internal/catalog/data/18/ext/seg/pg_range.tsv b/check/postgres/catalog/data/18/ext/seg/pg_range.tsv similarity index 100% rename from internal/catalog/data/18/ext/seg/pg_range.tsv rename to check/postgres/catalog/data/18/ext/seg/pg_range.tsv diff --git a/internal/catalog/data/18/ext/seg/pg_type.tsv b/check/postgres/catalog/data/18/ext/seg/pg_type.tsv similarity index 100% rename from internal/catalog/data/18/ext/seg/pg_type.tsv rename to check/postgres/catalog/data/18/ext/seg/pg_type.tsv diff --git a/internal/catalog/data/18/ext/tablefunc/META b/check/postgres/catalog/data/18/ext/tablefunc/META similarity index 100% rename from internal/catalog/data/18/ext/tablefunc/META rename to check/postgres/catalog/data/18/ext/tablefunc/META diff --git a/internal/catalog/data/18/ext/tablefunc/pg_aggregate.tsv b/check/postgres/catalog/data/18/ext/tablefunc/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/18/ext/tablefunc/pg_aggregate.tsv rename to check/postgres/catalog/data/18/ext/tablefunc/pg_aggregate.tsv diff --git a/internal/catalog/data/18/ext/tablefunc/pg_cast.tsv b/check/postgres/catalog/data/18/ext/tablefunc/pg_cast.tsv similarity index 100% rename from internal/catalog/data/18/ext/tablefunc/pg_cast.tsv rename to check/postgres/catalog/data/18/ext/tablefunc/pg_cast.tsv diff --git a/internal/catalog/data/18/ext/tablefunc/pg_class.tsv b/check/postgres/catalog/data/18/ext/tablefunc/pg_class.tsv similarity index 100% rename from internal/catalog/data/18/ext/tablefunc/pg_class.tsv rename to check/postgres/catalog/data/18/ext/tablefunc/pg_class.tsv diff --git a/internal/catalog/data/18/ext/tablefunc/pg_operator.tsv b/check/postgres/catalog/data/18/ext/tablefunc/pg_operator.tsv similarity index 100% rename from internal/catalog/data/18/ext/tablefunc/pg_operator.tsv rename to check/postgres/catalog/data/18/ext/tablefunc/pg_operator.tsv diff --git a/internal/catalog/data/18/ext/tablefunc/pg_proc.tsv b/check/postgres/catalog/data/18/ext/tablefunc/pg_proc.tsv similarity index 100% rename from internal/catalog/data/18/ext/tablefunc/pg_proc.tsv rename to check/postgres/catalog/data/18/ext/tablefunc/pg_proc.tsv diff --git a/internal/catalog/data/18/ext/tablefunc/pg_range.tsv b/check/postgres/catalog/data/18/ext/tablefunc/pg_range.tsv similarity index 100% rename from internal/catalog/data/18/ext/tablefunc/pg_range.tsv rename to check/postgres/catalog/data/18/ext/tablefunc/pg_range.tsv diff --git a/internal/catalog/data/18/ext/tablefunc/pg_type.tsv b/check/postgres/catalog/data/18/ext/tablefunc/pg_type.tsv similarity index 100% rename from internal/catalog/data/18/ext/tablefunc/pg_type.tsv rename to check/postgres/catalog/data/18/ext/tablefunc/pg_type.tsv diff --git a/internal/catalog/data/18/ext/unaccent/META b/check/postgres/catalog/data/18/ext/unaccent/META similarity index 100% rename from internal/catalog/data/18/ext/unaccent/META rename to check/postgres/catalog/data/18/ext/unaccent/META diff --git a/internal/catalog/data/18/ext/unaccent/pg_aggregate.tsv b/check/postgres/catalog/data/18/ext/unaccent/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/18/ext/unaccent/pg_aggregate.tsv rename to check/postgres/catalog/data/18/ext/unaccent/pg_aggregate.tsv diff --git a/internal/catalog/data/18/ext/unaccent/pg_cast.tsv b/check/postgres/catalog/data/18/ext/unaccent/pg_cast.tsv similarity index 100% rename from internal/catalog/data/18/ext/unaccent/pg_cast.tsv rename to check/postgres/catalog/data/18/ext/unaccent/pg_cast.tsv diff --git a/internal/catalog/data/18/ext/unaccent/pg_class.tsv b/check/postgres/catalog/data/18/ext/unaccent/pg_class.tsv similarity index 100% rename from internal/catalog/data/18/ext/unaccent/pg_class.tsv rename to check/postgres/catalog/data/18/ext/unaccent/pg_class.tsv diff --git a/internal/catalog/data/18/ext/unaccent/pg_operator.tsv b/check/postgres/catalog/data/18/ext/unaccent/pg_operator.tsv similarity index 100% rename from internal/catalog/data/18/ext/unaccent/pg_operator.tsv rename to check/postgres/catalog/data/18/ext/unaccent/pg_operator.tsv diff --git a/internal/catalog/data/18/ext/unaccent/pg_proc.tsv b/check/postgres/catalog/data/18/ext/unaccent/pg_proc.tsv similarity index 100% rename from internal/catalog/data/18/ext/unaccent/pg_proc.tsv rename to check/postgres/catalog/data/18/ext/unaccent/pg_proc.tsv diff --git a/internal/catalog/data/18/ext/unaccent/pg_range.tsv b/check/postgres/catalog/data/18/ext/unaccent/pg_range.tsv similarity index 100% rename from internal/catalog/data/18/ext/unaccent/pg_range.tsv rename to check/postgres/catalog/data/18/ext/unaccent/pg_range.tsv diff --git a/internal/catalog/data/18/ext/unaccent/pg_type.tsv b/check/postgres/catalog/data/18/ext/unaccent/pg_type.tsv similarity index 100% rename from internal/catalog/data/18/ext/unaccent/pg_type.tsv rename to check/postgres/catalog/data/18/ext/unaccent/pg_type.tsv diff --git a/internal/catalog/data/18/ext/uuid-ossp/META b/check/postgres/catalog/data/18/ext/uuid-ossp/META similarity index 100% rename from internal/catalog/data/18/ext/uuid-ossp/META rename to check/postgres/catalog/data/18/ext/uuid-ossp/META diff --git a/internal/catalog/data/18/ext/uuid-ossp/pg_aggregate.tsv b/check/postgres/catalog/data/18/ext/uuid-ossp/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/18/ext/uuid-ossp/pg_aggregate.tsv rename to check/postgres/catalog/data/18/ext/uuid-ossp/pg_aggregate.tsv diff --git a/internal/catalog/data/18/ext/uuid-ossp/pg_cast.tsv b/check/postgres/catalog/data/18/ext/uuid-ossp/pg_cast.tsv similarity index 100% rename from internal/catalog/data/18/ext/uuid-ossp/pg_cast.tsv rename to check/postgres/catalog/data/18/ext/uuid-ossp/pg_cast.tsv diff --git a/internal/catalog/data/18/ext/uuid-ossp/pg_class.tsv b/check/postgres/catalog/data/18/ext/uuid-ossp/pg_class.tsv similarity index 100% rename from internal/catalog/data/18/ext/uuid-ossp/pg_class.tsv rename to check/postgres/catalog/data/18/ext/uuid-ossp/pg_class.tsv diff --git a/internal/catalog/data/18/ext/uuid-ossp/pg_operator.tsv b/check/postgres/catalog/data/18/ext/uuid-ossp/pg_operator.tsv similarity index 100% rename from internal/catalog/data/18/ext/uuid-ossp/pg_operator.tsv rename to check/postgres/catalog/data/18/ext/uuid-ossp/pg_operator.tsv diff --git a/internal/catalog/data/18/ext/uuid-ossp/pg_proc.tsv b/check/postgres/catalog/data/18/ext/uuid-ossp/pg_proc.tsv similarity index 100% rename from internal/catalog/data/18/ext/uuid-ossp/pg_proc.tsv rename to check/postgres/catalog/data/18/ext/uuid-ossp/pg_proc.tsv diff --git a/internal/catalog/data/18/ext/uuid-ossp/pg_range.tsv b/check/postgres/catalog/data/18/ext/uuid-ossp/pg_range.tsv similarity index 100% rename from internal/catalog/data/18/ext/uuid-ossp/pg_range.tsv rename to check/postgres/catalog/data/18/ext/uuid-ossp/pg_range.tsv diff --git a/internal/catalog/data/18/ext/uuid-ossp/pg_type.tsv b/check/postgres/catalog/data/18/ext/uuid-ossp/pg_type.tsv similarity index 100% rename from internal/catalog/data/18/ext/uuid-ossp/pg_type.tsv rename to check/postgres/catalog/data/18/ext/uuid-ossp/pg_type.tsv diff --git a/internal/catalog/data/18/pg_aggregate.tsv b/check/postgres/catalog/data/18/pg_aggregate.tsv similarity index 100% rename from internal/catalog/data/18/pg_aggregate.tsv rename to check/postgres/catalog/data/18/pg_aggregate.tsv diff --git a/internal/catalog/data/18/pg_cast.tsv b/check/postgres/catalog/data/18/pg_cast.tsv similarity index 100% rename from internal/catalog/data/18/pg_cast.tsv rename to check/postgres/catalog/data/18/pg_cast.tsv diff --git a/internal/catalog/data/18/pg_class.tsv b/check/postgres/catalog/data/18/pg_class.tsv similarity index 100% rename from internal/catalog/data/18/pg_class.tsv rename to check/postgres/catalog/data/18/pg_class.tsv diff --git a/internal/catalog/data/18/pg_operator.tsv b/check/postgres/catalog/data/18/pg_operator.tsv similarity index 100% rename from internal/catalog/data/18/pg_operator.tsv rename to check/postgres/catalog/data/18/pg_operator.tsv diff --git a/internal/catalog/data/18/pg_proc.tsv b/check/postgres/catalog/data/18/pg_proc.tsv similarity index 100% rename from internal/catalog/data/18/pg_proc.tsv rename to check/postgres/catalog/data/18/pg_proc.tsv diff --git a/internal/catalog/data/18/pg_range.tsv b/check/postgres/catalog/data/18/pg_range.tsv similarity index 100% rename from internal/catalog/data/18/pg_range.tsv rename to check/postgres/catalog/data/18/pg_range.tsv diff --git a/internal/catalog/data/18/pg_type.tsv b/check/postgres/catalog/data/18/pg_type.tsv similarity index 100% rename from internal/catalog/data/18/pg_type.tsv rename to check/postgres/catalog/data/18/pg_type.tsv diff --git a/internal/catalog/extension.go b/check/postgres/catalog/extension.go similarity index 100% rename from internal/catalog/extension.go rename to check/postgres/catalog/extension.go diff --git a/internal/catalog/gen/main.go b/check/postgres/catalog/gen/main.go similarity index 98% rename from internal/catalog/gen/main.go rename to check/postgres/catalog/gen/main.go index 312c976..ebc9d1c 100644 --- a/internal/catalog/gen/main.go +++ b/check/postgres/catalog/gen/main.go @@ -20,9 +20,9 @@ import ( "path/filepath" "strings" - "github.com/kr9ly/sqlshape/internal/catalog" - "github.com/kr9ly/sqlshape/internal/oracle" - "github.com/kr9ly/sqlshape/internal/pgparse" + "github.com/kr9ly/sqlshape/check/postgres/v2/catalog" + "github.com/kr9ly/sqlshape/check/postgres/v2/oracle" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" ) // Each query yields one TSV. Column order is the parsing contract in catalog.go. diff --git a/check/postgres/dialect/columns.go b/check/postgres/dialect/columns.go new file mode 100644 index 0000000..2ba4f93 --- /dev/null +++ b/check/postgres/dialect/columns.go @@ -0,0 +1,199 @@ +package dialect + +import ( + "strconv" + + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/check/postgres/v2/catalog" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" + "github.com/kr9ly/sqlshape/v2/x/dialect" +) + +// ColumnOf spells an analyzed result column in the contract: PostgreSQL's unnamed +// column ("?column?") has no name, a void column has Kind Void, a record's fields are the +// analyzer's, a composite's the type's. +func ColumnOf(s *schema.Schema, c analyze.Column) dialect.Column { + out := dialect.Column{Name: c.Name, Nullable: c.Nullable, Source: SourceOf(s, c.Source)} + if out.Name == "?column?" { + out.Name = "" + } + if c.Type.OID == catalog.Void { + out.Type = dialect.Type{Name: "void", Kind: dialect.Void} + return out + } + out.Type = TypeOf(s, c.Type) + out.Type.ElemNotNull = c.ElemNotNull + if len(c.Fields) > 0 { + fields := make([]dialect.Column, 0, len(c.Fields)) + for _, f := range c.Fields { + fields = append(fields, ColumnOf(s, f)) + } + out.Type.Fields = fields + if out.Type.Kind == dialect.Array && out.Type.Elem != nil { + elem := *out.Type.Elem + elem.Fields = fields + out.Type.Elem = &elem + } + } + return out +} + +// ParamOf spells a parameter: its type and the column it stands for. PostgreSQL itself +// resolves a parameter compared with (or assigned to) a domain column to the domain's +// base type -- select_common_type falls back to the base type the moment the other side +// is a placeholder, so t already lost the domain by the time it reaches here. A result +// *column* of the same domain keeps it (TypeOf reads the column's own declared type, +// never the placeholder's resolved one), so without this a plain int64 parameter met +// against a domain column would be silently accepted where -strict means to flag it +// (docs/checks.md "Do not mix domains of different units"). The column t was resolved +// against still names the domain; use it in place of t when its base type is what +// PostgreSQL actually settled on, so the parameter keeps the same unit its column has. +func ParamOf(s *schema.Schema, t schema.TypeRef, src *analyze.Source) dialect.Param { + if src != nil { + if rel := s.ByFullName(src.Table); rel != nil { + if col := rel.Column(src.Column); col != nil { + if pt := s.Types.ByOID(col.Type.OID); pt != nil && pt.Kind == 'd' && pt.BaseType == t.OID { + t = col.Type + } + } + } + } + return dialect.Param{Type: TypeOf(s, t), Source: SourceOf(s, src)} +} + +// SourceOf spells a column's provenance, with what the schema says about the column: the +// key it carries (Identity, following single-column foreign keys to their root primary +// key), the values it is limited to (a CHECK IN list, or the rows a lookup table is seeded +// with), its DEFAULT / generation, its COMMENT. +func SourceOf(s *schema.Schema, src *analyze.Source) *dialect.Source { + if src == nil { + return nil + } + out := &dialect.Source{Table: src.Table, Column: src.Column, NotNull: src.NotNull, Assigned: src.Assigned} + out.Comment = s.Comments[src.Table+"."+src.Column] + if rel := s.ByFullName(src.Table); rel != nil { + if col := rel.Column(src.Column); col != nil { + out.HasDefault = col.Default != nil + out.Generated = col.Identity != 0 || col.Generated != nil + if len(col.Values) > 0 { + out.Values, out.ValuesFrom = col.Values, "check" + } + } + } + if id, ok := identity(s, src.Table, src.Column, 0); ok { + out.Identity = id + if out.Values == nil { + if labels, ok := lookupLabels(s, id); ok { + out.Values, out.ValuesFrom = labels, "seed" + } + } + } + return out +} + +// identity follows single-column PK / FK structure to the root key column. +func identity(s *schema.Schema, table, column string, depth int) (string, bool) { + if depth > 10 { + return "", false + } + rel := s.ByFullName(table) + if rel == nil { + return "", false + } + for _, con := range rel.Constraints { + if con.Kind != schema.ForeignKey { + continue + } + pos := -1 + for i, col := range con.Columns { + if col == column { + pos = i + } + } + if pos < 0 { + continue + } + // a composite FK maps its columns to the referenced key by position + refCols := con.RefColumns + if len(refCols) == 0 { + if ref := s.ByFullName(con.RefTable); ref != nil { + for _, rc := range ref.Constraints { + if rc.Kind == schema.PrimaryKey { + refCols = rc.Columns + } + } + } + } + if pos < len(refCols) { + if id, ok := identity(s, con.RefTable, refCols[pos], depth+1); ok { + return id, true + } + } + } + for _, con := range rel.Constraints { + if con.Kind != schema.PrimaryKey { + continue + } + for _, col := range con.Columns { + if col == column { + // each column of a (possibly composite) primary key is an identity of its own + return table + "." + column, true + } + } + } + return "", false +} + +// lookupLabels returns the values of the key column "table.column" when the table is +// seeded with rows identified by that column alone. +func lookupLabels(s *schema.Schema, id string) ([]string, bool) { + dot := lastDot(id) + if dot < 0 { + return nil, false + } + rel := s.ByFullName(id[:dot]) + if rel == nil || rel.Seed == nil || len(rel.Seed.Key) != 1 || rel.Seed.Key[0] != id[dot+1:] { + return nil, false + } + idx := -1 + for i, c := range rel.Seed.Columns { + if c == id[dot+1:] { + idx = i + } + } + if idx < 0 { + return nil, false + } + labels := make([]string, 0, len(rel.Seed.Rows)) + for _, row := range rel.Seed.Rows { + labels = append(labels, constText(row[idx])) + } + return labels, true +} + +func lastDot(s string) int { + for i := len(s) - 1; i >= 0; i-- { + if s[i] == '.' { + return i + } + } + return -1 +} + +// constText is the value of a constant as Go constants spell it: the string itself for a +// string, digits for an integer; other expressions as SQL text. +func constText(e schema.Expr) string { + if ac := e.GetAConst(); ac != nil { + switch v := ac.Val.(type) { + case *pgparse.A_Const_Sval: + return v.Sval.GetSval() + case *pgparse.A_Const_Ival: + return strconv.Itoa(int(v.Ival.GetIval())) + } + } + if tc := e.GetTypeCast(); tc != nil { + return constText(tc.Arg) + } + return schema.Deparse(e) +} diff --git a/check/postgres/dialect/facts_probe_test.go b/check/postgres/dialect/facts_probe_test.go new file mode 100644 index 0000000..bc5636d --- /dev/null +++ b/check/postgres/dialect/facts_probe_test.go @@ -0,0 +1,84 @@ +package dialect + +import ( + "context" + "database/sql" + "flag" + "os" + "strings" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + + "github.com/kr9ly/sqlshape/check/postgres/v2/oracle" + "github.com/kr9ly/sqlshape/v2/x/stmtprobe" +) + +var ( + factsN = flag.Int("facts-probe-n", 300, "statements the facts probe judges") + factsSeed = flag.Int64("facts-probe-seed", 1, "seed of the facts probe's generator") + factsReport = flag.String("facts-probe-report", "", "write the facts probe's report here (default: the test log)") +) + +// factsKnownUnreached names the alphabet entries the generator does not produce yet on +// PostgreSQL, each with why; an entry reached anyway is reported as stale. +var factsKnownUnreached = map[string]string{ + "pred contains": "range columns and Contains predicates are not generated yet", + "pred origin view": "no producer emits FromView: a view's predicates stay in its leaf's Body", + "pred origin policy": "row-level security policies are not generated yet", + "leaf function": "a set-returning function in FROM is not generated yet", + "leaf single": "a scalar function in FROM is not generated yet", + "leaf key partial": "partial unique indexes are not generated yet", + "leaf key temporal": "WITHOUT OVERLAPS keys are PostgreSQL 18's and not generated yet", + "kind call": "CALL takes no rows of its own to judge", +} + +// TestFactsProbe judges the PostgreSQL analyzer's statement facts against the embedded +// server: x/factsprobe generates schemas, rows and statements, and refutes every claim of +// the facts (a predicate holding on every row, a fixed column, an at-most-one-row proof) +// with the rows the server returns. +func TestFactsProbe(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + o, err := oracle.Start(ctx, "") + if err != nil { + t.Fatal(err) + } + defer o.Close() + db, err := sql.Open("pgx", o.ConnString()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + rep, err := stmtprobe.Run(ctx, stmtprobe.Options{ + Dialect: stmtprobe.Dialect{Header: "-- sqlshape: postgres 17", IntType: "integer", StrType: "text"}, + Load: Load, + DB: db, + Seed: *factsSeed, + N: *factsN, + KnownUnreached: factsKnownUnreached, + Log: t.Logf, + }) + if err != nil { + t.Fatal(err) + } + text := rep.String("facts probe (postgres)") + if *factsReport != "" { + if err := os.WriteFile(*factsReport, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + t.Logf("report: %s", *factsReport) + } else { + t.Log(text) + } + if len(rep.Findings) > 0 { + t.Errorf("%d finding(s), see the report", len(rep.Findings)) + } + if len(rep.Missed) > 0 { + t.Errorf("alphabet entries never reached: %s", strings.Join(rep.Missed, ", ")) + } + if len(rep.Stale) > 0 { + t.Errorf("known-unreached entries that were reached (drop them from the list): %s", strings.Join(rep.Stale, ", ")) + } +} diff --git a/check/postgres/dialect/failure_probe_test.go b/check/postgres/dialect/failure_probe_test.go new file mode 100644 index 0000000..90b5d53 --- /dev/null +++ b/check/postgres/dialect/failure_probe_test.go @@ -0,0 +1,83 @@ +package dialect + +import ( + "context" + "database/sql" + "flag" + "os" + "strings" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + + "github.com/kr9ly/sqlshape/check/postgres/v2/oracle" + "github.com/kr9ly/sqlshape/postgres/v2" + "github.com/kr9ly/sqlshape/v2/x/stmtprobe" +) + +var ( + failN = flag.Int("failure-probe-n", 300, "writes the failure probe judges") + failSeed = flag.Int64("failure-probe-seed", 1, "seed of the failure probe's generator") + failReport = flag.String("failure-probe-report", "", "write the failure probe's report here (default: the test log)") +) + +// failureKnownUnreached names the failure alphabet's entries this dialect's run does not +// reach, each with why. +var failureKnownUnreached = map[string]string{ + "insert ignore": "INSERT IGNORE is MySQL's (ON CONFLICT DO NOTHING is the PostgreSQL shape)", + "replace": "REPLACE is MySQL's", +} + +// TestFailureProbe judges the PostgreSQL analyzer's predicted failure modes against the +// embedded server: x/stmtprobe generates writes against a schema carrying every +// constraint kind, runs them, and requires every constraint error the server raises to be +// one of the predicted violations under a key postgres.Violates matches. +func TestFailureProbe(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + o, err := oracle.Start(ctx, "") + if err != nil { + t.Fatal(err) + } + defer o.Close() + db, err := sql.Open("pgx", o.ConnString()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + rep, err := stmtprobe.RunFailures(ctx, stmtprobe.FailureOptions{ + Options: stmtprobe.Options{ + Dialect: stmtprobe.Dialect{Header: "-- sqlshape: postgres 17", IntType: "integer", StrType: "text"}, + Load: Load, + DB: db, + Seed: *failSeed, + N: *failN, + KnownUnreached: failureKnownUnreached, + Log: t.Logf, + }, + Wrap: postgres.WrapError, + Violates: postgres.Violates[string], + }) + if err != nil { + t.Fatal(err) + } + text := rep.String("failure probe (postgres)") + if *failReport != "" { + if err := os.WriteFile(*failReport, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + t.Logf("report: %s", *failReport) + } else { + t.Log(text) + } + if len(rep.Findings) > 0 { + t.Errorf("%d finding(s), see the report", len(rep.Findings)) + } + if len(rep.Missed) > 0 { + t.Errorf("alphabet entries never reached: %s", strings.Join(rep.Missed, ", ")) + } + if len(rep.Stale) > 0 { + t.Errorf("known-unreached entries that were reached (drop them from the list): %s", strings.Join(rep.Stale, ", ")) + } +} diff --git a/check/postgres/dialect/param_domain_test.go b/check/postgres/dialect/param_domain_test.go new file mode 100644 index 0000000..e8074fa --- /dev/null +++ b/check/postgres/dialect/param_domain_test.go @@ -0,0 +1,83 @@ +package dialect + +import ( + "testing" + + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" + "github.com/kr9ly/sqlshape/v2/x/dialect" +) + +// TestParamOfKeepsDomainOfComparedColumn is docs/checks.md's "Do not mix domains of +// different units (PostgreSQL)": a parameter compared with a domain column +// (`price > {{.Max}}`) should carry that domain the way a domain *result* column already +// does (TypeOf reads the schema column's own declared type), so a plain int64 parameter +// gets the same -strict "carries domain ... as a plain int64" advisory a plain int64 +// result column gets. +// +// PostgreSQL itself does not hand this to us on the wire: a parameter compared against a +// domain column is resolved to the domain's own *base* type (select_common_type falls +// back the moment one side is an unknown-typed placeholder -- domains reuse their base +// type's operators), confirmed by TestDomainNotes ("SELECT balance - $1 FROM users" +// keeps typ "yen" only because the *result* is balance's own expression; the parameter's +// own analyze.Result.Params entry is plain bigint, not the domain). ParamOf recovers the +// domain from the parameter's Source (the column it was compared with) instead. +func TestParamOfKeepsDomainOfComparedColumn(t *testing.T) { + schemaSQL := ` +CREATE DOMAIN yen AS bigint; +CREATE DOMAIN gram AS integer; +CREATE TABLE products (id bigint PRIMARY KEY, price yen NOT NULL, weight gram NOT NULL); +` + s, err := schema.Load(schemaSQL) + if err != nil { + t.Fatal(err) + } + r, aerr := analyze.Analyze(s, `SELECT id FROM products WHERE price > $1`) + if aerr != nil { + t.Fatalf("analyze: %v", aerr) + } + if len(r.Params) != 1 { + t.Fatalf("want 1 param, got %d", len(r.Params)) + } + // the raw analyzer type PostgreSQL itself resolved: the domain's base type, not yen + // -- this is the fact ParamOf has to work around, not a bug in Analyze. + if got := s.Types.Format(r.Params[0]); got != "bigint" { + t.Fatalf("analyze.Result.Params[0] = %s, want bigint (PostgreSQL resolves a parameter met against a domain column to the domain's base type)", got) + } + + out := ResultOf(s, r) + if len(out.Params) != 1 { + t.Fatalf("want 1 param in the contract, got %d", len(out.Params)) + } + p := out.Params[0] + if p.Type.Kind != dialect.Domain { + t.Errorf("Params[0].Type.Kind = %v, want Domain", p.Type.Kind) + } + if p.Type.Named != "yen" { + t.Errorf("Params[0].Type.Named = %q, want %q", p.Type.Named, "yen") + } + if p.Source == nil || p.Source.Table != "products" || p.Source.Column != "price" { + t.Errorf("Params[0].Source = %+v, want products.price", p.Source) + } +} + +// TestParamOfLeavesNonDomainParamsAlone is the negative case: a parameter compared with +// a plain (non-domain) column keeps whatever type PostgreSQL resolved, unchanged. +func TestParamOfLeavesNonDomainParamsAlone(t *testing.T) { + schemaSQL := `CREATE TABLE products (id bigint PRIMARY KEY, qty integer NOT NULL);` + s, err := schema.Load(schemaSQL) + if err != nil { + t.Fatal(err) + } + r, aerr := analyze.Analyze(s, `SELECT id FROM products WHERE qty > $1`) + if aerr != nil { + t.Fatalf("analyze: %v", aerr) + } + out := ResultOf(s, r) + if len(out.Params) != 1 { + t.Fatalf("want 1 param, got %d", len(out.Params)) + } + if out.Params[0].Type.Named != "" { + t.Errorf("Params[0].Type.Named = %q, want \"\" (qty is not a domain)", out.Params[0].Type.Named) + } +} diff --git a/check/postgres/dialect/result.go b/check/postgres/dialect/result.go new file mode 100644 index 0000000..8f30cbf --- /dev/null +++ b/check/postgres/dialect/result.go @@ -0,0 +1,138 @@ +package dialect + +import ( + "strings" + + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" + "github.com/kr9ly/sqlshape/v2/x/dialect" + "github.com/kr9ly/sqlshape/v2/x/facts" +) + +// Analyzer is the PostgreSQL analyzer behind the neutral contract: the checker's one way +// of asking about a statement, whatever the dialect. +type Analyzer struct { + S *schema.Schema + + defined bool + defs []dialect.Definition + fnRefs map[*schema.Function][]analyze.RelationRef + fnAdvice map[*schema.Function][]analyze.Note +} + +// New wraps a loaded schema. +func New(s *schema.Schema) *Analyzer { return &Analyzer{S: s} } + +// Analyze types one statement; a statement PostgreSQL would reject comes back as a +// *dialect.Error (its Code spelled "SQLSTATE 42703"). +func (a *Analyzer) Analyze(sql string) (*dialect.Result, error) { + r, err := analyze.Analyze(a.S, sql) + if err != nil { + return nil, ErrorOf(err) + } + return ResultOf(a.S, r), nil +} + +// Problems are what the loader could not apply of the schema text. +func (a *Analyzer) Problems() []string { + var out []string + for _, p := range a.S.Problems { + out = append(out, p.String()) + } + return out +} + +// Traits are pgx's. +func (a *Analyzer) Traits() dialect.Traits { return Traits } + +// ErrorOf spells an analyzer error in the contract; other errors pass through. +func ErrorOf(err error) error { + ae, ok := err.(*analyze.Error) + if !ok { + return err + } + return &dialect.Error{Message: ae.Message, Code: "SQLSTATE " + ae.Code, Position: int(ae.Position) - 1} +} + +// ResultOf spells an analysis in the contract. Positions become 0-based (-1 unknown). +func ResultOf(s *schema.Schema, r *analyze.Result) *dialect.Result { + out := &dialect.Result{Facts: r.Facts} + for i, t := range r.Params { + var src *analyze.Source + if i < len(r.ParamSources) { + src = r.ParamSources[i] + } + out.Params = append(out.Params, ParamOf(s, t, src)) + } + for _, c := range r.Columns { + out.Columns = append(out.Columns, ColumnOf(s, c)) + } + for _, n := range r.Notes { + out.Notes = append(out.Notes, dialect.Note{Message: n.Message, Position: pos(n.Position), Advisory: n.Advisory(), Param: int(n.Param)}) + } + for _, v := range r.Violations { + out.Violations = append(out.Violations, dialect.Violation{Key: v.Key(), Name: v.Name, Code: "SQLSTATE " + v.Code, Table: v.Table, Columns: v.Columns, Constraint: v.Constraint, Detail: describeViolation(v), Param: int(v.Param)}) + } + for _, ref := range r.Relations { + name := ref.Name + if ref.Schema != "public" { + name = ref.Schema + "." + name + } + kind := facts.Table + switch ref.Kind { + case 'v': + kind = facts.View + case 'm': + kind = facts.MatView + } + out.Relations = append(out.Relations, dialect.RelationRef{Name: name, Schema: ref.Schema, Kind: kind, Position: pos(ref.Position), Target: ref.Target}) + } + for _, u := range r.Uses { + out.Uses = append(out.Uses, facts.Use{Table: u.Table, Column: u.Column, Position: int32(pos(u.Position))}) + } + return out +} + +// pos turns the analyzer's 1-based position (0 unknown) into the contract's 0-based (-1 unknown). +func pos(p int32) int { + if p <= 0 { + return -1 + } + return int(p) - 1 +} + +// describeViolation spells a possible violation the way the checker reports it. +func describeViolation(v analyze.Violation) string { + s := describeViolationAt(v) + if v.Function != "" { + s += ", through " + v.Function + "()" + } + return s +} + +func describeViolationAt(v analyze.Violation) string { + cols := strings.Join(v.Columns, ", ") + switch v.Code { + case "23505": + return "UNIQUE (" + cols + ") on " + v.Table + ", SQLSTATE 23505" + case "23503": + return "FOREIGN KEY (" + cols + ") on " + v.Table + " REFERENCES " + v.RefTable + ", SQLSTATE 23503" + case "23514": + return "CHECK on " + v.Table + " (" + cols + "), SQLSTATE 23514" + case "23502": + return "NOT NULL on " + v.Table + "." + cols + ", SQLSTATE 23502" + case "23P01": + return "EXCLUDE (" + cols + ") on " + v.Table + ", SQLSTATE 23P01" + } + if v.Trigger != "" { + s := "raised by trigger " + v.Trigger + " on " + v.Table + if v.Name != "" { + s += " as " + v.Name + } + return s + ", SQLSTATE " + v.Code + } + if v.Name != "" { + return "raised as " + v.Name + ", SQLSTATE " + v.Code + } + return "SQLSTATE " + v.Code +} diff --git a/check/postgres/dialect/schema.go b/check/postgres/dialect/schema.go new file mode 100644 index 0000000..7f1230e --- /dev/null +++ b/check/postgres/dialect/schema.go @@ -0,0 +1,265 @@ +package dialect + +import ( + "fmt" + "strings" + + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" + "github.com/kr9ly/sqlshape/v2/x/dialect" + "github.com/kr9ly/sqlshape/v2/x/facts" + "github.com/kr9ly/sqlshape/v2/x/obligation" +) + +func init() { + dialect.Register(dialect.Postgres, Load) +} + +// Load is the PostgreSQL loader behind `-- sqlshape: postgres ` (and behind a +// schema that declares no dialect): the text must declare its version, since the version +// decides the grammar, the catalog and the server pgtest runs. +func Load(schemaSQL string) (dialect.Analyzer, error) { + if err := schema.RequireVersion("schema.sql", schemaSQL); err != nil { + return nil, err + } + s, err := analyze.Load(schemaSQL) + if err != nil { + return nil, err + } + return New(s), nil +} + +// Schema is the loaded schema as the checker sees it. +func (a *Analyzer) Schema() dialect.Schema { return a } + +// Contract is the schema as the obligations see it. +func (a *Analyzer) Contract() obligation.Schema { return a.S.Contract() } + +// Lower parses and types a declared predicate against a relation. +func (a *Analyzer) Lower(expr string, rel obligation.Relation) ([]facts.Pred, error) { + return analyze.Lower(a.S, expr, a.S.ByFullName(rel.FullName())) +} + +// Type resolves a declared type name. +func (a *Analyzer) Type(name string) (dialect.Type, bool) { + sch, n := "", name + if i := strings.LastIndex(name, "."); i >= 0 { + sch, n = name[:i], name[i+1:] + } + pt := a.S.Types.Lookup(sch, n) + if pt == nil { + return dialect.Type{}, false + } + return TypeOf(a.S, schema.TypeRef{OID: pt.OID, Typmod: -1}), true +} + +// Source describes a column of a relation as a statement's Source would. +func (a *Analyzer) Source(table, column string) *dialect.Source { + rel := a.Relation(table) + if rel == nil { + return nil + } + col := rel.Column(column) + if col == nil { + return nil + } + return SourceOf(a.S, &analyze.Source{Table: rel.Name, Column: column, NotNull: col.NotNull, Assigned: true}) +} + +// Relation resolves a name the way a program writes it. +func (a *Analyzer) Relation(name string) *dialect.Relation { + sch, n := "", name + if i := strings.LastIndex(name, "."); i >= 0 { + sch, n = name[:i], name[i+1:] + } + rel := a.S.Relation(sch, n) + if rel == nil { + return nil + } + return a.relation(rel) +} + +func (a *Analyzer) relation(rel *schema.Relation) *dialect.Relation { + r := &dialect.Relation{Name: rel.FullName(), Schema: rel.Schema, Kind: facts.Table, Comment: a.S.Comments[rel.FullName()]} + switch rel.Kind { + case schema.View: + r.Kind = facts.View + case schema.MatView: + r.Kind = facts.MatView + } + for _, c := range rel.Columns { + r.Columns = append(r.Columns, dialect.SchemaColumn{Name: c.Name, Type: TypeOf(a.S, c.Type), NotNull: c.NotNull, + HasDefault: c.Default != nil, Identity: c.Identity != 0, Generated: c.Generated != nil, Comment: a.S.Comments[rel.FullName()+"."+c.Name]}) + } + for _, con := range rel.Constraints { + if con.Kind == schema.Unique && con.Predicate == nil { + r.Unique = true + } + } + return r +} + +// Errors lists the schema's own `-- sqlshape: error = ` declarations: every +// function's Raises (a trigger function's own, and a plain function's, the same way). +func (a *Analyzer) Errors() []dialect.ErrorName { + var out []dialect.ErrorName + for _, fn := range a.S.Functions { + for _, r := range fn.Raises { + out = append(out, dialect.ErrorName{Code: r.Code, Name: r.Name, Subject: "function " + fn.Name}) + } + } + return out +} + +// Definitions analyzes the schema's own statements once: function bodies (SQL and +// PL/pgSQL, checked like PostgreSQL does at CREATE time), row-security policies (typed +// like CREATE POLICY does), and view bodies (sqlshape's own findings are the view's). +func (a *Analyzer) Definitions() []dialect.Definition { + a.define() + return a.defs +} + +// Advice: what the schema does less well than it looks (-strict). An enum column that a +// seeded lookup table would serve better, a materialized view without a unique index, row +// security enabled without a policy or read through current_setting, a SECURITY DEFINER +// function that policies do not bind, and the functions' own advisory notes. +func (a *Analyzer) Advice() []dialect.Advice { + a.define() + var out []dialect.Advice + for _, rel := range a.S.Relations { + if rel.Kind != schema.Table || !rel.RowSecurity { + continue + } + if len(rel.Policies) == 0 { + out = append(out, dialect.Advice{Table: rel.FullName(), + Message: fmt.Sprintf("%s has row level security enabled and no policy: every role but the owner sees no rows", rel.FullName())}) + } + for _, pol := range rel.Policies { + for _, name := range analyze.SettingReads(pol) { + out = append(out, dialect.Advice{Table: rel.FullName(), + Message: fmt.Sprintf("policy %s on %s reads current_setting(%q, true): a session that never set it gets NULL, so the predicate hides every row silently; without missing_ok the session fails loudly instead", pol.Name, rel.FullName(), name)}) + } + } + if rel.ForceRowSecurity { + continue + } + // a SECURITY DEFINER function runs as its owner, whom the policies do not bind + // unless the table forces them + for _, fn := range a.S.Functions { + if !fn.SecurityDefiner { + continue + } + for _, ref := range a.fnRefs[fn] { + if ref.Schema == rel.Schema && ref.Name == rel.Name { + out = append(out, dialect.Advice{Table: rel.FullName(), + Message: fmt.Sprintf("function %s is SECURITY DEFINER and reaches %s, whose policies do not bind the owner: rows are unrestricted inside it (ALTER TABLE %s FORCE ROW LEVEL SECURITY applies them)", fn.Name, rel.FullName(), rel.FullName())}) + break + } + } + } + } + for _, fn := range a.S.Functions { + for _, n := range a.fnAdvice[fn] { + // a function's own advisory is not about one relation: it may read or write + // several, or none + out = append(out, dialect.Advice{Message: fmt.Sprintf("function %s: %s", fn.Name, n.Message)}) + } + } + for _, rel := range a.S.Relations { + if rel.Kind == schema.Table { + // a value set kept as an enum cannot lose or reorder a label without the type + // being rebuilt under every column (see migrate); a seeded lookup table changes + // with a MERGE, its rows can carry a label and an order, and the checker reads + // it just as well + for _, col := range rel.Columns { + if t := TypeOf(a.S, col.Type); t.Kind == dialect.Enum && !rel.Temp { + out = append(out, dialect.Advice{Table: rel.FullName(), Column: col.Name, + Message: fmt.Sprintf("%s.%s is enum %s: a seeded lookup table (rows in schema.sql, referenced by a foreign key) is easier to change — an enum cannot drop or reorder a label without being recreated under every column — and is checked the same way", rel.FullName(), col.Name, t.Named)}) + } + } + } + if rel.Kind != schema.MatView { + continue + } + if !a.relation(rel).Unique { + out = append(out, dialect.Advice{Table: rel.FullName(), + Message: fmt.Sprintf("materialized view %s has no unique index, so REFRESH MATERIALIZED VIEW CONCURRENTLY is not possible", rel.FullName())}) + } + } + return out +} + +// define runs the schema's own statements once. +func (a *Analyzer) define() { + if a.defined { + return + } + a.defined = true + a.fnRefs = map[*schema.Function][]analyze.RelationRef{} + a.fnAdvice = map[*schema.Function][]analyze.Note{} + for _, fn := range a.S.Functions { + fr, err := analyze.AnalyzeFunction(a.S, fn) + if err != nil { + a.defs = append(a.defs, dialect.Definition{What: "function " + fn.Name, Err: err.Error()}) + continue + } + a.fnRefs[fn] = fr.Relations + for _, st := range fr.Statements { + what := "function " + fn.Name + if st.Line > 0 { + what = fmt.Sprintf("%s: line %d", what, st.Line) + } + a.defs = append(a.defs, dialect.Definition{What: what, Facts: st.Facts}) + } + d := dialect.Definition{What: "function " + fn.Name} + for _, n := range fr.Notes { + if n.Advisory() { + a.fnAdvice[fn] = append(a.fnAdvice[fn], n) + } else { + d.Notes = append(d.Notes, dialect.Note{Message: n.Message, Position: pos(n.Position)}) + } + } + if len(d.Notes) > 0 { + a.defs = append(a.defs, d) + } + } + for _, rel := range a.S.Relations { + for _, pol := range rel.Policies { + d := dialect.Definition{What: fmt.Sprintf("policy %s on %s", pol.Name, rel.FullName())} + notes, err := analyze.AnalyzePolicy(a.S, rel, pol) + if err != nil { + d.Err = err.Error() + } + for _, n := range notes { + if !n.Advisory() { + d.Notes = append(d.Notes, dialect.Note{Message: n.Message, Position: pos(n.Position)}) + } + } + if d.Err != "" || len(d.Notes) > 0 { + a.defs = append(a.defs, d) + } + } + if len(rel.Policies) > 0 && !rel.RowSecurity { + a.defs = append(a.defs, dialect.Definition{Err: fmt.Sprintf("%s has policies but row level security is not enabled, so they do not apply: ALTER TABLE %s ENABLE ROW LEVEL SECURITY", rel.FullName(), rel.FullName())}) + } + } + for _, rel := range a.S.Relations { + if rel.Kind != schema.View && rel.Kind != schema.MatView { + continue + } + d := dialect.Definition{What: "view " + rel.Name} + r, err := analyze.AnalyzeView(a.S, rel) + if err != nil { + d.Err = err.Error() + a.defs = append(a.defs, d) + continue + } + for _, n := range r.Notes { + if !n.Advisory() { + d.Notes = append(d.Notes, dialect.Note{Message: n.Message, Position: pos(n.Position)}) + } + } + d.Facts = r.Facts + a.defs = append(a.defs, d) + } +} diff --git a/check/postgres/dialect/types.go b/check/postgres/dialect/types.go new file mode 100644 index 0000000..f5d0f92 --- /dev/null +++ b/check/postgres/dialect/types.go @@ -0,0 +1,219 @@ +// Package dialect is the PostgreSQL adapter to the checker's dialect-neutral contract +// (x/dialect): it spells the analyzer's types, results and findings in the shape every +// dialect shares, so that the frontend reads PostgreSQL the way it reads MySQL. The Go +// type table lives here, as data: what pgx v5 scans a type into and encodes it from. +package dialect + +import ( + "github.com/kr9ly/sqlshape/check/postgres/v2/catalog" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" + "github.com/kr9ly/sqlshape/v2/x/dialect" +) + +const pgtype = "github.com/jackc/pgx/v5/pgtype." + +// Traits of pgx: any Go string encodes as a parameter of any type (text format), and +// every pgtype value carries Valid. +var Traits = dialect.Traits{TextParams: true, NullWrappers: []string{"jackc/pgx/v5/pgtype"}} + +func fits(gos ...string) []dialect.GoFit { + out := make([]dialect.GoFit, len(gos)) + for i, g := range gos { + out[i] = dialect.GoFit{Go: g} + } + return out +} + +func lossy(g, why string) dialect.GoFit { return dialect.GoFit{Go: g, Lossy: why} } + +// TypeOf spells a PostgreSQL type as the contract's Type: domains keep their name over +// their Base, arrays and ranges carry their Elem, composites their Fields. Result and +// Param are pgx's table, best first (the first is what a struct written from the SQL +// uses; an option the program already imports is preferred by the checker). +func TypeOf(s *schema.Schema, ref schema.TypeRef) dialect.Type { + return typeOf(s, ref, 0) +} + +func typeOf(s *schema.Schema, ref schema.TypeRef, depth int) dialect.Type { + pt := s.Types.ByOID(ref.OID) + if pt == nil || depth > 8 { + return dialect.Type{Name: s.Types.Format(ref)} + } + t := dialect.Type{Name: s.Types.Format(ref)} + if pt.Schema != "" && pt.Schema != "pg_catalog" || pt.Kind == 'e' || pt.Kind == 'd' || pt.Kind == 'c' || pt.Kind == 'r' || pt.Kind == 'm' { + t.Named = qualified(s, pt) + } + switch pt.Kind { + case 'd': + base := typeOf(s, schema.TypeRef{OID: pt.BaseType, Typmod: -1}, depth+1) + t.Kind, t.Base = dialect.Domain, &base + t.Result, t.Param = base.Result, base.Param + t.Elem, t.Fields, t.Labels = base.Elem, base.Fields, base.Labels + return t + case 'e': + t.Kind, t.Labels = dialect.Enum, s.Types.Enums[pt.OID] + t.Result, t.Param = fits("string"), fits("string") + return t + case 'c': + t.Kind = dialect.Composite + t.Fields = rowFields(s, pt.OID, depth) + t.Result, t.Param = fits("struct"), fits("struct") + return t + case 'r': + if rng := s.Types.RangeOf(pt.OID); rng != nil { + sub := typeOf(s, schema.TypeRef{OID: rng.Subtype, Typmod: -1}, depth+1) + t.Kind, t.Elem = dialect.Range, &sub + t.Result, t.Param = fits(pgtype+"Range[$elem]"), fits(pgtype+"Range[$elem]") + } + return t + case 'm': + if rng := s.Types.RangeOfMulti(pt.OID); rng != nil { + r := typeOf(s, schema.TypeRef{OID: rng.OID, Typmod: -1}, depth+1) + t.Kind, t.Elem = dialect.Multirange, &r + t.Result, t.Param = fits(pgtype+"Multirange[$elem]"), fits(pgtype+"Multirange[$elem]") + } + return t + } + if pt.IsArray() { + elem := typeOf(s, schema.TypeRef{OID: pt.Elem, Typmod: -1}, depth+1) + t.Kind, t.Elem = dialect.Array, &elem + t.Result, t.Param = fits("[]$elem"), fits("[]$elem") + if elem.Unknown() { + t.Result, t.Param = nil, nil + } + return t + } + t.Result, t.Param = scalarFits(pt) + if pt.OID == catalog.Record { + t.Kind = dialect.Record + } + return t +} + + +// qualified is the name a `// sqlshape: type X` declaration uses for a user type. +func qualified(s *schema.Schema, pt *catalog.Type) string { + if sch := s.Types.Schemas[pt.OID]; sch != "" && sch != "public" { + return sch + "." + pt.Name + } + if pt.Schema != "" && pt.Schema != "pg_catalog" && pt.Schema != "public" { + return pt.Schema + "." + pt.Name + } + return pt.Name +} + +// rowFields are a composite type's columns: the relation whose row type it is. A +// composite value's fields are not subject to the table's NOT NULL, and their nullability +// in a result is the analyzer's to say (ColumnOf takes its fields); here they are plain. +func rowFields(s *schema.Schema, oid catalog.OID, depth int) []dialect.Column { + for _, r := range s.Relations { + if r.RowType != oid { + continue + } + var out []dialect.Column + for _, c := range r.Columns { + out = append(out, dialect.Column{Name: c.Name, Type: typeOf(s, c.Type, depth+1)}) + } + return out + } + return nil +} + +// scalarFits is pgx's table for a base type, result and parameter directions. Each entry is +// what pgx v5 actually scans or encodes, verified against a running PG; keep it honest +// rather than generous, since a wrong fit only fails at runtime. +func scalarFits(pt *catalog.Type) (result, param []dialect.GoFit) { + switch pt.OID { + case catalog.Int2: + return fits("int16", "int32", "int64", "int"), + []dialect.GoFit{{Go: "int16"}, lossy("int32", "int32 into smallint may overflow"), lossy("int64", "int64 into smallint may overflow"), lossy("int", "int into smallint may overflow")} + case catalog.Int4: + return []dialect.GoFit{{Go: "int32"}, {Go: "int64"}, {Go: "int"}, lossy("int16", "integer into int16")}, + []dialect.GoFit{{Go: "int16"}, {Go: "int32"}, lossy("int64", "int64 into integer may overflow"), lossy("int", "int into integer may overflow")} + case catalog.Int8: + return []dialect.GoFit{{Go: "int64"}, {Go: "int"}, lossy("int32", "bigint into int32")}, + fits("int32", "int64", "int") + case catalog.OIDType: + return fits("uint32"), fits("uint32", "int32", "int64", "int", "uint64", "uint") + case catalog.Float4: + return fits("float32", "float64"), + []dialect.GoFit{{Go: "float32"}, lossy("float64", "float64 into real loses precision")} + case catalog.Float8: + return []dialect.GoFit{{Go: "float64"}, lossy("float32", "double precision into float32")}, + fits("float32", "float64") + case catalog.Numeric: + exact := []string{pgtype + "Numeric", "github.com/shopspring/decimal.Decimal", "github.com/cockroachdb/apd.Decimal", "math/big.Rat", "string"} + return append(fits(exact...), + lossy("float64", "numeric into float64 loses precision"), lossy("float32", "numeric into float32 loses precision"), + lossy("int64", "numeric into int64 drops the fraction"), lossy("int", "numeric into int drops the fraction"), lossy("int32", "numeric into int32 drops the fraction")), + fits(append(exact, "float64", "float32", "int64", "int", "int32")...) + case catalog.Bool: + return fits("bool"), fits("bool") + case catalog.Text, catalog.Varchar, catalog.BPChar, catalog.Name, catalog.Char, catalog.Cstring: + return fits("string"), fits("string") + case catalog.Bytea: + return fits("[]byte"), fits("[]byte") + case catalog.UUID: + return fits("string", "github.com/google/uuid.UUID", "[16]byte"), fits("string", "github.com/google/uuid.UUID", "[16]byte") + case catalog.Timestamp: + advice := "timestamp without time zone into time.Time: which zone the value is in becomes the application's implicit choice (prefer timestamptz)" + return []dialect.GoFit{{Go: "time.Time", Advice: advice}}, []dialect.GoFit{{Go: "time.Time", Advice: advice}} + case catalog.Date: + advice := "date into time.Time: a zone conversion can move the day (keep it at UTC midnight or use a civil date type)" + return []dialect.GoFit{{Go: "time.Time", Advice: advice}}, []dialect.GoFit{{Go: "time.Time", Advice: advice}} + case catalog.TimestampTZ: + return fits("time.Time"), fits("time.Time") + case catalog.Time: + return fits("time.Time", "string"), fits("time.Time", "string") + case catalog.TimeTZ: + return fits("string"), fits("string") // pgx has no timetz codec: text only + case catalog.Interval: + durationLossy := lossy("time.Duration", "interval into time.Duration approximates months as 30 days; PostgreSQL's own calendar arithmetic on the same interval can land on a different day") + return []dialect.GoFit{durationLossy}, []dialect.GoFit{durationLossy} + case catalog.JSON, catalog.JSONB: + return fits("encoding/json.RawMessage", "json"), fits("encoding/json.RawMessage", "json") + case catalog.Record: + return fits("struct"), fits("struct") + } + if pt.Schema == "" || pt.Schema == "pg_catalog" { + switch pt.Name { + case "inet": + return fits("net/netip.Prefix", "net/netip.Addr"), fits("net/netip.Prefix", "net/netip.Addr") + case "cidr": + return fits("net/netip.Prefix"), fits("net/netip.Prefix") + case "macaddr", "macaddr8": + return fits("net.HardwareAddr", "string", "[]byte"), fits("net.HardwareAddr", "string", "[]byte") + case "bit", "varbit": + return fits(pgtype + "Bits"), fits(pgtype + "Bits") + case "point": + return fits(pgtype + "Point"), fits(pgtype + "Point") + case "lseg": + return fits(pgtype + "Lseg"), fits(pgtype + "Lseg") + case "path": + return fits(pgtype + "Path"), fits(pgtype + "Path") + case "box": + return fits(pgtype + "Box"), fits(pgtype + "Box") + case "polygon": + return fits(pgtype + "Polygon"), fits(pgtype + "Polygon") + case "line": + return fits(pgtype + "Line"), fits(pgtype + "Line") + case "circle": + return fits(pgtype + "Circle"), fits(pgtype + "Circle") + case "tsvector": + return fits(pgtype + "TSVector"), fits(pgtype + "TSVector") + case "xml": + return fits("string", "[]byte"), fits("string", "[]byte") + case "money", "tsquery", "jsonpath", "tid", "pg_lsn", "txid_snapshot", "pg_snapshot", "aclitem", "regclass", "regtype", "regproc", "regprocedure", "regoper", "regoperator", "regnamespace", "regrole", "regconfig", "regdictionary", "regcollation": + return fits("string"), fits("string") + } + } + switch pt.Name { + case "hstore": + // the runtime registers pgx's HstoreCodec: map[string]*string (NULL values) or pgtype.Hstore + return []dialect.GoFit{{Go: "map[string]*string"}, {Go: pgtype + "Hstore"}, lossy("map[string]string", "hstore into map[string]string fails at scan time when a value is NULL; use map[string]*string")}, + fits("map[string]*string", pgtype+"Hstore", "map[string]string") + case "citext", "ltree", "lquery", "ltxtquery": + return fits("string"), fits("string") + } + return nil, nil +} diff --git a/check/postgres/diff/alphabet.go b/check/postgres/diff/alphabet.go new file mode 100644 index 0000000..5ffd2c2 --- /dev/null +++ b/check/postgres/diff/alphabet.go @@ -0,0 +1,254 @@ +package diff + +// Alphabet: the migrate probe (check/postgres/migrate/probe_test.go) needs a coverage +// unit for its random schema pairs. The planner's whole input is a diff, so the unit +// lives here: every (Op, Kind[, Field]) triple Compare's differ can produce. Op is '+' / +// '-' / '~'; Field is only set for '~' (a key of the Props map the differ compared). +// +// The triples are mined mechanically from the same functions Compare itself calls +// (relProps, colProps, conProps, idxProps, ruleProps, polProps, fnProps, trgProps, and +// UserTypes for enum / domain / composite / range) against alphabetSQL, a schema built +// so that one object exercises every optional branch each of them has. A field a future +// change adds unconditionally to one of those functions shows up here automatically; one +// gated by a brand new condition needs alphabetSQL extended to trigger it, same as any +// other missed branch in a hand-picked fixture -- there is no way around that, chosen +// over hardcoding the key lists a second time (which is exactly the copy diff-outgrows +// silently that this alphabet exists to catch). +// +// Two things are not Props-mined, both because they are not a Props map at all: which +// Kinds Compare ever Adds / Drops versus Alter-only (rows) or Alter-never (schema, +// extension) -- that grouping is read off the differ's call sites and hardcoded in +// buildAlphabet; and rows / comment's single Field ("row " / "text"), added by hand +// for the same reason. + +import ( + "fmt" + "sort" + "strings" + "sync" + + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" +) + +// AlphabetEntry is one (Op, Kind[, Field]) triple. +type AlphabetEntry struct { + Op Op + Kind string + Field string // "" for Add / Drop +} + +func (e AlphabetEntry) String() string { + if e.Field == "" { + return fmt.Sprintf("%c %s", e.Op, e.Kind) + } + return fmt.Sprintf("%c %s %s", e.Op, e.Kind, e.Field) +} + +// NormalizeField maps a Field.Name that carries a user-chosen identifier -- a domain's +// CHECK constraint name ("check ck1"), a composite type's attribute name ("attribute +// a"), a seeded row's key ("row 3") -- to the class Alphabet lists for it. A real +// Change's Field.Name needs this before it is looked up against Alphabet's entries: the +// literal name is the generator's (or the user's) to choose, not part of the alphabet. +func NormalizeField(kind, name string) string { + switch { + case kind == "domain" && strings.HasPrefix(name, "check "): + return "check " + case kind == "composite" && strings.HasPrefix(name, "attribute "): + return "attribute " + case kind == "rows" && strings.HasPrefix(name, "row "): + return "row " + } + return name +} + +// alphabetOnlyAdds are Kinds Compare only ever Adds / Drops, never Alters (no Props call +// exists for them): schema and extension are name sets (differ.strings), not diffed +// object properties. +var alphabetNoAlter = map[string]bool{"schema": true, "extension": true} + +// alphabetAlterOnly are Kinds Compare only ever Alters: d.rows calls d.add(Alter, "rows", +// ...) alone, never Add / Drop (a row's own presence is a Field within the change, not +// the change itself). +var alphabetAlterOnly = map[string]bool{"rows": true} + +var ( + alphabetOnce sync.Once + alphabetCache []AlphabetEntry + alphabetErr error +) + +// Alphabet lists every (Op, Kind[, Field]) triple Compare's differ can produce (see the +// package doc comment above for how). The result is cached: the fixture parses once. +func Alphabet() ([]AlphabetEntry, error) { + alphabetOnce.Do(func() { + alphabetCache, alphabetErr = buildAlphabet() + }) + return alphabetCache, alphabetErr +} + +func buildAlphabet() ([]AlphabetEntry, error) { + s, err := schema.Load(alphabetSQL) + if err != nil { + return nil, fmt.Errorf("alphabet fixture: %w", err) + } + if len(s.Problems) > 0 { + return nil, fmt.Errorf("alphabet fixture: %v", s.Problems) + } + + fields := map[string]map[string]bool{} + add := func(kind string, props map[string]string) { + if fields[kind] == nil { + fields[kind] = map[string]bool{} + } + for k := range props { + fields[kind][NormalizeField(kind, k)] = true + } + } + need := func(cond bool, format string, args ...any) error { + if !cond { + return fmt.Errorf("alphabet fixture: "+format, args...) + } + return nil + } + + ut := UserTypes(s) + for _, n := range []string{"dom", "comp", "e", "myrange"} { + u, ok := ut[n] + if err := need(ok, "type %s missing", n); err != nil { + return nil, err + } + add(u.Kind, u.Props) + } + + relKindOf := map[string]string{ + "parent": "table", "child": "table", "base": "table", "kid": "table", "typed": "table", + "t": "table", "v": "view", "mv": "matview", "seq": "sequence", + } + rels := map[string]*schema.Relation{} + for n, kind := range relKindOf { + r := s.Relation("", n) + if err := need(r != nil, "relation %s missing", n); err != nil { + return nil, err + } + rels[n] = r + add(kind, relProps(s, r)) + } + + t := rels["t"] + for _, c := range t.Columns { + add("column", colProps(s, c)) + } + for _, r := range []*schema.Relation{t, rels["base"]} { + for _, c := range r.Constraints { + add("constraint", conProps(c)) + } + } + for _, i := range t.Indexes { + add("index", idxProps(i)) + } + for _, rd := range t.Rules() { + add("rule", ruleProps(rd)) + } + for _, p := range t.Policies { + add("policy", polProps(p)) + } + for _, fn := range s.Functions { + add("function", fnProps(s, fn)) + } + if err := need(len(s.Triggers) > 0, "no trigger in the fixture"); err != nil { + return nil, err + } + for _, trg := range s.Triggers { + add("trigger", trgProps(trg)) + } + // rows / comment: not a Props() map (see the package doc comment). + add("rows", map[string]string{"row ": ""}) + add("comment", map[string]string{"text": ""}) + + var kinds []string + for k := range fields { + kinds = append(kinds, k) + } + for k := range alphabetNoAlter { + if fields[k] == nil { + kinds = append(kinds, k) + } + } + sort.Strings(kinds) + + var out []AlphabetEntry + for _, k := range kinds { + if !alphabetAlterOnly[k] { + out = append(out, AlphabetEntry{Op: Add, Kind: k}, AlphabetEntry{Op: Drop, Kind: k}) + } + if alphabetNoAlter[k] { + continue + } + var fs []string + for f := range fields[k] { + fs = append(fs, f) + } + sort.Strings(fs) + for _, f := range fs { + out = append(out, AlphabetEntry{Op: Alter, Kind: k, Field: f}) + } + } + return out, nil +} + +// alphabetSQL: a schema built to have one object triggering every optional branch of +// every Props-computing function (see Alphabet's doc comment). "-- sqlshape: postgres +// 18" opts into the PG18-only forms it uses (NOT ENFORCED, WITHOUT OVERLAPS / PERIOD, +// VIRTUAL generated columns). +const alphabetSQL = `-- sqlshape: postgres 18 +CREATE DOMAIN dom AS integer NOT NULL CHECK (VALUE > 0); +CREATE TYPE comp AS (a integer, b text); +CREATE TYPE e AS ENUM ('x', 'y'); +CREATE TYPE myrange AS RANGE (subtype = integer); + +CREATE TABLE parent (id integer PRIMARY KEY) PARTITION BY RANGE (id); +CREATE TABLE child PARTITION OF parent FOR VALUES FROM (1) TO (100); + +CREATE TABLE base (id integer PRIMARY KEY, n integer, valid_at daterange, CONSTRAINT base_pk PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)); +CREATE TABLE kid (n2 integer) INHERITS (base); + +CREATE TABLE typed OF comp; + +CREATE TABLE t ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + a integer NOT NULL DEFAULT 1, + g integer GENERATED ALWAYS AS (a + 1) STORED, + gv integer GENERATED ALWAYS AS (a + 2) VIRTUAL, + u integer, + w integer, + valid_at daterange, + CONSTRAINT t_u_key UNIQUE (u) DEFERRABLE, + CONSTRAINT t_w_key UNIQUE NULLS NOT DISTINCT (w), + CONSTRAINT t_a_check CHECK (a > 0) NOT ENFORCED, + CONSTRAINT t_fk FOREIGN KEY (u) REFERENCES base (id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT t_temporal_fk FOREIGN KEY (u, PERIOD valid_at) REFERENCES base (id, PERIOD valid_at), + CONSTRAINT t_excl EXCLUDE USING btree (a WITH =) WHERE (a > 0) +); +CREATE INDEX t_a_idx ON t (a) WHERE (a > 0); +ALTER TABLE t ENABLE ROW LEVEL SECURITY; +ALTER TABLE t FORCE ROW LEVEL SECURITY; +CREATE POLICY t_pol ON t AS RESTRICTIVE FOR SELECT TO some_role USING (a > 0) WITH CHECK (a > 0); +COMMENT ON TABLE t IS 'a table'; +COMMENT ON COLUMN t.a IS 'a column'; + +CREATE VIEW v AS SELECT id, a FROM t WITH CASCADED CHECK OPTION; +CREATE MATERIALIZED VIEW mv AS SELECT id, a FROM t; + +CREATE SEQUENCE seq OWNED BY t.a; + +CREATE FUNCTION f(x integer DEFAULT 1, OUT y integer) LANGUAGE sql AS $$ SELECT x $$; +CREATE FUNCTION agg_f(x integer) RETURNS integer LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT x $$; +CREATE PROCEDURE p1(x integer) LANGUAGE sql AS $$ SELECT x $$; +CREATE AGGREGATE myagg(integer) (sfunc = int4pl, stype = integer); +CREATE FUNCTION winfn(x integer) RETURNS integer LANGUAGE internal WINDOW AS 'row_number'; + +CREATE FUNCTION tr_fn() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$; +CREATE TRIGGER tr AFTER INSERT OR UPDATE OF a OR DELETE ON t FOR EACH ROW EXECUTE FUNCTION tr_fn(); + +CREATE RULE r AS ON INSERT TO t DO INSTEAD NOTHING; +` diff --git a/internal/diff/diff.go b/check/postgres/diff/diff.go similarity index 98% rename from internal/diff/diff.go rename to check/postgres/diff/diff.go index a6ac4b7..86fd940 100644 --- a/internal/diff/diff.go +++ b/check/postgres/diff/diff.go @@ -13,9 +13,9 @@ import ( "sort" "strings" - "github.com/kr9ly/sqlshape/internal/catalog" - "github.com/kr9ly/sqlshape/internal/pgparse" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/catalog" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" ) // Op is what happens to an object. @@ -228,12 +228,13 @@ func relProps(s *schema.Schema, r *schema.Relation) map[string]string { if len(parents) > 0 { if r.IsPartition { p["partition of"] = strings.Join(parents, ", ") + p["partition bound"] = r.PartBound } else { p["inherits"] = strings.Join(parents, ", ") } } if len(r.PartKey) > 0 { - p["partition key"] = strings.Join(r.PartKey, ", ") + p["partition key"] = r.PartStrategy + " (" + r.PartKeyText + ")" } if r.OfType != 0 { p["of type"] = s.Types.Format(schema.TypeRef{OID: r.OfType, Typmod: -1}) diff --git a/internal/diff/diff_test.go b/check/postgres/diff/diff_test.go similarity index 97% rename from internal/diff/diff_test.go rename to check/postgres/diff/diff_test.go index 015eed2..77272df 100644 --- a/internal/diff/diff_test.go +++ b/check/postgres/diff/diff_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" ) func load(t *testing.T, sql string) *schema.Schema { @@ -178,9 +178,12 @@ CREATE TABLE p_2024 PARTITION OF p FOR VALUES FROM ('2024-01-01') TO ('2025-01-0 if props["partition of"] != "p" { t.Errorf("partition of = %q, want %q", props["partition of"], "p") } + if got := props["partition bound"]; got != "FOR VALUES FROM ('2024-01-01') TO ('2025-01-01')" { + t.Errorf("partition bound = %q, want %q", got, "FOR VALUES FROM ('2024-01-01') TO ('2025-01-01')") + } parent := s.Relation("", "p") - if got := relProps(s, parent)["partition key"]; got != "d" { - t.Errorf("partition key = %q, want %q", got, "d") + if got := relProps(s, parent)["partition key"]; got != "RANGE (d)" { + t.Errorf("partition key = %q, want %q", got, "RANGE (d)") } }) t.Run("inherits", func(t *testing.T) { diff --git a/internal/dump/dump.go b/check/postgres/dump/dump.go similarity index 97% rename from internal/dump/dump.go rename to check/postgres/dump/dump.go index a0e7330..125b4f3 100644 --- a/internal/dump/dump.go +++ b/check/postgres/dump/dump.go @@ -27,10 +27,10 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/oracle" - "github.com/kr9ly/sqlshape/internal/pgparse" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/check/postgres/v2/oracle" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" ) // Binary is the pg_dump executable: $SQLSHAPE_PG_DUMP, else "pg_dump" on PATH. The diff --git a/internal/dump/dump_test.go b/check/postgres/dump/dump_test.go similarity index 97% rename from internal/dump/dump_test.go rename to check/postgres/dump/dump_test.go index a912cea..595fbc3 100644 --- a/internal/dump/dump_test.go +++ b/check/postgres/dump/dump_test.go @@ -8,9 +8,9 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/diff" - "github.com/kr9ly/sqlshape/internal/pgparse" + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/check/postgres/v2/diff" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" ) func TestNormalize(t *testing.T) { @@ -78,7 +78,7 @@ func TestCanonicalFixedPoint(t *testing.T) { defer srv.Close() for _, ex := range []string{"1-tables", "2-views", "3-database-api", "4-everything"} { t.Run(ex, func(t *testing.T) { - sql, err := os.ReadFile(filepath.Join("../../examples", ex, "schema.sql")) + sql, err := os.ReadFile(filepath.Join("../../../examples", ex, "schema.sql")) if err != nil { t.Fatal(err) } @@ -109,7 +109,7 @@ func TestCanonicalFixedPoint(t *testing.T) { func TestCanonicalChanges(t *testing.T) { requirePgDump(t) ctx := context.Background() - base, err := os.ReadFile("../../examples/1-tables/schema.sql") + base, err := os.ReadFile("../../../examples/1-tables/schema.sql") if err != nil { t.Fatal(err) } diff --git a/check/postgres/go.mod b/check/postgres/go.mod new file mode 100644 index 0000000..a6aff70 --- /dev/null +++ b/check/postgres/go.mod @@ -0,0 +1,27 @@ +module github.com/kr9ly/sqlshape/check/postgres/v2 + +go 1.26.1 + +// The PostgreSQL side of the checker: the analyzer, the schema loader, the embedded +// catalog, the libpg_query parser (WebAssembly), the oracle (embedded PostgreSQL), the +// migration tools. Tool-facing: cmd/sqlshape and pgtest import it; its packages are +// exported so that they can, and carry no compatibility promise of their own. The root +// module is required by version; scripts/release.sh keeps it at the release's version. + +require ( + github.com/fergusstrange/embedded-postgres v1.34.0 + github.com/jackc/pgx/v5 v5.10.0 + github.com/kr9ly/sqlshape/postgres/v2 v2.0.0-rc.2 + github.com/kr9ly/sqlshape/v2 v2.0.0-rc.2 + github.com/tetratelabs/wazero v1.12.0 + google.golang.org/protobuf v1.36.12 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/check/postgres/go.sum b/check/postgres/go.sum new file mode 100644 index 0000000..9797fc3 --- /dev/null +++ b/check/postgres/go.sum @@ -0,0 +1,44 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fergusstrange/embedded-postgres v1.34.0 h1:c6RKhPKFsLVU+Tdxsx8q0UxCHsvZZ/iShAnljRBXs6s= +github.com/fergusstrange/embedded-postgres v1.34.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr9ly/sqlshape/v2 v2.0.0-rc.2 h1:BJLzDY0RI2+U3qcnyuRa6uLktWF+QdaEG2jmytF8Voc= +github.com/kr9ly/sqlshape/v2 v2.0.0-rc.2/go.mod h1:qMnSUVDy+MMtTjCCBq8TKTyPgLvzFCbvawmWFEiix8s= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/check/postgres/migrate/adv_migrate3_test.go b/check/postgres/migrate/adv_migrate3_test.go new file mode 100644 index 0000000..7488428 --- /dev/null +++ b/check/postgres/migrate/adv_migrate3_test.go @@ -0,0 +1,187 @@ +package migrate + +// Adversarial cases for lane "migrate", round 3: diff / plan / apply behavior around +// generated columns, probed against a running PostgreSQL (17 for the STORED-expression +// cases, 18 for the VIRTUAL-vs-STORED case, since VIRTUAL generated columns are new in +// PostgreSQL 18 and dumping a PG18 database needs pg_dump 18). Each test documents the +// *correct* behavior and therefore fails until fixed. + +import ( + "context" + "strings" + "testing" + + "github.com/kr9ly/sqlshape/check/postgres/v2/diff" + "github.com/kr9ly/sqlshape/check/postgres/v2/dump" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" +) + +// TestAdvMigrateGeneratedColumnRewriteRestoresDependentIndex: migrate.go's alterTable +// has no in-place ALTER for a changed generation expression (PostgreSQL has none +// either), so it emits ALTER TABLE ... DROP COLUMN b; ALTER TABLE ... ADD COLUMN b ... +// (migrate.go alterTable, the "generated" branch). PostgreSQL's DROP COLUMN silently +// takes any index defined solely on that column down with it (no CASCADE needed, +// verified below), but the plan does not know this happened and never re-emits the +// index's CREATE statement -- because the "adds" phase's index pass only creates an +// index when its definition differs from (or is missing from) the *modeled* from-schema, +// and the model still says the index exists (it was never told about the DROP COLUMN's +// side effect). The result: a schema.sql edit that only changes a generation expression +// silently deletes a unique index, with no error and no note, and the database ends up +// missing an index the target schema still declares. +// +// Repro (PG 17): +// +// CREATE TABLE t (id bigint PRIMARY KEY, a integer NOT NULL, b integer GENERATED ALWAYS AS (a * 2) STORED); +// CREATE UNIQUE INDEX t_b_idx ON t (b); +// -- edit: change the expression to (a * 3) +// +// sqlshape's plan: `ALTER TABLE "public"."t" DROP COLUMN "b"; ALTER TABLE "public"."t" +// ADD COLUMN "b" integer GENERATED ALWAYS AS (a * 3) STORED;` -- no CREATE INDEX, no note. +// PostgreSQL's answer, ground truth: after running that plan, t_b_idx no longer exists. +// migrate.Verify (comparing the resulting database to the target) confirms the plan does +// not reach the target: it reports the target's t_b_idx as missing. +func TestAdvMigrateGeneratedColumnRewriteRestoresDependentIndex(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE TABLE t (id bigint PRIMARY KEY, a integer NOT NULL, b integer GENERATED ALWAYS AS (a * 2) STORED); +CREATE UNIQUE INDEX t_b_idx ON t (b); +`) + to := mustCanonical(t, ` +CREATE TABLE t (id bigint PRIMARY KEY, a integer NOT NULL, b integer GENERATED ALWAYS AS (a * 3) STORED); +CREATE UNIQUE INDEX t_b_idx ON t (b); +`) + plan, err := Plan(from.s, to.s, nil) + if err != nil { + t.Fatalf("plan: %v\nplan so far:\n%s", err, strings.Join(plan, "\n")) + } + ddl := strings.Join(plan, "\n") + t.Logf("plan:\n%s", ddl) + + changes, notes, err := Verify(context.Background(), server, from.text, ddl, to.s) + if err != nil { + t.Fatalf("verify: %v", err) + } + if len(changes) != 0 { + t.Errorf("expected the plan to restore t_b_idx (rewriting a generated column's expression must not silently drop a dependent index), but Verify still finds a difference: %v (notes=%v)", changes, notes) + } +} + +// TestAdvMigrateGeneratedColumnRewriteWithForeignKeyDependent: the same DROP COLUMN / +// ADD COLUMN rewrite (see above) fails outright, instead of merely losing an object, +// when another table's foreign key references the generated column (through a UNIQUE +// constraint on it): PostgreSQL refuses to drop a column another table's constraint +// depends on. sqlshape's plan has no note, no error at Plan time, and no attempt to +// drop/recreate the dependent foreign key around the rewrite -- it just emits a DROP +// COLUMN that PostgreSQL is guaranteed to refuse. +// +// Repro (PG 17): +// +// CREATE TABLE t (id bigint PRIMARY KEY, a integer NOT NULL, b integer GENERATED ALWAYS AS (a * 2) STORED UNIQUE); +// CREATE TABLE child (id bigint PRIMARY KEY, t_b integer REFERENCES t(b)); +// -- edit: change t's generation expression to (a * 3) +// +// sqlshape's plan: `ALTER TABLE "public"."t" DROP COLUMN "b"; ALTER TABLE "public"."t" +// ADD COLUMN "b" integer GENERATED ALWAYS AS (a * 3) STORED;` (Plan returns no error). +// PostgreSQL's answer, ground truth: running that DDL fails with "ERROR: cannot drop +// column b of table t because other objects depend on it" (SQLSTATE 2BP01) -- the plan +// can never be applied as generated, only hand-edited by an operator who already +// understands the dependency migrate.go itself computed and threw away. +func TestAdvMigrateGeneratedColumnRewriteWithForeignKeyDependent(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE TABLE t (id bigint PRIMARY KEY, a integer NOT NULL, b integer GENERATED ALWAYS AS (a * 2) STORED UNIQUE); +CREATE TABLE child (id bigint PRIMARY KEY, t_b integer REFERENCES t(b)); +`) + to := mustCanonical(t, ` +CREATE TABLE t (id bigint PRIMARY KEY, a integer NOT NULL, b integer GENERATED ALWAYS AS (a * 3) STORED UNIQUE); +CREATE TABLE child (id bigint PRIMARY KEY, t_b integer REFERENCES t(b)); +`) + plan, err := Plan(from.s, to.s, nil) + if err != nil { + t.Fatalf("plan: %v\nplan so far:\n%s", err, strings.Join(plan, "\n")) + } + ddl := strings.Join(plan, "\n") + t.Logf("plan:\n%s", ddl) + + _, _, err = Verify(context.Background(), server, from.text, ddl, to.s) + if err != nil { + t.Errorf("expected the plan to be runnable (drop and recreate the dependent foreign key around the column rewrite, or at least warn instead of emitting a DROP COLUMN PostgreSQL is certain to refuse), got: %v", err) + } +} + +// TestAdvMigrateGeneratedColumnKindChangeIsNotIgnored: a generated column's kind +// (STORED vs VIRTUAL, PostgreSQL 18) is tracked by diff.Compare (schema.Column. +// GeneratedVirtual feeds diff's "generated kind" property) but migrate.go's alterTable +// only compares fp["generated"] (the deparsed expression text) -- never "generated +// kind" -- before deciding a generated column needs no DROP COLUMN / ADD COLUMN +// rewrite. When only the kind changes and the expression stays byte-identical, alters() +// finds no property it knows to check different and Plan emits nothing at all: schema.sql +// changes from VIRTUAL to STORED (or back), Plan reports zero DDL, and the database is +// left exactly as it was -- silently out of step with the target schema forever, since +// nothing about this ever produces an error or a note for an operator to notice. +// +// Repro (PG 18, needs pg_dump 18 -- run this file's suite with +// `nix-shell -p postgresql_18 --run "go test ./check/postgres/migrate/..."`, since +// this package's shared `server` boots PostgreSQL 17): +// +// -- sqlshape: postgres 18 +// CREATE TABLE t (id bigint PRIMARY KEY, a integer NOT NULL, b integer GENERATED ALWAYS AS (a * 2) VIRTUAL); +// -- edit: VIRTUAL -> STORED, same expression +// +// PostgreSQL's answer, ground truth: diff.Compare(from, to) reports `~ column t.b / +// generated kind: virtual -> ` -- the two schemas are genuinely different. sqlshape's +// answer: migrate.Plan(from, to, nil) returns an empty plan and no error, as if the two +// schemas already matched. +func TestAdvMigrateGeneratedColumnKindChangeIsNotIgnored(t *testing.T) { + requirePgDump(t) + ctx := context.Background() + srv18, err := dump.NewServer(ctx, pgparse.PG18) + if err != nil { + t.Skipf("pg_dump 18 / postgres 18 not available: %v", err) + } + defer srv18.Close() + + canon := func(sql string) canonical { + t.Helper() + s, text, err := srv18.Canonical(ctx, sql, nil) + if err != nil { + if strings.Contains(err.Error(), "server version mismatch") { + t.Skipf("pg_dump 18 not on PATH (nix-shell -p postgresql_18): %v", err) + } + t.Fatalf("canonical: %v", err) + } + in, err := ParseIntents(sql) + if err != nil { + t.Fatalf("intents: %v", err) + } + return canonical{s, text, in} + } + + from := canon(`-- sqlshape: postgres 18 +CREATE TABLE t (id bigint PRIMARY KEY, a integer NOT NULL, b integer GENERATED ALWAYS AS (a * 2) VIRTUAL); +`) + to := canon(`-- sqlshape: postgres 18 +CREATE TABLE t (id bigint PRIMARY KEY, a integer NOT NULL, b integer GENERATED ALWAYS AS (a * 2) STORED); +`) + + // Ground truth: the schemas genuinely differ. + if changes := diff.Compare(from.s, to.s); len(changes) == 0 { + t.Fatal("test setup: expected diff.Compare to see the VIRTUAL -> STORED change") + } + + plan, err := Plan(from.s, to.s, nil) + if err != nil { + t.Fatalf("plan: %v", err) + } + if len(plan) == 0 { + t.Fatalf("expected a non-empty plan for a VIRTUAL -> STORED generated column kind change; got an empty plan even though the schemas differ") + } + + got, _, err := srv18.Canonical(ctx, from.text+"\nRESET search_path;\n"+strings.Join(plan, "\n"), to.s) + if err != nil { + t.Fatalf("apply: %v", err) + } + if changes := diff.Compare(got, to.s); len(changes) != 0 { + t.Errorf("plan did not reach the target: %v", changes) + } +} diff --git a/internal/migrate/adv_migrate_test.go b/check/postgres/migrate/adv_migrate_test.go similarity index 99% rename from internal/migrate/adv_migrate_test.go rename to check/postgres/migrate/adv_migrate_test.go index 630f483..6be3d72 100644 --- a/internal/migrate/adv_migrate_test.go +++ b/check/postgres/migrate/adv_migrate_test.go @@ -12,7 +12,7 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/oracle" + "github.com/kr9ly/sqlshape/check/postgres/v2/oracle" ) // TestAdvSeedRemovalLeavesExistingRowsInPlace pins down a deliberate design choice (not a diff --git a/internal/migrate/intents.go b/check/postgres/migrate/intents.go similarity index 83% rename from internal/migrate/intents.go rename to check/postgres/migrate/intents.go index 011a60d..00e1c52 100644 --- a/internal/migrate/intents.go +++ b/check/postgres/migrate/intents.go @@ -5,9 +5,9 @@ import ( "regexp" "strings" - "github.com/kr9ly/sqlshape/internal/catalog" - "github.com/kr9ly/sqlshape/internal/diff" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/catalog" + "github.com/kr9ly/sqlshape/check/postgres/v2/diff" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" ) // Intent is one `-- @migrate` declaration in the schema source: what a diff of two @@ -159,7 +159,27 @@ func (p *planner) readIntents(list []Intent) { bad := func(i Intent, format string, args ...any) { in.problems = append(in.problems, fmt.Sprintf("@migrate %s (line %d): %s", i, i.Line, fmt.Sprintf(format, args...))) } + // table renames first, whatever line they are on: a column rename's right side names + // the table as it will be, which only the table's own declaration explains + isTableRename := func(i Intent) bool { + if i.Kind != Rename { + return false + } + fr, fc := resolveName(p.from, i.From) + return fr != nil && fc == "" + } + ordered := make([]Intent, 0, len(list)) for _, i := range list { + if isTableRename(i) { + ordered = append(ordered, i) + } + } + for _, i := range list { + if !isTableRename(i) { + ordered = append(ordered, i) + } + } + for _, i := range ordered { switch i.Kind { case Rename: fr, fc := resolveName(p.from, i.From) @@ -329,6 +349,60 @@ func (p *planner) renames() { p.emit("ALTER %s %s RENAME COLUMN %s TO %s", relWord(f), qrel(t), q(from), q(cols[from])) } } + // a serial column's sequence keeps its name when its table or column is renamed, while + // the target (a fresh CREATE) names it after the new names: rename it too (measured: + // the plan's SET DEFAULT nextval('_seq') is 42P01 otherwise) + fromRels, toRels := p.rels(p.from), p.rels(p.to) + for _, name := range sortedKeys(fromRels) { + seq := fromRels[name] + if seq.Kind != schema.Sequence || seq.OwnedBy == "" { + continue + } + // OwnedBy is always schema-qualified; relation names (FullName, relTo, colTo) drop + // "public." -- ownerRelation gives the latter form + owner, col := ownerRelation(seq.OwnedBy), ownerColumn(seq.OwnedBy) + fr := fromRels[owner] + if fr == nil || fr.Column(col) == nil || fr.Column(col).Identity != 0 { + continue // an identity column's sequence is the server's own; nothing names it + } + toOwner, toCol := p.toName(owner), col + if m := p.in.colTo[owner]; m != nil { + if c, ok := m[col]; ok { + toCol = c + } + } + if toOwner == owner && toCol == col { + continue + } + wantOwned := toOwner + "." + toCol + if !strings.Contains(toOwner, ".") { + wantOwned = "public." + wantOwned + } + for _, tn := range sortedKeys(toRels) { + ts := toRels[tn] + if ts.Kind != schema.Sequence || ts.OwnedBy != wantOwned { + continue + } + cur := qrel(seq) + switch { + case ts.Schema != seq.Schema && fr.Schema == seq.Schema: + // ALTER TABLE ... SET SCHEMA already carries an owned sequence with it + // (measured): the table's own move (emitted elsewhere) gets this one + // there too, since it starts out in the same schema as its owner. A + // separate ALTER SEQUENCE ... SET SCHEMA here finds nothing left to move + // (42P01 "relation does not exist", measured); what follows still needs + // to name it where it now lives. + cur = q(ts.Schema) + "." + q(seq.Name) + case ts.Schema != seq.Schema: + p.emit("ALTER SEQUENCE %s SET SCHEMA %s", cur, q(ts.Schema)) + cur = q(ts.Schema) + "." + q(seq.Name) + } + if ts.Name != seq.Name { + p.emit("ALTER SEQUENCE %s RENAME TO %s", cur, q(ts.Name)) + } + break + } + } } // --- backfills ----------------------------------------------------------------------- @@ -448,6 +522,18 @@ func (p *planner) usesEnum(r *schema.Relation, oid catalog.OID) bool { return false } +// typeInUse reports whether any table, anywhere in the from-side schema, has a column of +// type oid (or an array of it): the shape ALTER TYPE runs against before any table's own +// ALTERs (alters() emits type changes first). +func (p *planner) typeInUse(oid catalog.OID) bool { + for _, r := range p.from.Relations { + if r.Kind == schema.Table && p.usesEnum(r, oid) { + return true + } + } + return false +} + // enumColumn reports whether a current column's type is an enum being recreated (its // type change is emitted by recreateEnum, not as a plain ALTER COLUMN TYPE). func (p *planner) enumColumn(c *schema.Column) bool { diff --git a/internal/migrate/intents_test.go b/check/postgres/migrate/intents_test.go similarity index 99% rename from internal/migrate/intents_test.go rename to check/postgres/migrate/intents_test.go index f671660..bca83cd 100644 --- a/internal/migrate/intents_test.go +++ b/check/postgres/migrate/intents_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" ) func mustLoad(t *testing.T, sql string) *schema.Schema { diff --git a/check/postgres/migrate/migrate.go b/check/postgres/migrate/migrate.go new file mode 100644 index 0000000..3faec80 --- /dev/null +++ b/check/postgres/migrate/migrate.go @@ -0,0 +1,2120 @@ +// Package migrate turns the difference between two schemas into DDL, and checks DDL by +// its end state: applied to a PostgreSQL holding the current schema, does the database +// read back as the target? +// +// Both schemas should be canonical (dump.Canonical / dump.Load): the target's statement +// texts are then PostgreSQL's own, fully qualified, and its objects come in dependency +// order, which the plan follows for additions. Whole objects are created from the text +// that declared them (Relation.Definition and friends); parts (columns, constraints, +// labels) are rendered from the model. +// +// Seeded tables (schema.Relation.Seed) are brought to their declared rows last, with a +// MERGE per table whose content differs (seed.go). +// +// The plan is a proposal: it does not know how to rename, which value an enum label +// should map to when it goes, or what to backfill a new NOT NULL column with. Those show +// up as statements PostgreSQL will refuse or as a leftover difference in Verify, and are +// the intent declarations' job to settle. +package migrate + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/check/postgres/v2/diff" + "github.com/kr9ly/sqlshape/check/postgres/v2/dump" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" +) + +// Plan lists the statements that turn from into to: drops (dependents first), the +// declared renames, then alterations, then additions in the target's order, then the +// rows of the seeded tables (a MERGE per table whose content differs). The intents +// (ParseIntents of the target's source) decide what the diff cannot: a table or column +// that disappears must be declared dropped or renamed, an enum label that disappears +// must say which label its values become, and backfills fill new columns before they +// turn NOT NULL. The error lists every declaration the schemas do not bear out and every +// change no declaration explains; the DDL is still returned for reading. +func Plan(from, to *schema.Schema, list []Intent) ([]string, error) { + p := &planner{from: from, to: to, recreated: map[string]bool{}, backfilled: map[string]bool{}} + p.readIntents(list) + p.enumRecreates() + p.generatedRecreates() + p.detectRepartitions() + p.drops() + p.createSchemas() + p.renames() + p.dropSchemas() + p.alters() + p.adds() + p.seeds() + if len(p.in.problems) > 0 { + return p.out, errors.New(strings.Join(p.in.problems, "\n")) + } + return p.out, nil +} + +// check type-checks a data statement of the plan against the target schema. +func (p *planner) check(sql string) error { + _, err := analyze.Analyze(p.to, sql) + return err +} + +// Verify applies ddl to a fresh database holding currentSQL and lists what still +// differs from target. An error is a statement PostgreSQL refused (or the server +// failing); no changes and no error means the DDL reaches the target. Column order is +// tolerated (diff.Change.OrderOnly) and returned separately as notes. +func Verify(ctx context.Context, c dump.Canonicalizer, currentSQL, ddl string, target *schema.Schema) (changes, notes []diff.Change, err error) { + // the current schema is a dump, whose session has search_path emptied; the plan's + // rendered statements name public objects unqualified + got, _, err := c.Canonical(ctx, currentSQL+"\nRESET search_path;\n"+ddl, target) + if err != nil { + return nil, nil, err + } + for _, c := range diff.Compare(got, target) { + if c.OrderOnly() { + notes = append(notes, c) + } else { + changes = append(changes, c) + } + } + return changes, notes, nil +} + +type planner struct { + from, to *schema.Schema + out []string + // recreated are relations dropped in the drop phase that the add phase creates anew + recreated map[string]bool + in *intents + // fromRels / toRels cache relations(); backfilled marks "rel.col" backfills emitted + fromRels, toRels map[string]*schema.Relation + backfilled map[string]bool + // redoFK marks "rel.constraint" (target names) foreign keys drops() took down ahead of a + // key they rest on, unchanged in the target and so re-added by adds() all the same + redoFK map[string]bool + // restored marks "rel.name" constraints and indexes a generated-column rewrite + // (addGenerated) already put back, which adds() must not add again + restored map[string]bool + // enumRecreate: enums whose labels shrink, recreated under the same name with the + // declared label mapping; the tables whose columns carry them, and the views over + // those tables (dropped first, created anew) + enumRecreate map[string]diff.UserType + // pendingAttach: partitions alterTable() found reattaching (a new parent, or the same + // parent with a new bound) -- collected rather than emitted on the spot, so every + // DETACH PARTITION in the plan (alters(), run before adds()) precedes every ATTACH + // (emitted in adds(), alongside newly created partitions'): a partition's incoming + // bound can overlap another partition's outgoing one, which PostgreSQL refuses if the + // ATTACH runs before that other DETACH (23514 "would overlap", measured). + pendingAttach []pendingPartitionAttach + // pendingRepartition: a table repartitionTable renamed aside, waiting for its new, + // partitioned self (and every partition it declares) to exist before its rows can + // come back (adds(), after the ATTACH loops). + pendingRepartition []pendingRepartition + // repartitioning: a from-side table (by FullName) detectRepartitions found eligible + // for repartitionTable, worked out ahead of drops() so it can tell the table's own + // old partitions apart from an ordinary undeclared drop (see repartitionChild). + repartitioning map[string]*repartitionPlan + // repartitionChild: a from-side relation (by FullName) that is a current partition of + // a table detectRepartitions found eligible -- it has no target counterpart of its + // own (the target's declared children, if any, are unrelated new objects under the + // rebuilt parent) and goes down with its old parent's temporary name once + // repartitionTable's rows have safely moved out of it, not on its own and not + // requiring a `-- @migrate drop` declaration. + repartitionChild map[string]bool +} + +// repartitionPlan is what detectRepartitions works out for one table whose partition key +// is changing, ahead of drops(): repartitionTable (alters()) only has to act on it. +type repartitionPlan struct { + from, to *schema.Relation + tempName string + // ownedSeqs: from-side sequences OWNED BY one of from's columns (a plain bigserial-style + // column, not IDENTITY -- see repartitionTable's own doc comment). Detached (OWNED BY + // NONE) right after the RENAME so the old table's own eventual DROP TABLE does not take + // them down (42P01 otherwise, measured), and re-owned by pendingRepartition once the new + // table exists: the sequence itself is never recreated, so its current value survives + // the whole repartition unchanged, same as an ordinary column rename does today. + ownedSeqs []*schema.Relation +} + +func (p *planner) emit(format string, args ...any) { + p.out = append(p.out, fmt.Sprintf(format, args...)+";") +} + +// note leaves a comment for what the plan cannot do. +func (p *planner) note(format string, args ...any) { + p.out = append(p.out, "-- "+fmt.Sprintf(format, args...)) +} + +// --- naming -------------------------------------------------------------------- + +// q quotes an identifier. +func q(name string) string { return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` } + +// qrel is a relation's fully qualified, quoted name. +func qrel(r *schema.Relation) string { return q(r.Schema) + "." + q(r.Name) } + +// lit is a string literal. +func lit(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" } + +func qlist(names []string) string { + out := make([]string, len(names)) + for i, n := range names { + out[i] = q(n) + } + return strings.Join(out, ", ") +} + +// --- lookups ------------------------------------------------------------------- + +func relations(s *schema.Schema) (map[string]*schema.Relation, []*schema.Relation) { + m := map[string]*schema.Relation{} + var order []*schema.Relation + for _, r := range s.Relations { + if r.Temp || r.Kind == 'c' { + continue + } + m[r.FullName()] = r + order = append(order, r) + } + return m, order +} + +func functions(s *schema.Schema) (map[string]*schema.Function, []*schema.Function) { + m := map[string]*schema.Function{} + var order []*schema.Function + for _, f := range s.Functions { + if f.Language == "internal" && isRangeConstructor(s, f) { + continue // a range/multirange constructor: PostgreSQL creates and drops it with its type + } + m[diff.Signature(s, f)] = f + order = append(order, f) + } + return m, order +} + +// isRangeConstructor reports whether f is one of the constructor functions PostgreSQL +// stamps out alongside CREATE TYPE ... AS RANGE (schema/ddl.go's createRange: name(sub, +// sub), name(sub, sub, text) for the range itself and multi(), multi(range), +// multi(range[]) for its paired multirange) rather than a user-declared LANGUAGE +// internal function. Both share prorettype = their own range/multirange type and a +// name equal to that type's; a legitimate user function (e.g. a custom internal WINDOW +// function) practically never collides with a type name it doesn't return, so it stays +// tracked instead of being dropped along with the real constructors. +func isRangeConstructor(s *schema.Schema, f *schema.Function) bool { + t := s.Types.ByOID(f.RetType.OID) + if t == nil || t.Name != f.Name { + return false + } + return t.Kind == 'r' || t.Kind == 'm' +} + +func triggers(s *schema.Schema) map[string]*schema.Trigger { + m := map[string]*schema.Trigger{} + for _, t := range s.Triggers { + m[t.Table+"."+t.Name] = t + } + return m +} + +func constraints(r *schema.Relation) map[string]*schema.Constraint { + idx := map[string]bool{} + for _, i := range r.Indexes { + idx[i.Name] = true + } + m := map[string]*schema.Constraint{} + for _, c := range r.Constraints { + if c.Kind == schema.Unique && idx[c.Name] { + continue // the loader's mirror of a unique index + } + m[c.Name] = c + } + return m +} + +func indexes(r *schema.Relation) map[string]*schema.Index { + m := map[string]*schema.Index{} + for _, i := range r.Indexes { + m[i.Name] = i + } + return m +} + +func columnsOf(r *schema.Relation) map[string]*schema.Column { + m := map[string]*schema.Column{} + for _, c := range r.Columns { + m[c.Name] = c + } + return m +} + +// same reports whether two objects compare equal under diff (their properties match). +func same(a, b *schema.Schema, x, y any) bool { + return len(diff.Props(a, x)) > 0 && fmt.Sprint(diff.Props(a, x)) == fmt.Sprint(diff.Props(b, y)) +} + +// --- drops ------------------------------------------------------------------------- + +func (p *planner) drops() { + fromRels, fromOrder := relations(p.from) + toRels, _ := relations(p.to) + fromFns, _ := functions(p.from) + toFns, _ := functions(p.to) + fromTrg, toTrg := triggers(p.from), triggers(p.to) + + // parts of relations that survive: triggers, rules, indexes, constraints + for _, n := range sortedKeys(fromTrg) { + t := fromTrg[n] + if toRels[p.toName(t.Table)] == nil { + continue // goes with the table + } + if tt := toTrg[p.toName(t.Table)+"."+t.Name]; tt == nil || !same(p.from, p.to, t, tt) { + p.emit("DROP TRIGGER %s ON %s", q(t.Name), qrel(fromRels[t.Table])) + } + } + for _, r := range fromOrder { + tr := p.toOf(r) + if tr == nil || tr.Kind != r.Kind { + continue + } + toRules := tr.Rules() + for _, n := range sortedKeys(r.Rules()) { + if rd, ok := toRules[n]; !ok || !same(p.from, p.to, r.Rules()[n], rd) { + p.emit("DROP RULE %s ON %s", q(n), qrel(r)) + } + } + for _, pol := range r.Policies { + if tp := tr.Policy(pol.Name); tp == nil || !same(p.from, p.to, pol, tp) { + p.emit("DROP POLICY %s ON %s", q(pol.Name), qrel(r)) + } + } + } + // foreign keys of every surviving table first: they may reference keys dropped below, + // on this table or another one + droppedFK := map[string]bool{} + for _, r := range fromOrder { + tr := p.toOf(r) + if tr == nil || tr.Kind != r.Kind { + continue + } + toCon := constraints(tr) + for _, n := range sortedKeys(constraints(r)) { + c := constraints(r)[n] + if c.Kind != schema.ForeignKey { + continue + } + tc, ok := toCon[n] + // a foreign key resting on a table repartitionTable is about to rename aside + // and rebuild fresh (detectRepartitions, migrate.go) breaks regardless of + // whether its own definition changed: the physical table it names goes with + // the temporary name's DROP TABLE, so it must be dropped ahead of that and + // redone (redoFK) once the rebuilt table exists, the same way a key the FK + // rests on being dropped outright already forces (below). + repartitioned := p.repartitioning[c.RefTable] != nil + if !ok || !same(p.from, p.to, c, tc) || repartitioned { + p.emit("ALTER TABLE %s DROP CONSTRAINT %s", qrel(r), q(n)) + droppedFK[r.FullName()+"."+n] = true + if repartitioned && ok && p.toOf(r) != nil { + if p.redoFK == nil { + p.redoFK = map[string]bool{} + } + p.redoFK[p.toName(r.FullName())+"."+n] = true + } + } + } + } + for _, r := range fromOrder { + tr := p.toOf(r) + if tr == nil || tr.Kind != r.Kind { + continue + } + toIdx := indexes(tr) + for _, n := range sortedKeys(indexes(r)) { + if ti, ok := toIdx[n]; !ok || !same(p.from, p.to, indexes(r)[n], ti) { + p.emit("DROP INDEX %s", q(r.Schema)+"."+q(n)) + } + } + toCon := constraints(tr) + for _, n := range sortedKeys(constraints(r)) { + c := constraints(r)[n] + if c.Kind == schema.ForeignKey { + continue + } + if tc, ok := toCon[n]; !ok || !same(p.from, p.to, c, tc) { + if c.Kind == schema.PrimaryKey || c.Kind == schema.Unique { + // a foreign key elsewhere that rests on this key and survives unchanged + // blocks the drop (2BP01, measured): it goes ahead of the key and comes + // back in adds (redoFK), once the target's key over its columns exists + for _, other := range fromOrder { + for _, fn := range sortedKeys(constraints(other)) { + fk := constraints(other)[fn] + if fk.Kind != schema.ForeignKey || fk.RefTable != r.FullName() || droppedFK[other.FullName()+"."+fn] { + continue + } + if !(len(fk.RefColumns) == 0 && c.Kind == schema.PrimaryKey || sameStrings(fk.RefColumns, c.Columns)) { + continue + } + p.emit("ALTER TABLE %s DROP CONSTRAINT %s", qrel(other), q(fn)) + droppedFK[other.FullName()+"."+fn] = true + if p.toOf(other) == nil { + continue // the table goes below; nothing to re-add + } + if p.redoFK == nil { + p.redoFK = map[string]bool{} + } + p.redoFK[p.toName(other.FullName())+"."+fn] = true + } + } + } + p.emit("ALTER TABLE %s DROP CONSTRAINT %s", qrel(r), q(n)) + } + } + } + // views and materialized views, latest declared first (they may depend on each other) + for i := len(fromOrder) - 1; i >= 0; i-- { + r := fromOrder[i] + if r.Kind != schema.View && r.Kind != schema.MatView { + continue + } + tr := p.toOf(r) + if tr != nil && tr.Kind == r.Kind && !p.recreated[tr.FullName()] && (same(p.from, p.to, r, tr) || r.Kind == schema.View && replaceable(p.from, p.to, r, tr)) { + continue // a view that only grows is replaced in place; otherwise recreated + } + p.emit("DROP %s %s", relWord(r), qrel(r)) + if tr != nil { + p.recreated[tr.FullName()] = true + } + } + // columns of tables that survive + for _, r := range fromOrder { + tr := p.toOf(r) + if tr == nil || r.Kind != schema.Table || tr.Kind != schema.Table { + continue + } + if r.IsPartition && tr.IsPartition { + // a partition's column list only ever changes because its parent's did (a + // dump-canonical schema repeats every column on each partition's own CREATE + // TABLE); the parent's own pass through this same loop already requires and + // emits the drop, and DROP COLUMN on the partition itself is refused (42P16, + // measured) once the parent's has already run. + continue + } + toCols := columnsOf(tr) + var gone []*schema.Column + for _, c := range r.Columns { + if toCols[p.toCol(r, c.Name)] == nil { + if !p.in.dropOK[r.FullName()+"."+c.Name] { + p.problem("column %s.%s is dropped, which no @migrate declares: add `-- @migrate drop %s.%s` or `-- @migrate rename %s.%s -> ...`", r.FullName(), c.Name, r.FullName(), c.Name, r.FullName(), c.Name) + } + gone = append(gone, c) + } + } + // a generated column that reads another gone column goes first: PostgreSQL refuses + // to drop a column a generated column still reads (2BP01, measured) + for _, c := range orderGoneColumns(gone) { + p.emit("ALTER TABLE %s DROP COLUMN %s", qrel(r), q(c.Name)) + } + } + // a sequence that survives (with different, or no, ownership) needs its OWNED BY + // cleared before its current owning table drops: DROP TABLE cascades to a sequence + // still OWNED BY one of its columns (42P01 "does not exist" on the ALTER SEQUENCE + // alters() runs afterward for a surviving sequence, measured) -- alters() only + // reaches surviving *tables*, so a table that is itself gone needs this handled here. + for _, name := range sortedKeys(fromRels) { + seq := fromRels[name] + if seq.Kind != schema.Sequence || seq.OwnedBy == "" { + continue + } + owner := fromRels[ownerRelation(seq.OwnedBy)] + if owner == nil { + continue + } + if tr := p.toOf(owner); tr != nil && tr.Kind == owner.Kind { + continue // the owning table survives; alters() handles this sequence + } + if ts := p.toOf(seq); ts != nil && ts.Kind == schema.Sequence { + p.emit("ALTER SEQUENCE %s OWNED BY NONE", qrel(seq)) + } + } + // tables and sequences: referencing tables before the tables they reference + var gone []*schema.Relation + for i := len(fromOrder) - 1; i >= 0; i-- { + r := fromOrder[i] + if r.Kind != schema.Table && r.Kind != schema.Sequence { + continue + } + if r.Kind == schema.Sequence && r.OwnedBy != "" && ownerColumnGone(p, fromRels, r) { + continue // goes with its column (or the column's table) + } + if p.repartitionChild[r.FullName()] { + continue // goes with its old parent's temporary name (repartitionTable), not on its own + } + if tr := p.toOf(r); tr == nil || tr.Kind != r.Kind { + gone = append(gone, r) + } + } + for _, r := range fkOrder(gone) { + if r.Kind == schema.Table && !p.in.dropOK[r.FullName()] { + p.problem("table %s is dropped, which no @migrate declares: add `-- @migrate drop %s` or `-- @migrate rename %s -> ...`", r.FullName(), r.FullName(), r.FullName()) + } + p.emit("DROP %s %s", relWord(r), qrel(r)) + } + // functions after the tables: a trigger function is held by the triggers of a table + // that goes (they go with the table, not by DROP TRIGGER above), and PostgreSQL refuses + // to drop it while they exist (2BP01, measured); a view calling one is already gone + for _, n := range sortedKeys(fromFns) { + if toFns[n] == nil { + p.emit("DROP %s %s", fnWord(fromFns[n]), n) + } + } + // types + fromTypes, toTypes := diff.UserTypes(p.from), diff.UserTypes(p.to) + for _, n := range sortedKeys(fromTypes) { + if t, ok := toTypes[n]; !ok || t.Kind != fromTypes[n].Kind { + p.emit("DROP %s %s", typeWord(fromTypes[n].Kind), n) + } + } + // extensions + toExt := extensions(p.to) + for _, e := range p.from.Catalog.Extensions { + if !toExt[e.Name] { + p.emit("DROP EXTENSION %s", q(e.Name)) + } + } +} + +// createSchemas creates a schema the target newly declares, before renames(): a table +// declared to move into it (-- @migrate rename public.t -> app.t) needs it to already +// exist (3F000 "schema does not exist", measured) -- adds() (which otherwise creates +// every new object, schemas included) runs too late for that ALTER TABLE ... SET SCHEMA. +func (p *planner) createSchemas() { + fromSchemas := set(p.from.Schemas()) + for _, n := range p.to.Schemas() { + if !fromSchemas[n] { + p.emit("CREATE SCHEMA %s", q(n)) + } + } +} + +// dropSchemas drops a schema the target no longer declares, after renames(): a table +// declared to move out of it (-- @migrate rename app.t -> public.t) still reads as +// belonging to the schema from drops()'s (from-side) point of view, and PostgreSQL +// refuses to drop a schema anything still lives in (2BP01 "other objects depend on it", +// measured) -- the ALTER TABLE ... SET SCHEMA that empties it has to run first. +func (p *planner) dropSchemas() { + toSchemas := set(p.to.Schemas()) + for _, n := range p.from.Schemas() { + if !toSchemas[n] { + p.emit("DROP SCHEMA %s", q(n)) + } + } +} + +// --- alters ------------------------------------------------------------------------ + +func (p *planner) alters() { + fromTypes, toTypes := diff.UserTypes(p.from), diff.UserTypes(p.to) + for _, n := range sortedKeys(toTypes) { + t := toTypes[n] + f, ok := fromTypes[n] + if !ok || f.Kind != t.Kind || fmt.Sprint(f.Props) == fmt.Sprint(t.Props) { + continue + } + p.alterType(n, f, t) + } + + fromRels, _ := relations(p.from) + _, toOrder := relations(p.to) + for _, r := range toOrder { + f := p.fromOf(r) + if f == nil || f.Kind != r.Kind { + continue + } + switch r.Kind { + case schema.Table: + p.alterTable(f, r) + p.backfillsLeft(r, f) + case schema.View: + // replaced in adds, once every column the new definition may read exists + case schema.Sequence: + if f.OwnedBy != r.OwnedBy { + switch ownerColumnStatus(p, fromRels, f) { + case ownerTableGone: + // drops() already cleared this sequence's OWNED BY (ALTER SEQUENCE + // ... OWNED BY NONE) before its then-owning table dropped (measured: + // redoing that same ALTER here is 42P01, the table already took the + // column that named it down) -- but the target may still want it + // owned by something else (a "set sequence owner" mutation drawn + // alongside the table's own drop), which that NONE does not give it. + if r.OwnedBy != "" { + p.emit("ALTER SEQUENCE %s OWNED BY %s", currentSeqName(p, f), qdot(r.OwnedBy)) + } + case ownerColumnGoneNotTable: + // the owning table survives; the column itself is really dropped + // (not renamed). PostgreSQL drops a column-owned sequence along with + // the column itself (measured: no DROP SEQUENCE needed, or possible + // -- 42P07 "already exists" if adds() is not told this sequence needs + // recreating, or 42P01 if this ALTER SEQUENCE ... OWNED BY runs + // instead, against an object dropCcolumn already took with it). + p.recreated[r.FullName()] = true + default: // renamed, or newly (un)owned: the sequence itself never went away + owner := "NONE" + if r.OwnedBy != "" { + parts := strings.Split(r.OwnedBy, ".") + owner = qdot(strings.Join(parts, ".")) + } + cur := currentSeqSchema(p, f) + p.emit("ALTER SEQUENCE %s OWNED BY %s", q(cur)+"."+q(f.Name), owner) + if cur != r.Schema { + // OWNED BY NONE / a different owner does not undo the schema + // cascade its old owner's SET SCHEMA already gave it (renames(), + // which ran before this): move it the rest of the way to where + // the target actually declares it (measured: without this, the + // plan reads back with the sequence still in cur's schema). + p.emit("ALTER SEQUENCE %s SET SCHEMA %s", q(cur)+"."+q(f.Name), q(r.Schema)) + } + } + } + } + } + + fromFns, _ := functions(p.from) + _, toFnOrder := functions(p.to) + for _, fn := range toFnOrder { + n := diff.Signature(p.to, fn) + f := fromFns[n] + if f == nil || same(p.from, p.to, f, fn) { + continue + } + def := fn.Definition + if fnProps(p.from, f)["returns"] != fnProps(p.to, fn)["returns"] || len(f.Args) != len(fn.Args) || + f.IsProc != fn.IsProc || f.IsAgg != fn.IsAgg || f.IsWindow != fn.IsWindow { + // CREATE OR REPLACE cannot change the return type, the parameter list, or + // the object's own kind (function / procedure / aggregate; a plain function + // turning into a window one is still CREATE FUNCTION, but PostgreSQL still + // refuses OR REPLACE to add WINDOW, 42P13 "cannot change routine kind" -- + // simplest to always drop and recreate whenever any of these differ, same + // as a return type change; CREATE AGGREGATE has no OR REPLACE form at all, + // so leaving it to the "same returns/args" fallthrough re-declared it over + // the old function outright, 42723 "already exists", measured) + p.emit("DROP %s %s", fnWord(f), n) + } else { + def = strings.Replace(def, "CREATE FUNCTION", "CREATE OR REPLACE FUNCTION", 1) + def = strings.Replace(def, "CREATE PROCEDURE", "CREATE OR REPLACE PROCEDURE", 1) + } + p.emit("%s", def) + } +} + +func (p *planner) alterType(name string, f, t diff.UserType) { + switch t.Kind { + case "enum": + from, to := labels(f.Props["labels"]), labels(t.Props["labels"]) + have := set(to) + var gone []string + for _, l := range from { + if !have[l] { + gone = append(gone, l) + } + } + if len(gone) > 0 { + if _, ok := p.enumRecreate[name]; ok { + p.recreateEnum(name, f, t) + } else { + p.problem("enum %s: labels %s are removed, which no @migrate declares: add `-- @migrate enum %s: drop '
__seq name whether the table is plain or partitioned, measured) -- + // repartitionTable detaches it (OWNED BY NONE) right after the RENAME so the old + // table's later DROP TABLE does not take it down, and pendingRepartition re-owns it + // once the new table exists. Its current value is never touched, so it is preserved + // across the repartition exactly as an ordinary column rename already leaves it. + for _, seq := range p.from.Relations { + if seq.Kind != schema.Sequence || seq.OwnedBy == "" || ownerRelation(seq.OwnedBy) != f.FullName() { + continue + } + if oc := f.Column(ownerColumn(seq.OwnedBy)); oc != nil && isIdentity(oc.Identity) { + // modeled the same OWNED BY way as a plain sequence's, but ALTER SEQUENCE + // ... OWNED BY NONE on an identity-owned one is 0A000 "cannot change + // ownership of identity sequence" regardless (measured) -- repartitionTable + // renames it aside instead, by its own assumed canonical name. + continue + } + rp.ownedSeqs = append(rp.ownedSeqs, seq) + } + p.repartitioning[f.FullName()] = rp + for _, child := range p.from.Relations { + if len(child.Parents) > 0 && child.Parents[0].FullName() == f.FullName() { + p.repartitionChild[child.FullName()] = true + } + } + } +} + +// freeRelName is base, or base prefixed with enough "z_" to name nothing already declared +// in either schema (schemaName's, or public's short form) -- the temporary name +// repartitionTable renames a table aside to on its way to being rebuilt under its own +// name, chosen so it can never collide with an existing or a target-declared relation. +func (p *planner) freeRelName(schemaName, base string) string { + full := func(n string) string { + if schemaName == "" || schemaName == "public" { + return n + } + return schemaName + "." + n + } + from, _ := relations(p.from) + to, _ := relations(p.to) + name := base + for from[full(name)] != nil || to[full(name)] != nil { + name = "z_" + name + } + return name +} + +// repartitionTable turns f (a plain table) into r (partitioned), the reverse, or from one +// partition key / strategy to another, keeping every row: PostgreSQL has no +// ALTER ... PARTITION BY. Instead, this renames the table aside, lets the target's own +// shape (r.Definition, PK / indexes / checks included) get created fresh under the old +// name and its declared partitions attach under it -- both through the ordinary "new +// object" path adds() already takes for a genuinely new table and its partitions, once +// p.recreated below routes r there too -- and only after every partition exists moves +// every row across with a plain INSERT (adds(), pendingRepartition): PostgreSQL's own +// partition router decides where each one goes, and refuses the whole statement (23514) +// if even one fits nowhere, correctly, since the target schema does not cover every row +// the source table held. +// +// Scoped by repartitionEligible's original doc comment (brief-holes-pg.md item D, widened +// twice since): a policy, a rule, a trigger, a comment of f's own, and another table's +// foreign key resting on it are all in scope -- drops()/adds() already reattach or redo +// every one of those around a p.recreated relation, the same bookkeeping +// generatedRecreates() uses for a column rewrite. An IDENTITY column and a plain +// (bigserial-style) owned sequence are in scope too now: an IDENTITY column's own sequence +// is the server's, tied to the column's attnum, so it cannot be handed over the way an +// owned sequence's OWNED BY can -- r.Definition (adds(), the same path a genuinely new +// IDENTITY column takes) creates a fresh one under the canonical
__seq name, +// which collides (42P07 via 0A000 "cannot change ownership of identity sequence" on the ADD +// GENERATED itself, measured) with the *old* table's own identity sequence still standing +// under that exact name -- ALTER TABLE ... RENAME TO does not rename a column's sequence +// along with it (measured), identity or owned alike, so f's still carries its original, +// pre-rename name even once f itself answers to rp.tempName. Renamed aside first (assumed +// to still be PostgreSQL's own default
__seq naming -- true of every +// identity column this planner or its probe ever creates, since neither ever asks for a +// custom SEQUENCE NAME) so the fresh one can take the canonical name back; it goes down +// regardless once f's old, renamed-aside self is finally dropped, whatever it is called by +// then. pendingRepartition's INSERT then carries OVERRIDING SYSTEM VALUE so the old rows' +// explicit ids still fit under GENERATED ALWAYS, and a setval() afterward resumes the fresh +// sequence from the row with the highest value, since it otherwise starts at 1, oblivious +// to what came before (measured). A bigserial-style owned sequence, unlike IDENTITY's, is +// the very object r's own DEFAULT nextval(...) still names (same canonical name whichever +// side is partitioned, and no ADD GENERATED to collide with in the first place) -- +// detectRepartitions already worked out which sequences these are +// (repartitionPlan.ownedSeqs); this only has to detach them (OWNED BY NONE) before f's old, +// renamed-aside self is eventually dropped (42P01 otherwise, measured), leaving their +// current value untouched. pendingRepartition re-owns them once r itself exists. +func (p *planner) repartitionTable(f, r *schema.Relation) bool { + rp := p.repartitioning[f.FullName()] + if rp == nil { + return false + } + p.emit("ALTER TABLE %s RENAME TO %s", qrel(f), q(rp.tempName)) + for _, seq := range rp.ownedSeqs { + p.emit("ALTER SEQUENCE %s OWNED BY NONE", qrel(seq)) + } + for _, c := range f.Columns { + if !isIdentity(c.Identity) { + continue + } + orig := q(f.Schema) + "." + q(f.Name+"_"+c.Name+"_seq") + fresh := p.freeRelName(f.Schema, "z_migrate_"+f.Name+"_"+c.Name+"_seq") + p.emit("ALTER SEQUENCE %s RENAME TO %s", orig, q(fresh)) + } + p.recreated[r.FullName()] = true + for _, v := range p.from.DependentViews(f) { + if tv := p.toOf(v); tv != nil { + p.recreated[tv.FullName()] = true + } + } + p.pendingRepartition = append(p.pendingRepartition, pendingRepartition{table: r, old: f, tempName: rp.tempName, ownedSeqs: rp.ownedSeqs}) + return true +} + +// orderNewColumns orders cols (the columns a table gains) so a generated column comes after +// every other new column its expression reads. +func orderNewColumns(cols []*schema.Column) []*schema.Column { + var out []*schema.Column + done := map[string]bool{} + var visit func(c *schema.Column, stack map[string]bool) + visit = func(c *schema.Column, stack map[string]bool) { + if done[c.Name] || stack[c.Name] { + return + } + stack[c.Name] = true + if c.Generated != nil { + for _, other := range cols { + if other != c && readsColumn(c, other.Name) { + visit(other, stack) + } + } + } + done[c.Name] = true + out = append(out, c) + } + for _, c := range cols { + visit(c, map[string]bool{}) + } + return out +} + +// orderGoneColumns orders cols (the columns a table loses) so a generated column comes +// before every other gone column it reads: the reverse of orderNewColumns's order. +func orderGoneColumns(cols []*schema.Column) []*schema.Column { + fwd := orderNewColumns(cols) + out := make([]*schema.Column, len(fwd)) + for i, c := range fwd { + out[len(fwd)-1-i] = c + } + return out +} + +func sameStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// hasMatchingConstraint reports whether parent already carries a constraint of the same +// kind and columns as c (see stableAttachParent): PostgreSQL's own constraint name on the +// partition never matches the parent's, so this compares shape rather than name. +func hasMatchingConstraint(parent *schema.Relation, c *schema.Constraint) bool { + for _, pc := range parent.Constraints { + if pc.Kind == c.Kind && sameStrings(pc.Columns, c.Columns) { + return true + } + } + return false +} + +// hasMatchingIndex reports whether parent already carries an index over the same columns +// as i (see stableAttachParent), by the same reasoning as hasMatchingConstraint. +func hasMatchingIndex(parent *schema.Relation, i *schema.Index) bool { + for _, pi := range parent.Indexes { + if sameStrings(pi.Columns, i.Columns) { + return true + } + } + return false +} + +// renamedExpr is a from-side expression text with f's declared column renames applied: +// RENAME COLUMN rewrites the expressions that name the column (a generated column's, a +// CHECK's) on its own, so an expression that differs from the target's only by the renames +// is not a change (rewriting the generated column for it would drop and recompute it, and +// take a dependent view down with it -- 2BP01, measured). +func (p *planner) renamedExpr(f *schema.Relation, expr string) string { + for from, to := range p.in.colTo[f.FullName()] { + if from == to { + continue + } + re := regexp.MustCompile(`(^|[^A-Za-z0-9_"])` + regexp.QuoteMeta(from) + `($|[^A-Za-z0-9_"])`) + expr = re.ReplaceAllString(expr, "${1}"+to+"${2}") + expr = strings.ReplaceAll(expr, q(from), q(to)) + } + return expr +} + +// readsColumn reports whether generated column g's expression names column name. +func readsColumn(g *schema.Column, name string) bool { + for _, ref := range schema.ColumnRefs(g.Generated) { + if ref == name { + return true + } + } + return false +} + +// dropGenerated and addGenerated are the two halves of rewriting a generated column +// (alterTable): DROP COLUMN silently takes down, without needing CASCADE, any index or +// table constraint defined solely on this column -- and refuses outright if another +// table's foreign key rests on such a constraint. dropGenerated drops what would block +// the DROP COLUMN ahead of it; addGenerated adds the target's column back and restores +// what the drop took down. +func (p *planner) dropGenerated(r *schema.Relation, c *schema.Column) { + for _, fk := range p.foreignKeysReferencing(r, c.Name) { + p.emit("ALTER TABLE %s DROP CONSTRAINT %s", qrel(fk.rel), q(fk.con.Name)) + } + p.emit("ALTER TABLE %s DROP COLUMN %s", qrel(r), q(c.Name)) +} + +func (p *planner) addGenerated(r *schema.Relation, c *schema.Column) { + if p.restored == nil { + p.restored = map[string]bool{} + } + p.emit("ALTER TABLE %s ADD COLUMN %s", qrel(r), columnText(p.to, c, true)) + for _, con := range p.soleColumnConstraints(r, c.Name) { + p.emitConstraint(r, con) + p.restored[r.FullName()+"."+con.Name] = true + } + for _, idx := range p.soleColumnIndexes(r, c.Name) { + p.emit("%s", idx.Definition) + p.restored[r.FullName()+"."+idx.Name] = true + } + for _, fk := range p.foreignKeysReferencing(r, c.Name) { + p.emitConstraint(fk.rel, fk.con) + p.restored[fk.rel.FullName()+"."+fk.con.Name] = true + } +} + +// rewritesGenerated reports whether alterTable will drop and re-add a generated column of +// r (its expression changed, or the type of a column it reads does): the views over r must +// be recreated around it, since PostgreSQL refuses the DROP COLUMN while a view reads the +// column (2BP01, measured). +func (p *planner) rewritesGenerated(f, r *schema.Relation) bool { + fromCols := columnsOf(f) + for _, c := range r.Columns { + fc := fromCols[p.fromCol(r, c.Name)] + if fc == nil { + continue + } + fp, tp := colProps(p.from, fc), colProps(p.to, c) + if fp["type"] != tp["type"] && !p.enumColumn(fc) { + for _, g := range r.Columns { + if g.Generated != nil && fromCols[p.fromCol(r, g.Name)] != nil && readsColumn(g, fc.Name) { + return true + } + } + } + if c.Generated != nil && (p.renamedExpr(f, fp["generated"]) != tp["generated"] || fp["generated kind"] != tp["generated kind"]) { + return true + } + } + return false +} + +// generatedRecreates marks the views over every table whose generated column alterTable +// rewrites for recreation (dropped in drops, created anew in adds). +func (p *planner) generatedRecreates() { + for _, r := range p.from.Relations { + tr := p.toOf(r) + if r.Kind != schema.Table || tr == nil || tr.Kind != schema.Table || !p.rewritesGenerated(r, tr) { + continue + } + for _, v := range p.from.DependentViews(r) { + if tv := p.toOf(v); tv != nil { + p.recreated[tv.FullName()] = true + } + } + } +} + +// soleColumnIndexes are r's target-schema indexes over column name (alone or with others): a +// generated-column DROP COLUMN / ADD COLUMN rewrite (alterTable) takes these down with +// the column, without PostgreSQL needing CASCADE, and never re-creates them on its own. +func (p *planner) soleColumnIndexes(r *schema.Relation, name string) []*schema.Index { + var out []*schema.Index + for _, i := range r.Indexes { + if hasString(i.Columns, name) { + out = append(out, i) + } + } + return out +} + +func hasString(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +// ownerColumnGone reports whether seq's owning column (a bigserial / IDENTITY column's +// sequence, or a standalone sequence's declared OWNED BY) is itself gone in the target -- +// dropped outright, or its table dropped or renamed away. A sequence whose owning column +// survives is not implicitly handled by any column-level DROP and needs its own DROP +// SEQUENCE (measured: an unowned-by-drop sequence otherwise never appeared in the plan +// at all). +// ownerColumnReallyGone reports whether the column that used to own seq (a surviving +// sequence, since the caller already knows it is not itself dropped) is truly gone in the +// target -- its owning table dropped, or the column itself dropped -- as opposed to +// merely renamed (ownerColumnGone's own "goes with its column" also covers a rename, +// which does not cascade-drop the sequence and so must not be treated the same way here: +// see alters()'s Sequence case). +// ownerColumnState is ownerColumnStatus's verdict on a surviving sequence's previous +// owning column. +type ownerColumnState int + +const ( + // ownerColumnRenamed: the owning table survives and so does the column (under its + // declared name, if renamed) -- the sequence itself was never touched. + ownerColumnRenamed ownerColumnState = iota + // ownerTableGone: the owning table itself does not survive; drops() already cleared + // this sequence's OWNED BY ahead of that table's own DROP TABLE (see its own comment). + ownerTableGone + // ownerColumnGoneNotTable: the owning table survives, but the column itself is truly + // dropped (not renamed) -- PostgreSQL drops a column-owned sequence along with the + // column (measured), so the sequence needs recreating, not an ALTER ... OWNED BY. + ownerColumnGoneNotTable +) + +// currentSeqName is the qualified name a from-side sequence f (still owned by its column +// at this point in the plan -- alters() runs after renames()) actually has in the live +// database right now, which is not always qrel of its target-schema counterpart: +// PostgreSQL moves a column-owned sequence's schema along with an ALTER TABLE ... SET +// SCHEMA on its owning table automatically, whatever the target eventually does with that +// ownership afterward (rename it elsewhere, clear it to NONE, drop it) -- so by the time +// this ALTER SEQUENCE runs, a sequence whose owner already moved schema in renames() is +// no longer where its own (still-public, say) from/to names would suggest (42P01, +// measured: TestProbeSequenceCascadesWithOwnerSchemaMove). A sequence's own name never +// moves this way on its own, only its schema. +func currentSeqName(p *planner, f *schema.Relation) string { + return q(currentSeqSchema(p, f)) + "." + q(f.Name) +} + +// currentSeqSchema is currentSeqName's schema half on its own, so a caller that also +// needs to move the sequence on to its target schema (an explicit ALTER SEQUENCE ... SET +// SCHEMA: clearing OWNED BY does not undo the cascade -- the sequence stays wherever its +// owner's move already put it) can compare it against the target's. +func currentSeqSchema(p *planner, f *schema.Relation) string { + if f.OwnedBy != "" { + if owner := p.rels(p.from)[ownerRelation(f.OwnedBy)]; owner != nil { + if to := p.toOf(owner); to != nil { + return to.Schema + } + } + } + return f.Schema +} + +func ownerColumnStatus(p *planner, fromRels map[string]*schema.Relation, seq *schema.Relation) ownerColumnState { + owner, col := ownerRelation(seq.OwnedBy), ownerColumn(seq.OwnedBy) + fr := fromRels[owner] + if fr == nil || fr.Column(col) == nil { + return ownerTableGone // defensive: nothing on the from-side to check against + } + tr := p.toOf(fr) + if tr == nil || tr.Kind != fr.Kind { + return ownerTableGone + } + if fr.Column(col).Identity != 0 { + return ownerColumnRenamed // an IDENTITY column's sequence is the server's own + } + if tr.Column(p.toCol(fr, col)) == nil { + return ownerColumnGoneNotTable + } + return ownerColumnRenamed +} + +func ownerColumnGone(p *planner, fromRels map[string]*schema.Relation, seq *schema.Relation) bool { + owner, col := ownerRelation(seq.OwnedBy), ownerColumn(seq.OwnedBy) + fr := fromRels[owner] + if fr == nil || fr.Column(col) == nil { + return true + } + if fr.Column(col).Identity != 0 { + // an IDENTITY column's sequence is the server's own: it goes (or is replaced) + // with ALTER COLUMN DROP IDENTITY / TYPE, emitted elsewhere, not a DROP SEQUENCE + return true + } + tr := p.toOf(fr) + if tr == nil || tr.Kind != fr.Kind { + // the owning table is itself gone: its own DROP TABLE (which drops the column + // and, with it, the column's DEFAULT nextval(...)) takes the sequence down too. + // A separate DROP SEQUENCE ahead of that DROP TABLE is 2BP01 "other objects + // depend on it" (measured) -- the column's own DEFAULT still names it. + return true + } + if tr.Column(p.toCol(fr, col)) == nil { + // the table survives but this column itself does not: its own DROP COLUMN + // (declared -- @migrate drop) takes the sequence down the same way, and a + // separate DROP SEQUENCE after it is 42P01 "does not exist" (measured). + return true + } + // the same match intents.go's sequence-follows-rename logic looks for: if some + // sequence in the target still claims this (possibly renamed) ownership, this one + // is not gone, just relocated or renamed -- that logic gets it there, and a table's + // own SET SCHEMA / RENAME (also emitted elsewhere) carries a same-schema owned + // sequence with it (measured) without this one needing to move separately. + toOwner, toCol := p.toName(owner), col + if m := p.in.colTo[owner]; m != nil { + if c, ok := m[col]; ok { + toCol = c + } + } + wantOwned := toOwner + "." + toCol + if !strings.Contains(toOwner, ".") { + wantOwned = "public." + wantOwned + } + toRels, _ := relations(p.to) + for _, r := range toRels { + if r.Kind == schema.Sequence && r.OwnedBy == wantOwned { + return true + } + } + return false +} + +// columnWordRe matches name as a whole word (e.g. in "new.id" or "id", not inside +// "identifier"): used to look for a column's name in text that is not walkable AST +// (a rule's deparsed definition). +func columnWordRe(name string) *regexp.Regexp { + return regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\b`) +} + +// soleColumnConstraints are r's target-schema unique / primary key constraints over column +// name (alone or with others) -- also taken down, silently, by the same rewrite (a +// multi-column constraint goes with any of its columns, measured). +func (p *planner) soleColumnConstraints(r *schema.Relation, name string) []*schema.Constraint { + var out []*schema.Constraint + for _, n := range sortedKeys(constraints(r)) { + c := constraints(r)[n] + switch { + case (c.Kind == schema.PrimaryKey || c.Kind == schema.Unique || c.Kind == schema.Exclude) && hasString(c.Columns, name): + out = append(out, c) + case c.Kind == schema.Check && hasString(schema.ColumnRefs(c.Expr), name): + out = append(out, c) // a CHECK reading the column goes with it too (measured) + } + } + return out +} + +// referencingFK is a foreign key found on another table, elsewhere in the target +// schema, that references r's column name. +type referencingFK struct { + rel *schema.Relation + con *schema.Constraint +} + +// foreignKeysReferencing lists the foreign keys, anywhere in the target schema, whose +// REFERENCES points at r's column name: the referenced side is a unique or primary key +// constraint a generated-column rewrite is about to take down along with the column, and +// PostgreSQL refuses the DROP COLUMN outright while such a foreign key still depends on +// it (SQLSTATE 2BP01). +func (p *planner) foreignKeysReferencing(r *schema.Relation, name string) []referencingFK { + pk := p.soleColumnConstraints(r, name) + isPK := false + for _, c := range pk { + if c.Kind == schema.PrimaryKey { + isPK = true + } + } + var out []referencingFK + _, toOrder := relations(p.to) + for _, other := range toOrder { + for _, n := range sortedKeys(constraints(other)) { + c := constraints(other)[n] + if c.Kind != schema.ForeignKey || c.RefTable != r.FullName() { + continue + } + switch { + case len(c.RefColumns) == 1 && c.RefColumns[0] == name: + out = append(out, referencingFK{other, c}) + case len(c.RefColumns) == 0 && isPK: + // empty RefColumns means the referenced table's primary key + out = append(out, referencingFK{other, c}) + } + } + } + return out +} + +// emitConstraint re-adds a target-schema constraint that a generated-column rewrite took +// down along with the column it was defined on. +func (p *planner) emitConstraint(r *schema.Relation, c *schema.Constraint) { + if c.Definition != "" { + p.emit("%s", c.Definition) + return + } + p.emit("ALTER TABLE %s ADD CONSTRAINT %s %s", qrel(r), q(c.Name), constraintText(p.to, c)) +} + +// sequenceFollowsRename reports whether seq (a target-schema sequence) is the renamed +// continuation of some from-side serial sequence: renames() (intents.go) already emits +// its ALTER SEQUENCE ... RENAME TO for a serial column's sequence when the column or its +// table is renamed, without recording that in p.in.relFrom / relTo (only explicit +// `-- @migrate rename` declarations go there), so p.fromOf(seq) alone cannot tell adds() +// this sequence already exists under its old name -- it would otherwise emit a second +// CREATE SEQUENCE + ALTER SEQUENCE ... OWNED BY for it (42P07 "already exists", measured: +// TestProbeSerialSequenceFollowsRename). Mirrors the match renames() itself makes: a +// from-side sequence whose owning column, carried through any declared rename, lands on +// seq's own OwnedBy. +func (p *planner) sequenceFollowsRename(seq *schema.Relation) bool { + for _, name := range sortedKeys(p.rels(p.from)) { + fseq := p.rels(p.from)[name] + if fseq.Kind != schema.Sequence || fseq.OwnedBy == "" { + continue + } + owner, col := ownerRelation(fseq.OwnedBy), ownerColumn(fseq.OwnedBy) + toOwner, toCol := p.toName(owner), col + if m := p.in.colTo[owner]; m != nil { + if c, ok := m[col]; ok { + toCol = c + } + } + if toOwner == owner && toCol == col { + // nothing about fseq's ownership changed, so it is not "following a rename" + // at all -- matching it here on OwnedBy alone would also catch an unrelated, + // stationary sequence that happens to already own the very column seq is + // newly declared OWNED BY (two sequences legitimately can name the same + // column), losing seq's own CREATE SEQUENCE as if it were that other + // sequence's rename (measured: TestProbeTwoSequencesOwnTheSameColumn) + continue + } + want := toOwner + "." + toCol + if !strings.Contains(toOwner, ".") { + want = "public." + want + } + if want == seq.OwnedBy { + return true + } + } + return false +} + +// --- adds ---------------------------------------------------------------------------- + +func (p *planner) adds() { + fromExt := extensions(p.from) + for _, e := range p.to.Catalog.Extensions { + if !fromExt[e.Name] { + p.emit("CREATE EXTENSION %s", q(e.Name)) + } + } + // types in declaration order (a domain over another domain, a composite of an enum) + fromTypes, toTypes := diff.UserTypes(p.from), diff.UserTypes(p.to) + for _, t := range p.to.Types.User() { + def := p.to.TypeDefs[t.OID] + if def == "" { + continue + } + for n, ut := range toTypes { + if ut.OID != t.OID { + continue + } + if f, ok := fromTypes[n]; !ok || f.Kind != ut.Kind { + p.emit("%s", def) + } + } + } + fromFns, _ := functions(p.from) + _, toFnOrder := functions(p.to) + // functions before relations (defaults, generated columns and views use them); their + // bodies may name tables not yet there + var newFns []*schema.Function + for _, fn := range toFnOrder { + if fromFns[diff.Signature(p.to, fn)] == nil { + newFns = append(newFns, fn) + } + } + if len(newFns) > 0 { + p.emit("SET check_function_bodies = false") + for _, fn := range newFns { + p.emit("%s", fn.Definition) + } + } + fromRels := p.rels(p.from) + toRels, toOrder := relations(p.to) + // A sequence newly owned by a column that is itself new on a table that already + // exists (a bigserial-style column, no IDENTITY) needs its CREATE SEQUENCE emitted + // explicitly, and before the column's own ADD COLUMN (whose DEFAULT nextval(...) + // names it) rather than wherever the sequence happens to fall in toOrder. + // seqForNewColumn keys that sequence by "table.column"; inlineSeq lists it by its own + // name so the general per-relation pass below skips it. + seqForNewColumn := map[string]*schema.Relation{} + inlineSeq := map[string]bool{} + // repartitionedSeq: a bigserial-style owned sequence detectRepartitions already worked + // out (repartitionPlan.ownedSeqs, carried into pendingRepartition) as one to detach and + // re-own, not recreate -- p.recreated on a repartitioned table (below) would otherwise + // make its own "table is new" sub-loop treat this exact sequence as needing its own + // fresh CREATE SEQUENCE too, on top of the one already standing (42P07 "already + // exists", measured: it is the very object rp.ownedSeqs / pendingRepartition means to + // keep, current value included). + repartitionedSeq := map[string]bool{} + for _, pr := range p.pendingRepartition { + for _, seq := range pr.ownedSeqs { + repartitionedSeq[seq.FullName()] = true + } + } + for _, seq := range toOrder { + if seq.Kind != schema.Sequence || seq.OwnedBy == "" { + continue + } + if f := p.fromOf(seq); f != nil && f.Kind == seq.Kind { + continue // the sequence already exists + } + if p.sequenceFollowsRename(seq) { + continue // renames() already carries it forward under its old name + } + owner := toRels[ownerRelation(seq.OwnedBy)] + if owner == nil || fromRels[owner.FullName()] == nil { + continue // no owner, or the owning table is itself new (handled with its creation) + } + tc := owner.Column(ownerColumn(seq.OwnedBy)) + if tc == nil || tc.Identity != 0 { + continue // IDENTITY creates its own sequence + } + if columnsOf(fromRels[owner.FullName()])[p.fromCol(owner, tc.Name)] != nil { + // the owning column already existed (only "set sequence owner" onto it, not + // "add column"): the general per-relation pass below emits this standalone + // sequence's CREATE + OWNED BY directly (its "+ sequence" branch), since there + // is no ADD COLUMN here for it to precede -- treating it as seqForNewColumn + // would mark it inlineSeq and skip the general pass, but orderNewColumns(fresh) + // never visits an existing column, so the sequence's DDL would never be + // emitted at all (measured: "add sequence; set sequence owner" onto an + // existing column produced an empty plan) + continue + } + seqForNewColumn[owner.FullName()+"."+ownerColumn(seq.OwnedBy)] = seq + inlineSeq[seq.FullName()] = true + } + // relations in declaration order, with their columns + // newPartitions: partitions newly created (or recreated) in this plan, whose ATTACH + // PARTITION is emitted once every new table exists (see below) -- a child can precede + // its parent in toOrder, or the other way around, so the ATTACH cannot go inline with + // either one's own CREATE TABLE. + var newPartitions []*schema.Relation + for _, r := range toOrder { + f := p.fromOf(r) + if p.recreated[r.FullName()] { + f = nil + } + if f == nil || f.Kind != r.Kind { + if r.Kind == schema.Sequence && r.OwnedBy != "" { + if inlineSeq[r.FullName()] { + continue // emitted right before its new column, in the surviving table's own loop below + } + if p.sequenceFollowsRename(r) { + continue // renames() already carries it forward under its old name + } + if owner := toRels[ownerRelation(r.OwnedBy)]; owner != nil { + switch tc := owner.Column(ownerColumn(r.OwnedBy)); { + case fromRels[owner.FullName()] == nil: + continue // the owning table is new; its own creation block (below) emits this sequence + case tc != nil && tc.Identity != 0: + continue // the owning column has (or is gaining) IDENTITY; that ALTER / ADD COLUMN creates the sequence itself + } + } + } + p.emit("%s", r.Definition) + if r.Kind == schema.Sequence && r.OwnedBy != "" { + p.emit("ALTER SEQUENCE %s OWNED BY %s", qrel(r), qdot(r.OwnedBy)) + } + if r.Kind == schema.Table { + // a dump declares a serial column's sequence and default outside CREATE TABLE; + // an identity column's sequence is created by the ADD GENERATED ... AS IDENTITY + // among the table's Alters (emitting the sequence here too is 0A000 "cannot + // change ownership of identity sequence", measured) + for _, seq := range toOrder { + if seq.Kind == schema.Sequence && seq.OwnedBy != "" && ownerRelation(seq.OwnedBy) == r.FullName() { + if repartitionedSeq[seq.FullName()] { + continue // pendingRepartition already re-owns this one; never recreated + } + if tc := r.Column(ownerColumn(seq.OwnedBy)); tc != nil && isIdentity(tc.Identity) { + continue + } + p.emit("%s", seq.Definition) + p.emit("ALTER SEQUENCE %s OWNED BY %s", qrel(seq), qdot(seq.OwnedBy)) + } + } + for _, a := range r.Alters { + p.emit("%s", a) + } + if r.IsPartition { + newPartitions = append(newPartitions, r) + } + } + continue + } + if r.Kind == schema.View && !same(p.from, p.to, f, r) && replaceable(p.from, p.to, f, r) { + // a view that only grows is replaced in place, here rather than in alters: its + // new definition may read a column added just above (42703 otherwise, measured) + p.emit("%s", strings.Replace(r.Definition, "CREATE VIEW", "CREATE OR REPLACE VIEW", 1)) + } + if r.Kind != schema.Table { + continue + } + if r.IsPartition && f.IsPartition { + continue // see alterTable's same skip: the parent's own ADD COLUMN reaches it + } + fromCols := columnsOf(f) + var fresh []*schema.Column + for _, c := range r.Columns { + if fromCols[p.fromCol(r, c.Name)] == nil { + fresh = append(fresh, c) + } + } + // a generated column reading a column added alongside it comes after that column + // (42703 "column does not exist" otherwise, measured) + for _, c := range orderNewColumns(fresh) { + { + // a sequence owned by this new column must exist before ADD COLUMN (its + // DEFAULT nextval(...) names it), but OWNED BY needs the column to exist - + // so create it now and defer OWNED BY until right after the column lands. + seq := seqForNewColumn[r.FullName()+"."+c.Name] + if seq != nil { + p.emit("%s", seq.Definition) + } + if c.NotNull && len(p.in.backfills[r.FullName()]) > 0 && p.hasBackfill(r, c.Name) { + // the rows exist already: add nullable, fill, then constrain + p.emit("ALTER TABLE %s ADD COLUMN %s", qrel(r), columnText(p.to, c, false)) + if seq != nil { + p.emit("ALTER SEQUENCE %s OWNED BY %s", qrel(seq), qdot(seq.OwnedBy)) + } + p.backfill(r, c.Name) + p.emit("ALTER TABLE %s ALTER COLUMN %s SET NOT NULL", qrel(r), q(c.Name)) + continue + } + p.emit("ALTER TABLE %s ADD COLUMN %s", qrel(r), columnText(p.to, c, true)) + if seq != nil { + p.emit("ALTER SEQUENCE %s OWNED BY %s", qrel(seq), qdot(seq.OwnedBy)) + } + } + } + p.backfillsLeft(r, nil) + } + // ATTACH PARTITION for every partition created above (parent and child alike may be + // brand new here, in either order in toOrder): the parent must exist, and so must the + // child, which a partition's own CREATE TABLE never declares by itself (unlike a + // hand-written schema.sql's single-statement CREATE TABLE ... PARTITION OF ... FOR + // VALUES ..., pg_dump -- and so this plan's inputs, always canonical -- spells it as a + // plain CREATE TABLE plus this ALTER, measured); together with alterTable's + // pendingAttach (a surviving table reattached, possibly with a new bound), so every + // DETACH in the plan (alters(), above) precedes every ATTACH. + for _, part := range newPartitions { + p.emit("ALTER TABLE ONLY %s ATTACH PARTITION %s %s", qrel(part.Parents[0]), qrel(part), part.PartBound) + } + for _, pa := range p.pendingAttach { + if pred, ok := partitionMovePredicate(pa.parent.PartKey, pa.parent.PartStrategy, pa.bound); ok { + // OVERRIDING SYSTEM VALUE: this INSERT names every one of pa.part's columns via + // SELECT * (positionally, including a GENERATED ALWAYS AS IDENTITY parent's own + // id), which PostgreSQL treats as an explicit value for that column regardless + // of whether the moved set actually turns out to hold any rows (428C9 + // "cannot insert a non-DEFAULT value", measured to fire even on an empty SELECT) + // -- the same reasoning pendingRepartition's own INSERT already carries this + // for. GENERATED BY DEFAULT accepts an explicit value either way, so this is + // harmless there too. + overriding := "" + for _, c := range pa.parent.Columns { + if c.Identity == 'a' { + overriding = " OVERRIDING SYSTEM VALUE" + break + } + } + p.emit("WITH moved AS (DELETE FROM %s WHERE NOT (%s) RETURNING *) INSERT INTO %s%s SELECT * FROM moved", + qrel(pa.part), pred, qrel(pa.parent), overriding) + } + p.emit("ALTER TABLE ONLY %s ATTACH PARTITION %s %s", qrel(pa.parent), qrel(pa.part), pa.bound) + } + // stableAttachParent: a partition attached in this plan (above) to a parent that + // already existed, unchanged -- PostgreSQL's ATTACH PARTITION immediately builds and + // attaches a matching constraint / index for every one the parent already carries + // (measured: attaching a plain table under an already-keyed, already-indexed parent + // and then also explicitly ADD CONSTRAINT / CREATE INDEX-ing the same shape on the + // partition is 42P16 "multiple primary keys"), so those two loops below skip a + // constraint / index of matching shape on such a partition. A parent created fresh in + // this same plan (both parties new) has nothing yet at ATTACH time -- PostgreSQL + // builds nothing automatically, and every partition gets its own explicit ADD + // CONSTRAINT / CREATE INDEX, the pg_dump form measured in the first partitioning round + // trip -- so this only applies when the parent was not itself just created. + stableAttachParent := map[string]*schema.Relation{} + for _, part := range newPartitions { + parent := part.Parents[0] + if f := p.fromOf(parent); f != nil && f.Kind == parent.Kind && !p.recreated[parent.FullName()] { + stableAttachParent[part.FullName()] = parent + } + } + for _, pa := range p.pendingAttach { + if f := p.fromOf(pa.parent); f != nil && f.Kind == pa.parent.Kind && !p.recreated[pa.parent.FullName()] { + stableAttachParent[pa.part.FullName()] = pa.parent + } + } + // constraints (keys before foreign keys), indexes, triggers, rules + for pass := 0; pass < 2; pass++ { + for _, r := range toOrder { + f := p.fromOf(r) + var fromCon map[string]*schema.Constraint + if f != nil && f.Kind == r.Kind { + fromCon = constraints(f) + } + for _, n := range sortedKeys(constraints(r)) { + c := constraints(r)[n] + if (c.Kind == schema.ForeignKey) != (pass == 1) { + continue + } + if fc := fromCon[n]; fc != nil && same(p.from, p.to, fc, c) && !p.redoFK[r.FullName()+"."+n] || p.restored[r.FullName()+"."+n] { + continue + } + if f == nil && c.Definition == "" { + continue // declared inside the CREATE TABLE just emitted + } + if parent := stableAttachParent[r.FullName()]; parent != nil && hasMatchingConstraint(parent, c) { + continue // ATTACH PARTITION already built this one (see stableAttachParent) + } + if c.Definition != "" { + p.emit("%s", c.Definition) + } else { + p.emit("ALTER TABLE %s ADD CONSTRAINT %s %s", qrel(r), q(c.Name), constraintText(p.to, c)) + } + } + } + } + for _, r := range toOrder { + f := p.fromOf(r) + var fromIdx map[string]*schema.Index + if f != nil && f.Kind == r.Kind { + fromIdx = indexes(f) + } + for _, n := range sortedKeys(indexes(r)) { + i := indexes(r)[n] + if fi := fromIdx[n]; fi != nil && same(p.from, p.to, fi, i) || p.restored[r.FullName()+"."+n] { + continue + } + if parent := stableAttachParent[r.FullName()]; parent != nil && hasMatchingIndex(parent, i) { + continue // ATTACH PARTITION already built this one (see stableAttachParent) + } + p.emit("%s", i.Definition) + } + } + fromTrg := triggers(p.from) + for _, t := range p.to.Triggers { + if ft := fromTrg[p.fromName(t.Table)+"."+t.Name]; ft != nil && toRels[t.Table] != nil && fromRels[p.fromName(t.Table)] != nil && !p.recreated[t.Table] && same(p.from, p.to, ft, t) { + continue + } + p.emit("%s", t.Definition) + } + for _, r := range toOrder { + f := p.fromOf(r) + var fromRules map[string]schema.RuleDef + if f != nil && f.Kind == r.Kind { + fromRules = f.Rules() + } + rules := r.Rules() + for _, n := range sortedKeys(rules) { + rd := rules[n] + // p.recreated (repartitionTable): the old rule perished with the old table + // under its temporary name, so an unchanged-looking rule still needs its + // CREATE RULE reissued against the rebuilt one -- same reasoning as the + // trigger loop just above and the policy loop just below. + if fr, ok := fromRules[n]; ok && same(p.from, p.to, fr, rd) && !p.recreated[r.FullName()] { + continue + } + p.emit("%s", schema.DeparseStmt(ruleNode(rd))) + if !rd.Enabled { + p.emit("ALTER TABLE %s DISABLE RULE %s", qrel(r), q(n)) + } + } + } + // row-level security policies (on tables created above, their ENABLE / FORCE came with + // the table's own ALTERs) + for _, r := range toOrder { + f := p.fromOf(r) + if f != nil && f.Kind != r.Kind { + f = nil + } + for _, pol := range r.Policies { + if f != nil && !p.recreated[r.FullName()] { + if fp := f.Policy(pol.Name); fp != nil && same(p.from, p.to, fp, pol) { + continue + } + } + p.emit("%s", pol.Definition) + } + } + // comments, the from side's keys spelled as the target names them (a comment on a + // renamed table is still the same comment; one the target drops is removed under the + // new name -- measured: the plan left it in place before) + fromComments := map[string]string{} + for k, v := range p.from.Comments { + fromComments[p.commentKeyTo(k)] = v + } + for _, k := range sortedKeys(p.to.Comments) { + v := p.to.Comments[k] + // p.recreated (repartitionTable): a comment attaches to the object's OID, gone + // the moment the old (renamed-aside) table is dropped, so even identical text + // needs COMMENT ON reissued against the rebuilt table/column -- otherwise this + // generic "same text, nothing to do" skip would silently leave the new object + // with no comment at all (repartitionEligible's own doc comment once excluded + // any table carrying one for exactly this reason; now handled here instead). + if fv, ok := fromComments[k]; ok && fv == v && !p.recreated[commentRelKey(k)] { + continue + } + if t := commentText(p.to, k, v); t != "" { + p.emit("%s", t) + } + } + for _, k := range sortedKeys(fromComments) { + if _, ok := p.to.Comments[k]; ok { + continue + } + if t := commentText(p.to, k, ""); t != "" { + p.emit("%s", t) + } + } + // repartitionTable's rows come back last, once everything else about the new + // partitioned table (itself, its declared partitions attached, constraints, indexes, + // comments) is fully in place: a plain INSERT lets PostgreSQL's own partition router + // place every row, refusing the whole statement (23514) if even one fits nowhere. + for _, pr := range p.pendingRepartition { + // a bigserial-style owned sequence comes back onto its (possibly renamed) column + // once that column exists on the new table -- ahead of the INSERT below, though + // nothing requires that particular order (ownership does not gate DML). Skipped + // (not an error) if the owning column itself did not survive the repartition; some + // other declared mutation is then responsible for that sequence's own fate. + for _, seq := range pr.ownedSeqs { + col := p.toCol(pr.old, ownerColumn(seq.OwnedBy)) + if pr.table.Column(col) == nil { + continue + } + p.emit("ALTER SEQUENCE %s OWNED BY %s", qrel(seq), q(pr.table.Schema)+"."+q(pr.table.Name)+"."+q(col)) + } + temp := q(pr.old.Schema) + "." + q(pr.tempName) + fromCols := columnsOf(pr.old) + var insCols, selCols, identityCols []string + for _, c := range pr.table.Columns { + if c.Generated != nil { + continue // never settable directly; the router fills it in from what it reads + } + fc := fromCols[p.fromCol(pr.table, c.Name)] + if fc == nil { + continue // a column gained alongside the repartition: out of pendingRepartition's scope, its own default applies + } + insCols = append(insCols, q(c.Name)) + selCols = append(selCols, q(fc.Name)) + if isIdentity(c.Identity) { + // a bigserial column (c.Identity == 's') is excluded here: its sequence is + // never recreated (rp.ownedSeqs re-owns the very same object, current value + // untouched), so forcing it to max(column) risks moving it *backwards* if + // any row with a higher id was ever deleted -- unlike a fresh IDENTITY + // sequence, which starts at 1 with nothing yet to lose. + identityCols = append(identityCols, c.Name) + } + } + // an IDENTITY column's own sequence is fresh (r.Definition's ADD GENERATED ... AS + // IDENTITY created it, oblivious to the old table's values): OVERRIDING SYSTEM VALUE + // lets the router still insert the old, explicit values under GENERATED ALWAYS + // (428C9 without it, measured; GENERATED BY DEFAULT accepts them either way, but + // this is harmless there too), and setval() afterward resumes the fresh sequence + // from the highest value actually inserted, so the very next nextval() continues + // where the old one left off instead of colliding at 1. Skipped when the table holds + // no rows for it to see (an empty derived table's own WHERE never lets setval() run + // at all, so is_called is left alone rather than forced true on nothing). + overriding := "" + if len(identityCols) > 0 { + overriding = " OVERRIDING SYSTEM VALUE" + } + p.emit("INSERT INTO %s (%s)%s SELECT %s FROM %s", qrel(pr.table), strings.Join(insCols, ", "), overriding, strings.Join(selCols, ", "), temp) + p.emit("DROP TABLE %s", temp) + for _, col := range identityCols { + p.emit("SELECT setval(pg_get_serial_sequence(%s, %s), m, true) FROM (SELECT max(%s) AS m FROM %s) s WHERE m IS NOT NULL", + lit(qrel(pr.table)), lit(col), q(col), qrel(pr.table)) + } + } +} + +// commentKeyTo is a from-side comment key ("schema.rel" or "schema.rel.column") with the +// declared renames applied. +func (p *planner) commentKeyTo(k string) string { + rel := commentRelation(k) + if p.from.Relation(splitRel(rel)) == nil { + return k + } + to := p.toName(rel) + if rel == k { + return to + } + col := k[len(rel)+1:] + if m := p.in.colTo[rel]; m != nil { + if c, ok := m[col]; ok { + col = c + } + } + return to + "." + col +} + +func splitRel(full string) (string, string) { + if i := strings.Index(full, "."); i > 0 { + return full[:i], full[i+1:] + } + return "public", full +} + +// rowSecurity emits the ENABLE / DISABLE / FORCE / NO FORCE ROW LEVEL SECURITY a +// surviving table needs. +func (p *planner) rowSecurity(f, r *schema.Relation) { + if f.RowSecurity != r.RowSecurity { + if r.RowSecurity { + p.emit("ALTER TABLE %s ENABLE ROW LEVEL SECURITY", qrel(r)) + } else { + p.emit("ALTER TABLE %s DISABLE ROW LEVEL SECURITY", qrel(r)) + } + } + if f.ForceRowSecurity != r.ForceRowSecurity { + if r.ForceRowSecurity { + p.emit("ALTER TABLE %s FORCE ROW LEVEL SECURITY", qrel(r)) + } else { + p.emit("ALTER TABLE %s NO FORCE ROW LEVEL SECURITY", qrel(r)) + } + } +} + +// fkOrder sorts tables to drop so that a table comes before the tables its foreign +// keys reference (among those dropped). +func fkOrder(rels []*schema.Relation) []*schema.Relation { + pending := map[string]*schema.Relation{} + for _, r := range rels { + pending[r.FullName()] = r + } + var out []*schema.Relation + for len(pending) > 0 { + progress := false + for _, r := range rels { + if pending[r.FullName()] == nil { + continue + } + referenced := false + for _, o := range pending { + if o == r { + continue + } + for _, c := range o.Constraints { + if c.Kind == schema.ForeignKey && strings.TrimPrefix(c.RefTable, "public.") == r.FullName() { + referenced = true + } + } + } + if !referenced { + out = append(out, r) + delete(pending, r.FullName()) + progress = true + } + } + if !progress { // a cycle: take the rest as they come + for _, r := range rels { + if pending[r.FullName()] != nil { + out = append(out, r) + delete(pending, r.FullName()) + } + } + } + } + return out +} diff --git a/internal/migrate/migrate_test.go b/check/postgres/migrate/migrate_test.go similarity index 92% rename from internal/migrate/migrate_test.go rename to check/postgres/migrate/migrate_test.go index 2ff82e7..28903f8 100644 --- a/internal/migrate/migrate_test.go +++ b/check/postgres/migrate/migrate_test.go @@ -6,13 +6,16 @@ import ( "os" "os/exec" "path/filepath" + "regexp" + "strconv" "strings" + "sync" "testing" - "github.com/kr9ly/sqlshape/internal/diff" - "github.com/kr9ly/sqlshape/internal/dump" - "github.com/kr9ly/sqlshape/internal/pgparse" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/diff" + "github.com/kr9ly/sqlshape/check/postgres/v2/dump" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" ) type canonical struct { @@ -27,6 +30,55 @@ type canonical struct { // costs seconds; a database on it, milliseconds). var server *dump.Server +// server18 is a second, PostgreSQL 18 instance, started lazily (server18Once) only by +// TestMigrateProbe18 -- every other test in the package stays on the 17 server, and +// nothing pays embedded-postgres's PG18 download / boot cost unless that test actually +// runs (brief-holes-pg.md item E). +var ( + server18 *dump.Server + server18Once sync.Once + server18Err error +) + +// pgDumpMajor is dump.Binary()'s own major version ("pg_dump (PostgreSQL) 18.0" -> 18), +// which must be at least the server's (dump/dump.go's own doc comment: pg_dump can't dump +// a server newer than itself) -- nix-shell -p postgresql_18 on PATH, not postgresql_17. +func pgDumpMajor() (int, error) { + out, err := exec.Command(dump.Binary(), "--version").Output() + if err != nil { + return 0, err + } + m := regexp.MustCompile(`\)\s+(\d+)`).FindSubmatch(out) + if m == nil { + return 0, fmt.Errorf("%s --version: unrecognized output %q", dump.Binary(), out) + } + n, err := strconv.Atoi(string(m[1])) + if err != nil { + return 0, err + } + return n, nil +} + +func requirePgDump18(t *testing.T) { + t.Helper() + if _, err := exec.LookPath(dump.Binary()); err != nil { + t.Skipf("%s not found", dump.Binary()) + } + major, err := pgDumpMajor() + if err != nil { + t.Skipf("%s --version: %v", dump.Binary(), err) + } + if major < 18 { + t.Skipf("%s is PostgreSQL %d, need at least 18 (nix-shell -p postgresql_18)", dump.Binary(), major) + } + server18Once.Do(func() { + server18, server18Err = dump.NewServer(context.Background(), pgparse.PG18) + }) + if server18Err != nil { + t.Fatalf("start PostgreSQL 18: %v", server18Err) + } +} + func TestMain(m *testing.M) { if _, err := exec.LookPath(dump.Binary()); err == nil { srv, err := dump.NewServer(context.Background(), pgparse.Default) @@ -40,6 +92,9 @@ func TestMain(m *testing.M) { if server != nil { server.Close() } + if server18 != nil { + server18.Close() + } os.Exit(code) } @@ -103,7 +158,7 @@ func roundTrip(t *testing.T, from, to canonical, back string, oneWay bool) { func example(t *testing.T, name string) string { t.Helper() - sql, err := os.ReadFile(filepath.Join("../../examples", name, "schema.sql")) + sql, err := os.ReadFile(filepath.Join("../../../examples", name, "schema.sql")) if err != nil { t.Fatal(err) } @@ -234,6 +289,44 @@ ALTER TABLE orders ALTER COLUMN status DROP DEFAULT; ALTER TABLE orders ALTER CO t.Errorf("got %v\nwant a problem containing %q", err, want) } }) + // A domain's base type, a range's subtype, and a table's INHERITS / PARTITION OF / OF + // type: none of these can be altered in place (no lossless DDL exists, same ruling as + // a partition key change and a composite attribute type change under a column that + // uses it -- apply stops instead of leaving verify-schema reporting the same + // difference forever). These operate on statically parsed schemas (schema.Load), no + // database needed. + load := func(sql string) *schema.Schema { + t.Helper() + s, err := schema.Load(sql) + if err != nil { + t.Fatal(err) + } + return s + } + t.Run("domain base type", func(t *testing.T) { + from, to := load("CREATE DOMAIN d AS integer;"), load("CREATE DOMAIN d AS text;") + _, err := Plan(from, to, nil) + if want := "domain d: base type integer -> text has no lossless DDL"; err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("got %v\nwant a problem containing %q", err, want) + } + }) + t.Run("range subtype", func(t *testing.T) { + from := load("CREATE TYPE r1 AS RANGE (subtype = integer);") + to := load("CREATE TYPE r1 AS RANGE (subtype = numeric);") + _, err := Plan(from, to, nil) + if err == nil || !strings.Contains(err.Error(), "range r1:") || !strings.Contains(err.Error(), "has no lossless DDL") { + t.Errorf("got %v\nwant a problem about the range's subtype", err) + } + }) + t.Run("table property the plan cannot alter", func(t *testing.T) { + composite := "CREATE TYPE point2 AS (x integer, y integer);\n" + from := load(composite + "CREATE TABLE p1 OF point2;") + to := load(composite + "CREATE TABLE p1 (x integer, y integer);") + _, err := Plan(from, to, nil) + if want := `table p1: of type "point2" -> "" has no lossless DDL`; err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("got %v\nwant a problem containing %q", err, want) + } + }) } func TestParseIntents(t *testing.T) { @@ -442,63 +535,6 @@ func TestCommentHelpers(t *testing.T) { } } -// TestPlanNotes covers the "-- " notes the plan leaves for changes it cannot express as -// DDL: a domain's base type, a range's subtype, and a table's INHERITS / PARTITION OF / -// OF type - none of which can be altered in place. These operate on statically parsed -// schemas (schema.Load), no database needed. -func TestPlanNotes(t *testing.T) { - load := func(sql string) *schema.Schema { - t.Helper() - s, err := schema.Load(sql) - if err != nil { - t.Fatal(err) - } - return s - } - t.Run("domain base type", func(t *testing.T) { - from, to := load("CREATE DOMAIN d AS integer;"), load("CREATE DOMAIN d AS text;") - plan, err := Plan(from, to, nil) - if err != nil { - t.Fatal(err) - } - ddl := strings.Join(plan, "\n") - if !strings.Contains(ddl, "-- domain d: base type integer -> text cannot be altered") { - t.Errorf("want a note about the domain's base type, got:\n%s", ddl) - } - if strings.Contains(ddl, "ALTER DOMAIN") { - t.Errorf("want no ALTER for an unalterable base type change:\n%s", ddl) - } - }) - t.Run("range subtype", func(t *testing.T) { - from := load("CREATE TYPE r1 AS RANGE (subtype = integer);") - to := load("CREATE TYPE r1 AS RANGE (subtype = numeric);") - plan, err := Plan(from, to, nil) - if err != nil { - t.Fatal(err) - } - ddl := strings.Join(plan, "\n") - if !strings.Contains(ddl, "-- range r1:") || !strings.Contains(ddl, "cannot be altered") { - t.Errorf("want a note about the range's subtype, got:\n%s", ddl) - } - }) - t.Run("table property the plan cannot alter", func(t *testing.T) { - composite := "CREATE TYPE point2 AS (x integer, y integer);\n" - from := load(composite + "CREATE TABLE p1 OF point2;") - to := load(composite + "CREATE TABLE p1 (x integer, y integer);") - plan, err := Plan(from, to, nil) - if err != nil { - t.Fatal(err) - } - ddl := strings.Join(plan, "\n") - if !strings.Contains(ddl, `-- table p1: of type "point2" -> "" cannot be altered by the plan`) { - t.Errorf("want a note about the unalterable \"of type\" property, got:\n%s", ddl) - } - if strings.Contains(ddl, "ALTER TABLE") { - t.Errorf("want no ALTER TABLE for a property the plan cannot change:\n%s", ddl) - } - }) -} - // TestAlterTypeEnumAddValueBefore covers alterType's enum branch positioning a new label // with BEFORE (as opposed to AFTER, or no position at all for a label appended at the // end) - reached only when the new label isn't last and nothing already-known precedes diff --git a/check/postgres/migrate/probe_alphabet_test.go b/check/postgres/migrate/probe_alphabet_test.go new file mode 100644 index 0000000..2ad164a --- /dev/null +++ b/check/postgres/migrate/probe_alphabet_test.go @@ -0,0 +1,133 @@ +package migrate + +// Split from probe_test.go (see its header): the diff.Alphabet() coverage unit -- every +// (Op, Kind, Field) triple diff.Compare can report, the ones the probe accepts as +// structurally unreachable instead of requiring a mutation to produce, and directed +// coverage (one pass applying every mutation alone, so the alphabet's hit set does not +// depend on which mutations 200 random pairs happen to draw). Pure move. + +import ( + "context" + "math/rand" + "strings" + "testing" + + "github.com/kr9ly/sqlshape/check/postgres/v2/diff" + "github.com/kr9ly/sqlshape/check/postgres/v2/dump" +) + +var alphabetKnownUnreached = map[string]string{ + "~ table inherits": "migrate.go's alterTable reports inherits/of type changes as a problem (\"has no lossless DDL\", the same ruling as a partition key change); never DDL. Regular table INHERITS (distinct from PARTITION OF, in this round's vocabulary) is still out of this probe's vocabulary", + "~ table of type": "same problem-only path as table inherits; typed tables (CREATE TABLE OF) are also not in this probe's vocabulary", + "~ domain base": "migrate.go's alterType domain case reports a base type change as a problem (\"has no lossless DDL\"); never DDL", + "~ range subtype": "migrate.go's alterType range case reports any change as a problem (\"has no lossless DDL\"); never DDL. The generator also has no range type vocabulary", + "~ constraint without overlaps": "a PostgreSQL 18 constraint attribute (WITHOUT OVERLAPS). Under the 17 gate the server refuses the syntax outright, so this stays an accepted excuse there; under 18, generate() itself seeds a temporal key from the start (mutations18's \"toggle key without overlaps\" then flips its flag in place) and pg18OnlyUnreached drops this excuse, so 18 requires an actual hit", + "~ constraint period": "a PostgreSQL 18 constraint attribute (FOREIGN KEY ... PERIOD, needing a WITHOUT OVERLAPS primary key on the referenced side): under the 17 gate the server refuses the syntax outright, so this stays an accepted excuse there; under 18, generate() seeds an ordinary foreign key resting on that same temporal key from the start (mutations18's \"toggle foreign key period\" widens it onto the key's own range column, PERIOD) and pg18OnlyUnreached drops this excuse, so 18 requires an actual hit", + "~ column generated kind": "a PostgreSQL 18 generated-column form (VIRTUAL, versus this probe's STORED); same PostgreSQL 17 server ceiling as \"without overlaps\"", + "~ constraint not enforced": "a PostgreSQL 18 constraint attribute (NOT ENFORCED); same PostgreSQL 17 server ceiling as \"without overlaps\"", + "~ index unique": "structurally unreachable (measured): pg_dump always renders a named UNIQUE constraint as ALTER TABLE ... ADD CONSTRAINT, never a CREATE INDEX + ADD CONSTRAINT ... UNIQUE USING INDEX pair, so toggling a key between UNIQUE and a plain index changes its diff.Change Kind (constraint <-> index) rather than the same-named index's \"unique\" property -- no vocabulary addition reaches it, since the generator would have to reproduce a pg_dump form pg_dump itself never produces", +} + +// pg18OnlyUnreached is the subset of alphabetKnownUnreached that stops being an accepted +// excuse under TestMigrateProbe18 (brief-holes-pg.md item E): PostgreSQL 18 syntax +// mutations18 exercises there, so alphabetKnownUnreached's own text (written for the 17 +// gate, where the server itself refuses the syntax) does not apply -- isKnownUnreached +// reports these as needing an actual hit instead once pg18 is true. +var pg18OnlyUnreached = map[string]bool{ + "~ column generated kind": true, + "~ constraint not enforced": true, + "~ constraint without overlaps": true, + "~ constraint period": true, +} + +// isKnownUnreached is alphabetKnownUnreached's excuse for s, "" when none applies -- +// except a pg18OnlyUnreached entry under TestMigrateProbe18, whose whole excuse was the 17 +// server's syntax ceiling: gone once pg18 is true, so it must be hit like anything else. +func isKnownUnreached(s string, pg18 bool) string { + if pg18 && pg18OnlyUnreached[s] { + return "" + } + return alphabetKnownUnreached[s] +} + +// tallyHit folds one judged pair's diff.Compare output into the alphabet coverage set: +// every Alter's Fields as their own (Op, Kind, Field) entries, every Add / Drop as one +// (Op, Kind) entry with no Field (matching diff.Alphabet's own grouping for rows and +// comment's single field, and for schema / extension which are never Alter). +func tallyHit(hit map[string]bool, changes []diff.Change) { + for _, ch := range changes { + if ch.Op == diff.Alter { + for _, f := range ch.Fields { + hit[diff.AlphabetEntry{Op: ch.Op, Kind: ch.Kind, Field: diff.NormalizeField(ch.Kind, f.Name)}.String()] = true + } + continue + } + hit[diff.AlphabetEntry{Op: ch.Op, Kind: ch.Kind}.String()] = true + } +} + +// directedAttempts bounds how many freshly generated schemas one mutation gets to find a +// candidate against on its own, in directedCoverage, before it is reported unable to +// apply alone. +const directedAttempts = 30 + +// directedCoverage runs one pair per mutation -- that mutation alone against a freshly +// generated schema, retried against a new schema up to directedAttempts times if it finds +// no candidate -- so the alphabet coverage gate no longer depends on TestMigrateProbe's +// random pairs happening to draw every mutation at least once. That dependency is real: +// adding a mutation shifts every later draw's position in the PRNG sequence, so a vocabulary +// addition can silently stop a *different*, already-covered alphabet entry from being hit +// by the random pairs at the same seed (brief-holes-common.md's whole reason for this +// pass). judge and the finding / hit bookkeeping are exactly what the random loop does; +// unlike a random pair (up to 7 mutations), a directed pair is already minimal, so there is +// nothing to shrink if it turns up a finding. +func directedCoverage(t *testing.T, ctx context.Context, srv *dump.Server, muts []mutation, pg18 bool, seed int64, counts, byMutation map[string]int, hit map[string]bool, findings *[]verdict) []string { + // every mutation draws from a PRNG of its own, so the mutations are independent and + // are judged side by side (see parallel); the tally below runs in their order + type directed struct { + applied []string + v verdict + } + results := make([]directed, len(muts)) + indices := make([]int, len(muts)) + for i := range indices { + indices[i] = i + } + parallel(indices, func(mi int) { + r := rand.New(rand.NewSource(seed*100003 + int64(mi))) + for attempt := 0; attempt < directedAttempts; attempt++ { + src := generate(r, pg18) + target, applied := mutate(muts, src, []step{{m: mi, seed: r.Int63()}}) + if len(applied) > 0 { + results[mi] = directed{applied: applied, v: judge(ctx, srv, src, target, applied)} + return + } + } + }) + var stuck []string + for mi, m := range muts { + res := results[mi] + if len(res.applied) == 0 { + stuck = append(stuck, m.name) + continue + } + v := res.v + for _, a := range res.applied { + byMutation[a]++ + } + tallyHit(hit, v.changes) + switch { + case v.kind == "": + counts["pass"]++ + case strings.HasPrefix(v.kind, "generator"): + counts[v.kind]++ + if counts[v.kind] <= 3 { + t.Logf("%s (directed, %s): %s\n%s", v.kind, m.name, v.detail, v.aSQL) + } + default: + counts["finding"]++ + *findings = append(*findings, v) + } + } + return stuck +} diff --git a/check/postgres/migrate/probe_findings_test.go b/check/postgres/migrate/probe_findings_test.go new file mode 100644 index 0000000..0cf7ad5 --- /dev/null +++ b/check/postgres/migrate/probe_findings_test.go @@ -0,0 +1,1072 @@ +package migrate + +// What the migrate probe (probe_test.go) found and the planner now does, each pinned as a +// minimal pair replayed against a real PostgreSQL the way roundTrip does: the DDL applies +// cleanly and reads back as the target. + +import ( + "context" + "strings" + "testing" + + "github.com/kr9ly/sqlshape/check/postgres/v2/diff" +) + +// A table that goes takes its triggers with it, but not their function: DROP FUNCTION must +// come after DROP TABLE (2BP01 "other objects depend on it" otherwise, measured). +func TestProbeTriggerFunctionDroppedAfterItsTable(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE TABLE keep (id integer PRIMARY KEY); +CREATE TABLE gone (id integer PRIMARY KEY, n integer); +CREATE FUNCTION gone_fn() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN NEW.n := 1; RETURN NEW; END $$; +CREATE TRIGGER gone_tr BEFORE INSERT ON gone FOR EACH ROW EXECUTE FUNCTION gone_fn(); +`) + to := mustCanonical(t, ` +-- @migrate drop gone +CREATE TABLE keep (id integer PRIMARY KEY); +`) + roundTrip(t, from, to, "", true) +} + +// Changing the type of a column a generated column reads: PostgreSQL refuses the ALTER +// TYPE (0A000, measured) until the generated column is dropped, and it is re-added after, +// with the index and the CHECK over it. +func TestProbeTypeChangeUnderGeneratedColumn(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, a integer NOT NULL, g bigint GENERATED ALWAYS AS (a + 1) STORED, + CONSTRAINT t_g_check CHECK (g > 0)); +CREATE INDEX t_g_idx ON t (g, id); +`) + to := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, a bigint NOT NULL, g bigint GENERATED ALWAYS AS (a + 1) STORED, + CONSTRAINT t_g_check CHECK (g > 0)); +CREATE INDEX t_g_idx ON t (g, id); +`) + roundTrip(t, from, to, "", false) +} + +// A new table with an identity column: the dump's ADD GENERATED ... AS IDENTITY among the +// table's ALTERs creates the sequence; emitting the sequence too is 0A000 "cannot change +// ownership of identity sequence" (measured). +func TestProbeNewTableWithIdentityColumn(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, `CREATE TABLE keep (id integer PRIMARY KEY);`) + to := mustCanonical(t, ` +CREATE TABLE keep (id integer PRIMARY KEY); +CREATE TABLE fresh (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text); +`) + roundTrip(t, from, to, "-- @migrate drop fresh", false) +} + +// Columns added and dropped in dependency order: a generated column reading a column added +// alongside it after that column (42703 otherwise), and a gone generated column before the +// gone column it reads (2BP01 otherwise), both measured. +func TestProbeGeneratedColumnsOrderedByDependency(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, `CREATE TABLE t (id integer PRIMARY KEY);`) + to := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, g bigint GENERATED ALWAYS AS (x + 1) STORED, x integer); +`) + roundTrip(t, from, to, "-- @migrate drop t.x\n-- @migrate drop t.g", false) +} + +// A key another table's foreign key rests on cannot be dropped while the foreign key +// stands (2BP01, measured): the foreign key goes first and comes back once the target's +// key exists -- when the primary key moves off the referenced column, when the primary +// key constraint is renamed along with its table, and when the referencing table itself +// goes. +func TestProbeForeignKeyAroundDroppedKey(t *testing.T) { + requirePgDump(t) + t.Run("primary key moves", func(t *testing.T) { + from := mustCanonical(t, ` +CREATE TABLE parents (id integer PRIMARY KEY, code bigint NOT NULL); +CREATE TABLE children (id integer PRIMARY KEY, parent_id integer, + CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES parents (id) ON DELETE SET NULL); +`) + to := mustCanonical(t, ` +CREATE TABLE parents (id integer NOT NULL, code bigint PRIMARY KEY, CONSTRAINT parents_id_key UNIQUE (id)); +CREATE TABLE children (id integer PRIMARY KEY, parent_id integer, + CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES parents (id) ON DELETE SET NULL); +`) + roundTrip(t, from, to, "", false) + }) + t.Run("primary key constraint renamed with its table", func(t *testing.T) { + from := mustCanonical(t, ` +CREATE TABLE parents (id integer PRIMARY KEY); +CREATE TABLE children (id integer PRIMARY KEY, parent_id integer, + CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES parents (id)); +`) + to := mustCanonical(t, ` +-- @migrate rename parents -> folks +CREATE TABLE folks (id integer NOT NULL, CONSTRAINT folks_pkey PRIMARY KEY (id)); +CREATE TABLE children (id integer PRIMARY KEY, parent_id integer, + CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES folks (id)); +`) + roundTrip(t, from, to, "-- @migrate rename folks -> parents", false) + }) + t.Run("referencing table goes", func(t *testing.T) { + from := mustCanonical(t, ` +CREATE TABLE parents (id integer PRIMARY KEY, code bigint NOT NULL); +CREATE TABLE children (id integer PRIMARY KEY, parent_id integer, + CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES parents (id)); +`) + to := mustCanonical(t, ` +-- @migrate drop children +CREATE TABLE parents (id integer NOT NULL, code bigint PRIMARY KEY); +`) + roundTrip(t, from, to, "", true) + }) +} + +// A view over a table whose generated column is rewritten is recreated around the rewrite +// (the DROP COLUMN is 2BP01 while the view reads the column, measured); a generated +// expression that differs only by a declared rename is not rewritten at all (RENAME +// COLUMN carries it), so the view stays. +func TestProbeGeneratedRewriteWithDependentView(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, a integer NOT NULL, b integer NOT NULL, g bigint GENERATED ALWAYS AS (a + 1) STORED); +CREATE VIEW v AS SELECT id, g FROM t; +`) + t.Run("expression changes", func(t *testing.T) { + to := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, a integer NOT NULL, b integer NOT NULL, g bigint GENERATED ALWAYS AS (b + 1) STORED); +CREATE VIEW v AS SELECT id, g FROM t; +`) + roundTrip(t, from, to, "", false) + }) + t.Run("read column renamed", func(t *testing.T) { + to := mustCanonical(t, ` +-- @migrate rename t.a -> t.alpha +CREATE TABLE t (id integer PRIMARY KEY, alpha integer NOT NULL, b integer NOT NULL, g bigint GENERATED ALWAYS AS (alpha + 1) STORED); +CREATE VIEW v AS SELECT id, g FROM t; +`) + plan, err := Plan(from.s, to.s, to.intents) + if err != nil { + t.Fatal(err) + } + for _, s := range plan { + if strings.Contains(s, "DROP COLUMN") { + t.Errorf("a rename alone must not rewrite the generated column:\n%s", s) + } + } + roundTrip(t, from, to, "-- @migrate rename t.alpha -> t.a", false) + }) +} + +// A replaced view whose new definition reads a column the plan adds is replaced after the +// ADD COLUMN (42703 otherwise, measured); a view an ENUM recreation already dropped is +// created once, not replaced and then created (42P07 otherwise, measured). +func TestProbeReplacedViewOrder(t *testing.T) { + requirePgDump(t) + t.Run("reads an added column", func(t *testing.T) { + from := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, a integer); +CREATE VIEW v AS SELECT id, a FROM t; +`) + to := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, a integer, b integer); +CREATE VIEW v AS SELECT id, a, b FROM t; +`) + roundTrip(t, from, to, "-- @migrate drop t.b", true) + }) + t.Run("recreated by an enum label drop", func(t *testing.T) { + from := mustCanonical(t, ` +CREATE TYPE mood AS ENUM ('ok', 'meh'); +CREATE TABLE t (id integer PRIMARY KEY, m mood, a integer); +CREATE VIEW v AS SELECT id FROM t; +`) + to := mustCanonical(t, ` +-- @migrate enum mood: drop 'meh' using 'ok' +CREATE TYPE mood AS ENUM ('ok'); +CREATE TABLE t (id integer PRIMARY KEY, m mood, a integer); +CREATE VIEW v AS SELECT id, a FROM t; +`) + roundTrip(t, from, to, "", true) + }) +} + +// A column rename whose right side names the table as it will be, declared before the +// table's own rename: the table renames are read first whatever their line. +func TestProbeColumnRenameNamesRenamedTable(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, `CREATE TABLE t (id integer PRIMARY KEY, a integer);`) + to := mustCanonical(t, ` +-- @migrate rename t.a -> u.b +-- @migrate rename t -> u +CREATE TABLE u (id integer PRIMARY KEY, b integer); +`) + roundTrip(t, from, to, "-- @migrate rename u.b -> t.a\n-- @migrate rename u -> t", false) +} + +// A comment on a table that is renamed is the same comment under the new name; one the +// target no longer declares is removed under the new name (measured: it stayed before). +func TestProbeCommentFollowsRename(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, a integer); +COMMENT ON TABLE t IS 'about t'; +COMMENT ON COLUMN t.a IS 'a'; +`) + t.Run("kept", func(t *testing.T) { + to := mustCanonical(t, ` +-- @migrate rename t -> u +CREATE TABLE u (id integer PRIMARY KEY, a integer); +COMMENT ON TABLE u IS 'about t'; +COMMENT ON COLUMN u.a IS 'a'; +`) + plan, err := Plan(from.s, to.s, to.intents) + if err != nil { + t.Fatal(err) + } + for _, s := range plan { + if strings.HasPrefix(s, "COMMENT ON") { + t.Errorf("an unchanged comment on a renamed table is not re-issued: %s", s) + } + } + roundTrip(t, from, to, "-- @migrate rename u -> t", false) + }) + t.Run("dropped", func(t *testing.T) { + to := mustCanonical(t, ` +-- @migrate rename t -> u +CREATE TABLE u (id integer PRIMARY KEY, a integer); +`) + roundTrip(t, from, to, "-- @migrate rename u -> t", false) + }) +} + +// A serial column's sequence keeps its name when its table or column is renamed, while the +// target (a fresh CREATE) names it after the new names: the plan renames the sequence too +// (the SET DEFAULT nextval('_seq') it emits is 42P01 otherwise, measured). An +// identity column's sequence is the server's own and is left alone. +func TestProbeSerialSequenceFollowsRename(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, `CREATE TABLE t (id bigserial PRIMARY KEY, n bigserial, a integer);`) + t.Run("table", func(t *testing.T) { + to := mustCanonical(t, "-- @migrate rename t -> u\nCREATE TABLE u (id bigserial PRIMARY KEY, n bigserial, a integer);") + roundTrip(t, from, to, "-- @migrate rename u -> t", false) + }) + t.Run("column", func(t *testing.T) { + to := mustCanonical(t, "-- @migrate rename t.n -> t.m\nCREATE TABLE t (id bigserial PRIMARY KEY, m bigserial, a integer);") + roundTrip(t, from, to, "-- @migrate rename t.m -> t.n", false) + }) +} + +// A composite type's ALTER ATTRIBUTE TYPE goes through the same catalog check as ALTER +// COLUMN TYPE (find_composite_type_dependencies) and PostgreSQL refuses it while any +// column, anywhere, has the type (0A000 "cannot alter type ... because column ... uses +// it", measured) -- unlike ADD / DROP ATTRIBUTE, which touch no stored value and go +// through untouched (also measured, against a table holding a row of the type). There is +// no rewrite to offer in ALTER ATTRIBUTE TYPE's place (the column would need to be +// dropped and re-added, which loses data), so the plan reports it as a problem instead of +// DDL PostgreSQL would refuse. +func TestProbeCompositeAlterAttributeTypeWhileColumnUsesIt(t *testing.T) { + requirePgDump(t) + t.Run("used: a problem, not DDL", func(t *testing.T) { + from := mustCanonical(t, ` +CREATE TYPE pt AS (x integer, y integer); +CREATE TABLE t (id integer PRIMARY KEY, p pt NOT NULL); +`) + to := mustCanonical(t, ` +CREATE TYPE pt AS (x bigint, y integer); +CREATE TABLE t (id integer PRIMARY KEY, p pt NOT NULL); +`) + _, err := Plan(from.s, to.s, to.intents) + if err == nil || !strings.Contains(err.Error(), "attribute x changes type") || !strings.Contains(err.Error(), "a column uses the type") { + t.Errorf("got %v\nwant a problem naming the column that uses the type", err) + } + }) + t.Run("unused: ALTER ATTRIBUTE TYPE", func(t *testing.T) { + from := mustCanonical(t, `CREATE TYPE pt AS (x integer, y integer);`) + to := mustCanonical(t, `CREATE TYPE pt AS (x bigint, y integer);`) + roundTrip(t, from, to, "", false) + }) + t.Run("used: ADD / DROP ATTRIBUTE still plan and apply", func(t *testing.T) { + from := mustCanonical(t, ` +CREATE TYPE pt AS (x integer, y integer); +CREATE TABLE t (id integer PRIMARY KEY, p pt NOT NULL); +`) + to := mustCanonical(t, ` +CREATE TYPE pt AS (x integer, z text); +CREATE TABLE t (id integer PRIMARY KEY, p pt NOT NULL); +`) + roundTrip(t, from, to, "", true) + }) +} + +// An EXCLUDE constraint on a generated column is a table constraint "defined solely on +// this column" the same way a UNIQUE or CHECK is: dropGenerated's DROP COLUMN takes it +// down silently, but addGenerated only put PRIMARY KEY / UNIQUE / CHECK back +// (soleColumnConstraints), leaving the EXCLUDE gone (measured: the plan's DDL applied but +// left "+ constraint" in the diff -- it never came back). +func TestProbeExcludeRestoredAfterGeneratedRewrite(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, a integer NOT NULL, g bigint GENERATED ALWAYS AS (a + 1) STORED, + CONSTRAINT t_g_excl EXCLUDE USING btree (g WITH =)); +`) + to := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, a bigint NOT NULL, g bigint GENERATED ALWAYS AS (a + 1) STORED, + CONSTRAINT t_g_excl EXCLUDE USING btree (g WITH =)); +`) + roundTrip(t, from, to, "", false) +} + +// A row-level security policy's USING / WITH CHECK reading a column blocks ALTER COLUMN +// TYPE the same way a generated column's expression does (0A000 "cannot alter type of a +// column used in a policy definition", measured), whether or not the policy itself +// changes: the plan drops it and recreates it around the ALTER. +func TestProbePolicyAroundColumnTypeChange(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, a integer); +ALTER TABLE t ENABLE ROW LEVEL SECURITY; +CREATE POLICY pol ON t USING (a > 0) WITH CHECK (a > 0); +`) + to := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, a bigint); +ALTER TABLE t ENABLE ROW LEVEL SECURITY; +CREATE POLICY pol ON t USING (a > 0) WITH CHECK (a > 0); +`) + roundTrip(t, from, to, "", false) +} + +// The same refusal ALTER COLUMN TYPE meets from a policy also comes from a RULE that +// reads the column ("cannot alter type of a column used by a view or rule", 0A000, +// measured): the plan drops it and recreates it around the ALTER, the same way as a +// policy -- found and fixed together, pinned separately since a rule's condition is +// unanalyzed AST (matched by deparsed text) rather than a policy's schema.Expr. +func TestProbeRuleAroundColumnTypeChange(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, a integer); +CREATE RULE r AS ON INSERT TO t WHERE (a > 0) DO ALSO NOTHING; +`) + to := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, a bigint); +CREATE RULE r AS ON INSERT TO t WHERE (a > 0) DO ALSO NOTHING; +`) + roundTrip(t, from, to, "", false) +} + +// A rule that already gets its own DROP RULE / CREATE RULE elsewhere in the plan (here, +// because the table it is on renames, which the rule's deparsed definition reads into, +// making it differ from the from-side rule of the same name) must not also go through +// the column-type-change drop/recreate above: a second DROP RULE for the same rule is +// 42704 "rule ... does not exist" (measured), since the first one already ran it. +func TestProbeRuleNotDroppedTwiceAroundRenameAndTypeChange(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, a integer); +CREATE RULE r AS ON INSERT TO t WHERE (a > 0) DO ALSO NOTHING; +`) + to := mustCanonical(t, ` +-- @migrate rename t -> u +CREATE TABLE u (id integer PRIMARY KEY, a bigint); +CREATE RULE r AS ON INSERT TO u WHERE (a > 0) DO ALSO NOTHING; +`) + roundTrip(t, from, to, "-- @migrate rename u -> t", true) +} + +// DROP SCHEMA must come after the ALTER TABLE ... SET SCHEMA that empties it of the +// table declared to move out (-- @migrate rename): dropSchemas (from-side view of what +// is "still in" a schema) ran inside drops(), before renames() moved the table, so it +// saw the table as still there and it was 2BP01 "other objects depend on it" (measured). +func TestProbeDropSchemaAfterTableMovesOut(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE SCHEMA app; +CREATE TABLE app.t (id integer PRIMARY KEY); +`) + to := mustCanonical(t, ` +-- @migrate rename app.t -> public.t +CREATE TABLE public.t (id integer PRIMARY KEY); +`) + roundTrip(t, from, to, "", true) +} + +// The mirror image of DROP SCHEMA's ordering bug: CREATE SCHEMA must come before the +// ALTER TABLE ... SET SCHEMA that moves a table into it, but it was emitted from adds(), +// which runs after renames() -- 3F000 "schema does not exist" (measured). +func TestProbeCreateSchemaBeforeTableMovesIn(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, `CREATE TABLE public.t (id integer PRIMARY KEY);`) + to := mustCanonical(t, ` +-- @migrate rename public.t -> app.t +CREATE SCHEMA app; +CREATE TABLE app.t (id integer PRIMARY KEY); +`) + roundTrip(t, from, to, "", true) +} + +// ALTER TABLE ... SET SCHEMA already carries an owned sequence (bigserial / IDENTITY) +// with it when the sequence starts out in its owning table's own schema: a separate +// ALTER SEQUENCE ... SET SCHEMA for it, emitted because the table's rename also moves +// the sequence's declared owner, found nothing left in the old schema to move (42P01 +// "relation does not exist", measured). +func TestProbeOwnedSequenceFollowsTableSchemaMove(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE SCHEMA app; +CREATE TABLE app.t (id bigserial PRIMARY KEY); +`) + to := mustCanonical(t, ` +-- @migrate rename app.t -> public.t +CREATE TABLE public.t (id bigserial PRIMARY KEY); +`) + roundTrip(t, from, to, "-- @migrate rename public.t -> app.t", false) +} + +// --- partitioning (brief-partition-pg.md / brief-partition-common.md) -------------------- + +// A brand-new partitioned table with its partitions: pg_dump (and so this plan's always- +// canonical inputs) never spells a partition as a single CREATE TABLE ... PARTITION OF ... +// FOR VALUES ...; it is a plain CREATE TABLE plus a later ALTER TABLE ... ATTACH PARTITION +// naming the *parent*. Replaying that ALTER inline with the parent's own CREATE TABLE (the +// schema.Relation.Alters it was recorded under) ran before the child's own CREATE TABLE +// existed (42P01, measured) -- migrate.go now synthesizes ATTACH PARTITION itself, once +// every new table exists. +func TestProbeNewPartitionedTableAttachOrder(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, `CREATE TABLE keep (id integer PRIMARY KEY);`) + to := mustCanonical(t, ` +CREATE TABLE keep (id integer PRIMARY KEY); +CREATE TABLE p (id integer NOT NULL) PARTITION BY RANGE (id); +CREATE TABLE p_1 PARTITION OF p FOR VALUES FROM (1) TO (10); +CREATE TABLE p_default PARTITION OF p DEFAULT; +`) + roundTrip(t, from, to, "-- @migrate drop p\n-- @migrate drop p_1\n-- @migrate drop p_default", false) +} + +// A partition's bound moved, and another partition detached in the same plan: every +// DETACH in the plan must precede every ATTACH, not just the same partition's own -- an +// incoming bound can overlap another partition's outgoing one otherwise (23514 "would +// overlap", measured, when a wider bound landed before the sibling's own DETACH). +func TestProbePartitionBoundMoveAndDetachOrdering(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE TABLE p (id integer NOT NULL) PARTITION BY RANGE (id); +CREATE TABLE p_1 PARTITION OF p FOR VALUES FROM (1) TO (10); +CREATE TABLE p_2 PARTITION OF p FOR VALUES FROM (10) TO (20); +`) + to := mustCanonical(t, ` +CREATE TABLE p (id integer NOT NULL) PARTITION BY RANGE (id); +CREATE TABLE p_1 PARTITION OF p FOR VALUES FROM (1) TO (15); +CREATE TABLE p_2 (id integer NOT NULL); +`) + roundTrip(t, from, to, "", true) +} + +// A new partition attached to a parent that already has a PRIMARY KEY and an index: +// PostgreSQL's own ATTACH PARTITION immediately builds and attaches a matching PRIMARY +// KEY and index on the incoming partition (unlike attaching to a parent created fresh in +// the same plan, which has neither yet) -- the plan's own explicit ADD CONSTRAINT / +// CREATE INDEX for the same shape is then 42P16 "multiple primary keys" (measured). +func TestProbeAttachPartitionUnderKeyedParent(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE TABLE p (id integer NOT NULL PRIMARY KEY) PARTITION BY RANGE (id); +CREATE TABLE p_1 PARTITION OF p FOR VALUES FROM (1) TO (10); +CREATE TABLE p_new (id integer NOT NULL PRIMARY KEY); +`) + to := mustCanonical(t, ` +CREATE TABLE p (id integer NOT NULL PRIMARY KEY) PARTITION BY RANGE (id); +CREATE TABLE p_1 PARTITION OF p FOR VALUES FROM (1) TO (10); +CREATE TABLE p_new PARTITION OF p FOR VALUES FROM (10) TO (20); +`) + roundTrip(t, from, to, "", true) +} + +// A column dropped from a table that survives, and that no longer-owned column happened +// to own a standalone sequence which the target still keeps (unowned): PostgreSQL drops +// a column-owned sequence along with the column itself (measured, not merely clearing its +// ownership) -- the plan's own ALTER SEQUENCE ... OWNED BY NONE, unconditionally emitted +// whenever a surviving sequence's OwnedBy differs, then found nothing left (42P01). The +// sequence needs recreating instead once its owning column is genuinely dropped, not +// renamed (ownerColumnStatus's ownerColumnGoneNotTable case). +func TestProbeSequenceRecreatedAfterOwningColumnDropped(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, r integer); +CREATE SEQUENCE sq; +ALTER SEQUENCE sq OWNED BY t.r; +`) + to := mustCanonical(t, ` +-- @migrate drop t.r +CREATE TABLE t (id integer PRIMARY KEY); +CREATE SEQUENCE sq; +`) + roundTrip(t, from, to, "", true) +} + +// A sequence reassigned to a new owner while its old owning *table* is dropped in the same +// plan: drops() already clears the sequence's OWNED BY (ALTER SEQUENCE ... OWNED BY NONE) +// ahead of that table's own DROP TABLE, since alters() only reaches surviving tables -- +// but alters() then left it at NONE rather than the target's actual new owner, since it +// saw the sequence's own OWNED BY change and assumed nothing needed doing once the old +// owning table was gone. +func TestProbeSequenceReownedWhileOldOwnerTableDrops(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE TABLE keep (id integer PRIMARY KEY, c integer); +CREATE TABLE gone (id integer PRIMARY KEY, r integer); +CREATE SEQUENCE sq; +ALTER SEQUENCE sq OWNED BY gone.r; +`) + to := mustCanonical(t, ` +-- @migrate drop gone +CREATE TABLE keep (id integer PRIMARY KEY, c integer); +CREATE SEQUENCE sq; +ALTER SEQUENCE sq OWNED BY keep.c; +`) + roundTrip(t, from, to, "", true) +} + +// pg_dump attaches a partition's own index to the parent's partitioned index with +// ALTER INDEX ... ATTACH PARTITION, an AlterTableStmt whose Objtype is OBJECT_INDEX +// (schema.go's generic ALTER TABLE dispatch looked up the *index* name as if it were a +// relation and refused it as "does not exist", measured) rather than OBJECT_TABLE; a +// partitioned schema's own dump could not even load. +func TestProbeAlterIndexAttachPartitionLoads(t *testing.T) { + requirePgDump(t) + // no roundTrip: this is a loader-only regression (mustCanonical itself is the + // assertion -- it fails the test if the schema does not load). + mustCanonical(t, ` +CREATE TABLE p (id integer NOT NULL, n integer) PARTITION BY RANGE (id); +CREATE TABLE p_1 PARTITION OF p FOR VALUES FROM (1) TO (10); +CREATE INDEX p_n_idx ON p (n); +`) +} + +// A brand-new standalone sequence owned by a column that already exists (only "ALTER +// SEQUENCE ... OWNED BY" changes, not "ADD COLUMN") was wrongly folded into the +// new-column-with-its-own-sequence path (adds()'s seqForNewColumn): that path only ever +// emits a sequence from orderNewColumns(fresh), which never visits an existing column, so +// the sequence's CREATE + OWNED BY were silently dropped from the plan (measured: the +// plan came back empty even though the target held one more sequence). +func TestProbeNewSequenceOwnedByExistingColumn(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, c integer); +`) + to := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, c integer); +CREATE SEQUENCE sq; +ALTER SEQUENCE sq OWNED BY t.c; +`) + roundTrip(t, from, to, "", true) +} + +// Two sequences legitimately can be OWNED BY the same column (PostgreSQL only refuses two +// sequences with the same fully-qualified name, not the same OwnedBy): adds()'s +// sequenceFollowsRename matched a brand-new sequence's OwnedBy against any from-side +// sequence sharing it, even one whose own ownership never changed -- so a second, new +// sequence declared OWNED BY a column an existing, untouched sequence already owns was +// mistaken for that existing sequence's rename and never got its own CREATE SEQUENCE +// (measured: DROP SEQUENCE for the source's spare unowned sequence, no matching CREATE). +func TestProbeTwoSequencesOwnTheSameColumn(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, c integer); +CREATE SEQUENCE sq1; +ALTER SEQUENCE sq1 OWNED BY t.c; +CREATE SEQUENCE sq2; +`) + to := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, c integer); +CREATE SEQUENCE sq1; +ALTER SEQUENCE sq1 OWNED BY t.c; +CREATE SEQUENCE sq2; +ALTER SEQUENCE sq2 OWNED BY t.c; +`) + roundTrip(t, from, to, "", true) +} + +// A partition's bound moving (DETACH + ATTACH, PostgreSQL has no ALTER ... FOR VALUES) can +// leave rows that no longer satisfy the new bound: PostgreSQL checks every row against a +// bound at ATTACH time and refuses the whole statement (23514) the moment even one is out +// of range. The plan now moves such rows back to the parent before ATTACH +// (partitionMovePredicate + the WITH moved AS (DELETE ... RETURNING *) INSERT step, +// migrate.go), letting the parent's own router place them (here, in DEFAULT) -- +// roundTrip's schema-only canonical round trip cannot carry rows for a plain (non-seed) +// table, so this checks the actual counts itself via a DO block Verify has no equivalent +// of. +func TestProbePartitionBoundMoveMovesOutOfRangeRows(t *testing.T) { + requirePgDump(t) + ctx := context.Background() + from := mustCanonical(t, ` +CREATE TABLE p (id integer NOT NULL, n integer) PARTITION BY RANGE (id); +CREATE TABLE p_1 PARTITION OF p FOR VALUES FROM (1) TO (10); +CREATE TABLE p_default PARTITION OF p DEFAULT; +`) + to := mustCanonical(t, ` +CREATE TABLE p (id integer NOT NULL, n integer) PARTITION BY RANGE (id); +CREATE TABLE p_1 PARTITION OF p FOR VALUES FROM (1) TO (5); +CREATE TABLE p_default PARTITION OF p DEFAULT; +`) + ddl, err := Plan(from.s, to.s, to.intents) + if err != nil { + t.Fatalf("plan: %v", err) + } + t.Logf("plan:\n%s", strings.Join(ddl, "\n")) + rows := "INSERT INTO p_1 (id, n) VALUES (1, 1), (2, 2), (8, 3);\n" // id=8 no longer fits [1, 5) + assert := `DO $$ BEGIN + IF (SELECT count(*) FROM p) <> 3 THEN RAISE EXCEPTION 'p count: %', (SELECT count(*) FROM p); END IF; + IF (SELECT count(*) FROM p_1) <> 2 THEN RAISE EXCEPTION 'p_1 count: %', (SELECT count(*) FROM p_1); END IF; + IF (SELECT count(*) FROM p_default) <> 1 THEN RAISE EXCEPTION 'p_default count: %', (SELECT count(*) FROM p_default); END IF; +END $$;` + script := from.text + "\nRESET search_path;\n" + rows + "\n" + strings.Join(ddl, "\n") + "\n" + assert + got, _, err := server.Canonical(ctx, script, to.s) + if err != nil { + t.Fatalf("apply: %v\nplan:\n%s", err, strings.Join(ddl, "\n")) + } + var lines []string + for _, c := range diff.Compare(got, to.s) { + if !c.OrderOnly() { + lines = append(lines, c.String()) + } + } + if len(lines) > 0 { + t.Errorf("plan does not reach the target:\n%s", strings.Join(lines, "\n")) + } +} + +// Attaching a previously-independent table under an existing keyed parent: rows already in +// it that do not satisfy the bound it is about to take on move to the parent first, same as +// a bound move (partitionMovePredicate does not care whether the table was ever a +// partition). +func TestProbeAttachIndependentTableMovesOutOfRangeRows(t *testing.T) { + requirePgDump(t) + ctx := context.Background() + from := mustCanonical(t, ` +CREATE TABLE p (id integer NOT NULL, n integer) PARTITION BY LIST (id); +CREATE TABLE p_default PARTITION OF p DEFAULT; +CREATE TABLE spare (id integer NOT NULL, n integer); +`) + to := mustCanonical(t, ` +CREATE TABLE p (id integer NOT NULL, n integer) PARTITION BY LIST (id); +CREATE TABLE p_default PARTITION OF p DEFAULT; +CREATE TABLE spare PARTITION OF p FOR VALUES IN (1, 2); +`) + ddl, err := Plan(from.s, to.s, to.intents) + if err != nil { + t.Fatalf("plan: %v", err) + } + t.Logf("plan:\n%s", strings.Join(ddl, "\n")) + rows := "INSERT INTO spare (id, n) VALUES (1, 1), (2, 2), (3, 3);\n" // id=3 does not fit IN (1, 2) + assert := `DO $$ BEGIN + IF (SELECT count(*) FROM p) <> 3 THEN RAISE EXCEPTION 'p count: %', (SELECT count(*) FROM p); END IF; + IF (SELECT count(*) FROM spare) <> 2 THEN RAISE EXCEPTION 'spare count: %', (SELECT count(*) FROM spare); END IF; + IF (SELECT count(*) FROM p_default) <> 1 THEN RAISE EXCEPTION 'p_default count: %', (SELECT count(*) FROM p_default); END IF; +END $$;` + script := from.text + "\nRESET search_path;\n" + rows + "\n" + strings.Join(ddl, "\n") + "\n" + assert + got, _, err := server.Canonical(ctx, script, to.s) + if err != nil { + t.Fatalf("apply: %v\nplan:\n%s", err, strings.Join(ddl, "\n")) + } + var lines []string + for _, c := range diff.Compare(got, to.s) { + if !c.OrderOnly() { + lines = append(lines, c.String()) + } + } + if len(lines) > 0 { + t.Errorf("plan does not reach the target:\n%s", strings.Join(lines, "\n")) + } +} + +// A partition with a true GENERATED ... AS IDENTITY id (inherited from its parent, the only +// way a partition ever gets one) loses it outright the moment it is detached (attidentity +// clears along with the column's own default, measured) -- an explicit ALTER COLUMN ... DROP +// IDENTITY on top of that is 55000 "column ... is not an identity column", which the plan +// used to emit unconditionally whenever the target showed no identity at all +// (partitionAttach's own doc comment on why it now reports whether it detached). The reverse +// (backward direction here) needs nothing explicit either: ATTACH PARTITION grants the +// parent's identity to a plain-columned incoming partition automatically (measured), so a +// once-detached, now-plain child re-attaches with no ADD GENERATED of its own. +func TestProbeDetachPartitionDropsIdentity(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE TABLE t (id integer GENERATED ALWAYS AS IDENTITY, kind text NOT NULL) PARTITION BY LIST (kind); +CREATE TABLE t_a PARTITION OF t FOR VALUES IN ('a'); +CREATE TABLE t_b PARTITION OF t FOR VALUES IN ('b'); +`) + to := mustCanonical(t, ` +CREATE TABLE t (id integer GENERATED ALWAYS AS IDENTITY, kind text NOT NULL) PARTITION BY LIST (kind); +CREATE TABLE t_a PARTITION OF t FOR VALUES IN ('a'); +CREATE TABLE t_b (id integer NOT NULL, kind text NOT NULL); +`) + roundTrip(t, from, to, "", false) +} + +// A bigserial-style id, unlike true IDENTITY just above, is unaffected by DETACH / ATTACH +// either way: its DEFAULT nextval(...) always names the *parent's* own sequence (copied down +// textually the moment the partition was created, never a sequence of the partition's own), +// so a detached child keeps it working exactly as it did while attached, and re-attaching +// needs nothing beyond the plain ATTACH PARTITION itself. +func TestProbeDetachPartitionKeepsBigserialDefault(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE TABLE t (id bigserial NOT NULL, kind text NOT NULL) PARTITION BY LIST (kind); +CREATE TABLE t_a PARTITION OF t FOR VALUES IN ('a'); +CREATE TABLE t_b PARTITION OF t FOR VALUES IN ('b'); +`) + to := mustCanonical(t, ` +CREATE TABLE t (id bigserial NOT NULL, kind text NOT NULL) PARTITION BY LIST (kind); +CREATE TABLE t_a PARTITION OF t FOR VALUES IN ('a'); +CREATE TABLE t_b (id bigint NOT NULL DEFAULT nextval('t_id_seq'::regclass), kind text NOT NULL); +`) + roundTrip(t, from, to, "", false) +} + +// An independent table carrying its own true IDENTITY column joining a partitioned parent +// for the first time (as opposed to a former partition of the very same parent re-attaching, +// just above): PostgreSQL refuses the ATTACH outright while the incoming table still has one +// (55000 "table ... being attached contains an identity column", measured, regardless of +// whether the parent has one of its own) -- partitionAttach now drops it first, whichever +// parent it is joining. The reverse (backward direction) needs the ordinary ADD GENERATED +// ... AS IDENTITY the per-column diff already emits for any standalone column gaining one, +// unconditionally correct here too since detachedNow only ever suppresses the DROP IDENTITY +// half. +func TestProbeAttachDropsIdentityFirst(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE TABLE p (id integer NOT NULL, kind text NOT NULL) PARTITION BY LIST (kind); +CREATE TABLE p_a PARTITION OF p FOR VALUES IN ('a'); +CREATE TABLE spare (id integer GENERATED ALWAYS AS IDENTITY, kind text NOT NULL); +`) + to := mustCanonical(t, ` +CREATE TABLE p (id integer NOT NULL, kind text NOT NULL) PARTITION BY LIST (kind); +CREATE TABLE p_a PARTITION OF p FOR VALUES IN ('a'); +CREATE TABLE spare PARTITION OF p FOR VALUES IN ('b'); +`) + roundTrip(t, from, to, "", false) +} + +// A column-owned sequence's schema cascades along with an ALTER TABLE ... SET SCHEMA on +// its owning table, automatically, the moment that statement runs -- before this plan's +// own ALTER SEQUENCE ... OWNED BY (clearing, or otherwise changing, that same ownership) +// gets a chance to run. alters()'s Sequence case (migrate.go) referenced the sequence by +// its target-schema name, which is wrong once the ownership itself is what is changing in +// that very statement (42P01 "does not exist", measured): the owning table's schema move +// (renames(), which runs first) already carried it somewhere else. +func TestProbeSequenceCascadesWithOwnerSchemaMove(t *testing.T) { + requirePgDump(t) + from := mustCanonical(t, ` +CREATE TABLE t (id integer PRIMARY KEY, r integer NOT NULL); +CREATE SEQUENCE sq; +ALTER SEQUENCE sq OWNED BY t.r; +`) + to := mustCanonical(t, ` +-- @migrate rename public.t -> app.t +CREATE SCHEMA app; +CREATE TABLE app.t (id integer PRIMARY KEY, r integer NOT NULL); +CREATE SEQUENCE sq; +`) + roundTrip(t, from, to, "-- @migrate rename app.t -> public.t", true) +} + +// A plain table becoming a partitioned one, keeping its rows (brief-holes-pg.md item D, +// "empty -> X"): PostgreSQL has no ALTER ... PARTITION BY, so the plan renames the table +// aside, creates the target's own partitioned shape (and its declared partitions) fresh +// under the old name, and moves every row across with a plain INSERT -- the partition +// router decides where each one goes (repartitionTable, migrate.go). +func TestProbeRepartitionEmptyToRange(t *testing.T) { + requirePgDump(t) + ctx := context.Background() + from := mustCanonical(t, ` +CREATE TABLE t (id integer NOT NULL, kind text NOT NULL); +`) + to := mustCanonical(t, ` +CREATE TABLE t (id integer NOT NULL, kind text NOT NULL) PARTITION BY LIST (kind); +CREATE TABLE t_a PARTITION OF t FOR VALUES IN ('a'); +CREATE TABLE t_b PARTITION OF t FOR VALUES IN ('b'); +CREATE TABLE t_default PARTITION OF t DEFAULT; +`) + ddl, err := Plan(from.s, to.s, to.intents) + if err != nil { + t.Fatalf("plan: %v", err) + } + t.Logf("plan:\n%s", strings.Join(ddl, "\n")) + rows := "INSERT INTO t (id, kind) VALUES (1, 'a'), (2, 'b'), (3, 'c');\n" + assert := `DO $$ BEGIN + IF (SELECT count(*) FROM t) <> 3 THEN RAISE EXCEPTION 't count: %', (SELECT count(*) FROM t); END IF; + IF (SELECT count(*) FROM t_a) <> 1 THEN RAISE EXCEPTION 't_a count: %', (SELECT count(*) FROM t_a); END IF; + IF (SELECT count(*) FROM t_b) <> 1 THEN RAISE EXCEPTION 't_b count: %', (SELECT count(*) FROM t_b); END IF; + IF (SELECT count(*) FROM t_default) <> 1 THEN RAISE EXCEPTION 't_default count: %', (SELECT count(*) FROM t_default); END IF; +END $$;` + script := from.text + "\nRESET search_path;\n" + rows + "\n" + strings.Join(ddl, "\n") + "\n" + assert + got, _, err := server.Canonical(ctx, script, to.s) + if err != nil { + t.Fatalf("apply: %v\nplan:\n%s", err, strings.Join(ddl, "\n")) + } + var lines []string + for _, c := range diff.Compare(got, to.s) { + if !c.OrderOnly() { + lines = append(lines, c.String()) + } + } + if len(lines) > 0 { + t.Errorf("plan does not reach the target:\n%s", strings.Join(lines, "\n")) + } + // a second plan against the result must be empty (idempotent) + again, err := Plan(got, to.s, nil) + if err != nil || len(again) > 0 { + t.Errorf("a second plan is not empty: %v\n%s", err, strings.Join(again, "\n")) + } +} + +// The reverse of repartitioning: a partitioned table's key going away entirely +// ("X -> empty"). Every row across its partitions comes back into one plain table (the +// same repartitionTable path, generalized: it does not special-case which side is +// partitioned). +func TestProbeRepartitionRangeToEmpty(t *testing.T) { + requirePgDump(t) + ctx := context.Background() + from := mustCanonical(t, ` +CREATE TABLE t (id integer NOT NULL, kind text NOT NULL) PARTITION BY LIST (kind); +CREATE TABLE t_a PARTITION OF t FOR VALUES IN ('a'); +CREATE TABLE t_b PARTITION OF t FOR VALUES IN ('b'); +CREATE TABLE t_default PARTITION OF t DEFAULT; +`) + to := mustCanonical(t, ` +CREATE TABLE t (id integer NOT NULL, kind text NOT NULL); +`) + ddl, err := Plan(from.s, to.s, to.intents) + if err != nil { + t.Fatalf("plan: %v", err) + } + t.Logf("plan:\n%s", strings.Join(ddl, "\n")) + rows := "INSERT INTO t_a (id, kind) VALUES (1, 'a');\n" + + "INSERT INTO t_b (id, kind) VALUES (2, 'b');\n" + + "INSERT INTO t_default (id, kind) VALUES (3, 'c');\n" + assert := `DO $$ BEGIN + IF (SELECT count(*) FROM t) <> 3 THEN RAISE EXCEPTION 't count: %', (SELECT count(*) FROM t); END IF; +END $$;` + script := from.text + "\nRESET search_path;\n" + rows + "\n" + strings.Join(ddl, "\n") + "\n" + assert + got, _, err := server.Canonical(ctx, script, to.s) + if err != nil { + t.Fatalf("apply: %v\nplan:\n%s", err, strings.Join(ddl, "\n")) + } + var lines []string + for _, c := range diff.Compare(got, to.s) { + if !c.OrderOnly() { + lines = append(lines, c.String()) + } + } + if len(lines) > 0 { + t.Errorf("plan does not reach the target:\n%s", strings.Join(lines, "\n")) + } + again, err := Plan(got, to.s, nil) + if err != nil || len(again) > 0 { + t.Errorf("a second plan is not empty: %v\n%s", err, strings.Join(again, "\n")) + } +} + +// Re-partitioning: one key/strategy directly to another ("X -> Y") -- the same +// repartitionTable path again, since it diffs on "partition key" alone, not on which +// side (if either) is unpartitioned. +func TestProbeRepartitionRangeToList(t *testing.T) { + requirePgDump(t) + ctx := context.Background() + from := mustCanonical(t, ` +CREATE TABLE t (id integer NOT NULL, kind text NOT NULL) PARTITION BY RANGE (id); +CREATE TABLE t_1 PARTITION OF t FOR VALUES FROM (1) TO (10); +CREATE TABLE t_2 PARTITION OF t FOR VALUES FROM (10) TO (20); +`) + to := mustCanonical(t, ` +CREATE TABLE t (id integer NOT NULL, kind text NOT NULL) PARTITION BY LIST (kind); +CREATE TABLE t_a PARTITION OF t FOR VALUES IN ('a'); +CREATE TABLE t_b PARTITION OF t FOR VALUES IN ('b'); +CREATE TABLE t_default PARTITION OF t DEFAULT; +`) + ddl, err := Plan(from.s, to.s, to.intents) + if err != nil { + t.Fatalf("plan: %v", err) + } + t.Logf("plan:\n%s", strings.Join(ddl, "\n")) + rows := "INSERT INTO t_1 (id, kind) VALUES (1, 'a');\n" + + "INSERT INTO t_2 (id, kind) VALUES (11, 'b');\n" + assert := `DO $$ BEGIN + IF (SELECT count(*) FROM t) <> 2 THEN RAISE EXCEPTION 't count: %', (SELECT count(*) FROM t); END IF; + IF (SELECT count(*) FROM t_a) <> 1 THEN RAISE EXCEPTION 't_a count: %', (SELECT count(*) FROM t_a); END IF; + IF (SELECT count(*) FROM t_b) <> 1 THEN RAISE EXCEPTION 't_b count: %', (SELECT count(*) FROM t_b); END IF; +END $$;` + script := from.text + "\nRESET search_path;\n" + rows + "\n" + strings.Join(ddl, "\n") + "\n" + assert + got, _, err := server.Canonical(ctx, script, to.s) + if err != nil { + t.Fatalf("apply: %v\nplan:\n%s", err, strings.Join(ddl, "\n")) + } + var lines []string + for _, c := range diff.Compare(got, to.s) { + if !c.OrderOnly() { + lines = append(lines, c.String()) + } + } + if len(lines) > 0 { + t.Errorf("plan does not reach the target:\n%s", strings.Join(lines, "\n")) + } + again, err := Plan(got, to.s, nil) + if err != nil || len(again) > 0 { + t.Errorf("a second plan is not empty: %v\n%s", err, strings.Join(again, "\n")) + } +} + +// A repartitioned table's own bigserial (owned sequence) column keeps its current value: +// unlike IDENTITY (the server's own, tied to the column's attnum -- see +// TestProbeRepartitionIdentityAlways), the sequence a bigserial column's DEFAULT +// nextval(...) names is the very object the new table's own definition still refers to +// (pg_dump's canonical
__seq name, unchanged whichever side is partitioned), +// so repartitionTable only has to detach it (ALTER SEQUENCE ... OWNED BY NONE) before the +// old, renamed-aside table's DROP TABLE and re-own it once the new one exists -- never +// recreating the sequence itself, so a row inserted afterward with no explicit id continues +// exactly where the old table left off (brief-pg-repartition-seq.md item 1). +func TestProbeRepartitionBigserialOwnedSequence(t *testing.T) { + requirePgDump(t) + ctx := context.Background() + from := mustCanonical(t, ` +CREATE TABLE t (id bigserial NOT NULL, kind text NOT NULL); +`) + to := mustCanonical(t, ` +CREATE TABLE t (id bigserial NOT NULL, kind text NOT NULL) PARTITION BY LIST (kind); +CREATE TABLE t_a PARTITION OF t FOR VALUES IN ('a'); +CREATE TABLE t_b PARTITION OF t FOR VALUES IN ('b'); +CREATE TABLE t_default PARTITION OF t DEFAULT; +`) + ddl, err := Plan(from.s, to.s, to.intents) + if err != nil { + t.Fatalf("plan: %v", err) + } + t.Logf("plan:\n%s", strings.Join(ddl, "\n")) + rows := "INSERT INTO t (kind) VALUES ('a'), ('b'), ('c');\n" // id: 1, 2, 3 (nextval) + assert := `DO $$ BEGIN + IF (SELECT max(id) FROM t) <> 3 THEN RAISE EXCEPTION 'max id before continuation: %', (SELECT max(id) FROM t); END IF; +END $$; +INSERT INTO t (kind) VALUES ('a'); +DO $$ BEGIN + IF (SELECT max(id) FROM t) <> 4 THEN RAISE EXCEPTION 'sequence did not continue: %', (SELECT max(id) FROM t); END IF; +END $$;` + script := from.text + "\nRESET search_path;\n" + rows + "\n" + strings.Join(ddl, "\n") + "\n" + assert + got, _, err := server.Canonical(ctx, script, to.s) + if err != nil { + t.Fatalf("apply: %v\nplan:\n%s", err, strings.Join(ddl, "\n")) + } + var lines []string + for _, c := range diff.Compare(got, to.s) { + if !c.OrderOnly() { + lines = append(lines, c.String()) + } + } + if len(lines) > 0 { + t.Errorf("plan does not reach the target:\n%s", strings.Join(lines, "\n")) + } + again, err := Plan(got, to.s, nil) + if err != nil || len(again) > 0 { + t.Errorf("a second plan is not empty: %v\n%s", err, strings.Join(again, "\n")) + } +} + +// A repartitioned table's own GENERATED ALWAYS AS IDENTITY column: its sequence is the +// server's own, tied to the column's attnum, so it cannot survive the old (renamed-aside) +// table's DROP TABLE the way a bigserial's owned sequence does -- the new table's own +// ADD GENERATED ... AS IDENTITY (r.Definition, adds()) creates a fresh one, oblivious to the +// old values. pendingRepartition's INSERT carries OVERRIDING SYSTEM VALUE so the old rows' +// explicit ids still fit under GENERATED ALWAYS (428C9 without it, measured), and a +// setval() afterward resumes the fresh sequence from the highest value actually inserted, +// so a further row (no explicit id) continues from there instead of colliding at 1. +func TestProbeRepartitionIdentityAlways(t *testing.T) { + requirePgDump(t) + ctx := context.Background() + from := mustCanonical(t, ` +CREATE TABLE t (id integer GENERATED ALWAYS AS IDENTITY, kind text NOT NULL); +`) + to := mustCanonical(t, ` +CREATE TABLE t (id integer GENERATED ALWAYS AS IDENTITY, kind text NOT NULL) PARTITION BY LIST (kind); +CREATE TABLE t_a PARTITION OF t FOR VALUES IN ('a'); +CREATE TABLE t_b PARTITION OF t FOR VALUES IN ('b'); +CREATE TABLE t_default PARTITION OF t DEFAULT; +`) + ddl, err := Plan(from.s, to.s, to.intents) + if err != nil { + t.Fatalf("plan: %v", err) + } + t.Logf("plan:\n%s", strings.Join(ddl, "\n")) + rows := "INSERT INTO t (kind) VALUES ('a'), ('b'), ('c');\n" // id: 1, 2, 3 + assert := `DO $$ BEGIN + IF (SELECT max(id) FROM t) <> 3 THEN RAISE EXCEPTION 'max id before continuation: %', (SELECT max(id) FROM t); END IF; +END $$; +INSERT INTO t (kind) VALUES ('a'); +DO $$ BEGIN + IF (SELECT max(id) FROM t) <> 4 THEN RAISE EXCEPTION 'sequence did not continue: %', (SELECT max(id) FROM t); END IF; +END $$;` + script := from.text + "\nRESET search_path;\n" + rows + "\n" + strings.Join(ddl, "\n") + "\n" + assert + got, _, err := server.Canonical(ctx, script, to.s) + if err != nil { + t.Fatalf("apply: %v\nplan:\n%s", err, strings.Join(ddl, "\n")) + } + var lines []string + for _, c := range diff.Compare(got, to.s) { + if !c.OrderOnly() { + lines = append(lines, c.String()) + } + } + if len(lines) > 0 { + t.Errorf("plan does not reach the target:\n%s", strings.Join(lines, "\n")) + } + again, err := Plan(got, to.s, nil) + if err != nil || len(again) > 0 { + t.Errorf("a second plan is not empty: %v\n%s", err, strings.Join(again, "\n")) + } +} + +// The BY DEFAULT half of TestProbeRepartitionIdentityAlways: OVERRIDING SYSTEM VALUE is not +// required for GENERATED BY DEFAULT AS IDENTITY to accept the old rows' explicit ids +// (unlike ALWAYS), but pendingRepartition emits it unconditionally whenever the target +// column is IDENTITY at all -- harmless here, measured -- and the fresh sequence still +// needs its own setval() to resume past the old values instead of colliding at 1. +func TestProbeRepartitionIdentityByDefault(t *testing.T) { + requirePgDump(t) + ctx := context.Background() + from := mustCanonical(t, ` +CREATE TABLE t (id integer GENERATED BY DEFAULT AS IDENTITY, kind text NOT NULL); +`) + to := mustCanonical(t, ` +CREATE TABLE t (id integer GENERATED BY DEFAULT AS IDENTITY, kind text NOT NULL) PARTITION BY LIST (kind); +CREATE TABLE t_a PARTITION OF t FOR VALUES IN ('a'); +CREATE TABLE t_b PARTITION OF t FOR VALUES IN ('b'); +CREATE TABLE t_default PARTITION OF t DEFAULT; +`) + ddl, err := Plan(from.s, to.s, to.intents) + if err != nil { + t.Fatalf("plan: %v", err) + } + t.Logf("plan:\n%s", strings.Join(ddl, "\n")) + rows := "INSERT INTO t (kind) VALUES ('a'), ('b'), ('c');\n" // id: 1, 2, 3 + assert := `DO $$ BEGIN + IF (SELECT max(id) FROM t) <> 3 THEN RAISE EXCEPTION 'max id before continuation: %', (SELECT max(id) FROM t); END IF; +END $$; +INSERT INTO t (kind) VALUES ('a'); +DO $$ BEGIN + IF (SELECT max(id) FROM t) <> 4 THEN RAISE EXCEPTION 'sequence did not continue: %', (SELECT max(id) FROM t); END IF; +END $$;` + script := from.text + "\nRESET search_path;\n" + rows + "\n" + strings.Join(ddl, "\n") + "\n" + assert + got, _, err := server.Canonical(ctx, script, to.s) + if err != nil { + t.Fatalf("apply: %v\nplan:\n%s", err, strings.Join(ddl, "\n")) + } + var lines []string + for _, c := range diff.Compare(got, to.s) { + if !c.OrderOnly() { + lines = append(lines, c.String()) + } + } + if len(lines) > 0 { + t.Errorf("plan does not reach the target:\n%s", strings.Join(lines, "\n")) + } + again, err := Plan(got, to.s, nil) + if err != nil || len(again) > 0 { + t.Errorf("a second plan is not empty: %v\n%s", err, strings.Join(again, "\n")) + } +} diff --git a/check/postgres/migrate/probe_generate_test.go b/check/postgres/migrate/probe_generate_test.go new file mode 100644 index 0000000..e68609f --- /dev/null +++ b/check/postgres/migrate/probe_generate_test.go @@ -0,0 +1,473 @@ +package migrate + +// Split from probe_test.go (see its header): generating schemas from the vocabulary -- +// pick(), the newX() constructors, addX() helpers that attach constraints/indexes/views/ +// triggers to an already-generated table, and generate() itself. Pure move. + +import ( + "fmt" + "math/rand" + "strings" +) + +// ---- generating ------------------------------------------------------------------------- + +func pick[T any](r *rand.Rand, list []T) T { return list[r.Intn(len(list))] } + +func (s *pSchema) newCol(r *rand.Rand) *pCol { + c := &pCol{name: s.next("c"), typ: pick(r, probeTypes)} + if c.typ == "domain" { + if len(s.domains) == 0 || r.Intn(3) == 0 { + s.domains = append(s.domains, &pDomain{name: s.next("num")}) + } + c.typ = pick(r, s.domains).name + } + if c.typ == "enum" { + if len(s.enums) == 0 || r.Intn(3) == 0 { + e := &pEnum{name: s.next("e"), labels: []string{"a", "b", "c"}[:2+r.Intn(2)], fresh: s.mutating} + s.enums = append(s.enums, e) + } + c.typ = pick(r, s.enums).name + } + if c.typ == "composite" { + if len(s.composites) == 0 || r.Intn(3) == 0 { + s.composites = append(s.composites, s.newComposite(r)) + } + c.typ = pick(r, s.composites).name + } + c.notNull = r.Intn(2) == 0 + if c.notNull || r.Intn(3) == 0 { + c.def = s.defaultFor(c) + } + if r.Intn(5) == 0 { + c.comment = "about " + c.name + } + return c +} + +// newComposite is a two-attribute composite type, one numeric and one text-like: attrValue +// gives both a value that varies by row, so a unique key or a plain comparison over a +// composite column holds like it would over a plain one. +// ensureExtension adds name to the schema's declared extensions unless already there. +func (s *pSchema) ensureExtension(name string) { + for _, e := range s.extensions { + if e == name { + return + } + } + s.extensions = append(s.extensions, name) +} + +func (s *pSchema) newComposite(r *rand.Rand) *pComposite { + return &pComposite{name: s.next("ct"), attrs: []*pAttr{ + {name: "n", typ: "integer"}, + {name: "w", typ: "text"}, + }} +} + +func (t *pTable) numericCols() []*pCol { + var out []*pCol + for _, c := range t.cols { + if isNumeric(c.typ) && c.gen == "" && !c.identity { + out = append(out, c) + } + } + return out +} + +func (s *pSchema) newTable(r *rand.Rand) *pTable { + t := &pTable{name: s.next("t"), schema: pick(r, []string{"public", "public", "app"})} + t.orig = t.full() + id := &pCol{name: "id", typ: pick(r, []string{"integer", "bigint"}), notNull: true, pk: true} + switch r.Intn(3) { + case 0: + id.identity = true + case 1: + id.typ, id.serial = "bigint", true + } + t.cols = append(t.cols, id) + n := 2 + r.Intn(4) + for i := 0; i < n; i++ { + t.cols = append(t.cols, s.newCol(r)) + } + if src := t.numericCols(); len(src) > 0 && r.Intn(2) == 0 { + g := &pCol{name: s.next("g"), typ: "bigint", gen: pick(r, src).name} + at := r.Intn(len(t.cols) + 1) + t.cols = append(t.cols[:at], append([]*pCol{g}, t.cols[at:]...)...) + } + s.addKey(r, t) + if r.Intn(3) == 0 { + s.addCheck(r, t) + } + if r.Intn(2) == 0 { + s.addExclude(r, t) + } + if r.Intn(4) == 0 { + s.addPartialIndex(r, t) + } + if len(s.tables) > 0 { + if r.Intn(4) == 0 { + s.addCompositeFK(r, t, pick(r, s.tables)) + } else { + s.addFK(r, t, pick(r, s.tables)) + } + } + if r.Intn(4) == 0 { + t.comment = "about " + t.name + } + return t +} + +// newPartTable makes a RANGE or LIST partitioned table with three partitions: two +// ordinary ones and a DEFAULT. +func (s *pSchema) newPartTable(r *rand.Rand) *pPartTable { + name := s.next("part") + pt := &pPartTable{name: name, orig: "public." + name, strategy: "RANGE"} + if r.Intn(2) == 0 { + pt.strategy = "LIST" + } + // pt.id: plain integer most of the time, or a column whose own sequence a later + // repartition (change partition strategy / departition table) has to carry across the + // rebuild -- bigserial, or IDENTITY (BY DEFAULT / ALWAYS), exercising + // detectRepartitions/repartitionTable's own vocabulary (migrate.go, + // brief-pg-repartition-seq.md) through the generator instead of only by hand. + pt.id = &pCol{name: "id", typ: "integer", notNull: true} + switch r.Intn(4) { + case 1: + pt.id.serial = true + case 2: + pt.id.identity = true + case 3: + pt.id.identity = true + pt.id.idAlways = true + } + c1, c2 := s.next(name+"_"), s.next(name+"_") + def := s.next(name + "_") + if pt.strategy == "LIST" { + pt.parts = []*pPartChild{ + {name: c1, orig: "public." + c1, values: []string{"a", "b"}}, + {name: c2, orig: "public." + c2, values: []string{"c"}}, + {name: def, orig: "public." + def, isDefault: true}, + } + } else { + pt.parts = []*pPartChild{ + {name: c1, orig: "public." + c1, lo: 1, hi: 10}, + {name: c2, orig: "public." + c2, lo: 10, hi: 20}, + {name: def, orig: "public." + def, isDefault: true}, + } + } + return pt +} + +func (s *pSchema) addKey(r *rand.Rand, t *pTable) bool { + unique := r.Intn(2) == 0 + var cands []string + for _, c := range t.cols { + // three rows: a boolean or an enum column repeats a value, so no unique key over it + // (a column a mutation added holds one default in every row: no unique key over it) + if c.typ != "jsonb" && !c.pk && !(unique && (c.typ == "boolean" || s.enum(c.typ) != nil || c.fresh)) { + cands = append(cands, c.name) + } + } + if len(cands) == 0 { + return false + } + r.Shuffle(len(cands), func(i, j int) { cands[i], cands[j] = cands[j], cands[i] }) + n := 1 + if len(cands) > 1 && r.Intn(2) == 0 { + n = 2 + } + t.keys = append(t.keys, &pKey{name: s.next("k"), unique: unique, cols: cands[:n]}) + return true +} + +// addPartialIndex is a plain CREATE INDEX WHERE (...): "col IS NOT NULL" over any column, or +// "col > 0" over a numeric one -- both true for every row a table holds, so the index never +// needs to exclude a row the probe wrote. +func (s *pSchema) addPartialIndex(r *rand.Rand, t *pTable) bool { + var cands []*pCol + for _, c := range t.cols { + if !c.pk && c.typ != "jsonb" { + cands = append(cands, c) + } + } + if len(cands) == 0 { + return false + } + c := pick(r, cands) + where := "notnull" + if isNumeric(c.typ) && r.Intn(2) == 0 { + where = "positive" + } + t.keys = append(t.keys, &pKey{name: s.next("k"), cols: []string{c.name}, where: where}) + return true +} + +// addExprIndex is a plain CREATE INDEX over a single expression: lower(col) for a text-like +// column, (col + 1) for a numeric one. +func (s *pSchema) addExprIndex(r *rand.Rand, t *pTable) bool { + var textCands, numCands []*pCol + for _, c := range t.cols { + if c.pk || c.gen != "" { + continue + } + switch { + case c.typ == "text" || strings.HasPrefix(c.typ, "varchar"): + textCands = append(textCands, c) + case isNumeric(c.typ): + numCands = append(numCands, c) + } + } + switch { + case len(textCands) > 0 && (len(numCands) == 0 || r.Intn(2) == 0): + c := pick(r, textCands) + t.keys = append(t.keys, &pKey{name: s.next("k"), cols: []string{c.name}, expr: "lower"}) + case len(numCands) > 0: + c := pick(r, numCands) + t.keys = append(t.keys, &pKey{name: s.next("k"), cols: []string{c.name}, expr: "plus1"}) + default: + return false + } + return true +} + +// addExclude is CONSTRAINT ... EXCLUDE USING btree (col WITH =) over an integer column: a +// row's own value is always distinct from every other row's (the row number), so it holds +// under the fixture the same way a unique key would -- a fresh column excluded for the same +// reason (every row shares its one default). +func (s *pSchema) addExclude(r *rand.Rand, t *pTable) bool { + var cands []string + for _, c := range t.cols { + if isInteger(c.typ) && !c.pk && !c.fresh { + cands = append(cands, c.name) + } + } + if len(cands) == 0 { + return false + } + t.keys = append(t.keys, &pKey{name: s.next("k"), cols: []string{pick(r, cands)}, exclude: true}) + return true +} + +func (s *pSchema) addCheck(r *rand.Rand, t *pTable) bool { + var cands []string + for _, c := range t.cols { + if !c.identity && !c.pk && (isNumeric(c.typ) || c.typ == "text" || strings.HasPrefix(c.typ, "varchar")) { + cands = append(cands, c.name) + } + } + if len(cands) == 0 { + return false + } + t.checks = append(t.checks, &pCheck{name: s.next("ck"), col: pick(r, cands)}) + return true +} + +func (s *pSchema) addFK(r *rand.Rand, t, parent *pTable) bool { + if parent == t { + return false + } + ppk := parent.pk() + c := &pCol{name: s.next("r"), typ: ppk.typ, notNull: r.Intn(2) == 0} + t.cols = append(t.cols, c) + fk := &pFK{name: s.next("fk"), cols: []string{c.name}, refTable: parent.name, refCols: []string{ppk.name}, + onDelete: pick(r, []string{"", "CASCADE", "SET NULL", "RESTRICT"}), + onUpdate: pick(r, []string{"", "CASCADE", "RESTRICT", "NO ACTION"})} + if fk.onDelete == "SET NULL" { + c.notNull = false + } + t.fks = append(t.fks, fk) + return true +} + +// addCompositeFK gives t two new columns referencing a two-column UNIQUE key that parent +// gains over two new NOT NULL integer columns of its own (rows carry the row number in +// every integer column, so every (i, i) pair exists in the parent). +func (s *pSchema) addCompositeFK(r *rand.Rand, t, parent *pTable) bool { + if parent == t { + return false + } + a := &pCol{name: s.next("a"), typ: "integer", notNull: true, def: "0"} + b := &pCol{name: s.next("b"), typ: "integer", notNull: true, def: "0"} + parent.cols = append(parent.cols, a, b) + if s.mutating { + // the parent holds rows: the new key columns need distinct values (the key's) + a.fresh, b.fresh = true, true + pk := parent.pk().name + s.intents = append(s.intents, fmt.Sprintf("-- @migrate backfill %s.%s = %s", parent.full(), a.name, qi(pk)), + fmt.Sprintf("-- @migrate backfill %s.%s = %s", parent.full(), b.name, qi(pk))) + } + parent.keys = append(parent.keys, &pKey{name: s.next("k"), unique: true, cols: []string{a.name, b.name}}) + ra := &pCol{name: s.next("r"), typ: "integer", fresh: s.mutating} + rb := &pCol{name: s.next("r"), typ: "integer", fresh: s.mutating} + t.cols = append(t.cols, ra, rb) + t.fks = append(t.fks, &pFK{name: s.next("fk"), cols: []string{ra.name, rb.name}, refTable: parent.name, refCols: []string{a.name, b.name}, + onDelete: pick(r, []string{"", "CASCADE", "SET NULL"})}) + return true +} + +func (s *pSchema) addView(r *rand.Rand) bool { return s.addViewKind(r, r.Intn(3) == 0) } + +func (s *pSchema) addViewKind(r *rand.Rand, mat bool) bool { + t := pick(r, s.tables) + var cols []string + for _, c := range t.cols { + if r.Intn(2) == 0 { + cols = append(cols, c.name) + } + } + if len(cols) == 0 { + cols = []string{t.cols[0].name} + } + s.views = append(s.views, &pView{name: s.next("v"), table: t.name, cols: cols, mat: mat}) + return true +} + +func (s *pSchema) addTrigger(r *rand.Rand) bool { return s.addTriggerOn(r, pick(r, s.tables)) } + +func (s *pSchema) addTriggerOn(r *rand.Rand, t *pTable) bool { + var src []*pCol + for _, c := range t.numericCols() { + // the body adds to the column: not a reference, not a key, not a referenced column + if !t.inFK(c.name) && !c.pk && len(referencedBy(s, t, c.name)) == 0 { + src = append(src, c) + } + } + if len(src) == 0 { + return false + } + name := s.next("tr") + col, n := pick(r, src).name, 1+r.Intn(9) + s.triggerFns = append(s.triggerFns, &pTriggerFn{name: name, col: col, n: n}) + s.triggers = append(s.triggers, &pTrigger{name: name, table: t.name, col: col, n: n, callName: name}) + return true +} + +func generate(r *rand.Rand, pg18 bool) *pSchema { + s := &pSchema{pg18: pg18} + n := 1 + r.Intn(3) + for i := 0; i < n; i++ { + s.tables = append(s.tables, s.newTable(r)) + } + // a partitioned table from pair 0 too (see untouched-style helpers below for why): a + // spare "drop partition" / "detach partition" needs a candidate that isn't only one a + // same-recipe "add partitioned table" step happened to leave behind + s.partTables = append(s.partTables, s.newPartTable(r)) + // pt0 keeps newPartTable's own random id draw (plain / bigserial / IDENTITY ALWAYS / BY + // DEFAULT): it is also the pre-detached spare right below, and "detach partition" / + // "attach partition" no longer refuse a non-plain pt (brief-pg-attach-seq.md -- + // migrate.go's partitionAttach and detachedIDCol, just below, now know what each one + // does across a DETACH / ATTACH), so this spare exercises every id kind exactly as any + // other pt does. + // a spare already-detached partition from pair 0 too: "attach partition" alone + // (directed coverage) needs one already standing apart, not only one a same-recipe + // "detach partition" step happened to leave behind. Bound-picking mirrors "add + // partition"'s own (a LIST value / RANGE bound the other children don't already use). + { + pt0 := s.partTables[0] + name := s.next(pt0.name + "_") + if pt0.strategy == "LIST" { + used := map[string]bool{} + for _, c := range pt0.parts { + for _, v := range c.values { + used[v] = true + } + } + letter := "d" + for _, cand := range []string{"d", "e", "f", "g", "h"} { + if !used[cand] { + letter = cand + break + } + } + pt0.parts = append(pt0.parts, &pPartChild{name: name, orig: "public." + name, values: []string{letter}, detached: true}) + } else { + hi := 0 + for _, c := range pt0.parts { + if !c.isDefault && c.hi > hi { + hi = c.hi + } + } + pt0.parts = append(pt0.parts, &pPartChild{name: name, orig: "public." + name, lo: hi, hi: hi + 10, detached: true}) + } + } + if r.Intn(2) == 0 { + s.addViewKind(r, false) // a spare plain view, so "drop view" has one from pair 0 + } + if r.Intn(3) == 0 { + s.addViewKind(r, true) // a spare materialized view, so "drop materialized view" has one from pair 0 + } + if r.Intn(2) == 0 { + tt := pick(r, s.tables) + s.addTriggerOn(r, tt) + s.addTriggerOn(r, tt) // a second one on the same table, so "change trigger function" has a pair from pair 0 + } + s.funcs = append(s.funcs, &pFunc{name: s.next("f"), n: 1 + r.Intn(9), retType: "integer", volatility: "IMMUTABLE", lang: "sql"}) + // spares with no column using them: "drop domain" / "drop enum" / "drop range type" + // need a candidate from pair 0, not just one a same-recipe "add" step happened to + // leave behind + s.domains = append(s.domains, &pDomain{name: s.next("num")}) + s.enums = append(s.enums, &pEnum{name: s.next("e"), labels: []string{"a", "b", "c"}[:2+r.Intn(2)]}) + s.composites = append(s.composites, s.newComposite(r)) + s.ranges = append(s.ranges, &pRange{name: s.next("rg")}) + s.extensions = append(s.extensions, "pgcrypto") + // a policy / rule from pair 0 too, for the same reason as the spares above + t := pick(r, s.tables) + t.rowSec = true + command := pick(r, []string{"ALL", "SELECT", "INSERT", "UPDATE", "DELETE"}) + using, withCheck := policyPredicates(command) + s.policies = append(s.policies, &pPolicy{name: s.next("pol"), table: t.name, command: command, + permissive: r.Intn(4) != 0, using: using, withCheck: withCheck}) + t2 := pick(r, s.tables) + s.rules = append(s.rules, &pRule{name: s.next("ru"), table: t2.name, where: r.Intn(2) == 0, enabled: true}) + // a standalone sequence, owned by some table's plain integer column, from pair 0 too + sq := &pSeq{name: s.next("sq")} + s.seqs = append(s.seqs, sq) + for _, t3 := range s.tables { + for _, c := range t3.cols { + if isInteger(c.typ) && !c.pk && !c.identity && !c.serial && c.gen == "" { + sq.table, sq.col = t3.name, c.name + } + } + } + // a second, unowned standalone sequence from pair 0 too: "set sequence owner" alone + // (directed coverage) needs one with no owner yet, not only one a same-recipe "clear + // sequence owner" step happened to leave behind. + s.seqs = append(s.seqs, &pSeq{name: s.next("sq")}) + // a seed table from pair 0 too, so "change" / "drop seed row" have rows to act on + s.seedTable = &pSeedTable{name: s.next("lk"), rows: []pSeedRow{ + {code: s.next("code"), label: "a"}, + {code: s.next("code"), label: "b"}, + }} + if pg18 { + // a temporal (WITHOUT OVERLAPS) unique key and an ordinary (not yet PERIOD) + // foreign key resting on it, from pair 0 too: "toggle key without overlaps" / + // "toggle foreign key period" alone (directed coverage) need a candidate that + // already carries the shape they flip, not only one a same-recipe "add without + // overlaps key" step happened to leave behind -- mutations18's own vocabulary + // otherwise only ever adds a brand new temporal key (+ constraint), never changes + // an existing one's own WITHOUT OVERLAPS / PERIOD flag (the ~ field diff.Alphabet + // mines). + tp := &pTable{name: s.next("tp"), schema: "public"} + tp.orig = tp.full() + tid := &pCol{name: "id", typ: "integer", notNull: true, pk: true} + span := &pCol{name: "span", typ: "daterange", notNull: true} + tp.cols = []*pCol{tid, span} + tp.keys = []*pKey{{name: s.next("k"), unique: true, cols: []string{tid.name, span.name}, withoutOverlaps: true}} + s.tables = append(s.tables, tp) + s.ensureExtension("btree_gist") + + tc := &pTable{name: s.next("tp"), schema: "public"} + tc.orig = tc.full() + cid := &pCol{name: "id", typ: "integer", notNull: true, pk: true} + pid := &pCol{name: "pid", typ: "integer", notNull: true} + valid := &pCol{name: "valid", typ: "daterange", notNull: true} + tc.cols = []*pCol{cid, pid, valid} + // plain (not yet PERIOD) FK, resting on tp's own plain PK alone: "toggle foreign + // key period" widens it onto tp's temporal key instead (pid, valid) -> (id, span), + // PERIOD. + tc.fks = []*pFK{{name: s.next("fk"), cols: []string{pid.name}, refTable: tp.name, refCols: []string{tid.name}}} + s.tables = append(s.tables, tc) + } + return s +} diff --git a/check/postgres/migrate/probe_model_test.go b/check/postgres/migrate/probe_model_test.go new file mode 100644 index 0000000..c9d7f19 --- /dev/null +++ b/check/postgres/migrate/probe_model_test.go @@ -0,0 +1,1193 @@ +package migrate + +// Split from probe_test.go (see its header for the probe's overall design): the schema +// model (types), the type vocabulary, and rendering the model to DDL / DML. Pure move -- +// no behavior here changed by the split. + +import ( + "fmt" + "strings" +) + +// ---- the model -------------------------------------------------------------------------- + +type pSchema struct { + domains []*pDomain + enums []*pEnum + composites []*pComposite + ranges []*pRange + tables []*pTable + partTables []*pPartTable + views []*pView + triggers []*pTrigger + funcs []*pFunc + policies []*pPolicy + rules []*pRule + seqs []*pSeq + triggerFns []*pTriggerFn + seedTable *pSeedTable // at most one, for simplicity: see its own doc comment + extensions []string + intents []string + // checks are extra DO $$ ... $$ assertions a mutation wants run right after the plan's + // own DDL, against the same live database, before judge() compares schemas -- so far + // only "shrink partition bound" uses this, to confirm a partitioned table's total row + // count survives a row actually being moved between partitions (diff.Compare alone + // only ever sees schema shape, never data). + checks []string + seq int + mutating bool // set on a clone: the tables that exist are the source's, and hold rows + // pg18: this schema targets PostgreSQL 18 (TestMigrateProbe18), so render() declares + // `-- sqlshape: postgres 18` (schema.DeclaredVersion defaults to 17 otherwise, which + // analyze.Load -- Canonical's own seeds path when called with nil, judge()'s case -- + // would use to parse this text, refusing any PG18-only syntax a mutation wrote). + pg18 bool +} + +// pRange is a standalone range type over integer (CREATE TYPE name AS RANGE (subtype = +// integer)): never used as a column type -- only its own presence (+/-) is in the +// planner's vocabulary here (an ALTER of its subtype is note-only, see +// alphabetKnownUnreached), so it does not need one. +type pRange struct{ name string } + +// pPartTable is a small, self-contained RANGE- or LIST-partitioned table: a parent +// (PARTITION BY RANGE (id) / LIST (kind)) with two ordinary partitions and (usually) a +// DEFAULT one -- brief-partition-pg.md's vocabulary items 1-4 (create / drop the whole +// thing, a partition added or removed, DETACH / ATTACH, a bound moved). Deliberately +// narrow: no key, no index, no foreign key, nothing else references it -- pTable already +// exercises that vocabulary against ordinary tables (items 5-6, propagation to a +// partition's own columns and keys resting on the partition key, are instead measured by +// hand against the planner directly; see the round's report for why this generator does +// not fold partitioning into pTable itself). +type pPartTable struct { + name string + orig string // the source schema's qualified name (a declaration's left side) + strategy string // "RANGE" or "LIST" + parts []*pPartChild + // id is pt's own "id" column (see render(), rows()): a plain integer by default, or one + // carrying a sequence a repartition (change partition strategy / departition table) has + // to carry across the rebuild -- bigserial (id.serial, a column-owned sequence + // detectRepartitions/repartitionTable now detach and re-own) or IDENTITY (id.identity, + // the server's own -- BY DEFAULT or, with id.idAlways, ALWAYS), covering the vocabulary + // brief-pg-repartition-seq.md widened repartitionTable's scope into. Reuses pCol/pCol.render + // rather than its own text, so its SQL matches pTable's own bigserial / IDENTITY columns + // exactly. + id *pCol +} + +// pPartChild is one partition (or, once detached, a plain standalone table that used to +// be one): RANGE bound is [lo, hi), LIST bound is one or more values, and a DEFAULT +// partition has neither. +type pPartChild struct { + name string + orig string + isDefault bool + lo, hi int // RANGE bound; 0 for LIST / DEFAULT + values []string // LIST bound; nil for RANGE / DEFAULT + detached bool // stands alone now: rendered as a plain CREATE TABLE, no PARTITION OF + gone bool // dropped outright (declared, like pTable's own drop) +} + +// bound is the FOR VALUES clause (or DEFAULT) attached to a direct CREATE TABLE ... +// PARTITION OF, or ALTER TABLE ... ATTACH PARTITION -- the same text either way. +func (c *pPartChild) bound() string { + switch { + case c.isDefault: + return "DEFAULT" + case len(c.values) > 0: + var q []string + for _, v := range c.values { + q = append(q, "'"+v+"'") + } + return "FOR VALUES IN (" + strings.Join(q, ", ") + ")" + default: + return fmt.Sprintf("FOR VALUES FROM (%d) TO (%d)", c.lo, c.hi) + } +} + +func (pt *pPartTable) child(name string) *pPartChild { + for _, c := range pt.parts { + if c.name == name { + return c + } + } + return nil +} + +func (pt *pPartTable) full() string { return "public." + pt.name } +func (c *pPartChild) full() string { return "public." + c.name } + +// pRule is a CREATE RULE ... AS ON INSERT TO table [WHERE (id > 0)] DO INSTEAD NOTHING: +// a no-op rule (every row still holds id > 0, so the predicate is never false) whose +// presence, WHERE clause and enabled flag are the planner's rule vocabulary. +type pRule struct { + name string + table string + where bool + enabled bool +} + +// pSeq is a standalone CREATE SEQUENCE, optionally OWNED BY an existing integer column +// (never one already owning a bigserial / IDENTITY sequence of its own): "set sequence +// owner" / "clear sequence owner" toggle the OWNED BY clause on an existing sequence. +type pSeq struct { + name string + table string // "" (unowned) or an owning table + col string +} + +// pSeedTable is a standalone lookup table (schema.Seed): a CREATE TABLE the schema text +// itself gives fixed content via an INSERT right after it (`-- sqlshape: seed` for an +// additive one). It is a completely separate model from the probe's own rows() / +// s.value(): those exist only to give the migrated database something to hold when the +// plan runs; a seed's rows are declared CONTENT the migration keeps in step with the +// schema text, diffed by RowChanges ("~ rows row "), not backfilled or inserted by +// the probe. Kept to at most one per schema (pSchema.seedTable) and to a plain two +// column (code, label) shape for simplicity. +type pSeedTable struct { + name string + rows []pSeedRow + additive bool +} + +type pSeedRow struct { + code, label string +} + +type pEnum struct { + name string + labels []string + fresh bool // minted by a mutation (a fresh table's or column's): not in the source, so + // "drop enum label" (which writes an @migrate declaration naming it) must leave it alone +} + +// pDomain is a domain over integer, CHECK (VALUE > 0) or, once changed, (VALUE >= 0). +type pDomain struct { + name string + ge bool + notNull bool +} + +type pTable struct { + schema string // public or app + name string + orig string // the source schema's qualified name for the table (a declaration's left side) + cols []*pCol + keys []*pKey + fks []*pFK + checks []*pCheck + comment string + rowSec bool // ENABLE ROW LEVEL SECURITY + forceRowSec bool // FORCE ROW LEVEL SECURITY (only meaningful with rowSec) +} + +type pCol struct { + name string + typ string // integer, bigint, text, varchar(50), numeric(10,2), ..., or an enum type's name + notNull bool + def string + pk bool + identity bool + serial bool // bigserial: a sequence and a nextval default + gen string // the column this generated column reads + genVirtual bool // VIRTUAL instead of STORED (PostgreSQL 18 only; mutations18 alone sets this) + fresh bool // added by a mutation: not in the source, so its drop needs no declaration + comment string + idAlways bool // GENERATED ALWAYS (else BY DEFAULT) AS IDENTITY, when identity is set +} + +type pKey struct { + name string + unique bool // a UNIQUE constraint; else a CREATE INDEX (unless exclude) + cols []string + notDist bool // UNIQUE NULLS NOT DISTINCT (unique only) + defer_ bool // DEFERRABLE INITIALLY DEFERRED (unique only) + where string // a partial index's predicate, rendered over cols[0] (plain index only): "notnull" or "positive" + expr string // an expression index's sole element over cols[0] (plain index only): "lower" or "plus1" + exclude bool // CONSTRAINT name EXCLUDE USING btree (cols[0] WITH =): unique in the row + // sense, over an integer column, but declared as a table constraint rather than a + // CREATE INDEX / UNIQUE + opNE bool // WITH <> instead of =: cols[0] must then be a column every row shares one + // value in (a fresh column's own default), since <> forbids any two rows differing + usingGist bool // USING gist instead of btree (needs the btree_gist extension) + // withoutOverlaps (unique only): the last column (a range type) is WITHOUT OVERLAPS, + // a temporal UNIQUE (PostgreSQL 18 only; mutations18 alone sets this). + withoutOverlaps bool +} + +type pFK struct { + name string + cols []string + refTable string + refCols []string + onDelete string + onUpdate string + defer_ bool // DEFERRABLE INITIALLY DEFERRED + notEnforced bool // NOT ENFORCED (PostgreSQL 18 only; mutations18 alone sets this) + // withPeriod (PostgreSQL 18 only): the last column of cols / refCols is PERIOD -- a + // temporal foreign key, needing a WITHOUT OVERLAPS primary key or unique constraint on + // the referenced side over exactly refCols (pKey.withoutOverlaps; mutations18 alone + // sets this). + withPeriod bool +} + +type pCheck struct { + name string + col string + ge bool // col >= 0 rather than col > 0 (both hold for every generated row) + notEnforced bool // NOT ENFORCED (PostgreSQL 18 only; mutations18 alone sets this) +} + +// pComposite is a composite type, CREATE TYPE name AS (attrs...). Kept out of every table's +// columns once its attributes are mutated (ALTER TYPE ADD/DROP/ALTER ATTRIBUTE on a +// composite type a table column uses needs CASCADE and a view rebuild the probe does not +// attempt yet; see design notes) -- a table may still gain or drop a column of the type +// as it stands. +type pComposite struct { + name string + attrs []*pAttr +} + +type pAttr struct { + name string + typ string +} + +// pPolicy is a row-level security policy. Its predicate (both USING and WITH CHECK, when +// present) is always "the table's primary key column > 0": true for every row the probe +// generates, so the policy never hides or refuses a row the probe itself writes. +type pPolicy struct { + name string + table string + command string // ALL, SELECT, INSERT, UPDATE, DELETE + permissive bool + using bool + withCheck bool + role bool // TO CURRENT_USER (a pseudo-role that always exists) rather than PUBLIC +} + +type pView struct { + name string + table string + cols []string + mat bool // a materialized view + checkOption string // "", "LOCAL" or "CASCADED" (plain views only) +} + +type pTrigger struct { + name string + table string + col string + n int + onUpdate bool // also fires BEFORE UPDATE OF col (not just INSERT) + callName string // the pTriggerFn (by name) this trigger EXECUTEs -- its own, at + // creation; "change trigger function" repoints it to another trigger's function on + // the same table. A pTriggerFn is never removed when the trigger that made it is + // (it may still be called), so this can never dangle. +} + +// pTriggerFn is a trigger function, kept independent of the pTrigger that first made it +// so dropping that trigger cannot leave another one's callName pointing at a function +// that stops being created (measured: 42883 "function ... does not exist"). +type pTriggerFn struct { + name string // the function is name + "_fn" + col string + n int +} + +type pFunc struct { + name string + n int + retType string // "integer" or "bigint" + strict bool + volatility string // "IMMUTABLE" or "STABLE" + argDefault bool // the sole argument gets " DEFAULT 1" (arguments text changes; same signature) + lang string // "sql" or "plpgsql", plain functions only + kind string // "" (plain function), "procedure", "aggregate" or "window": all + // keep the same signature (a single integer argument) as a plain function, so + // converting one into another is a same-name Alter, not a drop-and-recreate. +} + +func (s *pSchema) next(prefix string) string { + s.seq++ + return fmt.Sprintf("%s%d", prefix, s.seq) +} + +func (s *pSchema) clone() *pSchema { + c := &pSchema{seq: s.seq, pg18: s.pg18} + for _, d := range s.domains { + nd := *d + c.domains = append(c.domains, &nd) + } + for _, e := range s.enums { + c.enums = append(c.enums, &pEnum{name: e.name, labels: append([]string(nil), e.labels...), fresh: e.fresh}) + } + for _, co := range s.composites { + nco := &pComposite{name: co.name} + for _, a := range co.attrs { + na := *a + nco.attrs = append(nco.attrs, &na) + } + c.composites = append(c.composites, nco) + } + for _, t := range s.tables { + nt := &pTable{schema: t.schema, name: t.name, orig: t.orig, comment: t.comment, rowSec: t.rowSec, forceRowSec: t.forceRowSec} + for _, col := range t.cols { + nc := *col + nt.cols = append(nt.cols, &nc) + } + for _, k := range t.keys { + nk := *k + nk.cols = append([]string(nil), k.cols...) + nt.keys = append(nt.keys, &nk) + } + for _, fk := range t.fks { + nfk := *fk + nfk.cols = append([]string(nil), fk.cols...) + nfk.refCols = append([]string(nil), fk.refCols...) + nt.fks = append(nt.fks, &nfk) + } + for _, ck := range t.checks { + nck := *ck + nt.checks = append(nt.checks, &nck) + } + c.tables = append(c.tables, nt) + } + for _, pt := range s.partTables { + npt := &pPartTable{name: pt.name, orig: pt.orig, strategy: pt.strategy} + if pt.id != nil { + nid := *pt.id + npt.id = &nid + } + for _, ch := range pt.parts { + nch := *ch + nch.values = append([]string(nil), ch.values...) + npt.parts = append(npt.parts, &nch) + } + c.partTables = append(c.partTables, npt) + } + for _, v := range s.views { + nv := *v + nv.cols = append([]string(nil), v.cols...) + c.views = append(c.views, &nv) + } + for _, tr := range s.triggers { + ntr := *tr + c.triggers = append(c.triggers, &ntr) + } + for _, f := range s.funcs { + nf := *f + c.funcs = append(c.funcs, &nf) + } + for _, p := range s.policies { + np := *p + c.policies = append(c.policies, &np) + } + for _, rg := range s.ranges { + nrg := *rg + c.ranges = append(c.ranges, &nrg) + } + for _, ru := range s.rules { + nru := *ru + c.rules = append(c.rules, &nru) + } + for _, sq := range s.seqs { + nsq := *sq + c.seqs = append(c.seqs, &nsq) + } + for _, fn := range s.triggerFns { + nfn := *fn + c.triggerFns = append(c.triggerFns, &nfn) + } + if s.seedTable != nil { + nst := *s.seedTable + nst.rows = append([]pSeedRow(nil), s.seedTable.rows...) + c.seedTable = &nst + } + c.extensions = append([]string(nil), s.extensions...) + return c +} + +func (s *pSchema) table(name string) *pTable { + for _, t := range s.tables { + if t.name == name { + return t + } + } + return nil +} + +// full is the table's qualified name as a declaration writes it. +func (t *pTable) full() string { return t.schema + "." + t.name } + +// ref is the table's qualified name as DDL writes it. +func (t *pTable) ref() string { return qi(t.schema) + "." + qi(t.name) } + +// qref is the DDL spelling of the table named name. +func (s *pSchema) qref(name string) string { + if t := s.table(name); t != nil { + return t.ref() + } + return qi(name) +} + +func (s *pSchema) enum(name string) *pEnum { + for _, e := range s.enums { + if e.name == name { + return e + } + } + return nil +} + +func (s *pSchema) composite(name string) *pComposite { + for _, c := range s.composites { + if c.name == name { + return c + } + } + return nil +} + +// compositeInUse reports whether any table column, anywhere in the schema, has the +// composite type name -- used to keep ALTER TYPE ... ATTRIBUTE and DROP TYPE mutations off +// a composite type a table is still using (see pComposite's doc comment). +func compositeInUse(s *pSchema, name string) bool { return typeInUse(s, name) } + +// typeInUse reports whether any table column, anywhere in the schema, has type name -- +// used to keep "drop" mutations for a domain / enum / composite type off one a column +// still carries. +func typeInUse(s *pSchema, name string) bool { + for _, t := range s.tables { + for _, c := range t.cols { + if c.typ == name { + return true + } + } + } + return false +} + +// compositeArityFrozen reports whether some column's own DEFAULT is a ROW(...)::name +// literal (a NOT NULL composite column defaultFor gave one, over existing rows) -- +// adding or dropping an attribute would leave that literal's value count stale for the +// changed type ("cannot cast type record to ", measured), so ADD / DROP ATTRIBUTE +// (which change the attribute count) stay off it; ALTER ATTRIBUTE TYPE does not change +// the count and is unaffected. +func compositeArityFrozen(s *pSchema, name string) bool { + for _, t := range s.tables { + for _, c := range t.cols { + if c.typ == name && c.def != "" { + return true + } + } + } + return false +} + +func (t *pTable) col(name string) *pCol { + for _, c := range t.cols { + if c.name == name { + return c + } + } + return nil +} + +func (t *pTable) pk() *pCol { + for _, c := range t.cols { + if c.pk { + return c + } + } + return nil +} + +func (t *pTable) inFK(col string) bool { + for _, fk := range t.fks { + if indexOf(fk.cols, col) >= 0 { + return true + } + } + return false +} + +// ---- the type vocabulary ---------------------------------------------------------------- + +var probeTypes = []string{"integer", "bigint", "smallint", "text", "varchar(50)", "numeric(10,2)", "double precision", "timestamptz", "date", "boolean", "jsonb", "enum", "domain", "composite"} + +func isNumeric(typ string) bool { + switch typ { + case "integer", "bigint", "smallint", "numeric(10,2)", "numeric(14,2)", "double precision": + return true + } + return strings.HasPrefix(typ, "num") // a domain over integer +} + +func isInteger(typ string) bool { return typ == "integer" || typ == "bigint" || typ == "smallint" } + +func (s *pSchema) defaultFor(c *pCol) string { + switch c.typ { + case "integer", "bigint", "smallint", "double precision": + return "1" + case "numeric(10,2)", "numeric(14,2)": + return "1.00" + case "text": + return "'x'" + case "varchar(50)", "varchar(100)": + return "'x'" + case "timestamptz": + return "now()" + case "date": + return "'2020-01-01'" + case "boolean": + return "false" + case "jsonb": + return "'{}'" + } + if e := s.enum(c.typ); e != nil { + return "'" + e.labels[0] + "'" + } + if co := s.composite(c.typ); co != nil { + return co.literal(func(a *pAttr) string { + if isNumeric(a.typ) { + return "1" + } + return "'x'" + }) + } + if strings.HasPrefix(c.typ, "num") { + return "1" + } + return "" +} + +func widen(typ string) string { + switch typ { + case "integer": + return "bigint" + case "smallint": + return "integer" + case "varchar(50)": + return "varchar(100)" + case "numeric(10,2)": + return "numeric(14,2)" + } + return "" +} + +// ---- rendering -------------------------------------------------------------------------- + +func qi(name string) string { return `"` + name + `"` } + +func qilist(names []string) string { + out := make([]string, len(names)) + for i, n := range names { + out[i] = qi(n) + } + return strings.Join(out, ", ") +} + +func (c *pCol) render() string { + var b strings.Builder + typ := c.typ + if c.serial { + typ = "bigserial" + } + b.WriteString(qi(c.name) + " " + typ) + if c.gen != "" { + kind := "STORED" + if c.genVirtual { + kind = "VIRTUAL" + } + b.WriteString(" GENERATED ALWAYS AS (" + qi(c.gen) + " + 1) " + kind) + return b.String() + } + if c.notNull { + b.WriteString(" NOT NULL") + } + if c.identity { + kind := "BY DEFAULT" + if c.idAlways { + kind = "ALWAYS" + } + b.WriteString(" GENERATED " + kind + " AS IDENTITY") + } else if c.def != "" { + b.WriteString(" DEFAULT " + c.def) + } + return b.String() +} + +func (t *pTable) render(s *pSchema) string { + var parts []string + for _, c := range t.cols { + parts = append(parts, " "+c.render()) + } + if pk := t.pk(); pk != nil { + parts = append(parts, " CONSTRAINT "+qi(t.name+"_pkey")+" PRIMARY KEY ("+qi(pk.name)+")") + } + for _, k := range t.keys { + if k.unique { + u := " CONSTRAINT " + qi(k.name) + " UNIQUE" + if k.notDist { + u += " NULLS NOT DISTINCT" + } + cols := qilist(k.cols) + if k.withoutOverlaps && len(k.cols) > 0 { + cols = qilist(k.cols[:len(k.cols)-1]) + if cols != "" { + cols += ", " + } + cols += qi(k.cols[len(k.cols)-1]) + " WITHOUT OVERLAPS" + } + u += " (" + cols + ")" + if k.defer_ { + u += " DEFERRABLE INITIALLY DEFERRED" + } + parts = append(parts, u) + } + if k.exclude { + method, op := "btree", "=" + if k.usingGist || k.opNE { + // btree has no "<>" member in its integer operator family (42809, + // measured); btree_gist's gist opclass does + method = "gist" + } + if k.opNE { + op = "<>" + } + s := " CONSTRAINT " + qi(k.name) + " EXCLUDE USING " + method + " (" + qi(k.cols[0]) + " WITH " + op + ")" + if k.where == "positive" { + s += " WHERE (" + qi(k.cols[0]) + " > 0)" + } + parts = append(parts, s) + } + } + for _, fk := range t.fks { + s := " CONSTRAINT " + qi(fk.name) + " FOREIGN KEY (" + periodList(fk.cols, fk.withPeriod) + ") REFERENCES " + s.qref(fk.refTable) + " (" + periodList(fk.refCols, fk.withPeriod) + ")" + if fk.onDelete != "" { + s += " ON DELETE " + fk.onDelete + } + if fk.onUpdate != "" { + s += " ON UPDATE " + fk.onUpdate + } + if fk.defer_ { + s += " DEFERRABLE INITIALLY DEFERRED" + } + if fk.notEnforced { + s += " NOT ENFORCED" + } + parts = append(parts, s) + } + for _, ck := range t.checks { + c := t.col(ck.col) + op := ">" + if ck.ge { + op = ">=" + } + expr := "(" + qi(ck.col) + " " + op + " 0)" + if !isNumeric(c.typ) { + expr = "(length(" + qi(ck.col) + ") > 0)" + } + s := " CONSTRAINT " + qi(ck.name) + " CHECK " + expr + if ck.notEnforced { + s += " NOT ENFORCED" + } + parts = append(parts, s) + } + var b strings.Builder + b.WriteString("CREATE TABLE " + t.ref() + " (\n" + strings.Join(parts, ",\n") + "\n);\n") + for _, k := range t.keys { + if !k.unique && !k.exclude { + b.WriteString(k.renderIndex(t)) + } + } + if t.comment != "" { + b.WriteString("COMMENT ON TABLE " + t.ref() + " IS '" + t.comment + "';\n") + } + for _, c := range t.cols { + if c.comment != "" { + b.WriteString("COMMENT ON COLUMN " + t.ref() + "." + qi(c.name) + " IS '" + c.comment + "';\n") + } + } + if t.rowSec { + b.WriteString("ALTER TABLE " + t.ref() + " ENABLE ROW LEVEL SECURITY;\n") + if t.forceRowSec { + b.WriteString("ALTER TABLE " + t.ref() + " FORCE ROW LEVEL SECURITY;\n") + } + } + return b.String() +} + +// render is the parent's CREATE TABLE ... PARTITION BY ..., then each surviving +// partition: PARTITION OF for one still attached, a plain CREATE TABLE for one detached +// (pPartChild.detached); a gone one (dropped outright, declared) writes nothing. A detached +// child's own id column is detachedIDCol, not pt.idCol() itself -- a real DETACH PARTITION +// does not leave every column property exactly as it was while attached (brief-pg-attach- +// seq.md's own measurements; see detachedIDCol's doc comment for identity and bigserial +// each). +func (pt *pPartTable) render() string { + col, keyword := "id", "RANGE" + if pt.strategy == "LIST" { + col, keyword = "kind", "LIST" + } + var b strings.Builder + fmt.Fprintf(&b, "CREATE TABLE %s (%s, kind text NOT NULL) PARTITION BY %s (%s);\n", qi(pt.name), pt.idCol().render(), keyword, qi(col)) + for _, c := range pt.parts { + if c.gone { + continue + } + if c.detached { + b.WriteString("CREATE TABLE " + qi(c.name) + " (" + pt.detachedIDCol().render() + ", kind text NOT NULL);\n") + continue + } + b.WriteString("CREATE TABLE " + qi(c.name) + " PARTITION OF " + qi(pt.name) + " " + c.bound() + ";\n") + } + return b.String() +} + +// detachedIDCol is the "id" column a detached partition renders with. Plain is unchanged +// (pt.idCol() itself): nothing about it was ever partition-specific. IDENTITY is not: a real +// ALTER TABLE ... DETACH PARTITION strips it from the child outright (attidentity clears and +// its GENERATED ... AS IDENTITY default disappears with it, measured), so the only form +// that round-trips -- both back through "attach partition" (which needs the child to carry +// no identity of its own; PostgreSQL refuses to ATTACH one that does, 55000, regardless of +// the parent's own -- migrate.go's partitionAttach) and through judge()'s own comparison +// against a real post-DETACH dump -- is a plain integer column, no identity, no default. +// Bigserial is unchanged in kind but not in spelling: its DEFAULT nextval(...) survives +// DETACH because it always named the *parent's* sequence to begin with (copied down +// textually the moment the child was created, never its own -- a partition's bigserial +// column is never separately OWNED BY it, measured), so this spells it out literally +// (bigint + that exact DEFAULT) rather than as the "bigserial" pseudo-type, which the +// loader would read as a *fresh*, separately owned sequence under the child's own name -- +// wrong on both counts, and, once "attach partition" re-attaches this very same child, a +// stale one the diff would then try to drop out from under a still-live default (2BP01, +// measured before this existed). +func (pt *pPartTable) detachedIDCol() *pCol { + switch { + case pt.idCol().identity: + return &pCol{name: "id", typ: "integer", notNull: true} + case pt.idCol().serial: + return &pCol{name: "id", typ: "bigint", notNull: true, def: "nextval('" + pt.name + "_id_seq'::regclass)"} + default: + return pt.idCol() + } +} + +// idCol is pt.id, defaulted to a plain "id integer NOT NULL" the first time it is asked for +// (pTableFromPart and any pPartTable a test constructs directly, unlike newPartTable, never +// set pt.id themselves): keeps render()/rows() from having to nil-check it everywhere. +func (pt *pPartTable) idCol() *pCol { + if pt.id == nil { + pt.id = &pCol{name: "id", typ: "integer", notNull: true} + } + return pt.id +} + +// maxRowID is the highest "id" rows() gives pt under its *current* parts, before any +// pending mutation replaces them: a RANGE child's own lower bound is its one row's id, and a +// LIST / DEFAULT row's is 100000 plus its position among the live parts (mirrors rows()'s +// own construction). A repartition (change partition strategy / departition table) computes +// this ahead of rebuilding pt.parts, so its own continuation check (an id-owning pt only) +// knows what a fresh row's id ought to pick up from. +func (pt *pPartTable) maxRowID() int { + max, i := 0, 0 + for _, c := range pt.parts { + if c.gone || c.detached { + continue + } + i++ + id := 100000 + i + if !c.isDefault && pt.strategy != "LIST" { + id = c.lo + } + if id > max { + max = id + } + } + return max +} + +// renderIndex is a plain CREATE INDEX: a partial predicate over cols[0] ("notnull" / +// "positive", both true for every row a table holds), or an expression over cols[0] +// ("lower" for a text-like column, "plus1" for a numeric one) in place of the column list. +func (k *pKey) renderIndex(t *pTable) string { + elems := qilist(k.cols) + if k.expr != "" { + c := t.col(k.cols[0]) + switch k.expr { + case "lower": + elems = "(lower(" + qi(c.name) + "))" + case "plus1": + elems = "((" + qi(c.name) + " + 1))" + } + } + s := "CREATE INDEX " + qi(k.name) + " ON " + t.ref() + " (" + elems + ")" + switch k.where { + case "notnull": + s += " WHERE (" + qi(k.cols[0]) + " IS NOT NULL)" + case "positive": + s += " WHERE (" + qi(k.cols[0]) + " > 0)" + } + return s + ";\n" +} + +// render is a CREATE POLICY over the table's primary key column ("id > 0", true for +// every row the probe inserts, so a policy's predicate never hides or refuses a row the +// probe itself wrote, whatever ENABLE / FORCE the table carries). +func (pol *pPolicy) render(s *pSchema) string { + t := s.table(pol.table) + pred := qi(t.pk().name) + " > 0" + b := "CREATE POLICY " + qi(pol.name) + " ON " + s.qref(pol.table) + if !pol.permissive { + b += " AS RESTRICTIVE" + } + if pol.command != "ALL" { + b += " FOR " + pol.command + } + if pol.role { + b += " TO CURRENT_USER" + } + if pol.using { + b += " USING (" + pred + ")" + } + if pol.withCheck { + b += " WITH CHECK (" + pred + ")" + } + return b + ";\n" +} + +// render is CREATE RULE ... AS ON INSERT TO table [WHERE (id > 0)] DO ALSO NOTHING, then +// DISABLE RULE when the rule is off. DO ALSO (not DO INSTEAD): INSTEAD replaces the +// original INSERT with nothing, which would silently drop every row the probe inserts +// (id > 0 always holds) -- measured, "generator (rows)" against a foreign key that then +// has no parent row to reference. +func (ru *pRule) render(s *pSchema) string { + t := s.table(ru.table) + b := "CREATE RULE " + qi(ru.name) + " AS ON INSERT TO " + s.qref(ru.table) + if ru.where { + b += " WHERE (" + qi(t.pk().name) + " > 0)" + } + b += " DO ALSO NOTHING;\n" + if !ru.enabled { + b += "ALTER TABLE " + t.ref() + " DISABLE RULE " + qi(ru.name) + ";\n" + } + return b +} + +// render is CREATE TABLE (code text PRIMARY KEY, label text NOT NULL) and the seeding +// INSERT right after it, `-- sqlshape: seed` ahead of it when additive. +func (st *pSeedTable) render() string { + var b strings.Builder + fmt.Fprintf(&b, "CREATE TABLE %s (code text PRIMARY KEY, label text NOT NULL);\n", qi(st.name)) + if len(st.rows) == 0 { + return b.String() + } + if st.additive { + b.WriteString("-- sqlshape: seed\n") + } + var vals []string + for _, row := range st.rows { + vals = append(vals, fmt.Sprintf("('%s', '%s')", row.code, row.label)) + } + fmt.Fprintf(&b, "INSERT INTO %s (code, label) VALUES %s;\n", qi(st.name), strings.Join(vals, ", ")) + return b.String() +} + +// render is CREATE FUNCTION, its RETURNS type, STRICT and volatility, and the sole +// argument's DEFAULT, all varying while the signature (the argument's type) stays put -- +// so a change to any of them is a same-name Alter, not a drop-and-recreate. +func (f *pFunc) render() string { + arg := "a integer" + if f.argDefault { + arg += " DEFAULT 1" + } + strict := "" + if f.strict { + strict = " STRICT" + } + switch f.kind { + case "procedure": + // a procedure body cannot use RETURN (it returns nothing): a read-only + // SELECT is the closest no-op equivalent of a plain function's body + return fmt.Sprintf("CREATE PROCEDURE %s(%s) LANGUAGE sql AS $$ SELECT a + %d $$;\n", qi(f.name), arg, f.n) + case "aggregate": + // keeps the same (integer) signature as a plain function: sfunc / stype only, + // no LANGUAGE or body -- CREATE AGGREGATE's own grammar + return fmt.Sprintf("CREATE AGGREGATE %s(integer) (sfunc = int4pl, stype = integer);\n", qi(f.name)) + case "window": + // LANGUAGE internal WINDOW binds to a real builtin fmgr symbol (window_row_number, + // the C function behind row_number()); PostgreSQL does not check the declared + // signature against the C function it names ("there is no built-in function named + // row_number": the SQL-visible row_number() and its underlying fmgr symbol are + // spelled differently), so the usual single-integer-argument shape still applies + // and this stays a same-name Alter like procedure / aggregate + return fmt.Sprintf("CREATE FUNCTION %s(%s) RETURNS %s LANGUAGE internal WINDOW AS 'window_row_number';\n", qi(f.name), arg, f.retType) + } + switch f.lang { + case "plpgsql": + return fmt.Sprintf("CREATE FUNCTION %s(%s) RETURNS %s LANGUAGE plpgsql %s%s AS $$ BEGIN RETURN a + %d; END $$;\n", + qi(f.name), arg, f.retType, f.volatility, strict, f.n) + default: + return fmt.Sprintf("CREATE FUNCTION %s(%s) RETURNS %s LANGUAGE sql %s%s RETURN a + %d;\n", + qi(f.name), arg, f.retType, f.volatility, strict, f.n) + } +} + +func (s *pSchema) render() string { + var b strings.Builder + if s.pg18 { + b.WriteString("-- sqlshape: postgres 18\n") + } + for _, in := range s.intents { + b.WriteString(in + "\n") + } + for _, t := range s.tables { + if t.schema != "public" { + b.WriteString("CREATE SCHEMA " + qi(t.schema) + ";\n") + break + } + } + for _, e := range s.extensions { + b.WriteString("CREATE EXTENSION " + qi(e) + ";\n") + } + for _, d := range s.domains { + op := ">" + if d.ge { + op = ">=" + } + b.WriteString("CREATE DOMAIN " + qi(d.name) + " AS integer") + if d.notNull { + b.WriteString(" NOT NULL") + } + b.WriteString(" CHECK (VALUE " + op + " 0);\n") + } + for _, e := range s.enums { + quoted := make([]string, len(e.labels)) + for i, l := range e.labels { + quoted[i] = "'" + l + "'" + } + b.WriteString("CREATE TYPE " + qi(e.name) + " AS ENUM (" + strings.Join(quoted, ", ") + ");\n") + } + for _, co := range s.composites { + var attrs []string + for _, a := range co.attrs { + attrs = append(attrs, qi(a.name)+" "+a.typ) + } + b.WriteString("CREATE TYPE " + qi(co.name) + " AS (" + strings.Join(attrs, ", ") + ");\n") + } + for _, rg := range s.ranges { + b.WriteString("CREATE TYPE " + qi(rg.name) + " AS RANGE (subtype = integer);\n") + } + for _, t := range s.tables { + b.WriteString(t.render(s)) + } + for _, pt := range s.partTables { + b.WriteString(pt.render()) + } + if st := s.seedTable; st != nil { + b.WriteString(st.render()) + } + for _, pol := range s.policies { + b.WriteString(pol.render(s)) + } + for _, ru := range s.rules { + b.WriteString(ru.render(s)) + } + for _, sq := range s.seqs { + // a sequence must live in the same schema as the table that owns it (55000, + // measured): render it under its owner's schema rather than always "public" + schema := "public" + if sq.table != "" { + schema = s.table(sq.table).schema + } + b.WriteString("CREATE SEQUENCE " + qi(schema) + "." + qi(sq.name) + ";\n") + if sq.table != "" { + b.WriteString("ALTER SEQUENCE " + qi(schema) + "." + qi(sq.name) + " OWNED BY " + s.qref(sq.table) + "." + qi(sq.col) + ";\n") + } + } + for _, v := range s.views { + kind := "VIEW" + if v.mat { + kind = "MATERIALIZED VIEW" + } + b.WriteString("CREATE " + kind + " " + qi(v.name) + " AS SELECT " + qilist(v.cols) + " FROM " + s.qref(v.table)) + if v.checkOption != "" { + b.WriteString(" WITH " + v.checkOption + " CHECK OPTION") + } + b.WriteString(";\n") + } + for _, f := range s.funcs { + b.WriteString(f.render()) + } + for _, fn := range s.triggerFns { + fmt.Fprintf(&b, "CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN NEW.%s := coalesce(NEW.%s, 0) + %d; RETURN NEW; END $$;\n", + qi(fn.name+"_fn"), qi(fn.col), qi(fn.col), fn.n) + } + for _, tr := range s.triggers { + events := "INSERT" + if tr.onUpdate { + events = "INSERT OR UPDATE OF " + qi(tr.col) + } + fmt.Fprintf(&b, "CREATE TRIGGER %s BEFORE %s ON %s FOR EACH ROW EXECUTE FUNCTION %s();\n", qi(tr.name), events, s.qref(tr.table), qi(tr.callName+"_fn")) + } + return b.String() +} + +// rows renders three rows per table, parents first: integer columns carry the row number +// (distinct, positive, a valid reference to a parent's id), a nullable column is NULL in the +// third row, an identity id is set explicitly (BY DEFAULT allows it) so children can +// reference it, generated columns are left to the server. +func (s *pSchema) rows() string { + var b strings.Builder + for _, t := range s.tables { + var names []string + var cols []*pCol + for _, c := range t.cols { + if c.gen == "" { + names = append(names, qi(c.name)) + cols = append(cols, c) + } + } + var rows []string + for i := 1; i <= 3; i++ { + var vals []string + for _, c := range cols { + if i == 3 && !c.notNull && !c.pk { + vals = append(vals, "NULL") + continue + } + vals = append(vals, s.value(c, i)) + } + rows = append(rows, "("+strings.Join(vals, ", ")+")") + } + fmt.Fprintf(&b, "INSERT INTO %s (%s) VALUES %s;\n", t.ref(), strings.Join(names, ", "), strings.Join(rows, ", ")) + } + for _, pt := range s.partTables { + b.WriteString(pt.rows()) + } + return b.String() +} + +// rows is one row per partition still attached (gone / detached ones get none: a +// detached partition is a plain table now, holding whatever it held while attached, which +// INSERT INTO the parent never reaches again), landing in it by construction: a RANGE +// child's id is its own lower bound (any DEFAULT partition's is a value comfortably above +// every bound this generator ever produces), a LIST child's kind is its own first value +// (a DEFAULT partition's is a value no non-default child here is ever given). +func (pt *pPartTable) rows() string { + var b strings.Builder + // a GENERATED ALWAYS AS IDENTITY id column refuses an explicit value without this + // (428C9, measured) -- BY DEFAULT and a plain bigserial both accept one either way, so + // this is harmless there too. + overriding := "" + if pt.idCol().identity && pt.idCol().idAlways { + overriding = " OVERRIDING SYSTEM VALUE" + } + i, max := 0, 0 + for _, c := range pt.parts { + if c.gone || c.detached { + continue + } + i++ + id, kind := 100000+i, "zz" + if !c.isDefault { + if pt.strategy == "LIST" { + kind = c.values[0] + } else { + id = c.lo + } + } + if id > max { + max = id + } + fmt.Fprintf(&b, "INSERT INTO %s (id, kind)%s VALUES (%d, '%s');\n", qi(pt.name), overriding, id, kind) + } + // an explicit id, serial or IDENTITY, never advances the sequence backing it on its own + // (measured: PostgreSQL only calls nextval() through the column's own DEFAULT, never as + // a side effect of an explicit value bypassing it) -- a later plain INSERT (no id given) + // would otherwise collide back at the sequence's own start value, not continue from + // what these rows already hold. Every repartition continuation check + // (repartitionSeqCheck, probe_mutations_test.go) assumes this has already run. + if max > 0 && (pt.idCol().serial || pt.idCol().identity) { + fmt.Fprintf(&b, "SELECT setval(pg_get_serial_sequence(%s, 'id'), %d, true);\n", lit(qi(pt.name)), max) + } + return b.String() +} + +func (s *pSchema) value(c *pCol, i int) string { + switch c.typ { + case "integer", "bigint", "smallint", "double precision", "numeric(10,2)", "numeric(14,2)": + return fmt.Sprint(i) + case "text", "varchar(50)", "varchar(100)": + return fmt.Sprintf("'v%d'", i) + case "timestamptz": + return fmt.Sprintf("'2024-01-%02d 00:00:00+00'", i) + case "date": + return fmt.Sprintf("'2024-01-%02d'", i) + case "boolean": + return fmt.Sprint(i%2 == 1) + case "jsonb": + return fmt.Sprintf(`'{"i": %d}'`, i) + case "daterange": + // distinct, non-overlapping per row (i and i+1 are consecutive days): the + // PostgreSQL 18 temporal (WITHOUT OVERLAPS) key generate() seeds needs its rows + // distinct in this column alone, whatever its other, ordinary column holds. + return fmt.Sprintf("daterange('2024-01-%02d'::date, '2024-01-%02d'::date)", i, i+1) + } + if e := s.enum(c.typ); e != nil { + return "'" + e.labels[(i-1)%len(e.labels)] + "'" + } + if co := s.composite(c.typ); co != nil { + return co.literal(func(a *pAttr) string { return attrValue(a, i) }) + } + if strings.HasPrefix(c.typ, "num") { + return fmt.Sprint(i) + } + return "NULL" +} + +// attrValue is a composite attribute's value in row i: distinct per row like a plain +// column's, so a unique key over a composite column (its attributes compare in order) holds. +func attrValue(a *pAttr, i int) string { + if isNumeric(a.typ) { + return fmt.Sprint(i) + } + return fmt.Sprintf("'v%d'", i) +} + +// literal is "ROW(...)::name", f giving each attribute's value. +func (co *pComposite) literal(f func(a *pAttr) string) string { + var vals []string + for _, a := range co.attrs { + vals = append(vals, f(a)) + } + return "ROW(" + strings.Join(vals, ", ") + ")::" + qi(co.name) +} + +// fill is the literal a backfill gives a column that turns NOT NULL under existing rows. +func (s *pSchema) fill(c *pCol) string { + switch { + case isNumeric(c.typ): + return "9" + case c.typ == "boolean": + return "true" + case c.typ == "jsonb": + return "'{}'" + case c.typ == "timestamptz": + return "'2024-02-01 00:00:00+00'" + case c.typ == "date": + return "'2024-02-01'" + } + if e := s.enum(c.typ); e != nil { + return "'" + e.labels[0] + "'" + } + if co := s.composite(c.typ); co != nil { + return co.literal(func(a *pAttr) string { + if isNumeric(a.typ) { + return "9" + } + return "'fill'" + }) + } + return "'fill'" +} diff --git a/check/postgres/migrate/probe_mutations_test.go b/check/postgres/migrate/probe_mutations_test.go new file mode 100644 index 0000000..e144b4f --- /dev/null +++ b/check/postgres/migrate/probe_mutations_test.go @@ -0,0 +1,2661 @@ +package migrate + +// Split from probe_test.go (see its header): the mutation vocabulary. mutations (and its +// PostgreSQL-18-only extension mutations18) stay a single var each -- the PRNG picks a +// mutation by its index into this slice, so a mutation's position controls what seed 1 +// draws; spreading the literal across files via per-file init() append would leave the +// final order to Go's (file-alphabetical) init sequencing instead of this expression, a +// silent way to change what every existing seed reproduces. So despite the vocabulary +// spanning columns, keys, foreign keys, tables, schemas, sequences, seed rows, functions, +// triggers, rules, policies, composite types, identity and partitions, it is one file, one +// var. Pure move -- no mutation's body, ordering or selection guard changed here. + +import ( + "fmt" + "math/rand" + "strings" +) + +// ---- mutations -------------------------------------------------------------------------- + +type mutation struct { + name string + apply func(r *rand.Rand, s *pSchema, touched map[string]bool) bool +} + +func (t *pTable) plainCols(touched map[string]bool) []*pCol { + var out []*pCol + for _, c := range t.cols { + if !c.pk && c.gen == "" && !c.fresh && !touched[t.name+"."+c.name] { + out = append(out, c) + } + } + return out +} + +func untouched(s *pSchema, touched map[string]bool) []*pTable { + var out []*pTable + for _, t := range s.tables { + if !touched[t.name] { + out = append(out, t) + } + } + return out +} + +func referencedBy(s *pSchema, t *pTable, col string) []*pFK { + var out []*pFK + for _, other := range s.tables { + for _, fk := range other.fks { + if fk.refTable == t.name && indexOf(fk.refCols, col) >= 0 { + out = append(out, fk) + } + } + } + return out +} + +// keyIsReferenced reports whether any column of k is referenced by another table's foreign +// key (a composite unique key rests under a composite foreign key column by column, so any +// one of its columns being referenced is enough). +func keyIsReferenced(s *pSchema, t *pTable, k *pKey) bool { + for _, col := range k.cols { + if len(referencedBy(s, t, col)) > 0 { + return true + } + } + return false +} + +func fkOwner(s *pSchema, fk *pFK) string { + for _, t := range s.tables { + for _, f := range t.fks { + if f == fk { + return t.name + } + } + } + return "" +} + +func indexOf(list []string, s string) int { + for i, x := range list { + if x == s { + return i + } + } + return -1 +} + +func indexOfCol(t *pTable, name string) int { + for i, c := range t.cols { + if c.name == name { + return i + } + } + return -1 +} + +// detach removes everything that reads t's column col. +func detach(s *pSchema, t *pTable, col string) { + var keys []*pKey + for _, k := range t.keys { + if indexOf(k.cols, col) < 0 { + keys = append(keys, k) + } + } + t.keys = keys + var checks []*pCheck + for _, ck := range t.checks { + if ck.col != col { + checks = append(checks, ck) + } + } + t.checks = checks + var fks []*pFK + for _, fk := range t.fks { + if indexOf(fk.cols, col) < 0 { + fks = append(fks, fk) + } + } + t.fks = fks + var cols []*pCol + for _, c := range t.cols { + if c.gen == col { + if !c.fresh { + s.intents = append(s.intents, "-- @migrate drop "+t.orig+"."+c.name) + } + detach(s, t, c.name) + continue + } + cols = append(cols, c) + } + t.cols = cols + var views []*pView + for _, v := range s.views { + if v.table == t.name { + var keep []string + for _, c := range v.cols { + if c != col { + keep = append(keep, c) + } + } + if len(keep) == 0 { + continue + } + v.cols = keep + } + views = append(views, v) + } + s.views = views + var triggers []*pTrigger + for _, tr := range s.triggers { + if tr.table != t.name || tr.col != col { + triggers = append(triggers, tr) + } + } + s.triggers = triggers +} + +func renameRefs(s *pSchema, t *pTable, from, to string) { + for _, k := range t.keys { + for i, c := range k.cols { + if c == from { + k.cols[i] = to + } + } + } + for _, ck := range t.checks { + if ck.col == from { + ck.col = to + } + } + for _, fk := range t.fks { + if i := indexOf(fk.cols, from); i >= 0 { + fk.cols[i] = to + } + } + for _, c := range t.cols { + if c.gen == from { + c.gen = to + } + } + for _, fk := range referencedBy(s, t, from) { + fk.refCols[indexOf(fk.refCols, from)] = to + } + for _, v := range s.views { + if v.table == t.name { + for i, c := range v.cols { + if c == from { + v.cols[i] = to + } + } + } + } + for _, tr := range s.triggers { + if tr.table == t.name && tr.col == from { + tr.col = to + } + } + for _, sq := range s.seqs { + if sq.table == t.name && sq.col == from { + sq.col = to + } + } +} + +func dropTable(s *pSchema, t *pTable) { + var tables []*pTable + for _, other := range s.tables { + if other == t { + continue + } + var fks []*pFK + for _, fk := range other.fks { + if fk.refTable != t.name { + fks = append(fks, fk) + } + } + other.fks = fks + tables = append(tables, other) + } + s.tables = tables + var views []*pView + for _, v := range s.views { + if v.table != t.name { + views = append(views, v) + } + } + s.views = views + var triggers []*pTrigger + for _, tr := range s.triggers { + if tr.table != t.name { + triggers = append(triggers, tr) + } + } + s.triggers = triggers + var policies []*pPolicy + for _, pol := range s.policies { + if pol.table != t.name { + policies = append(policies, pol) + } + } + s.policies = policies + var rules []*pRule + for _, ru := range s.rules { + if ru.table != t.name { + rules = append(rules, ru) + } + } + s.rules = rules + for _, sq := range s.seqs { + if sq.table == t.name { + sq.table, sq.col = "", "" + } + } +} + +func insertCol(r *rand.Rand, t *pTable, c *pCol) { + at := r.Intn(len(t.cols) + 1) + t.cols = append(t.cols[:at], append([]*pCol{c}, t.cols[at:]...)...) +} + +// excludeChangeColumn and excludeToggleOperator are registered twice in mutations (a +// second entry point, same body): see "toggle trigger fires on update "'s comment for why. + +func excludeChangeColumn(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + var cands []*pKey + for _, k := range t.keys { + if k.exclude && !k.opNE { // opNE needs the shared-value column it has + cands = append(cands, k) + } + } + if len(cands) == 0 { + continue + } + k := pick(r, cands) + var others []string + for _, c := range t.cols { + if isInteger(c.typ) && !c.pk && !c.fresh && c.name != k.cols[0] { + others = append(others, c.name) + } + } + if len(others) == 0 { + continue + } + k.cols[0] = pick(r, others) + return true + } + return false +} + +func excludeToggleOperator(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + var cands []*pKey + for _, k := range t.keys { + if k.exclude { + cands = append(cands, k) + } + } + if len(cands) == 0 { + continue + } + k := pick(r, cands) + if !k.opNE { + // WITH <> forbids any two rows differing: a fresh column, sharing one + // default value in every row, always holds. It also needs GIST (btree's + // integer operator family has no "<>" member, 42809, measured), so + // btree_gist too. + c := &pCol{name: s.next("ne"), typ: "integer", notNull: true, def: "1", fresh: true} + t.cols = append(t.cols, c) + k.cols[0], k.opNE = c.name, true + s.ensureExtension("btree_gist") + return true + } + var ints []string + for _, c := range t.cols { + if isInteger(c.typ) && !c.pk && !c.fresh && c.name != k.cols[0] { + ints = append(ints, c.name) + } + } + if len(ints) == 0 { + continue + } + k.cols[0], k.opNE = pick(r, ints), false + return true + } + return false +} + +var mutations = []mutation{ + {"add column", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + var c *pCol + if src := t.numericCols(); len(src) > 0 && r.Intn(3) == 0 { + c = &pCol{name: s.next("g"), typ: "bigint", gen: pick(r, src).name} + } else { + c = s.newCol(r) + } + c.fresh = true + insertCol(r, t, c) + touched[t.name+"."+c.name] = true + return true + }}, + {"add generated column reading a new column", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + x := &pCol{name: s.next("c"), typ: pick(r, []string{"integer", "numeric(10,2)", "smallint"})} + g := &pCol{name: s.next("g"), typ: "bigint", gen: x.name} + x.fresh, g.fresh = true, true + insertCol(r, t, g) + insertCol(r, t, x) + touched[t.name+"."+x.name], touched[t.name+"."+g.name] = true, true + return true + }}, + {"drop column", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pTable + for _, t := range untouched(s, touched) { + if len(t.plainCols(touched)) > 0 { + cands = append(cands, t) + } + } + if len(cands) == 0 { + return false + } + t := pick(r, cands) + c := pick(r, t.plainCols(touched)) + for _, g := range t.cols { + if g.gen == c.name && touched[t.name+"."+g.name] { + return false // the generated column reading it was moved by another step already + } + } + for _, sq := range s.seqs { + if sq.table == t.name && sq.col == c.name { + return false // a standalone sequence is OWNED BY this column + } + } + for _, fk := range referencedBy(s, t, c.name) { + owner := s.table(fkOwner(s, fk)) + var fks []*pFK + for _, f := range owner.fks { + if f != fk { + fks = append(fks, f) + } + } + owner.fks = fks + } + s.intents = append(s.intents, "-- @migrate drop "+t.orig+"."+c.name) + detach(s, t, c.name) + t.cols = append(t.cols[:indexOfCol(t, c.name)], t.cols[indexOfCol(t, c.name)+1:]...) + touched[t.name+"."+c.name] = true + return true + }}, + {"drop generated column", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + for _, c := range t.cols { + if c.gen != "" && !touched[t.name+"."+c.name] { + s.intents = append(s.intents, "-- @migrate drop "+t.orig+"."+c.name) + detach(s, t, c.name) + t.cols = append(t.cols[:indexOfCol(t, c.name)], t.cols[indexOfCol(t, c.name)+1:]...) + touched[t.name+"."+c.name] = true + return true + } + } + } + return false + }}, + {"rename column", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + var cands []*pCol + for _, c := range t.cols { + if !touched[t.name+"."+c.name] { + cands = append(cands, c) + } + } + if len(cands) == 0 { + return false + } + c := pick(r, cands) + to := c.name + "_new" + s.intents = append(s.intents, "-- @migrate rename "+t.orig+"."+c.name+" -> "+t.full()+"."+to) + // a backfill expression written earlier names the column as the target spells it + for i, in := range s.intents { + if strings.HasPrefix(in, "-- @migrate backfill "+t.full()+".") { + s.intents[i] = strings.ReplaceAll(in, " = "+qi(c.name), " = "+qi(to)) + } + } + renameRefs(s, t, c.name, to) + touched[t.name+"."+c.name], touched[t.name+"."+to] = true, true + c.name = to + return true + }}, + {"widen column", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + var cands []*pCol + for _, c := range t.cols { + if widen(c.typ) != "" && c.gen == "" && !c.identity && !c.serial && !touched[t.name+"."+c.name] { + cands = append(cands, c) + } + } + if len(cands) == 0 { + return false + } + c := pick(r, cands) + w := widen(c.typ) + if t.inFK(c.name) { + return false // a referencing column follows its parent's key + } + for _, fk := range referencedBy(s, t, c.name) { + child := s.table(fkOwner(s, fk)) + cc := child.col(fk.cols[indexOf(fk.refCols, c.name)]) + if touched[child.name+"."+cc.name] { + return false + } + cc.typ = w + touched[child.name+"."+cc.name] = true + } + c.typ = w + if c.def != "" { + c.def = s.defaultFor(c) + } + touched[t.name+"."+c.name] = true + return true + }}, + {"change nullability and default", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + cands := t.plainCols(touched) + if len(cands) == 0 { + return false + } + c := pick(r, cands) + if c.identity || c.serial { + return false + } + for _, fk := range t.fks { + if indexOf(fk.cols, c.name) >= 0 && fk.onDelete == "SET NULL" { + return false + } + } + c.notNull = !c.notNull + if c.notNull || r.Intn(2) == 0 { + c.def = s.defaultFor(c) + } else { + c.def = "" + } + if c.notNull { + f := s.fill(c) + if t.inFK(c.name) { + // a parent every table has (rows() gives every table a row 3), and never + // row 1's or row 2's own FK value: rows() only ever nulls a nullable + // column's third row, so this backfill only ever reaches row 3 -- reusing + // its own row number rather than a fixed "1" cannot duplicate row 1's or + // row 2's, which an EXCLUDE (WITH =) over this column would otherwise + // refuse as a duplicate (23P01, measured) + f = "3" + } + s.intents = append(s.intents, fmt.Sprintf("-- @migrate backfill %s.%s = %s where %s is null", t.full(), c.name, f, qi(c.name))) + } + touched[t.name+"."+c.name] = true + return true + }}, + {"add key", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + return s.addKey(r, pick(r, ts)) + }}, + {"drop key", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + if len(t.keys) > 0 { + i := r.Intn(len(t.keys)) + // a unique constraint a foreign key references stays + if t.keys[i].unique && len(referencedBy(s, t, t.keys[i].cols[0])) > 0 { + return false + } + t.keys = append(t.keys[:i], t.keys[i+1:]...) + return true + } + } + return false + }}, + {"move primary key", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + old := t.pk() + var cands []*pCol + for _, c := range t.cols { + // c.fresh is excluded: a column another mutation just added carries whatever + // single literal DEFAULT that mutation gave it (shared by every row, not the + // base generator's per-row-distinct value), so becoming the primary key + // without its own backfill risks a duplicate key on apply (measured: "toggle + // exclude constraint operator"'s "ne" column, shared default 1 so its WITH <> + // exclude always holds, moved onto as a PK loses that assumption -- !c.notNull + // alone missed it, since that column was already NOT NULL) + if !c.pk && c.gen == "" && isInteger(c.typ) && !c.fresh && !touched[t.name+"."+c.name] && !t.inFK(c.name) { + cands = append(cands, c) + } + } + if len(cands) == 0 || touched[t.name+"."+old.name] { + return false + } + c := pick(r, cands) + if !c.notNull { + s.intents = append(s.intents, fmt.Sprintf("-- @migrate backfill %s.%s = 9 where %s is null", t.full(), c.name, qi(c.name))) + } + c.pk, c.notNull, c.def = true, true, "" + old.pk = false + if len(referencedBy(s, t, old.name)) > 0 { + t.keys = append(t.keys, &pKey{name: s.next("k"), unique: true, cols: []string{old.name}}) + } else if !old.identity && !old.serial && r.Intn(2) == 0 { + old.notNull = false + } + touched[t.name+"."+old.name], touched[t.name+"."+c.name] = true, true + return true + }}, + {"move primary key onto a new column", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + old := t.pk() + if touched[t.name+"."+old.name] { + return false + } + c := &pCol{name: s.next("c"), typ: pick(r, []string{"integer", "bigint"}), notNull: true, pk: true, identity: r.Intn(2) == 0} + insertCol(r, t, c) + if !c.identity { + // the rows need distinct values before the key goes on: the old key's + s.intents = append(s.intents, fmt.Sprintf("-- @migrate backfill %s.%s = %s", t.full(), c.name, qi(old.name))) + } + old.pk = false + if len(referencedBy(s, t, old.name)) > 0 { + t.keys = append(t.keys, &pKey{name: s.next("k"), unique: true, cols: []string{old.name}}) + } + touched[t.name+"."+old.name], touched[t.name+"."+c.name] = true, true + return true + }}, + {"add foreign key", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) < 2 { + return false + } + i, j := r.Intn(len(ts)), r.Intn(len(ts)) + if i == j { + return false + } + if i < j { + i, j = j, i + } + t, parent := ts[i], ts[j] + if !s.addFK(r, t, parent) { + return false + } + c := t.cols[len(t.cols)-1] + if c.notNull { + // existing rows need a parent before the column turns NOT NULL + s.intents = append(s.intents, fmt.Sprintf("-- @migrate backfill %s.%s = 1", t.full(), c.name)) + } + touched[t.name+"."+c.name] = true + return true + }}, + {"add composite foreign key", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) < 2 { + return false + } + i, j := r.Intn(len(ts)), r.Intn(len(ts)) + if i == j { + return false + } + if i < j { + i, j = j, i + } + t, parent := ts[i], ts[j] + if !s.addCompositeFK(r, t, parent) { + return false + } + for _, c := range parent.cols[len(parent.cols)-2:] { + c.fresh = true + touched[parent.name+"."+c.name] = true + } + for _, c := range t.cols[len(t.cols)-2:] { + c.fresh = true + touched[t.name+"."+c.name] = true + } + return true + }}, + {"drop foreign key", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + if len(t.fks) > 0 { + i := r.Intn(len(t.fks)) + t.fks = append(t.fks[:i], t.fks[i+1:]...) + return true + } + } + return false + }}, + {"add check", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + return s.addCheck(r, pick(r, ts)) + }}, + {"drop check", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + if len(t.checks) > 0 { + i := r.Intn(len(t.checks)) + t.checks = append(t.checks[:i], t.checks[i+1:]...) + return true + } + } + return false + }}, + {"add table", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + t := s.newTable(r) + s.tables = append(s.tables, t) + touched[t.name] = true + return true + }}, + {"drop table", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) < 2 { + return false + } + t := pick(r, ts) + for k := range touched { + if strings.HasPrefix(k, t.name+".") { + return false + } + } + s.intents = append(s.intents, "-- @migrate drop "+t.orig) + dropTable(s, t) + touched[t.name] = true + return true + }}, + {"rename table", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + to := t.name + "_new" + s.intents = append(s.intents, "-- @migrate rename "+t.orig+" -> "+t.schema+"."+to) + for i, in := range s.intents { + in = strings.ReplaceAll(in, " -> "+t.full()+".", " -> "+t.schema+"."+to+".") + in = strings.ReplaceAll(in, "backfill "+t.full()+".", "backfill "+t.schema+"."+to+".") + s.intents[i] = in + } + for _, other := range s.tables { + for _, fk := range other.fks { + if fk.refTable == t.name { + fk.refTable = to + } + } + } + for _, v := range s.views { + if v.table == t.name { + v.table = to + } + } + for _, tr := range s.triggers { + if tr.table == t.name { + tr.table = to + } + } + for _, pol := range s.policies { + if pol.table == t.name { + pol.table = to + } + } + for _, ru := range s.rules { + if ru.table == t.name { + ru.table = to + } + } + for _, sq := range s.seqs { + if sq.table == t.name { + sq.table = to + } + } + touched[t.name], touched[to] = true, true + t.name = to + return true + }}, + {"move table to public schema", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pTable + for _, t := range untouched(s, touched) { + if t.schema != "public" { + cands = append(cands, t) + } + } + if len(cands) == 0 { + return false + } + t := pick(r, cands) + s.intents = append(s.intents, "-- @migrate rename "+t.orig+" -> public."+t.name) + for i, in := range s.intents { + in = strings.ReplaceAll(in, " -> "+t.full()+".", " -> public."+t.name+".") + in = strings.ReplaceAll(in, "backfill "+t.full()+".", "backfill public."+t.name+".") + s.intents[i] = in + } + touched[t.name] = true + t.schema = "public" + return true + }}, + {"move table to app schema", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pTable + for _, t := range untouched(s, touched) { + if t.schema == "public" { + cands = append(cands, t) + } + } + if len(cands) == 0 { + return false + } + t := pick(r, cands) + s.intents = append(s.intents, "-- @migrate rename "+t.orig+" -> app."+t.name) + for i, in := range s.intents { + in = strings.ReplaceAll(in, " -> "+t.full()+".", " -> app."+t.name+".") + in = strings.ReplaceAll(in, "backfill "+t.full()+".", "backfill app."+t.name+".") + s.intents[i] = in + } + touched[t.name] = true + t.schema = "app" + return true + }}, + {"collapse app schema", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // every remaining "app" table at once, so the schema itself goes: leaving even + // one behind (as "move table to public schema" alone might, one at a time) + // keeps "app" declared + var cands []*pTable + for _, t := range untouched(s, touched) { + if t.schema != "public" { + cands = append(cands, t) + } + } + if len(cands) == 0 { + return false + } + for _, t := range cands { + s.intents = append(s.intents, "-- @migrate rename "+t.orig+" -> public."+t.name) + for i, in := range s.intents { + in = strings.ReplaceAll(in, " -> "+t.full()+".", " -> public."+t.name+".") + in = strings.ReplaceAll(in, "backfill "+t.full()+".", "backfill public."+t.name+".") + s.intents[i] = in + } + touched[t.name] = true + t.schema = "public" + } + return true + }}, + {"change domain check", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, d := range s.domains { + if !touched["domain:"+d.name] { + d.ge = !d.ge + touched["domain:"+d.name] = true + return true + } + } + return false + }}, + {"add enum label", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, e := range s.enums { + if !touched["enum:"+e.name] { + e.labels = append(e.labels, "z") + touched["enum:"+e.name] = true + return true + } + } + return false + }}, + {"drop enum label", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, e := range s.enums { + // a fresh enum (minted by an earlier "add table" / "add column" step, not in the + // source) has no from-side to declare the drop against: 42704 "no such enum" + if e.fresh { + continue + } + if len(e.labels) > 1 && !touched["enum:"+e.name] { + gone := e.labels[len(e.labels)-1] + e.labels = e.labels[:len(e.labels)-1] + s.intents = append(s.intents, fmt.Sprintf("-- @migrate enum %s: drop '%s' using '%s'", e.name, gone, e.labels[0])) + touched["enum:"+e.name] = true + return true + } + } + return false + }}, + {"toggle domain not null", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // a column of the domain may already hold NULL: keep this off a domain any + // column uses (existing NULLs would refuse the ALTER DOMAIN SET NOT NULL, 23502, + // the same way an ungueded "add identity" once did -- see its mutation) + for _, d := range s.domains { + if !typeInUse(s, d.name) && !touched["domain:"+d.name] { + d.notNull = !d.notNull + touched["domain:"+d.name] = true + return true + } + } + return false + }}, + {"drop domain", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []int + for i, d := range s.domains { + if !typeInUse(s, d.name) && !touched["domain:"+d.name] { + cands = append(cands, i) + } + } + if len(cands) == 0 { + return false + } + i := pick(r, cands) + touched["domain:"+s.domains[i].name] = true + s.domains = append(s.domains[:i], s.domains[i+1:]...) + return true + }}, + {"drop enum type", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []int + for i, e := range s.enums { + if !typeInUse(s, e.name) && !touched["enum:"+e.name] { + cands = append(cands, i) + } + } + if len(cands) == 0 { + return false + } + i := pick(r, cands) + touched["enum:"+s.enums[i].name] = true + s.enums = append(s.enums[:i], s.enums[i+1:]...) + return true + }}, + {"add range type", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + s.ranges = append(s.ranges, &pRange{name: s.next("rg")}) + return true + }}, + {"add composite type", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + s.composites = append(s.composites, s.newComposite(r)) + return true + }}, + {"add enum type", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // fresh: true, or a same-recipe "drop enum label" (unaware this enum has no + // from-side of its own) can pick it and declare `-- @migrate enum : drop ...` + // against an enum "no such enum in the current schema" refuses (42704, measured). + s.enums = append(s.enums, &pEnum{name: s.next("e"), labels: []string{"a", "b", "c"}[:2+r.Intn(2)], fresh: true}) + return true + }}, + {"add domain type", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + s.domains = append(s.domains, &pDomain{name: s.next("num")}) + return true + }}, + {"drop range type", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + if len(s.ranges) == 0 { + return false + } + i := r.Intn(len(s.ranges)) + s.ranges = append(s.ranges[:i], s.ranges[i+1:]...) + return true + }}, + {"add extension", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + have := map[string]bool{} + for _, e := range s.extensions { + have[e] = true + } + var cands []string + for _, e := range []string{"pgcrypto", "citext"} { + if !have[e] { + cands = append(cands, e) + } + } + if len(cands) == 0 { + return false + } + s.extensions = append(s.extensions, pick(r, cands)) + return true + }}, + {"drop extension", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + gistInUse := false + for _, t := range s.tables { + for _, k := range t.keys { + if k.exclude && (k.usingGist || k.opNE) { + gistInUse = true + } + } + } + var cands []int + for i, e := range s.extensions { + if e == "btree_gist" && gistInUse { + continue // a gist EXCLUDE constraint depends on it + } + cands = append(cands, i) + } + if len(cands) == 0 { + return false + } + i := pick(r, cands) + s.extensions = append(s.extensions[:i], s.extensions[i+1:]...) + return true + }}, + {"add rule", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + s.rules = append(s.rules, &pRule{name: s.next("ru"), table: t.name, where: r.Intn(2) == 0, enabled: true}) + return true + }}, + {"add sequence", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + s.seqs = append(s.seqs, &pSeq{name: s.next("sq")}) + return true + }}, + {"drop sequence", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + if len(s.seqs) == 0 { + return false + } + i := r.Intn(len(s.seqs)) + s.seqs = append(s.seqs[:i], s.seqs[i+1:]...) + return true + }}, + {"set sequence owner", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pSeq + for _, sq := range s.seqs { + if sq.table == "" { + cands = append(cands, sq) + } + } + if len(cands) == 0 { + return false + } + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + owned := map[string]bool{} // a column two sequences both claim OWNED BY is not + // a shape any real schema author would declare, and adds()/renames() only expect + // one from-side sequence to ever forward onto a given owner (sequenceFollowsRename, + // migrate.go): picking a column another sequence already owns can match this + // mutation's own new sequence to that unrelated one by coincidence and lose its + // CREATE SEQUENCE entirely (measured). + for _, sq := range s.seqs { + if sq.table != "" { + owned[sq.table+"."+sq.col] = true + } + } + var cols []*pCol + for _, c := range t.cols { + if isInteger(c.typ) && !c.pk && !c.identity && !c.serial && !c.fresh && c.gen == "" && !owned[t.name+"."+c.name] { + cols = append(cols, c) + } + } + if len(cols) == 0 { + return false + } + sq := pick(r, cands) + c := pick(r, cols) + sq.table, sq.col = t.name, c.name + return true + }}, + {"clear sequence owner", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pSeq + for _, sq := range s.seqs { + if sq.table != "" { + cands = append(cands, sq) + } + } + if len(cands) == 0 { + return false + } + sq := pick(r, cands) + sq.table, sq.col = "", "" + return true + }}, + {"add seed row", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + if s.seedTable == nil || touched["seed"] { + return false + } + st := s.seedTable + st.rows = append(st.rows, pSeedRow{code: s.next("code"), label: "z"}) + return true + }}, + {"drop seed row", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + st := s.seedTable + if st == nil || st.additive || len(st.rows) == 0 || touched["seed"] { + return false + } + i := r.Intn(len(st.rows)) + st.rows = append(st.rows[:i], st.rows[i+1:]...) + touched["seed"] = true + return true + }}, + {"change seed row", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + st := s.seedTable + if st == nil || len(st.rows) == 0 || touched["seed"] { + return false + } + row := &st.rows[r.Intn(len(st.rows))] + row.label += "!" + touched["seed"] = true + return true + }}, + {"toggle seed table additive", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + if s.seedTable == nil { + return false + } + s.seedTable.additive = !s.seedTable.additive + return true + }}, + {"drop rule", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []int + for i, ru := range s.rules { + if !touched["rule:"+ru.name] { + cands = append(cands, i) + } + } + if len(cands) == 0 { + return false + } + i := pick(r, cands) + touched["rule:"+s.rules[i].name] = true + s.rules = append(s.rules[:i], s.rules[i+1:]...) + return true + }}, + {"toggle rule enabled", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pRule + for _, ru := range s.rules { + if !touched["rule:"+ru.name] { + cands = append(cands, ru) + } + } + if len(cands) == 0 { + return false + } + ru := pick(r, cands) + ru.enabled = !ru.enabled + touched["rule:"+ru.name] = true + return true + }}, + {"toggle rule where", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pRule + for _, ru := range s.rules { + if !touched["rule:"+ru.name] { + cands = append(cands, ru) + } + } + if len(cands) == 0 { + return false + } + ru := pick(r, cands) + ru.where = !ru.where + touched["rule:"+ru.name] = true + return true + }}, + {"reword table comment", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pTable + for _, t := range untouched(s, touched) { + if t.comment != "" { + cands = append(cands, t) + } + } + if len(cands) == 0 { + return false + } + t := pick(r, cands) + t.comment += "!" + return true + }}, + {"reword column comment", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pCol + var owner []*pTable + for _, t := range s.tables { + for _, c := range t.cols { + if c.comment != "" && !touched[t.name+".comment."+c.name] { + cands = append(cands, c) + owner = append(owner, t) + } + } + } + if len(cands) == 0 { + return false + } + i := r.Intn(len(cands)) + cands[i].comment += "!" + touched[owner[i].name+".comment."+cands[i].name] = true + return true + }}, + {"change function return type", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pFunc + for _, f := range s.funcs { + if f.kind == "" && !touched["function:"+f.name] { + cands = append(cands, f) + } + } + if len(cands) == 0 { + return false + } + f := pick(r, cands) + if f.retType == "integer" { + f.retType = "bigint" + } else { + f.retType = "integer" + } + touched["function:"+f.name] = true + return true + }}, + {"toggle function strict", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pFunc + for _, f := range s.funcs { + if f.kind == "" && !touched["function:"+f.name] { + cands = append(cands, f) + } + } + if len(cands) == 0 { + return false + } + f := pick(r, cands) + f.strict = !f.strict + touched["function:"+f.name] = true + return true + }}, + {"toggle function volatility", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pFunc + for _, f := range s.funcs { + if f.kind == "" && !touched["function:"+f.name] { + cands = append(cands, f) + } + } + if len(cands) == 0 { + return false + } + f := pick(r, cands) + if f.volatility == "IMMUTABLE" { + f.volatility = "STABLE" + } else { + f.volatility = "IMMUTABLE" + } + touched["function:"+f.name] = true + return true + }}, + {"toggle function argument default", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pFunc + for _, f := range s.funcs { + if f.kind == "" && !touched["function:"+f.name] { + cands = append(cands, f) + } + } + if len(cands) == 0 { + return false + } + f := pick(r, cands) + f.argDefault = !f.argDefault + touched["function:"+f.name] = true + return true + }}, + {"toggle function language", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pFunc + for _, f := range s.funcs { + if f.kind == "" && !touched["function:"+f.name] { + cands = append(cands, f) + } + } + if len(cands) == 0 { + return false + } + f := pick(r, cands) + if f.lang == "plpgsql" { + f.lang = "sql" + } else { + f.lang = "plpgsql" + } + touched["function:"+f.name] = true + return true + }}, + {"convert function to procedure", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pFunc + for _, f := range s.funcs { + if f.kind == "" && !touched["function:"+f.name] { + cands = append(cands, f) + } + } + if len(cands) == 0 { + return false + } + f := pick(r, cands) + f.kind = "procedure" + touched["function:"+f.name] = true + return true + }}, + {"convert function to aggregate", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pFunc + for _, f := range s.funcs { + if f.kind == "" && f.retType == "integer" && !touched["function:"+f.name] { + cands = append(cands, f) + } + } + if len(cands) == 0 { + return false + } + f := pick(r, cands) + f.kind = "aggregate" + touched["function:"+f.name] = true + return true + }}, + {"convert function to window", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // migrate.go's functions() only drops a LANGUAGE internal function that is a + // range/multirange constructor (isRangeConstructor); a user-declared internal + // WINDOW function like this one is tracked and its "window" property diffed + var cands []*pFunc + for _, f := range s.funcs { + if f.kind == "" && !touched["function:"+f.name] { + cands = append(cands, f) + } + } + if len(cands) == 0 { + return false + } + f := pick(r, cands) + f.kind = "window" + touched["function:"+f.name] = true + return true + }}, + {"change exclude constraint column", excludeChangeColumn}, + {"change exclude constraint column ", excludeChangeColumn}, // a second entry point (same body): see "toggle trigger fires on update " + {"toggle exclude constraint where", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + var cands []*pKey + for _, k := range t.keys { + if k.exclude { + cands = append(cands, k) + } + } + if len(cands) == 0 { + continue + } + k := pick(r, cands) + if k.where == "positive" { + k.where = "" + } else { + k.where = "positive" + } + return true + } + return false + }}, + {"toggle exclude constraint operator", excludeToggleOperator}, + {"toggle exclude constraint operator ", excludeToggleOperator}, // a second entry point (same body): see "toggle trigger fires on update " + {"toggle exclude constraint using", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + var cands []*pKey + for _, k := range t.keys { + if k.exclude && !k.opNE { // opNE always renders as gist regardless + cands = append(cands, k) + } + } + if len(cands) == 0 { + continue + } + k := pick(r, cands) + if !k.usingGist { + s.ensureExtension("btree_gist") + } + k.usingGist = !k.usingGist + return true + } + return false + }}, + {"rename foreign key column", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + var cands []*pFK + for _, fk := range t.fks { + if len(fk.cols) == 1 && !touched[t.name+"."+fk.cols[0]] { + cands = append(cands, fk) + } + } + if len(cands) == 0 { + continue + } + c := t.col(pick(r, cands).cols[0]) + to := c.name + "_new" + s.intents = append(s.intents, "-- @migrate rename "+t.orig+"."+c.name+" -> "+t.full()+"."+to) + renameRefs(s, t, c.name, to) + touched[t.name+"."+c.name], touched[t.name+"."+to] = true, true + c.name = to + return true + } + return false + }}, + {"rename unique key column", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + var cands []string + for _, k := range t.keys { + if !k.unique { + continue + } + for _, cn := range k.cols { + if !touched[t.name+"."+cn] { + cands = append(cands, cn) + } + } + } + if len(cands) == 0 { + continue + } + c := t.col(pick(r, cands)) + to := c.name + "_new" + s.intents = append(s.intents, "-- @migrate rename "+t.orig+"."+c.name+" -> "+t.full()+"."+to) + renameRefs(s, t, c.name, to) + touched[t.name+"."+c.name], touched[t.name+"."+to] = true, true + c.name = to + return true + } + return false + }}, + {"change foreign key on delete", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + var cands []*pFK + for _, fk := range t.fks { + if len(fk.cols) == 1 { + cands = append(cands, fk) + } + } + if len(cands) == 0 { + continue + } + fk := pick(r, cands) + c := t.col(fk.cols[0]) + opts := []string{"", "CASCADE", "RESTRICT"} + if !c.notNull { + opts = append(opts, "SET NULL") + } + var filtered []string + for _, o := range opts { + if o != fk.onDelete { + filtered = append(filtered, o) + } + } + if len(filtered) == 0 { + continue + } + next := pick(r, filtered) + if next == "SET NULL" { + c.notNull = false + } + fk.onDelete = next + return true + } + return false + }}, + {"toggle key unique", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + var cands []*pKey + for _, k := range t.keys { + if k.exclude || k.where != "" || k.expr != "" { + continue + } + if k.unique { + // a foreign key elsewhere may rest on this unique key (same guard as + // "drop key"): going plain drops the index a REFERENCES needs (42830, + // measured) + if !keyIsReferenced(s, t, k) { + cands = append(cands, k) + } + continue + } + // going plain -> unique: every column needs a unique key's usual + // safety (see addKey's comment: no repeats, no shared fresh default) + safe := true + for _, cn := range k.cols { + c := t.col(cn) + if c.typ == "jsonb" || c.typ == "boolean" || s.enum(c.typ) != nil || c.fresh { + safe = false + } + } + if safe { + cands = append(cands, k) + } + } + if len(cands) == 0 { + continue + } + k := pick(r, cands) + k.unique = !k.unique + if !k.unique { + k.notDist, k.defer_ = false, false + } + return true + } + return false + }}, + {"change index columns", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + var cands []*pKey + for _, k := range t.keys { + if !k.unique && !k.exclude && k.expr == "" { + cands = append(cands, k) + } + } + if len(cands) == 0 { + continue + } + k := pick(r, cands) + var others []string + for _, c := range t.cols { + if c.typ == "jsonb" || c.name == k.cols[0] { + continue + } + if k.where == "positive" && !isNumeric(c.typ) { + continue // WHERE (col > 0) needs a numeric column + } + others = append(others, c.name) + } + if len(others) == 0 { + continue + } + k.cols[0] = pick(r, others) + return true + } + return false + }}, + {"change policy command", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pPolicy + for _, pol := range s.policies { + if !touched["policy:"+pol.name] { + cands = append(cands, pol) + } + } + if len(cands) == 0 { + return false + } + pol := pick(r, cands) + var opts []string + for _, c := range []string{"ALL", "SELECT", "INSERT", "UPDATE", "DELETE"} { + if c != pol.command { + opts = append(opts, c) + } + } + pol.command = pick(r, opts) + pol.using, pol.withCheck = policyPredicates(pol.command) + touched["policy:"+pol.name] = true + return true + }}, + {"toggle policy role", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pPolicy + for _, pol := range s.policies { + if !touched["policy:"+pol.name] { + cands = append(cands, pol) + } + } + if len(cands) == 0 { + return false + } + pol := pick(r, cands) + pol.role = !pol.role + touched["policy:"+pol.name] = true + return true + }}, + {"toggle view check option", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pView + for _, v := range s.views { + if !v.mat { + cands = append(cands, v) + } + } + if len(cands) == 0 { + return false + } + v := pick(r, cands) + if v.checkOption == "" { + v.checkOption = pick(r, []string{"LOCAL", "CASCADED"}) + } else { + v.checkOption = "" + } + return true + }}, + {"toggle trigger fires on update", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + if len(s.triggers) == 0 { + return false + } + tr := pick(r, s.triggers) + tr.onUpdate = !tr.onUpdate + return true + }}, + {"toggle trigger fires on update ", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // a second entry point (same body): raises this comparatively rare field's + // selection odds against the growing mutation list, same reasoning as the + // generate()-time spares elsewhere in this file + if len(s.triggers) == 0 { + return false + } + tr := pick(r, s.triggers) + tr.onUpdate = !tr.onUpdate + return true + }}, + {"add second trigger", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { return s.addTrigger(r) }}, + {"change trigger function", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // deterministically ordered (s.triggers is a slice, not a map): a repointed + // trigger's own column still exists (same table as the one it now calls) + var pairs [][2]*pTrigger + for i, a := range s.triggers { + for j, b := range s.triggers { + if i != j && a.table == b.table { + pairs = append(pairs, [2]*pTrigger{a, b}) + } + } + } + if len(pairs) == 0 { + return false + } + p := pick(r, pairs) + p[0].callName = p[1].callName + return true + }}, + {"change trigger function ", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // a second entry point (same body): raises this field's selection odds against + // the growing mutation list, same reasoning as "toggle trigger fires on update " + var pairs [][2]*pTrigger + for i, a := range s.triggers { + for j, b := range s.triggers { + if i != j && a.table == b.table { + pairs = append(pairs, [2]*pTrigger{a, b}) + } + } + } + if len(pairs) == 0 { + return false + } + p := pick(r, pairs) + p[0].callName = p[1].callName + return true + }}, + {"change generated expression", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + for _, c := range t.cols { + if c.gen == "" || touched[t.name+"."+c.name] { + continue + } + for _, src := range t.numericCols() { + if src.name != c.gen && !touched[t.name+"."+src.name] { + c.gen = src.name + touched[t.name+"."+c.name] = true + return true + } + } + } + } + return false + }}, + {"table comment", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + if t.comment == "" { + t.comment = "now about " + t.name + } else { + t.comment = "" + } + return true + }}, + {"add view", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { return s.addViewKind(r, false) }}, + {"add materialized view", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { return s.addViewKind(r, true) }}, + {"drop view", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // plain views only: a shared pool with materialized ones (the original form of this + // mutation, r.Intn(len(s.views)) over both) let directedCoverage's "- matview" entry + // depend on which kind the draw happened to land on, and its own retry loop (30 + // attempts) stops at the first *successful* application regardless of which kind was + // dropped -- a schema with only a plain view spare (generate()'s r.Intn(2)==0, no + // materialized one, r.Intn(3)==0) applies this mutation just fine and never comes + // back for another try, so a seed unlucky enough to draw only-plain across all 30 + // attempts leaves "- matview" unhit (measured: -migrate-probe-seed 2 -migrate-probe-n + // 100). Splitting into two mutations, one per kind (mirroring "add view" / "add + // materialized view" already being separate), gives directedCoverage its own + // dedicated draw for each: it can only report "- matview" stuck if no materialized + // view spare was generated in 30 fresh schemas, (2/3)^30 short of ever observed. + var idx []int + for i, v := range s.views { + if !v.mat { + idx = append(idx, i) + } + } + if len(idx) == 0 { + return false + } + i := pick(r, idx) + s.views = append(s.views[:i], s.views[i+1:]...) + return true + }}, + {"drop materialized view", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // the materialized half of "drop view" (see its own comment): kept as its own + // candidate pool so directedCoverage always gets a materialized view to drop when + // generate() seeded one, instead of sharing a draw with plain views. + var idx []int + for i, v := range s.views { + if v.mat { + idx = append(idx, i) + } + } + if len(idx) == 0 { + return false + } + i := pick(r, idx) + s.views = append(s.views[:i], s.views[i+1:]...) + return true + }}, + {"change view", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + if len(s.views) == 0 { + return false + } + v := pick(r, s.views) + t := s.table(v.table) + for _, c := range t.cols { + if indexOf(v.cols, c.name) < 0 { + v.cols = append(v.cols, c.name) + return true + } + } + return false + }}, + {"add trigger", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { return s.addTrigger(r) }}, + {"drop trigger", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // its pTriggerFn is not tied to the trigger's own lifetime (see pTrigger's doc + // comment), so this never needs to check who else might call it + if len(s.triggers) == 0 { + return false + } + i := r.Intn(len(s.triggers)) + s.triggers = append(s.triggers[:i], s.triggers[i+1:]...) + return true + }}, + {"change trigger body", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + if len(s.triggers) == 0 { + return false + } + pick(r, s.triggers).n += 10 + return true + }}, + {"add function", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + s.funcs = append(s.funcs, &pFunc{name: s.next("f"), n: 1 + r.Intn(9), retType: "integer", volatility: "IMMUTABLE", lang: "sql"}) + return true + }}, + {"drop function", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + if len(s.funcs) == 0 { + return false + } + i := r.Intn(len(s.funcs)) + s.funcs = append(s.funcs[:i], s.funcs[i+1:]...) + return true + }}, + {"change function body", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + if len(s.funcs) == 0 { + return false + } + pick(r, s.funcs).n += 10 + return true + }}, + {"column comment", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pCol + var owner []*pTable + for _, t := range s.tables { + for _, c := range t.cols { + if c.gen == "" && !touched[t.name+".comment."+c.name] { + cands = append(cands, c) + owner = append(owner, t) + } + } + } + if len(cands) == 0 { + return false + } + i := r.Intn(len(cands)) + c := cands[i] + if c.comment == "" { + c.comment = "about " + owner[i].name + "." + c.name + } else { + c.comment = "" + } + touched[owner[i].name+".comment."+c.name] = true + return true + }}, + {"change check operator", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + var cands []*pCheck + for _, ck := range t.checks { + if isNumeric(t.col(ck.col).typ) { + cands = append(cands, ck) + } + } + if len(cands) == 0 { + continue + } + ck := pick(r, cands) + ck.ge = !ck.ge + return true + } + return false + }}, + {"toggle unique nulls distinct", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + var cands []*pKey + for _, k := range t.keys { + if k.unique { + cands = append(cands, k) + } + } + if len(cands) == 0 { + continue + } + k := pick(r, cands) + k.notDist = !k.notDist + return true + } + return false + }}, + {"toggle unique deferrable", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + var cands []*pKey + for _, k := range t.keys { + // a foreign key elsewhere may rest on this unique constraint: PostgreSQL + // refuses a deferrable one there (55000 "cannot use a deferrable unique + // constraint for referenced table", measured) + if k.unique && !keyIsReferenced(s, t, k) { + cands = append(cands, k) + } + } + if len(cands) == 0 { + continue + } + k := pick(r, cands) + k.defer_ = !k.defer_ + return true + } + return false + }}, + {"toggle foreign key deferrable", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + if len(t.fks) == 0 { + continue + } + pick(r, t.fks).defer_ = !pick(r, t.fks).defer_ + return true + } + return false + }}, + {"change foreign key on update", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // "NO ACTION" dropped from the option list (unlike "change foreign key on delete", + // which never had it): confupdtype's own default byte is 'a' (no action), which + // diff.Props (diff.go) only ever records once it differs from 'a' -- so "" and + // explicit "NO ACTION" render as the very same absent "on update" property, and a + // draw landing on one in place of the other applies cleanly but changes nothing a + // diff can see. directedCoverage's retry loop only checks that a mutation *applied* + // (len(applied) > 0), not that it changed anything -- a seed unlucky enough to only + // ever draw that no-op transition across all 30 attempts left "~ constraint on + // update" unhit (measured: -migrate-probe-seed 2 -migrate-probe-n 100, exposed once + // "drop view" / "drop materialized view" split shifted this mutation's own draw). + for _, t := range untouched(s, touched) { + if len(t.fks) == 0 { + continue + } + fk := pick(r, t.fks) + var opts []string + for _, o := range []string{"", "CASCADE", "RESTRICT"} { + if o != fk.onUpdate { + opts = append(opts, o) + } + } + fk.onUpdate = pick(r, opts) + return true + } + return false + }}, + {"add partial index", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + return s.addPartialIndex(r, pick(r, ts)) + }}, + {"add expression index", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + return s.addExprIndex(r, pick(r, ts)) + }}, + {"add exclude constraint", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + return s.addExclude(r, pick(r, ts)) + }}, + {"change index predicate", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + var cands []*pKey + for _, k := range t.keys { + if !k.unique && k.where != "" { + cands = append(cands, k) + } + } + if len(cands) == 0 { + continue + } + k := pick(r, cands) + c := t.col(k.cols[0]) + if k.where == "notnull" && isNumeric(c.typ) { + k.where = "positive" + } else { + k.where = "notnull" + } + return true + } + return false + }}, + {"toggle row security", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + t.rowSec = !t.rowSec + if !t.rowSec { + t.forceRowSec = false + } + return true + }}, + {"toggle force row security", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pTable + for _, t := range untouched(s, touched) { + if t.rowSec { + cands = append(cands, t) + } + } + if len(cands) == 0 { + return false + } + t := pick(r, cands) + t.forceRowSec = !t.forceRowSec + return true + }}, + {"add policy", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + command := pick(r, []string{"ALL", "SELECT", "INSERT", "UPDATE", "DELETE"}) + using, withCheck := policyPredicates(command) + s.policies = append(s.policies, &pPolicy{name: s.next("pol"), table: t.name, command: command, + permissive: r.Intn(4) != 0, using: using, withCheck: withCheck}) + return true + }}, + {"drop policy", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []int + for i, pol := range s.policies { + if !touched["policy:"+pol.name] { + cands = append(cands, i) + } + } + if len(cands) == 0 { + return false + } + i := pick(r, cands) + s.policies = append(s.policies[:i], s.policies[i+1:]...) + return true + }}, + {"change policy", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pPolicy + for _, pol := range s.policies { + if !touched["policy:"+pol.name] { + cands = append(cands, pol) + } + } + if len(cands) == 0 { + return false + } + pol := pick(r, cands) + pol.permissive = !pol.permissive + touched["policy:"+pol.name] = true + return true + }}, + {"add composite attribute", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // ADD ATTRIBUTE leaves a using column's stored values alone (measured); a table + // using the type is not excluded here -- except one whose column default is a + // frozen ROW(...) literal, which the new attribute count would leave stale + var cands []*pComposite + for _, co := range s.composites { + if !compositeArityFrozen(s, co.name) && !touched["composite:"+co.name] { + cands = append(cands, co) + } + } + if len(cands) == 0 { + return false + } + co := pick(r, cands) + typ := "integer" + if r.Intn(2) == 0 { + typ = "text" + } + co.attrs = append(co.attrs, &pAttr{name: s.next("a"), typ: typ}) + touched["composite:"+co.name] = true + return true + }}, + {"drop composite attribute", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // DROP ATTRIBUTE leaves a using column's stored values alone (measured); a table + // using the type is not excluded here -- except one whose column default is a + // frozen ROW(...) literal, same reason as "add composite attribute" + var cands []*pComposite + for _, co := range s.composites { + if len(co.attrs) > 1 && !compositeArityFrozen(s, co.name) && !touched["composite:"+co.name] { + cands = append(cands, co) + } + } + if len(cands) == 0 { + return false + } + co := pick(r, cands) + co.attrs = co.attrs[:len(co.attrs)-1] + touched["composite:"+co.name] = true + return true + }}, + {"change composite attribute type", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // ALTER ATTRIBUTE TYPE, unlike ADD / DROP, is refused while a table uses the type + // (0A000, measured; the planner turns it into a problem -- see alterType's + // "composite" case): keep this one off a composite any column still carries. + var cands []*pComposite + for _, co := range s.composites { + if !compositeInUse(s, co.name) && !touched["composite:"+co.name] { + cands = append(cands, co) + } + } + if len(cands) == 0 { + return false + } + co := pick(r, cands) + a := pick(r, co.attrs) + switch a.typ { + case "integer": + a.typ = "bigint" + case "bigint": + a.typ = "integer" + case "text": + a.typ = "varchar(50)" + default: + a.typ = "text" + } + touched["composite:"+co.name] = true + return true + }}, + {"drop composite type", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []int + for i, co := range s.composites { + if !compositeInUse(s, co.name) && !touched["composite:"+co.name] { + cands = append(cands, i) + } + } + if len(cands) == 0 { + return false + } + i := pick(r, cands) + touched["composite:"+s.composites[i].name] = true + s.composites = append(s.composites[:i], s.composites[i+1:]...) + return true + }}, + {"toggle identity kind", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + var cands []*pCol + for _, t := range untouched(s, touched) { + for _, c := range t.cols { + if c.identity && !touched[t.name+"."+c.name] { + cands = append(cands, c) + } + } + } + if len(cands) == 0 { + return false + } + c := pick(r, cands) + c.idAlways = !c.idAlways + return true + }}, + {"add identity", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + var cands []*pCol + for _, c := range t.plainCols(touched) { + if !isInteger(c.typ) || c.serial { + continue + } + // ON DELETE SET NULL needs the column nullable; leave it alone (same guard as + // "change nullability and default") + settable := true + for _, fk := range t.fks { + if indexOf(fk.cols, c.name) >= 0 && fk.onDelete == "SET NULL" { + settable = false + } + } + if settable { + cands = append(cands, c) + } + } + if len(cands) == 0 { + return false + } + c := pick(r, cands) + c.identity, c.def = true, "" + if c.notNull { + c.idAlways = r.Intn(2) == 0 + } else { + // IDENTITY implies NOT NULL: existing NULLs need a backfill before the server's + // own SET NOT NULL (23502 "contains null values" otherwise, measured). A backfill + // UPDATE assigns the column a plain value, which GENERATED ALWAYS forbids by + // declaration (428C9 "can only be updated to DEFAULT") even though the UPDATE + // runs before the column turns identity: keep the column BY DEFAULT, which an + // explicit value is always allowed to override. + f := s.fill(c) + if t.inFK(c.name) { + // a parent every table has (rows() gives every table a row 3), and never + // row 1's or row 2's own FK value: rows() only ever nulls a nullable + // column's third row, so this backfill only ever reaches row 3 -- reusing + // its own row number rather than a fixed "1" cannot duplicate row 1's or + // row 2's, which an EXCLUDE (WITH =) over this column would otherwise + // refuse as a duplicate (23P01, measured) + f = "3" + } + s.intents = append(s.intents, fmt.Sprintf("-- @migrate backfill %s.%s = %s where %s is null", t.full(), c.name, f, qi(c.name))) + c.notNull, c.idAlways = true, false + } + touched[t.name+"."+c.name] = true + return true + }}, + {"drop identity", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + for _, c := range t.cols { + if c.identity && !touched[t.name+"."+c.name] { + c.identity = false + touched[t.name+"."+c.name] = true + return true + } + } + } + return false + }}, + {"add partitioned table", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + pt := s.newPartTable(r) + s.partTables = append(s.partTables, pt) + touched[pt.name] = true + return true + }}, + {"add partition", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + pts := untouchedPartTables(s, touched) + if len(pts) == 0 { + return false + } + pt := pick(r, pts) + name := s.next(pt.name + "_") + if pt.strategy == "LIST" { + used := map[string]bool{} + for _, c := range pt.parts { + for _, v := range c.values { + used[v] = true + } + } + letter := "d" + for _, cand := range []string{"d", "e", "f", "g", "h"} { + if !used[cand] { + letter = cand + break + } + } + pt.parts = append(pt.parts, &pPartChild{name: name, orig: "public." + name, values: []string{letter}}) + } else { + hi := 0 + for _, c := range pt.parts { + if !c.isDefault && !c.gone && c.hi > hi { + hi = c.hi + } + } + pt.parts = append(pt.parts, &pPartChild{name: name, orig: "public." + name, lo: hi, hi: hi + 10}) + } + touched[pt.name] = true + return true + }}, + {"drop partition", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, pt := range untouchedPartTables(s, touched) { + var live []*pPartChild + for _, c := range pt.parts { + if !c.gone && !c.detached && !c.isDefault { + live = append(live, c) + } + } + if len(live) < 2 { + continue // keep at least one ordinary partition besides DEFAULT + } + c := pick(r, live) + c.gone = true + s.intents = append(s.intents, "-- @migrate drop "+c.orig) + touched[pt.name] = true + return true + } + return false + }}, + {"detach partition", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // pt's id kind (plain / bigserial / IDENTITY ALWAYS / BY DEFAULT, newPartTable's own + // draw) is unrestricted here (brief-pg-attach-seq.md): migrate.go's partitionAttach + // now knows what a real DETACH PARTITION does to each -- plain and bigserial are + // untouched (the detached child keeps its own column exactly, bigserial's DEFAULT + // still naming the parent's sequence), true IDENTITY loses it outright, and + // detachedIDCol (probe_model_test.go) renders the target to match either way, so + // this mutation never has to check id kind itself. + for _, pt := range untouchedPartTables(s, touched) { + var live []*pPartChild + for _, c := range pt.parts { + if !c.gone && !c.detached && !c.isDefault { + live = append(live, c) + } + } + if len(live) < 2 { + continue // keep at least one ordinary partition besides DEFAULT + } + oldMax := pt.maxRowID() + c := pick(r, live) + c.detached = true + // repartitionSeqCheck's own check (a fresh id-omitted row into pt continues + // past oldMax): a bigserial / IDENTITY pt's own sequence lives on the parent, + // not on any one child, so a sibling detaching never touches it -- exercises + // exactly the DETACH side of the "id omitted insert still continues" oracle the + // repartition mutations already use. + repartitionSeqCheck(s, pt, oldMax) + touched[pt.name] = true + return true + } + return false + }}, + {"attach partition", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // Reattaches a child already standing apart -- one "detach partition" left mid- + // recipe, or the pair-0 spare generate() always leaves pre-detached (also + // unrestricted in id kind now, see newPartTable/generate's own comments): from pt's + // perspective this child is exactly as "independent" as any standalone table with + // its own bigserial / IDENTITY column would be, PostgreSQL-side (its column already + // carries no identity of its own to begin with -- detachedIDCol never renders one -- + // so partitionAttach's own DROP IDENTITY-before-ATTACH branch, for a genuinely + // external table that does carry one, is not what this exercises; that one is + // measured directly in probe_findings_test.go instead). + for _, pt := range untouchedPartTables(s, touched) { + for _, c := range pt.parts { + if c.detached && !c.gone { + oldMax := pt.maxRowID() + c.detached = false + repartitionSeqCheck(s, pt, oldMax) + touched[pt.name] = true + return true + } + } + } + return false + }}, + {"move partition bound", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, pt := range untouchedPartTables(s, touched) { + var last *pPartChild + for _, c := range pt.parts { + if !c.gone && !c.detached && !c.isDefault { + last = c // the highest RANGE bound / most recently added LIST child + } + } + if last == nil { + continue + } + if pt.strategy == "LIST" { + last.values = append(last.values, "w") + } else { + last.hi += 5 + } + touched[pt.name] = true + return true + } + return false + }}, + {"shrink partition bound", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // unlike "move partition bound" (which only ever widens, so the row already + // sitting at a child's own bound -- see pPartTable.rows -- always keeps fitting), + // this one narrows a child's bound past that very row, so it no longer belongs and + // the plan (partitionMovePredicate, migrate.go) has to move it back to the parent + // before DETACH + ATTACH runs, into the DEFAULT partition (brief-holes-pg.md item + // C). The row is not lost -- appends a DO $$ check confirming the parent's total + // count still holds it, since diff.Compare (judge's usual check) never looks at + // data. + for _, pt := range untouchedPartTables(s, touched) { + var live []*pPartChild + for _, c := range pt.parts { + if !c.gone && !c.detached && !c.isDefault { + live = append(live, c) + } + } + if len(live) == 0 { + continue + } + c := pick(r, live) + switch pt.strategy { + case "LIST": + used := map[string]bool{} + for _, o := range pt.parts { + for _, v := range o.values { + used[v] = true + } + } + letter := "" + for _, cand := range []string{"m", "n", "o", "p", "q"} { + if !used[cand] { + letter = cand + break + } + } + if letter == "" { + continue + } + c.values = []string{letter} // the row's own kind (c's former values[0]) no longer matches + default: // RANGE + if c.hi-c.lo < 2 { + continue // no room to raise lo past the row sitting at the old lo and stay a valid bound + } + c.lo++ // the row sits exactly at the old lo (pPartTable.rows), now excluded + } + total := 0 // one row per part from pt.rows() that is neither gone nor detached + for _, o := range pt.parts { + if !o.gone && !o.detached { + total++ + } + } + s.checks = append(s.checks, fmt.Sprintf( + "DO $$ BEGIN IF (SELECT count(*) FROM %s) <> %d THEN RAISE EXCEPTION 'partition %s row count: %%', (SELECT count(*) FROM %s); END IF; END $$;", + qi(pt.name), total, pt.name, qi(pt.name))) + touched[pt.name] = true + return true + } + return false + }}, + {"change partition strategy", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // Rebuilds pt under the other strategy (RANGE <-> LIST), reaching "~ table + // partition key" (diff.Alphabet) through the generator instead of only by hand + // (TestProbeRepartitionRangeToList, migrate_test.go): migrate.go's repartitionTable + // (detectRepartitions / repartitionEligible) renames the source table aside, + // creates the target's own shape fresh under the old name and lets PostgreSQL's + // partition router place every existing row -- refusing (23514) if one fits + // nowhere, which a DEFAULT partition on both the old and the new side always rules + // out. pPartTable is already narrow enough (no key, no index, no foreign key, and + // -- unlike pTable -- no policy / rule / trigger / comment of its own; see its doc + // comment) to sit inside repartitionEligible's scope unconditionally, so this + // mutation never needs to check eligibility itself the way a pTable-based one + // would have to. + pts := untouchedPartTables(s, touched) + if len(pts) == 0 { + return false + } + pt := pick(r, pts) + oldMax := pt.maxRowID() + // a child pt already carries detached (not gone) is its own independent table by + // now (detectRepartitions only folds a *currently attached* child into its old + // parent's demolition -- schema.Relation.Parents is empty the moment DETACH runs), + // so replacing pt.parts wholesale would silently drop it out of the target with no + // @migrate declaring so (measured): carried forward unchanged, same as + // pTableFromPart / "departition table" have to. + var carry []*pPartChild + for _, c := range pt.parts { + if c.detached && !c.gone { + carry = append(carry, c) + } + } + newStrategy := "LIST" + if pt.strategy == "LIST" { + newStrategy = "RANGE" + } + pt.strategy = newStrategy + c1, c2 := s.next(pt.name+"_"), s.next(pt.name+"_") + def := s.next(pt.name + "_") + if newStrategy == "LIST" { + pt.parts = []*pPartChild{ + {name: c1, orig: pt.orig, values: []string{"a", "b"}}, + {name: c2, orig: pt.orig, values: []string{"c"}}, + {name: def, orig: pt.orig, isDefault: true}, + } + } else { + pt.parts = []*pPartChild{ + {name: c1, orig: pt.orig, lo: 1, hi: 10}, + {name: c2, orig: pt.orig, lo: 10, hi: 20}, + {name: def, orig: pt.orig, isDefault: true}, + } + } + pt.parts = append(pt.parts, carry...) + repartitionSeqCheck(s, pt, oldMax) + touched[pt.name] = true + return true + }}, + {"departition table", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // The reverse direction of "add partitioned table": an existing partitioned pt + // loses its PARTITION BY entirely and becomes one plain table under its own name, + // its rows routed back by repartitionTable/pendingRepartition the same way. Uses + // pTableFromPart to get a pTable that renders as an ordinary table (see its doc + // comment); the old pt is removed from s.partTables so render()/rows() only ever + // describe it once, and the new plain pTable is otherwise indistinguishable from + // one that had started out unpartitioned. Any child pt already carries detached + // (not gone) is its own independent table by now, unaffected by departitioning its + // former parent -- carried forward as its own plain pTable, same reasoning as + // "change partition strategy" (measured: dropping it silently otherwise). + pts := untouchedPartTables(s, touched) + if len(pts) == 0 { + return false + } + pt := pick(r, pts) + oldMax := pt.maxRowID() + var kept []*pPartTable + for _, other := range s.partTables { + if other != pt { + kept = append(kept, other) + } + } + s.partTables = kept + s.tables = append(s.tables, pTableFromPart(pt)) + repartitionSeqCheck(s, pt, oldMax) + for _, c := range pt.parts { + if c.detached && !c.gone { + // render() gave this already-detached child its own detachedIDCol (plain once + // identity, literal parent-sequence DEFAULT once bigserial -- see its own doc + // comment), never pt.idCol() itself: carried through here too, or departitioning + // pt would silently make this unrelated table's id column look changed (measured). + s.tables = append(s.tables, pTableFromPart(&pPartTable{name: c.name, orig: c.orig, id: pt.detachedIDCol()})) + } + } + touched[pt.name] = true + return true + }}, +} + +// mutations18 is PostgreSQL-18-only vocabulary (brief-holes-pg.md item E), added to +// mutations for TestMigrateProbe18 alone: VIRTUAL generated columns, and NOT ENFORCED on +// a CHECK or a foreign key. WITHOUT OVERLAPS (a temporal PRIMARY KEY / UNIQUE, needing a +// range-typed column pTable's own vocabulary does not otherwise carry) and PERIOD foreign +// keys are measured by hand instead (see alphabetKnownUnreached's own entries for why). +var mutations18 = []mutation{ + {"add virtual generated column", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + x := &pCol{name: s.next("c"), typ: pick(r, []string{"integer", "numeric(10,2)", "smallint"})} + g := &pCol{name: s.next("g"), typ: "bigint", gen: x.name, genVirtual: true} + x.fresh, g.fresh = true, true + insertCol(r, t, g) + insertCol(r, t, x) + touched[t.name+"."+x.name], touched[t.name+"."+g.name] = true, true + return true + }}, + {"toggle generated kind", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + var cands []*pCol + for _, c := range t.cols { + if c.gen != "" && !c.fresh && !touched[t.name+"."+c.name] { + cands = append(cands, c) + } + } + if len(cands) == 0 { + continue + } + c := pick(r, cands) + c.genVirtual = !c.genVirtual + touched[t.name+"."+c.name] = true + return true + } + return false + }}, + {"toggle check not enforced", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + if len(t.checks) == 0 { + continue + } + ck := pick(r, t.checks) + ck.notEnforced = !ck.notEnforced + touched[t.name] = true + return true + } + return false + }}, + {"toggle foreign key not enforced", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + for _, t := range untouched(s, touched) { + if len(t.fks) == 0 { + continue + } + fk := pick(r, t.fks) + fk.notEnforced = !fk.notEnforced + touched[t.name] = true + return true + } + return false + }}, + {"add without overlaps key", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + ts := untouched(s, touched) + if len(ts) == 0 { + return false + } + t := pick(r, ts) + pk := t.pk() + if pk == nil || !isInteger(pk.typ) { + return false + } + // a fresh integer scalar column and a fresh daterange column, the range last + // (WITHOUT OVERLAPS requires it there); both backfilled from the table's own + // primary key so every row's span is [pk, pk+1) -- distinct integers make + // distinct, non-overlapping ranges, whatever pk's own values are. + scalar := &pCol{name: s.next("c"), typ: pk.typ, notNull: true, fresh: true} + span := &pCol{name: s.next("span"), typ: "daterange", notNull: true, fresh: true} + insertCol(r, t, scalar) + insertCol(r, t, span) + s.intents = append(s.intents, + fmt.Sprintf("-- @migrate backfill %s.%s = %s", t.full(), scalar.name, qi(pk.name)), + fmt.Sprintf("-- @migrate backfill %s.%s = daterange('2024-01-01'::date + %s::integer, '2024-01-02'::date + %s::integer)", + t.full(), span.name, qi(pk.name), qi(pk.name))) + t.keys = append(t.keys, &pKey{name: s.next("k"), unique: true, cols: []string{scalar.name, span.name}, withoutOverlaps: true}) + s.ensureExtension("btree_gist") + touched[t.name+"."+scalar.name], touched[t.name+"."+span.name] = true, true + return true + }}, + {"toggle key without overlaps", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // Flips an *existing* key's own WITHOUT OVERLAPS flag -- unlike "add without + // overlaps key" above (always a brand new constraint, + constraint), this reaches + // "~ constraint without overlaps" (diff.Alphabet): needs a candidate that already + // carries the flag, seeded by generate() itself (its own doc comment) since no + // in-recipe mutation adds one to an existing key in place. A plain UNIQUE over the + // same columns is valid regardless of the range column's type (ordinary btree + // equality), so turning the flag off never needs a column or shape change; turning + // it on for an arbitrary key elsewhere would (its last column would have to be a + // range type first), so this only ever finds the generate()-seeded temporal key in + // practice. + for _, t := range untouched(s, touched) { + for _, k := range t.keys { + if !k.unique || !k.withoutOverlaps { + continue + } + // a PERIOD foreign key elsewhere may already rest on this key (same + // guard "drop key" uses for an ordinary key an FK references): turning + // WITHOUT OVERLAPS off here would leave that FK referencing a key that no + // longer qualifies as temporal (measured). + rested := false + for _, other := range s.tables { + for _, fk := range other.fks { + if fk.withPeriod && fk.refTable == t.name { + rested = true + } + } + } + if rested { + continue + } + k.withoutOverlaps = !k.withoutOverlaps + touched[t.name] = true + return true + } + } + return false + }}, + {"toggle foreign key period", func(r *rand.Rand, s *pSchema, touched map[string]bool) bool { + // Widens an *existing*, ordinary foreign key onto the referenced table's own + // WITHOUT OVERLAPS key instead, PERIOD -- reaches "~ constraint period" + // (diff.Alphabet) the same way "toggle key without overlaps" reaches its own + // field: a candidate needs a plain FK resting on a table that also carries a + // temporal key over one more column than the FK currently uses, which only + // generate()'s own seeded pair (its doc comment) provides. + for _, t := range untouched(s, touched) { + for _, fk := range t.fks { + if fk.withPeriod { + continue + } + parent := s.table(fk.refTable) + if parent == nil { + continue + } + var tk *pKey + for _, k := range parent.keys { + if k.unique && k.withoutOverlaps && len(k.cols) == len(fk.refCols)+1 { + tk = k + break + } + } + if tk == nil { + continue + } + rangeCol := tk.cols[len(tk.cols)-1] + var ownRange *pCol + for _, c := range t.cols { + if c.typ == "daterange" && indexOf(fk.cols, c.name) < 0 { + ownRange = c + break + } + } + if ownRange == nil { + continue + } + fk.cols = append(append([]string(nil), fk.cols...), ownRange.name) + fk.refCols = append(append([]string(nil), fk.refCols...), rangeCol) + fk.withPeriod = true + touched[t.name] = true + return true + } + } + return false + }}, +} + +// pTableFromPart is pt rendered as one plain (unpartitioned) table: the same name, schema +// and two columns pPartTable.render() always uses (id integer NOT NULL, kind text NOT +// NULL), no PARTITION BY and no children -- "departition table"'s target side, so that +// side's diff.Props sees "partition key" go from pt's own RANGE/LIST text to empty (the +// same "~ table partition key" alphabet entry a strategy change produces, the other +// direction). No key, index or foreign key either, matching pt's own scope exactly so +// nothing about repartitionEligible needs to be reconsidered for it. +func pTableFromPart(pt *pPartTable) *pTable { + id := *pt.idCol() // pt's own id column (plain / bigserial / IDENTITY), copied so mutating it here never reaches back into pt + return &pTable{ + schema: "public", + name: pt.name, + orig: pt.orig, + cols: []*pCol{ + &id, + {name: "kind", typ: "text", notNull: true}, + }, + } +} + +// repartitionSeqCheck appends a check (run, like "shrink partition bound"'s own, after the +// plan's DDL and before judge() ever compares schemas) that a fresh row inserted with no +// explicit id continues pt's sequence from where the repartition (change partition strategy +// / departition table) found it, rather than colliding back at 1 the way a brand new +// IDENTITY sequence otherwise would (migrate.go's pendingRepartition is what is actually +// under test here: OVERRIDING SYSTEM VALUE on the carried-over rows, then setval()) -- +// 'zz' never matches any LIST child's own values and any id an auto-generated one produces +// is comfortably past 100000, so the new row always lands in whichever side's DEFAULT +// partition (RANGE or LIST), or the one remaining plain table once departitioned. A plain +// integer id has no default to insert without one at all, so only a bigserial or IDENTITY +// pt needs this. +func repartitionSeqCheck(s *pSchema, pt *pPartTable, oldMax int) { + if !pt.idCol().serial && !pt.idCol().identity { + return + } + s.checks = append(s.checks, fmt.Sprintf( + "INSERT INTO %s (kind) VALUES ('zz');\n"+ + "DO $$ BEGIN IF (SELECT max(id) FROM %s) <> %d THEN RAISE EXCEPTION 'partition %s id continuation: %%', (SELECT max(id) FROM %s); END IF; END $$;", + qi(pt.name), qi(pt.name), oldMax+1, pt.name, qi(pt.name))) +} + +// untouchedPartTables is untouched's counterpart for pPartTable: candidates a mutation may +// still act on, keyed the same way (touched[pt.name]) so a recipe never applies two +// partition mutations to the same partitioned table in one pair (matching how pTable +// mutations use touched[t.name] to keep a pair's changes disjoint and so unambiguous to +// re-derive @migrate declarations for). +func untouchedPartTables(s *pSchema, touched map[string]bool) []*pPartTable { + var out []*pPartTable + for _, pt := range s.partTables { + if !touched[pt.name] { + out = append(out, pt) + } + } + return out +} + +// policyPredicates is which of USING / WITH CHECK a policy for command needs: SELECT and +// DELETE read only (USING), INSERT writes only (WITH CHECK), ALL and UPDATE both. +func policyPredicates(command string) (using, withCheck bool) { + switch command { + case "SELECT", "DELETE": + return true, false + case "INSERT": + return false, true + } + return true, true +} + +type step struct { + m int + seed int64 +} + +func mutate(muts []mutation, src *pSchema, recipe []step) (*pSchema, []string) { + s := src.clone() + s.mutating = true + touched := map[string]bool{} + var applied []string + for _, st := range recipe { + r := rand.New(rand.NewSource(st.seed)) + if muts[st.m].apply(r, s, touched) { + applied = append(applied, muts[st.m].name) + } + } + return s, applied +} diff --git a/check/postgres/migrate/probe_test.go b/check/postgres/migrate/probe_test.go new file mode 100644 index 0000000..2a54f9a --- /dev/null +++ b/check/postgres/migrate/probe_test.go @@ -0,0 +1,332 @@ +package migrate + +// The migrate probe, PostgreSQL side: the same oracle check/mysql/migrate has (see its +// probe_test.go for the reasoning). Schemas are generated from the planner's vocabulary +// (column types, an ENUM type, identity and generated columns, unique constraints and +// indexes, foreign keys, CHECKs, table comments, views, functions, trigger functions with +// their triggers), mutated into a target (columns added / dropped / renamed / widened / +// re-nulled, constraints and indexes attached and detached, the primary key moved onto an +// existing or a new column, tables added / dropped / renamed, ENUM labels added and dropped, +// views, triggers and functions added / dropped / changed, generated expressions changed), +// and judged the way `sqlshape apply` runs the plan: the DDL applied to a database holding +// the source must read back as the target, column order aside (PostgreSQL cannot move a +// column; Verify reports order as notes), and a second plan from there must be empty. +// +// go test ./migrate -run TestMigrateProbe [-migrate-probe-n 200] [-migrate-probe-seed 1] \ +// [-migrate-probe-report /path/report.md] +// +// Skipped without pg_dump on PATH (nix-shell -p postgresql_17). + +import ( + "context" + "flag" + "fmt" + "math/rand" + "os" + "runtime" + "sort" + "strings" + "sync" + "testing" + + "github.com/kr9ly/sqlshape/check/postgres/v2/diff" + "github.com/kr9ly/sqlshape/check/postgres/v2/dump" +) + +var ( + probeN = flag.Int("migrate-probe-n", 200, "schema pairs the migrate probe judges") + probeSeed = flag.Int64("migrate-probe-seed", 1, "seed of the migrate probe's generator") + probeReport = flag.String("migrate-probe-report", "", "write the migrate probe's findings here (default: the test log)") +) + +// ---- judging ---------------------------------------------------------------------------- + +type verdict struct { + kind string + detail string + ddl []string + aSQL string + bSQL string + applied []string + // changes is diff.Compare(a, b): the planner's actual input, kept so the probe can + // tally which (Op, Kind, Field) triples (diff.Alphabet) the pair exercised, whatever + // judge makes of it afterward. + changes []diff.Change +} + +func judge(ctx context.Context, srv *dump.Server, src, target *pSchema, applied []string) verdict { + v := verdict{aSQL: src.render(), bSQL: target.render(), applied: applied} + a, aText, err := srv.Canonical(ctx, v.aSQL, nil) + if err != nil { + v.kind, v.detail = "generator (source)", err.Error() + return v + } + b, _, err := srv.Canonical(ctx, v.bSQL, nil) + if err != nil { + v.kind, v.detail = "generator (target)", err.Error() + return v + } + if len(a.Problems) > 0 || len(b.Problems) > 0 { + v.kind, v.detail = "generator (problems)", fmt.Sprint(a.Problems, b.Problems) + return v + } + v.changes = diff.Compare(a, b) + intents, err := ParseIntents(v.bSQL) + if err != nil { + v.kind, v.detail = "generator (intents)", err.Error() + return v + } + ddl, err := Plan(a, b, intents) + v.ddl = ddl + if err != nil { + v.kind, v.detail = "plan refused", err.Error() + return v + } + // the source database holds rows: what apply runs against is never empty + loaded := aText + "\nRESET search_path;\n" + src.rows() + script := loaded + "\n" + strings.Join(ddl, "\n") + if len(target.checks) > 0 { + // a mutation's own data assertions (so far: a partition bound shrinking past a + // row it used to hold, "shrink partition bound") run in the same script, right + // after the plan's DDL and before judge() ever compares schemas -- a RAISE + // EXCEPTION here surfaces as a plain Canonical error below, same as a bad DDL + // statement would. + script += "\n" + strings.Join(target.checks, "\n") + } + got, _, err := srv.Canonical(ctx, script, b) + if err != nil { + if _, _, rerr := srv.Canonical(ctx, loaded, a); rerr != nil { + v.kind, v.detail = "generator (rows)", rerr.Error()+"\n"+src.rows() + return v + } + v.kind, v.detail = "server refused the DDL", err.Error() + return v + } + var d []string + for _, ch := range diff.Compare(got, b) { + if !ch.OrderOnly() { + d = append(d, ch.String()) + } + } + if len(d) > 0 { + v.kind, v.detail = "DDL does not reach the target", strings.Join(d, "\n") + return v + } + again, err := Plan(got, b, nil) + if err != nil || len(again) > 0 { + v.kind, v.detail = "a second plan is not empty", fmt.Sprintf("%v\n%s", err, strings.Join(again, "\n")) + return v + } + return v +} + +func TestMigrateProbe(t *testing.T) { + requirePgDump(t) + runMigrateProbe(t, server, mutations, false, "postgres", *probeSeed, *probeN) +} + +func TestMigrateProbe18(t *testing.T) { + requirePgDump18(t) + muts := append(append([]mutation(nil), mutations...), mutations18...) + runMigrateProbe(t, server18, muts, true, "postgres18", *probeSeed, *probeN) +} + +// probeWorkers is how many pairs judge at once. Every pair has a database of its own on +// the one server (dump.Server.Canonical is safe for concurrent use), so the draw stays +// sequential -- the PRNG's stream, and with it the report, is the same as it was when the +// pairs were judged one after another -- and only the judging fans out. +var probeWorkers = min(8, runtime.GOMAXPROCS(0)) + +// parallel runs fn over items on probeWorkers goroutines and returns when all are done. +func parallel[T any](items []T, fn func(T)) { + var wg sync.WaitGroup + next := make(chan T) + for w := 0; w < probeWorkers; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for it := range next { + fn(it) + } + }() + } + for _, it := range items { + next <- it + } + close(next) + wg.Wait() +} + +// pair is one random draw of the probe: a source schema, the recipe applied to it, and +// after judging its verdict (recipe and verdict shrunk together when it found something). +type pair struct { + i int + src *pSchema + target *pSchema + recipe []step + applied []string + v verdict +} + +func runMigrateProbe(t *testing.T, srv *dump.Server, muts []mutation, pg18 bool, reportName string, seed int64, n int) { + ctx := context.Background() + r := rand.New(rand.NewSource(seed)) + counts := map[string]int{} + byMutation := map[string]int{} + hit := map[string]bool{} + var findings []verdict + var pairs []*pair + for i := 0; i < n; i++ { + src := generate(r, pg18) + nmut := 1 + r.Intn(5) + var recipe []step + for j := 0; j < nmut; j++ { + recipe = append(recipe, step{m: r.Intn(len(muts)), seed: r.Int63()}) + } + target, applied := mutate(muts, src, recipe) + if len(applied) == 0 { + continue + } + pairs = append(pairs, &pair{i: i, src: src, target: target, recipe: recipe, applied: applied}) + } + parallel(pairs, func(p *pair) { + p.v = judge(ctx, srv, p.src, p.target, p.applied) + if p.v.kind == "" || strings.HasPrefix(p.v.kind, "generator") { + return + } + // a finding: drop mutations while the failure stands + recipe, v := p.recipe, p.v + for j := 0; j < len(recipe); { + shorter := append(append([]step(nil), recipe[:j]...), recipe[j+1:]...) + tgt, app := mutate(muts, p.src, shorter) + if len(app) == 0 { + j++ + continue + } + if w := judge(ctx, srv, p.src, tgt, app); w.kind == v.kind { + recipe, v = shorter, w + continue + } + j++ + } + p.recipe, p.v = recipe, v + }) + for _, p := range pairs { + v := p.v + for _, a := range p.applied { + byMutation[a]++ + } + tallyHit(hit, v.changes) + if v.kind == "" { + counts["pass"]++ + continue + } + if strings.HasPrefix(v.kind, "generator") { + counts[v.kind]++ + if counts[v.kind] <= 3 { + t.Logf("%s (pair %d, %s): %s\n%s", v.kind, p.i, strings.Join(p.applied, "; "), v.detail, v.aSQL) + } + continue + } + counts["finding"]++ + findings = append(findings, v) + } + randomHit := len(hit) + stuck := directedCoverage(t, ctx, srv, muts, pg18, seed, counts, byMutation, hit, &findings) + combinedHit := len(hit) + + var keys []string + for k := range counts { + keys = append(keys, k) + } + sort.Strings(keys) + var summary strings.Builder + for _, k := range keys { + fmt.Fprintf(&summary, "%s: %d\n", k, counts[k]) + } + var ms []string + for m := range byMutation { + ms = append(ms, m) + } + sort.Strings(ms) + for _, m := range ms { + fmt.Fprintf(&summary, " %-45s %d\n", m, byMutation[m]) + } + t.Logf("migrate probe (%s), seed %d, %d pairs:\n%s", reportName, seed, n, summary.String()) + + seen := map[string]bool{} + var report strings.Builder + fmt.Fprintf(&report, "# migrate probe (%s), seed %d, %d pairs\n\n%s\n", reportName, seed, n, summary.String()) + distinct := 0 + for _, v := range findings { + key := v.kind + "\n" + v.detail + if seen[key] { + continue + } + seen[key] = true + distinct++ + fmt.Fprintf(&report, "## %d. %s\n\nmutations: %s\n\n%s\n\n### source\n\n```sql\n%s```\n\n### target\n\n```sql\n%s```\n\n### plan\n\n```sql\n%s\n```\n\n", + distinct, v.kind, strings.Join(v.applied, "; "), v.detail, v.aSQL, v.bSQL, strings.Join(v.ddl, "\n")) + } + alphabet, err := diff.Alphabet() + if err != nil { + t.Fatalf("diff.Alphabet: %v", err) + } + var missed []string + fmt.Fprintf(&report, "## alphabet coverage\n\n%d entries, %d hit (%d by the random pairs alone, %d with directed coverage added)\n\n", + len(alphabet), len(hit), randomHit, combinedHit) + if len(stuck) > 0 { + fmt.Fprintf(&report, "%d mutation(s) never found a candidate alone in %d attempts: %s\n\n", len(stuck), directedAttempts, strings.Join(stuck, ", ")) + } + for _, e := range alphabet { + s := e.String() + switch { + case hit[s]: + fmt.Fprintf(&report, "- [x] %s\n", s) + case isKnownUnreached(s, pg18) != "": + fmt.Fprintf(&report, "- [ ] %s -- known unreached: %s\n", s, isKnownUnreached(s, pg18)) + default: + fmt.Fprintf(&report, "- [ ] %s -- MISSED\n", s) + missed = append(missed, s) + } + } + // a known-unreached entry diff.Alphabet no longer lists is stale (the field moved, + // was renamed, or the planner learned to emit DDL for it): flag it so the list stays + // honest instead of silently over-forgiving forever. + known := map[string]bool{} + for _, e := range alphabet { + known[e.String()] = true + } + var stale []string + for s := range alphabetKnownUnreached { + if !known[s] { + stale = append(stale, s) + } + } + sort.Strings(stale) + for _, s := range stale { + fmt.Fprintf(&report, "- stale known-unreached entry (not in the current alphabet): %s\n", s) + } + + if *probeReport != "" { + if err := os.WriteFile(*probeReport, []byte(report.String()), 0o644); err != nil { + t.Fatal(err) + } + t.Logf("report: %s", *probeReport) + } else if distinct > 0 { + t.Log(report.String()) + } + if distinct > 0 { + t.Errorf("%d distinct findings (%d pairs)", distinct, counts["finding"]) + } + sort.Strings(missed) + if len(missed) > 0 { + t.Errorf("%d/%d alphabet entries neither hit nor known-unreached:\n%s", len(missed), len(alphabet), strings.Join(missed, "\n")) + } + if len(stale) > 0 { + t.Errorf("%d stale alphabetKnownUnreached entries (not in diff.Alphabet): %s", len(stale), strings.Join(stale, ", ")) + } + if len(stuck) > 0 { + t.Errorf("%d mutation(s) never found a candidate alone in %d directed attempts: %s", len(stuck), directedAttempts, strings.Join(stuck, ", ")) + } +} diff --git a/internal/migrate/render.go b/check/postgres/migrate/render.go similarity index 83% rename from internal/migrate/render.go rename to check/postgres/migrate/render.go index c4dd8e8..c53f73f 100644 --- a/internal/migrate/render.go +++ b/check/postgres/migrate/render.go @@ -4,10 +4,10 @@ import ( "sort" "strings" - "github.com/kr9ly/sqlshape/internal/catalog" - "github.com/kr9ly/sqlshape/internal/diff" - "github.com/kr9ly/sqlshape/internal/pgparse" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/catalog" + "github.com/kr9ly/sqlshape/check/postgres/v2/diff" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" ) func colProps(s *schema.Schema, c *schema.Column) map[string]string { return diff.Props(s, c) } @@ -91,6 +91,17 @@ func identityWord(id byte) string { return "BY DEFAULT" } +// isIdentity reports whether a column's Identity byte is a genuine GENERATED ... AS +// IDENTITY ('a' / 'd') -- as opposed to 's' (schema.go's own marker for a plain bigserial +// column, "behaves like identity for who owns the value" but with a column-owned sequence +// an ALTER SEQUENCE ... OWNED BY can still detach and re-point, unlike a true identity +// sequence, the server's own) or 0 (no identity at all). repartitionTable's own scope +// (migrate.go) needs this distinction: c.Identity != 0 alone also matches a bigserial +// column, which would wrongly skip its owned sequence's OWNED BY NONE / re-own handling and +// instead try to rename aside a sequence r.Definition never recreates in the first place +// (measured: 42P07 "already exists"). +func isIdentity(id byte) bool { return id != 0 && id != 's' } + // constraintText renders a table constraint body (after ADD CONSTRAINT name). func constraintText(s *schema.Schema, c *schema.Constraint) string { switch c.Kind { @@ -270,6 +281,19 @@ func commentRelation(key string) string { return key } +// commentRelKey is key's relation part as a schema.Relation.FullName() ("schema.rel"), +// whether key itself names a relation (a table/view/... comment, two dot-separated parts) +// or a column of one (a column comment, three parts, schema names never containing a dot +// themselves -- the same assumption splitRel makes): used to look up p.recreated, which is +// keyed by FullName, from either kind of comment key. +func commentRelKey(key string) string { + parts := strings.SplitN(key, ".", 3) + if len(parts) < 3 { + return key + } + return parts[0] + "." + parts[1] +} + func commentObjectSurvives(s *schema.Schema, key string) bool { kind, _ := commentTarget(s, key) return kind != "" diff --git a/internal/migrate/render_test.go b/check/postgres/migrate/render_test.go similarity index 98% rename from internal/migrate/render_test.go rename to check/postgres/migrate/render_test.go index 7b8a900..73e94c0 100644 --- a/internal/migrate/render_test.go +++ b/check/postgres/migrate/render_test.go @@ -3,7 +3,7 @@ package migrate import ( "testing" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" ) // TestRelWord covers relWord's four branches: the default (plain table) is not directly diff --git a/internal/migrate/seed.go b/check/postgres/migrate/seed.go similarity index 97% rename from internal/migrate/seed.go rename to check/postgres/migrate/seed.go index 37cc79b..e31f39f 100644 --- a/internal/migrate/seed.go +++ b/check/postgres/migrate/seed.go @@ -3,8 +3,8 @@ package migrate import ( "strings" - "github.com/kr9ly/sqlshape/internal/diff" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/diff" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" ) // seeds brings the content of every seeded table of the target to its declared rows: the diff --git a/check/postgres/obligation/adjudicated_test.go b/check/postgres/obligation/adjudicated_test.go new file mode 100644 index 0000000..feb6aa2 --- /dev/null +++ b/check/postgres/obligation/adjudicated_test.go @@ -0,0 +1,123 @@ +package obligation_test + +import ( + "strings" + "testing" + + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/v2/x/obligation" +) + +// The second adversarial round left six shapes for a decision; these pin the decisions. +// `paired` does not compare values and `sensitive` counts RETURNING as a read (kept, in +// docs); `col = NULL` fixes nothing; SET col = DEFAULT is the column's literal default; an +// opt-out above a CREATE TABLE is told where it belongs; a bare and a qualified name under +// search_path are one relation. + +func discharges(t *testing.T, schemaSQL, sql string) []obligation.Discharge { + t.Helper() + s, err := analyze.Load(schemaSQL) + if err != nil { + t.Fatal(err) + } + for _, p := range s.Problems { + t.Fatalf("schema problem: %s", p) + } + decls, problems := obligation.Declarations(s.Contract()) + if len(problems) > 0 { + t.Fatalf("problems: %+v", problems) + } + r, err := analyze.Analyze(s, sql) + if err != nil { + t.Fatalf("%s: %v", sql, err) + } + return obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) +} + +func failing(ds []obligation.Discharge) []string { + var out []string + for _, d := range ds { + if d.Failed() { + out = append(out, d.Message) + } + } + return out +} + +func TestNullEqualityPinsNothing(t *testing.T) { + const schemaSQL = ` +-- sqlshape: require pinned(tenant_id) +CREATE TABLE orders (id bigint PRIMARY KEY, tenant_id bigint NOT NULL); +` + for _, sql := range []string{ + `SELECT id FROM orders WHERE tenant_id = NULL`, + `DELETE FROM orders WHERE tenant_id = NULL::bigint`, + `SELECT id FROM orders WHERE tenant_id IN (NULL)`, + } { + if f := failing(discharges(t, schemaSQL, sql)); len(f) != 1 || !strings.Contains(f[0], "tenant_id is not pinned") { + t.Errorf("%s: %v, want the pinned failure", sql, f) + } + } + if f := failing(discharges(t, schemaSQL, `SELECT id FROM orders WHERE tenant_id = 1`)); len(f) != 0 { + t.Errorf("= 1: %v", f) + } +} + +func TestSetDefaultIsTheColumnsLiteralDefault(t *testing.T) { + const schemaSQL = ` +-- sqlshape: transitions status: draft -> submitted, submitted -> paid | draft +CREATE TABLE orders (id bigint PRIMARY KEY, status text NOT NULL DEFAULT 'draft', note text DEFAULT now()::text); +-- sqlshape: transitions state: a -> b +CREATE TABLE jobs (id bigint PRIMARY KEY, state text NOT NULL DEFAULT lower('A')); +` + // DEFAULT is 'draft': a transition to draft, which submitted may take + if f := failing(discharges(t, schemaSQL, `UPDATE orders SET status = DEFAULT WHERE id = $1 AND status = 'submitted'`)); len(f) != 0 { + t.Errorf("submitted -> DEFAULT (draft): %v", f) + } + f := failing(discharges(t, schemaSQL, `UPDATE orders SET status = DEFAULT WHERE id = $1`)) + if len(f) != 1 || !strings.Contains(f[0], "SET status = 'draft' must fix the current state in WHERE (status = 'submitted')") { + t.Errorf("DEFAULT without the current state: %v", f) + } + // a default that is not a literal is not a declared state + f = failing(discharges(t, schemaSQL, `UPDATE jobs SET state = DEFAULT WHERE id = $1`)) + if len(f) != 1 || !strings.Contains(f[0], "DEFAULT is not one here, the column's default is not a literal") { + t.Errorf("non-literal DEFAULT: %v", f) + } +} + +func TestOptOutAboveCreateTableIsExplained(t *testing.T) { + s, err := analyze.Load("-- sqlshape: unfiltered orders\nCREATE TABLE orders (id bigint PRIMARY KEY);\n-- sqlshape: waive orders pinned(x)\nCREATE TABLE t2 (id bigint PRIMARY KEY);") + if err != nil { + t.Fatal(err) + } + if len(s.Problems) != 2 { + t.Fatalf("problems: %v", s.Problems) + } + for _, p := range s.Problems { + if !strings.Contains(p.Message, "is a view's or a statement's opt-out: write it above the CREATE VIEW, or above the statement, whose reads it waives") { + t.Errorf("%s", p) + } + } +} + +func TestBareAndQualifiedNamesUnderSearchPathAreOneRelation(t *testing.T) { + const schemaSQL = ` +CREATE SCHEMA app; +SET search_path TO app, public; +-- sqlshape: require pinned(tenant_id) +CREATE TABLE orders (id bigint PRIMARY KEY, tenant_id bigint NOT NULL); +` + for _, sql := range []string{`SELECT id FROM orders WHERE id = $1`, `SELECT id FROM app.orders WHERE id = $1`} { + if f := failing(discharges(t, schemaSQL, sql)); len(f) != 1 || !strings.Contains(f[0], "tenant_id is not pinned") { + t.Errorf("%s: %v", sql, f) + } + } + // mixed in one statement: each occurrence is judged on its own + f := failing(discharges(t, schemaSQL, `SELECT o.id FROM app.orders o JOIN orders p ON p.id = o.id WHERE o.tenant_id = $1`)) + if len(f) != 1 || !strings.Contains(f[0], "(the occurrence p)") { + t.Errorf("mixed: %v", f) + } + if f := failing(discharges(t, schemaSQL, `SELECT o.id FROM app.orders o JOIN orders p ON p.id = o.id WHERE o.tenant_id = $1 AND p.tenant_id = $1`)); len(f) != 0 { + t.Errorf("both pinned: %v", f) + } +} diff --git a/internal/obligation/adv_context_test.go b/check/postgres/obligation/adv_context_test.go similarity index 85% rename from internal/obligation/adv_context_test.go rename to check/postgres/obligation/adv_context_test.go index 2d69388..41bd9d4 100644 --- a/internal/obligation/adv_context_test.go +++ b/check/postgres/obligation/adv_context_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/obligation" + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/v2/x/obligation" ) // A context's `waive on ` is scoped to those kinds: a waiver spelled `on @@ -21,7 +21,7 @@ CREATE TABLE orders (id bigint PRIMARY KEY, tenant_id bigint NOT NULL); if err != nil { t.Fatal(err) } - all, problems := obligation.Declarations(s) + all, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -33,7 +33,7 @@ CREATE TABLE orders (id bigint PRIMARY KEY, tenant_id bigint NOT NULL); t.Fatalf("%s: %v", sql, err) } var lines []string - for _, d := range obligation.Check(s, decls, r.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) { line := d.Leaf.Table + " " + d.Obligation.Body.Spec() + " " + pathName(d.Path) lines = append(lines, line) } @@ -62,19 +62,19 @@ CREATE TABLE orders (id bigint PRIMARY KEY, tenant_id bigint NOT NULL); if err != nil { t.Fatal(err) } - all, problems := obligation.Declarations(s) + all, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } // Mirrors how vet/check combine context selection with flag-derived obligations. - decls := obligation.InContext(append(all, obligation.FromFlags(s, "tenant_id", false, false)...), "ops") + decls := obligation.InContext(append(all, obligation.FromFlags(s.Contract(), "tenant_id", false, false)...), "ops") r, err := analyze.Analyze(s, `DELETE FROM orders WHERE id = $1`) if err != nil { t.Fatal(err) } var lines []string - for _, d := range obligation.Check(s, decls, r.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) { lines = append(lines, d.Leaf.Table+" "+d.Obligation.Body.Spec()+" "+pathName(d.Path)) } got := strings.Join(lines, "\n") diff --git a/internal/obligation/adv_grammar_test.go b/check/postgres/obligation/adv_grammar_test.go similarity index 94% rename from internal/obligation/adv_grammar_test.go rename to check/postgres/obligation/adv_grammar_test.go index 8237668..c5a86e7 100644 --- a/internal/obligation/adv_grammar_test.go +++ b/check/postgres/obligation/adv_grammar_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/obligation" + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/v2/x/obligation" ) // A directive written above a statement that takes none (ALTER TABLE, COMMENT ON) is @@ -24,7 +24,7 @@ func TestAdvDirectiveAboveOtherStatementIsAProblem(t *testing.T) { if len(s.Problems) != 1 || !strings.Contains(s.Problems[0].Message, `directive "require pinned(tenant_id) on select" is written above a statement that takes no directives`) { t.Errorf("%s: problems = %+v", stmt, s.Problems) } - if decls, _ := obligation.Declarations(s); len(decls) != 0 { + if decls, _ := obligation.Declarations(s.Contract()); len(decls) != 0 { t.Errorf("%s: the directive attached to something: %+v", stmt, decls) } } @@ -42,7 +42,7 @@ CREATE TABLE orders (id bigint PRIMARY KEY, note text NOT NULL); if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) t.Logf("decls=%+v problems=%+v", decls, problems) if len(problems) == 0 { for _, o := range decls { @@ -71,7 +71,7 @@ CREATE TABLE "App".order_items (id bigint PRIMARY KEY, order_id bigint NOT NULL if len(s.Problems) > 0 { t.Fatalf("schema problems: %+v", s.Problems) } - _, problems := obligation.Declarations(s) + _, problems := obligation.Declarations(s.Contract()) if len(problems) == 0 { t.Errorf("aggregate with quoted-schema child %q raised no Problem", `"App".order_items`) } else { @@ -93,7 +93,7 @@ CREATE TABLE orders (id bigint PRIMARY KEY, "Tenant Id" bigint NOT NULL); if err != nil { t.Fatal(err) } - _, problems := obligation.Declarations(s) + _, problems := obligation.Declarations(s.Contract()) if len(problems) == 0 { t.Errorf("expected a Problem for a quoted column name inside pinned(), got none (would mean it's silently accepted or silently broken)") } else { @@ -140,7 +140,7 @@ CREATE TABLE orders (id bigint PRIMARY KEY, tenant_id bigint NOT NULL); if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) == 0 { t.Errorf("`pinned(TENANT_ID)` against column `tenant_id` produced no Problem; decls=%+v", decls) } else { @@ -163,7 +163,7 @@ CREATE TABLE orders (id bigint PRIMARY KEY, status text NOT NULL); if err != nil { t.Fatal(err) } - all, problems := obligation.Declarations(s) + all, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -224,7 +224,7 @@ CREATE TABLE orders (id bigint PRIMARY KEY, note text NOT NULL); if err != nil { t.Fatal(err) } - all, problems := obligation.Declarations(s) + all, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } diff --git a/internal/obligation/adv_shape_test.go b/check/postgres/obligation/adv_shape_test.go similarity index 91% rename from internal/obligation/adv_shape_test.go rename to check/postgres/obligation/adv_shape_test.go index 8d1cb26..c005cb6 100644 --- a/internal/obligation/adv_shape_test.go +++ b/check/postgres/obligation/adv_shape_test.go @@ -4,9 +4,9 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/obligation" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" + "github.com/kr9ly/sqlshape/v2/x/obligation" ) // TestAdvNeverOnConflictUpdate: an INSERT ... ON CONFLICT DO UPDATE against a table @@ -28,7 +28,7 @@ CREATE TABLE ledger2 ( if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -39,7 +39,7 @@ CREATE TABLE ledger2 ( } var found bool var lines []string - for _, d := range obligation.Check(s, decls, r.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) { lines = append(lines, d.Obligation.Source+" "+pathName(d.Path)) if d.Obligation.Body.Never { found = true @@ -64,7 +64,7 @@ CREATE TABLE orders ( if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -77,7 +77,7 @@ CREATE TABLE orders ( } var found bool var lines []string - for _, d := range obligation.Check(s, decls, r.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) { lines = append(lines, d.Obligation.Source+" "+pathName(d.Path)+" "+d.Message) if d.Obligation.Body.Transitions != nil { found = true @@ -103,7 +103,7 @@ CREATE TABLE ledger (id bigint PRIMARY KEY, amount int NOT NULL); if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -114,7 +114,7 @@ CREATE TABLE ledger (id bigint PRIMARY KEY, amount int NOT NULL); } var lines []string var fail bool - for _, d := range obligation.Check(s, decls, r.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) { lines = append(lines, d.Obligation.Source+" "+pathName(d.Path)+" "+d.Message) if d.Obligation.Body.Never && d.Failed() { fail = true @@ -139,7 +139,7 @@ CREATE TABLE orders (id bigint PRIMARY KEY, status text NOT NULL); if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -155,7 +155,7 @@ WHEN MATCHED THEN UPDATE SET status = 'paid'` } var lines []string var sawPaidFail bool - for _, d := range obligation.Check(s, decls, r.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) { line := d.Obligation.Source + " " + pathName(d.Path) + " " + d.Message lines = append(lines, line) if strings.Contains(d.Message, "paid") { @@ -184,7 +184,7 @@ func sensLines(t *testing.T, s *schema.Schema, decls []obligation.Obligation, sq } var lines []string var anyFail bool - for _, d := range obligation.Check(s, decls, r.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) { if d.Obligation.Body.Sensitive == nil { continue } @@ -203,7 +203,7 @@ func TestAdvSensitive(t *testing.T) { if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -249,7 +249,7 @@ CREATE TABLE orders (id bigint PRIMARY KEY, status text NOT NULL, other_id bigin if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -259,7 +259,7 @@ CREATE TABLE orders (id bigint PRIMARY KEY, status text NOT NULL, other_id bigin return "ANALYZE ERROR: " + err.Error() } var lines []string - for _, d := range obligation.Check(s, decls, r.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) { if d.Obligation.Body.Transitions == nil { continue } @@ -306,7 +306,7 @@ CREATE TABLE ledger (id bigint PRIMARY KEY, amount int NOT NULL); if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -316,7 +316,7 @@ CREATE TABLE ledger (id bigint PRIMARY KEY, amount int NOT NULL); return "ANALYZE ERROR: " + err.Error() } var lines []string - for _, d := range obligation.Check(s, decls, r.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) { if d.Obligation.Body.Single && d.Obligation.Subject != "orders" && d.Obligation.Subject != "app.orders" { continue } @@ -370,7 +370,7 @@ CREATE FUNCTION all_memos() RETURNS SETOF memos LANGUAGE sql AS $$ SELECT * FROM if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -381,7 +381,7 @@ CREATE FUNCTION all_memos() RETURNS SETOF memos LANGUAGE sql AS $$ SELECT * FROM t.Fatalf("%s: %v", fn.Name, err) } for _, st := range fr.Statements { - for _, d := range obligation.Check(s, decls, st.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, st.Facts, lowerer{s}) { if d.Failed() { failed = append(failed, fn.Name+": "+d.Obligation.Source) } diff --git a/internal/obligation/adv_strict_test.go b/check/postgres/obligation/adv_strict_test.go similarity index 92% rename from internal/obligation/adv_strict_test.go rename to check/postgres/obligation/adv_strict_test.go index 543f68e..c4d8c36 100644 --- a/internal/obligation/adv_strict_test.go +++ b/check/postgres/obligation/adv_strict_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/obligation" + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/v2/x/obligation" ) // A `transitions` target with two or more declared predecessors (`shipped -> returned, @@ -23,7 +23,7 @@ CREATE TABLE parcels (id bigint PRIMARY KEY, status text NOT NULL); if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -33,7 +33,7 @@ CREATE TABLE parcels (id bigint PRIMARY KEY, status text NOT NULL); t.Fatalf("%s: %v", sql, err) } var lines []string - for _, d := range obligation.Check(s, decls, r.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) { line := d.Leaf.Table + " " + pathName(d.Path) if d.Message != "" { line += ": " + d.Message @@ -60,7 +60,7 @@ CREATE TABLE parcels (id bigint PRIMARY KEY, status text NOT NULL); if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -70,7 +70,7 @@ CREATE TABLE parcels (id bigint PRIMARY KEY, status text NOT NULL); t.Fatalf("%s: %v", sql, err) } var lines []string - for _, d := range obligation.Check(s, decls, r.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) { line := d.Leaf.Table + " " + pathName(d.Path) if d.Message != "" { line += ": " + d.Message @@ -102,7 +102,7 @@ func TestAdvStrictHolesNotFound(t *testing.T) { if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -112,7 +112,7 @@ func TestAdvStrictHolesNotFound(t *testing.T) { t.Fatalf("%s: %v", sql, err) } var lines []string - for _, d := range obligation.Check(s, decls, r.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) { line := d.Leaf.Table + " " + pathName(d.Path) if d.Message != "" { line += ": " + d.Message diff --git a/internal/obligation/adv_unsound_test.go b/check/postgres/obligation/adv_unsound_test.go similarity index 89% rename from internal/obligation/adv_unsound_test.go rename to check/postgres/obligation/adv_unsound_test.go index 462fd35..f2f3269 100644 --- a/internal/obligation/adv_unsound_test.go +++ b/check/postgres/obligation/adv_unsound_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/obligation" + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/v2/x/obligation" ) // A statement that touches a table with an obligation is always diagnosed when it does @@ -34,7 +34,7 @@ func TestAdvWriteThroughUpdatableViewIsJudgedByNever(t *testing.T) { if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -46,7 +46,7 @@ func TestAdvWriteThroughUpdatableViewIsJudgedByNever(t *testing.T) { if err != nil { t.Fatalf("%s: %v", sql, err) } - ds := obligation.Check(s, decls, r.Facts, lowerer{s}) + ds := obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) var failed bool for _, d := range ds { if d.Failed() { @@ -66,7 +66,7 @@ func TestAdvWriteThroughUpdatableViewIsJudgedByPinned(t *testing.T) { if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -75,7 +75,7 @@ func TestAdvWriteThroughUpdatableViewIsJudgedByPinned(t *testing.T) { if err != nil { t.Fatalf("%s: %v", sql, err) } - ds := obligation.Check(s, decls, r.Facts, lowerer{s}) + ds := obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) var failed bool for _, d := range ds { if d.Failed() { @@ -97,7 +97,7 @@ func TestAdvTruncateIsDiagnosedByNever(t *testing.T) { if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -110,7 +110,7 @@ func TestAdvTruncateIsDiagnosedByNever(t *testing.T) { if r.Facts == nil { t.Fatalf("%s: analyze.Analyze reported no error but also no facts (r.Facts == nil): TRUNCATE is silently unjudged, not diagnosed", sql) } - ds := obligation.Check(s, decls, r.Facts, lowerer{s}) + ds := obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) var failed bool for _, d := range ds { if d.Failed() { @@ -132,7 +132,7 @@ func TestAdvPinnedRejectsBareAssignment(t *testing.T) { if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -145,7 +145,7 @@ func TestAdvPinnedRejectsBareAssignment(t *testing.T) { if err != nil { t.Fatalf("%s: %v", sql, err) } - ds := obligation.Check(s, decls, r.Facts, lowerer{s}) + ds := obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) var failed bool var lines []string for _, d := range ds { diff --git a/internal/obligation/aggregate_test.go b/check/postgres/obligation/aggregate_test.go similarity index 94% rename from internal/obligation/aggregate_test.go rename to check/postgres/obligation/aggregate_test.go index ea7060d..5e81392 100644 --- a/internal/obligation/aggregate_test.go +++ b/check/postgres/obligation/aggregate_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/obligation" + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/v2/x/obligation" ) const aggregateSchema = ` @@ -26,7 +26,7 @@ func TestAggregate(t *testing.T) { if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -84,7 +84,7 @@ aggregate orders statement`}, continue } var lines []string - for _, d := range obligation.Check(s, decls, r.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) { line := d.Obligation.Source + " " + pathName(d.Path) if d.Message != "" { line += " " + d.Message @@ -101,12 +101,12 @@ aggregate orders statement`}, if err != nil { t.Fatal(err) } - _, problems = obligation.Declarations(bad) + _, problems = obligation.Declarations(bad.Contract()) if len(problems) != 1 || !strings.Contains(problems[0].Message, "sits above dummy, not currencies") { t.Errorf("misplaced aggregate: %+v", problems) } bad, _ = analyze.Load(strings.Replace(aggregateSchema, "aggregate orders (order_items, order_notes, order_item_tags)", "aggregate orders (order_items, currencies)", 1)) - _, problems = obligation.Declarations(bad) + _, problems = obligation.Declarations(bad.Contract()) if len(problems) != 1 || !strings.Contains(problems[0].Message, "currencies has no foreign key to orders or to another member") { t.Errorf("child without fk: %+v", problems) } diff --git a/internal/obligation/check_test.go b/check/postgres/obligation/check_test.go similarity index 94% rename from internal/obligation/check_test.go rename to check/postgres/obligation/check_test.go index 6ea8015..bf78e76 100644 --- a/internal/obligation/check_test.go +++ b/check/postgres/obligation/check_test.go @@ -4,10 +4,10 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/facts" - "github.com/kr9ly/sqlshape/internal/obligation" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" + "github.com/kr9ly/sqlshape/v2/x/facts" + "github.com/kr9ly/sqlshape/v2/x/obligation" ) const checkSchema = ` @@ -44,8 +44,8 @@ CREATE VIEW secret_titles AS SELECT id FROM secrets; type lowerer struct{ s *schema.Schema } -func (l lowerer) Lower(expr string, rel *schema.Relation) ([]facts.Pred, error) { - return analyze.Lower(l.s, expr, rel) +func (l lowerer) Lower(expr string, rel obligation.Relation) ([]facts.Pred, error) { + return analyze.Lower(l.s, expr, l.s.ByFullName(rel.FullName())) } func TestCheck(t *testing.T) { @@ -53,7 +53,7 @@ func TestCheck(t *testing.T) { if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -138,7 +138,7 @@ require status <> 'closed' AND amount > 0 on update FAIL tickets requires status continue } var lines []string - for _, d := range obligation.Check(s, decls, r.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) { line := d.Obligation.Source + " " + pathName(d.Path) if d.Message != "" { line += " " + d.Message diff --git a/check/postgres/obligation/checkoption_test.go b/check/postgres/obligation/checkoption_test.go new file mode 100644 index 0000000..4b0c427 --- /dev/null +++ b/check/postgres/obligation/checkoption_test.go @@ -0,0 +1,176 @@ +package obligation_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgconn" + + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/check/postgres/v2/oracle" + "github.com/kr9ly/sqlshape/v2/x/obligation" +) + +// TestCheckOptionPinsCascaded: a write through a CASCADED (or plain, since CASCADED is +// PostgreSQL's default) WITH CHECK OPTION view is pinned by whatever the view chain's own +// WHERE fixes, down to the base table -- the server refuses any row that would not satisfy +// it, so `require pinned(tenant_id)` is discharged (ByView) even though the writing +// statement's own WHERE never mentions tenant_id. +// +// Measured against a real, running PostgreSQL: an UPDATE through the CASCADED view that +// tries to move a row's tenant_id away from 1 (the underlying view v1's WHERE) is rejected +// with SQLSTATE 44000, so the pin genuinely holds for every write PostgreSQL accepts. +func TestCheckOptionPinsCascaded(t *testing.T) { + schema := ` +-- sqlshape: require pinned(tenant_id) +CREATE TABLE t (id bigint PRIMARY KEY, tenant_id int NOT NULL, v int NOT NULL); +CREATE VIEW v1 AS SELECT id, tenant_id, v FROM t WHERE tenant_id = 1; +CREATE VIEW v2 AS SELECT id, tenant_id, v FROM v1 WHERE v > 0 WITH CASCADED CHECK OPTION;` + s, err := analyze.Load(schema) + if err != nil { + t.Fatal(err) + } + all, problems := obligation.Declarations(s.Contract()) + if len(problems) > 0 { + t.Fatalf("problems: %+v", problems) + } + + // the statement itself never mentions tenant_id: only the view chain pins it + sql := `UPDATE v2 SET v = $1 WHERE id = $2` + r, aerr := analyze.Analyze(s, sql) + if aerr != nil { + t.Fatalf("analyze: %v", aerr) + } + var got string + for _, d := range obligation.Check(s.Contract(), all, r.Facts, lowerer{s}) { + if d.Obligation.Body.Pinned == "tenant_id" { + got = pathName(d.Path) + if d.Failed() { + got += ": " + d.Message + } + } + } + if got != "view" { + t.Errorf("pinned(tenant_id) discharge = %q, want \"view\" (through v2's CASCADED chain down to v1's WHERE tenant_id = 1)", got) + } + + // ground truth: a statement that does move the row's tenant_id is rejected by the + // server (44000), so the pin the analyzer reports is not a false promise + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + o, err := oracle.Start(ctx, schema) + if err != nil { + t.Fatalf("oracle start: %v", err) + } + defer o.Close() + conn := o.Conn() + if _, err := conn.Exec(ctx, `INSERT INTO t (id, tenant_id, v) VALUES (1, 1, 5)`); err != nil { + t.Fatalf("seed: %v", err) + } + _, err = conn.Exec(ctx, `UPDATE v2 SET tenant_id = 2 WHERE id = 1`) + var pe *pgconn.PgError + if !errors.As(err, &pe) || pe.Code != "44000" { + t.Fatalf("UPDATE v2 SET tenant_id = 2: want SQLSTATE 44000 (new row violates check option), got %v", err) + } +} + +// TestCheckOptionLocalDoesNotPinUnderlyingView: WITH LOCAL CHECK OPTION only re-checks the +// view's own WHERE after a write, not an underlying view's -- so a write through it is not +// pinned by the underlying view's WHERE. The analyzer must not discharge +// `require pinned(tenant_id)` here; PostgreSQL genuinely lets the write move the row's +// tenant_id away from the value v1's WHERE names, confirming the pin would be a false +// promise if the checker granted it. +func TestCheckOptionLocalDoesNotPinUnderlyingView(t *testing.T) { + schema := ` +-- sqlshape: require pinned(tenant_id) +CREATE TABLE t (id bigint PRIMARY KEY, tenant_id int NOT NULL, v int NOT NULL); +CREATE VIEW v1 AS SELECT id, tenant_id, v FROM t WHERE tenant_id = 1; +CREATE VIEW v2 AS SELECT id, tenant_id, v FROM v1 WHERE v > 0 WITH LOCAL CHECK OPTION;` + s, err := analyze.Load(schema) + if err != nil { + t.Fatal(err) + } + all, problems := obligation.Declarations(s.Contract()) + if len(problems) > 0 { + t.Fatalf("problems: %+v", problems) + } + sql := `UPDATE v2 SET v = $1 WHERE id = $2` + r, aerr := analyze.Analyze(s, sql) + if aerr != nil { + t.Fatalf("analyze: %v", aerr) + } + var got string + for _, d := range obligation.Check(s.Contract(), all, r.Facts, lowerer{s}) { + if d.Obligation.Body.Pinned == "tenant_id" { + got = pathName(d.Path) + if d.Failed() { + got += ": " + d.Message + } + } + } + if got == "view" { + t.Errorf("pinned(tenant_id) discharge = %q through v2's LOCAL check option, but LOCAL never re-checks v1's WHERE: this would be a false promise", got) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + o, err := oracle.Start(ctx, schema) + if err != nil { + t.Fatalf("oracle start: %v", err) + } + defer o.Close() + conn := o.Conn() + if _, err := conn.Exec(ctx, `INSERT INTO t (id, tenant_id, v) VALUES (1, 1, 5)`); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := conn.Exec(ctx, `UPDATE v2 SET tenant_id = 2 WHERE id = 1`); err != nil { + t.Fatalf("UPDATE v2 SET tenant_id = 2: WITH LOCAL CHECK OPTION should let this through (only v2's own v > 0 is re-checked), got %v", err) + } + var tenantID int + if err := conn.QueryRow(ctx, `SELECT tenant_id FROM t WHERE id = 1`).Scan(&tenantID); err != nil { + t.Fatalf("select: %v", err) + } + if tenantID != 2 { + t.Fatalf("tenant_id = %d, want 2 (LOCAL CHECK OPTION let the write move it away from v1's WHERE)", tenantID) + } +} + +// TestCheckOptionSkipsUnprojectedColumn: a WITH CHECK OPTION view's WHERE may name a +// column the view's own SELECT list never projects (tenant_id here); LiftThroughView has +// no output to translate it through, so it must be left out rather than guessed at. The +// obligation is not discharged -- a conservative, not a false, answer: nothing about the +// write statement's own leaf names tenant_id at all, so `require pinned(tenant_id)` simply +// does not apply to it in a way the checker can state. +func TestCheckOptionSkipsUnprojectedColumn(t *testing.T) { + schema := ` +-- sqlshape: require pinned(tenant_id) +CREATE TABLE t (id bigint PRIMARY KEY, tenant_id int NOT NULL, v int NOT NULL); +CREATE VIEW v1 AS SELECT id, v FROM t WHERE tenant_id = 1 WITH CASCADED CHECK OPTION;` + s, err := analyze.Load(schema) + if err != nil { + t.Fatal(err) + } + all, problems := obligation.Declarations(s.Contract()) + if len(problems) > 0 { + t.Fatalf("problems: %+v", problems) + } + sql := `UPDATE v1 SET v = $1 WHERE id = $2` + r, aerr := analyze.Analyze(s, sql) + if aerr != nil { + t.Fatalf("analyze: %v", aerr) + } + var got string + for _, d := range obligation.Check(s.Contract(), all, r.Facts, lowerer{s}) { + if d.Obligation.Body.Pinned == "tenant_id" { + got = pathName(d.Path) + if d.Failed() { + got += ": " + d.Message + } + } + } + if got == "view" { + t.Errorf("pinned(tenant_id) discharge = %q, but v1 never projects tenant_id: there is nothing on this write's own leaf to call tenant_id", got) + } +} diff --git a/internal/obligation/context_test.go b/check/postgres/obligation/context_test.go similarity index 91% rename from internal/obligation/context_test.go rename to check/postgres/obligation/context_test.go index 49c00d3..cb776bc 100644 --- a/internal/obligation/context_test.go +++ b/check/postgres/obligation/context_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/obligation" + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/v2/x/obligation" ) const contextSchema = ` @@ -23,7 +23,7 @@ func TestContext(t *testing.T) { if err != nil { t.Fatal(err) } - all, problems := obligation.Declarations(s) + all, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -47,7 +47,7 @@ func TestContext(t *testing.T) { t.Fatalf("%s: %v", sql, err) } var lines []string - for _, d := range obligation.Check(s, obligation.InContext(all, ctx), r.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), obligation.InContext(all, ctx), r.Facts, lowerer{s}) { line := d.Leaf.Table + " " + d.Obligation.Body.Spec() + " " + pathName(d.Path) lines = append(lines, line) } @@ -86,7 +86,7 @@ func TestContext(t *testing.T) { if err != nil { t.Fatal(err) } - if _, p := obligation.Declarations(s2); len(p) != 1 { + if _, p := obligation.Declarations(s2.Contract()); len(p) != 1 { t.Errorf("%s: problems %+v", bad, p) } } diff --git a/check/postgres/obligation/doc.go b/check/postgres/obligation/doc.go new file mode 100644 index 0000000..a39359c --- /dev/null +++ b/check/postgres/obligation/doc.go @@ -0,0 +1,4 @@ +// Package obligation holds the tests of x/obligation against the PostgreSQL schema loader +// and analyzer: the declarations are read from real schema.sql text and the facts come +// from real statements. The obligations themselves live in x/obligation, dialect-free. +package obligation diff --git a/internal/obligation/edge_test.go b/check/postgres/obligation/edge_test.go similarity index 96% rename from internal/obligation/edge_test.go rename to check/postgres/obligation/edge_test.go index ca8b538..0f9f1f0 100644 --- a/internal/obligation/edge_test.go +++ b/check/postgres/obligation/edge_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/obligation" + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/v2/x/obligation" ) const edgeSchema = ` @@ -30,7 +30,7 @@ func TestEdges(t *testing.T) { if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -40,7 +40,7 @@ func TestEdges(t *testing.T) { t.Fatalf("%s: %v", sql, err) } var lines []string - for _, d := range obligation.Check(s, decls, r.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) { line := d.Leaf.Table + " " + shortSpec(d.Obligation) + " " + pathName(d.Path) if d.Failed() { line += ": " + d.Message diff --git a/internal/obligation/exists_test.go b/check/postgres/obligation/exists_test.go similarity index 95% rename from internal/obligation/exists_test.go rename to check/postgres/obligation/exists_test.go index ab50291..06ddf71 100644 --- a/internal/obligation/exists_test.go +++ b/check/postgres/obligation/exists_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/obligation" + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/v2/x/obligation" ) const existsSchema = ` @@ -28,7 +28,7 @@ func TestExistsObligations(t *testing.T) { if err != nil { t.Fatal(err) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -48,7 +48,7 @@ func TestExistsObligations(t *testing.T) { t.Fatalf("%s: %v", sql, err) } var lines []string - for _, d := range obligation.Check(s, decls, r.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) { lines = append(lines, d.Leaf.Table+" "+shortSpec(d.Obligation)+" "+pathName(d.Path)) } return strings.Join(lines, "\n") diff --git a/internal/obligation/rules_test.go b/check/postgres/obligation/rules_test.go similarity index 94% rename from internal/obligation/rules_test.go rename to check/postgres/obligation/rules_test.go index 00906fb..8032bcf 100644 --- a/internal/obligation/rules_test.go +++ b/check/postgres/obligation/rules_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/obligation" + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/v2/x/obligation" ) const rulesSchema = ` @@ -26,7 +26,7 @@ func TestRules(t *testing.T) { if err != nil { t.Fatal(err) } - all, problems := obligation.Declarations(s) + all, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("problems: %+v", problems) } @@ -36,7 +36,7 @@ func TestRules(t *testing.T) { t.Fatalf("%s: %v", sql, err) } var lines []string - for _, d := range obligation.Check(s, obligation.InContext(all, ctx), r.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), obligation.InContext(all, ctx), r.Facts, lowerer{s}) { line := d.Leaf.Table + " " + d.Obligation.Body.Spec() + " " + pathName(d.Path) if d.Failed() { line += ": " + d.Message @@ -101,7 +101,7 @@ func TestRules(t *testing.T) { if err != nil { t.Fatal(err) } - if _, p := obligation.Declarations(s2); len(p) != 1 { + if _, p := obligation.Declarations(s2.Contract()); len(p) != 1 { t.Errorf("%s: problems %+v", bad, p) } } diff --git a/internal/obligation/visible_test.go b/check/postgres/obligation/visible_test.go similarity index 88% rename from internal/obligation/visible_test.go rename to check/postgres/obligation/visible_test.go index 7d0299b..167057c 100644 --- a/internal/obligation/visible_test.go +++ b/check/postgres/obligation/visible_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/obligation" + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/v2/x/obligation" ) // TestVisibleWhere: `-- sqlshape: visible where deleted_at IS NULL` on memos, the first @@ -24,7 +24,7 @@ func TestVisibleWhere(t *testing.T) { for _, p := range s.Problems { t.Fatalf("schema: %s", p) } - decls, problems := obligation.Declarations(s) + decls, problems := obligation.Declarations(s.Contract()) if len(problems) > 0 { t.Fatalf("declarations: %+v", problems) } @@ -59,7 +59,7 @@ func TestVisibleWhere(t *testing.T) { t.Fatalf("%s: %v", c.sql, err) } var got []string - for _, d := range obligation.Check(s, decls, r.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) { if d.Failed() { got = append(got, d.Message) } @@ -81,14 +81,14 @@ func TestVisibleWhere(t *testing.T) { if err != nil { t.Fatal(err) } - if ds := obligation.Check(bad, decls, r.Facts, lowerer{bad}); len(ds) != 1 || !ds[0].Failed() { + if ds := obligation.Check(bad.Contract(), decls, r.Facts, lowerer{bad}); len(ds) != 1 || !ds[0].Failed() { t.Errorf("AnalyzeView: %+v", ds) } r, err = analyze.Analyze(bad, "SELECT id FROM all_memos") if err != nil { t.Fatal(err) } - if ds := obligation.Check(bad, decls, r.Facts, lowerer{bad}); len(ds) != 0 { + if ds := obligation.Check(bad.Contract(), decls, r.Facts, lowerer{bad}); len(ds) != 0 { t.Errorf("reader of bad view: %+v", ds) } } diff --git a/internal/oracle/README.md b/check/postgres/oracle/README.md similarity index 91% rename from internal/oracle/README.md rename to check/postgres/oracle/README.md index 6243ad1..d14cbbd 100644 --- a/internal/oracle/README.md +++ b/check/postgres/oracle/README.md @@ -8,6 +8,6 @@ oracle.go) as the ground truth for the pure-Go analyzer. template data directory are cached per release under `~/.cache/sqlshape/pg-`, ~0.3 s start after the first download) - `Describe(ctx, sql)` = anonymous PREPARE. Returns parameter types, result column types (as `format_type` prints them), and table-column provenance with `attnotnull`. PG rejections come back as `*PgError` with SQLSTATE and position -- `testdata/queries/*.sql` + `.golden`: the contract the analyzer must reproduce. Regenerate with `go test ./internal/oracle -update` +- `testdata/queries/*.sql` + `.golden`: the contract the analyzer must reproduce. Regenerate with `go test ./check/postgres/oracle -update` Not used at lint time. diff --git a/internal/oracle/lock_unix.go b/check/postgres/oracle/lock_unix.go similarity index 100% rename from internal/oracle/lock_unix.go rename to check/postgres/oracle/lock_unix.go diff --git a/internal/oracle/lock_windows.go b/check/postgres/oracle/lock_windows.go similarity index 100% rename from internal/oracle/lock_windows.go rename to check/postgres/oracle/lock_windows.go diff --git a/internal/oracle/oracle.go b/check/postgres/oracle/oracle.go similarity index 93% rename from internal/oracle/oracle.go rename to check/postgres/oracle/oracle.go index 4830721..7e3af3c 100644 --- a/internal/oracle/oracle.go +++ b/check/postgres/oracle/oracle.go @@ -20,7 +20,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" - "github.com/kr9ly/sqlshape/internal/pgparse" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" ) // binaries is the PostgreSQL release the oracle runs for each supported major version. @@ -112,7 +112,7 @@ func StartVersion(ctx context.Context, v pgparse.Version, schemaSQL string) (*Or if err != nil { return nil, err } - runtimePath, err := os.MkdirTemp("", "sqlshape-oracle-") + runtimePath, err := os.MkdirTemp(scratchRoot(), "sqlshape-oracle-") if err != nil { return nil, err } @@ -170,6 +170,26 @@ func config(v pgparse.Version, cache, runtimePath, dataPath string, port int) em return cfg } +// scratchRoot is where a server's runtime and data directories go: SQLSHAPE_ORACLE_DIR when +// set, else /dev/shm when it is a writable directory (tmpfs on Linux: the copy of the +// template data directory and the server's own writes stay off the disk), else the system +// temp directory. TMPDIR alone does not opt out, since nix-shell and other wrappers set it +// to a disk path as a matter of course. The directory is removed on Close either way. +func scratchRoot() string { + if d := os.Getenv("SQLSHAPE_ORACLE_DIR"); d != "" { + return d + } + const shm = "/dev/shm" + if st, err := os.Stat(shm); err == nil && st.IsDir() { + if f, err := os.CreateTemp(shm, "sqlshape-probe-"); err == nil { + f.Close() + os.Remove(f.Name()) + return shm + } + } + return "" +} + // cacheDir is where the extracted binaries and the template data directory live: // $SQLSHAPE_PG_CACHE/pg-, else /sqlshape/pg-. func cacheDir(v pgparse.Version) (string, error) { diff --git a/internal/oracle/oracle_test.go b/check/postgres/oracle/oracle_test.go similarity index 100% rename from internal/oracle/oracle_test.go rename to check/postgres/oracle/oracle_test.go diff --git a/internal/oracle/testdata/queries/array_agg_row.golden b/check/postgres/oracle/testdata/queries/array_agg_row.golden similarity index 100% rename from internal/oracle/testdata/queries/array_agg_row.golden rename to check/postgres/oracle/testdata/queries/array_agg_row.golden diff --git a/internal/oracle/testdata/queries/array_agg_row.sql b/check/postgres/oracle/testdata/queries/array_agg_row.sql similarity index 100% rename from internal/oracle/testdata/queries/array_agg_row.sql rename to check/postgres/oracle/testdata/queries/array_agg_row.sql diff --git a/internal/oracle/testdata/queries/bare_param.golden b/check/postgres/oracle/testdata/queries/bare_param.golden similarity index 100% rename from internal/oracle/testdata/queries/bare_param.golden rename to check/postgres/oracle/testdata/queries/bare_param.golden diff --git a/internal/oracle/testdata/queries/bare_param.sql b/check/postgres/oracle/testdata/queries/bare_param.sql similarity index 100% rename from internal/oracle/testdata/queries/bare_param.sql rename to check/postgres/oracle/testdata/queries/bare_param.sql diff --git a/internal/oracle/testdata/queries/case_union.golden b/check/postgres/oracle/testdata/queries/case_union.golden similarity index 100% rename from internal/oracle/testdata/queries/case_union.golden rename to check/postgres/oracle/testdata/queries/case_union.golden diff --git a/internal/oracle/testdata/queries/case_union.sql b/check/postgres/oracle/testdata/queries/case_union.sql similarity index 100% rename from internal/oracle/testdata/queries/case_union.sql rename to check/postgres/oracle/testdata/queries/case_union.sql diff --git a/internal/oracle/testdata/queries/err_enum_text_compare.golden b/check/postgres/oracle/testdata/queries/err_enum_text_compare.golden similarity index 100% rename from internal/oracle/testdata/queries/err_enum_text_compare.golden rename to check/postgres/oracle/testdata/queries/err_enum_text_compare.golden diff --git a/internal/oracle/testdata/queries/err_enum_text_compare.sql b/check/postgres/oracle/testdata/queries/err_enum_text_compare.sql similarity index 100% rename from internal/oracle/testdata/queries/err_enum_text_compare.sql rename to check/postgres/oracle/testdata/queries/err_enum_text_compare.sql diff --git a/internal/oracle/testdata/queries/err_unknown_column.golden b/check/postgres/oracle/testdata/queries/err_unknown_column.golden similarity index 100% rename from internal/oracle/testdata/queries/err_unknown_column.golden rename to check/postgres/oracle/testdata/queries/err_unknown_column.golden diff --git a/internal/oracle/testdata/queries/err_unknown_column.sql b/check/postgres/oracle/testdata/queries/err_unknown_column.sql similarity index 100% rename from internal/oracle/testdata/queries/err_unknown_column.sql rename to check/postgres/oracle/testdata/queries/err_unknown_column.sql diff --git a/internal/oracle/testdata/queries/insert_returning.golden b/check/postgres/oracle/testdata/queries/insert_returning.golden similarity index 100% rename from internal/oracle/testdata/queries/insert_returning.golden rename to check/postgres/oracle/testdata/queries/insert_returning.golden diff --git a/internal/oracle/testdata/queries/insert_returning.sql b/check/postgres/oracle/testdata/queries/insert_returning.sql similarity index 100% rename from internal/oracle/testdata/queries/insert_returning.sql rename to check/postgres/oracle/testdata/queries/insert_returning.sql diff --git a/internal/oracle/testdata/queries/join_enum_param.golden b/check/postgres/oracle/testdata/queries/join_enum_param.golden similarity index 100% rename from internal/oracle/testdata/queries/join_enum_param.golden rename to check/postgres/oracle/testdata/queries/join_enum_param.golden diff --git a/internal/oracle/testdata/queries/join_enum_param.sql b/check/postgres/oracle/testdata/queries/join_enum_param.sql similarity index 100% rename from internal/oracle/testdata/queries/join_enum_param.sql rename to check/postgres/oracle/testdata/queries/join_enum_param.sql diff --git a/internal/oracle/testdata/queries/left_join_expr.golden b/check/postgres/oracle/testdata/queries/left_join_expr.golden similarity index 100% rename from internal/oracle/testdata/queries/left_join_expr.golden rename to check/postgres/oracle/testdata/queries/left_join_expr.golden diff --git a/internal/oracle/testdata/queries/left_join_expr.sql b/check/postgres/oracle/testdata/queries/left_join_expr.sql similarity index 100% rename from internal/oracle/testdata/queries/left_join_expr.sql rename to check/postgres/oracle/testdata/queries/left_join_expr.sql diff --git a/internal/oracle/testdata/queries/simple_select.golden b/check/postgres/oracle/testdata/queries/simple_select.golden similarity index 100% rename from internal/oracle/testdata/queries/simple_select.golden rename to check/postgres/oracle/testdata/queries/simple_select.golden diff --git a/internal/oracle/testdata/queries/simple_select.sql b/check/postgres/oracle/testdata/queries/simple_select.sql similarity index 100% rename from internal/oracle/testdata/queries/simple_select.sql rename to check/postgres/oracle/testdata/queries/simple_select.sql diff --git a/internal/oracle/testdata/queries/view_select.golden b/check/postgres/oracle/testdata/queries/view_select.golden similarity index 100% rename from internal/oracle/testdata/queries/view_select.golden rename to check/postgres/oracle/testdata/queries/view_select.golden diff --git a/internal/oracle/testdata/queries/view_select.sql b/check/postgres/oracle/testdata/queries/view_select.sql similarity index 100% rename from internal/oracle/testdata/queries/view_select.sql rename to check/postgres/oracle/testdata/queries/view_select.sql diff --git a/internal/oracle/testdata/schema.sql b/check/postgres/oracle/testdata/schema.sql similarity index 100% rename from internal/oracle/testdata/schema.sql rename to check/postgres/oracle/testdata/schema.sql diff --git a/internal/pgparse/README.md b/check/postgres/pgparse/README.md similarity index 81% rename from internal/pgparse/README.md rename to check/postgres/pgparse/README.md index 45a1a34..5d9cb8f 100644 --- a/internal/pgparse/README.md +++ b/check/postgres/pgparse/README.md @@ -19,21 +19,21 @@ gets (code-built schemas, tests); a `schema.sql` file must declare its version. Each step has a check; the regress probe at the end lists what the analyzer does not yet know about the version. -1. Parser. `cd internal/pgparse/wasm && nix-shell -p emscripten protobuf protoc-gen-go --run +1. Parser. `cd check/postgres/pgparse/wasm && nix-shell -p emscripten protobuf protoc-gen-go --run "./build.sh pb"` (`pb` regenerates the node types from that tag's proto; the tag reads `17-6.2.2` up to 17 and `18.0.0` from 18). Add the `//go:embed` and the `PG` constant in `pgparse.go`, set `newest`, and rebuild the older versions' modules without `pb` so the shim matches. Compare the new proto with the previous one (field renames / removals) and extend `upgrades` in `upgrade.go` for every older version. Check: `go test - ./internal/pgparse` — `TestCorpus` needs the previous version's regress corpus and fails on a + ./check/postgres/pgparse` — `TestCorpus` needs the previous version's regress corpus and fails on a field the upgrade missed. Call sites whose generated accessors changed (`ReturningList` → `GetReturningClause().GetExprs()` in 18) show up as build errors. -2. Oracle. Add the release to `binaries` in `internal/oracle/oracle.go` (embedded-postgres must - ship it). Check: `go test ./internal/oracle`. -3. Catalog. `go run ./internal/catalog/gen -pg ` then `-pg -all-ext` writes - `internal/catalog/data//`. Check: `go test ./internal/catalog`. -4. Regress corpus and baseline. `internal/analyze/testdata/tools/fetch-regress.sh REL__` - (the release the oracle runs), then `SQLSHAPE_PG= go test ./internal/analyze -run +2. Oracle. Add the release to `binaries` in `check/postgres/oracle/oracle.go` (embedded-postgres must + ship it). Check: `go test ./check/postgres/oracle`. +3. Catalog. `go run ./check/postgres/catalog/gen -pg ` then `-pg -all-ext` writes + `check/postgres/catalog/data//`. Check: `go test ./check/postgres/catalog`. +4. Regress corpus and baseline. `check/postgres/analyze/testdata/tools/fetch-regress.sh REL__` + (the release the oracle runs), then `SQLSHAPE_PG= go test ./check/postgres/analyze -run 'TestRegress$' -v -regress-report `. Statements the loader drops show as downstream 42P01s; new checks of the version show as LENIENT hits (the oracle errors, the analyzer does not); changed semantics as DIFFs. Port them under a version branch (`a.s.Version.Or() >= diff --git a/internal/pgparse/pg_query.pb.go b/check/postgres/pgparse/pg_query.pb.go similarity index 100% rename from internal/pgparse/pg_query.pb.go rename to check/postgres/pgparse/pg_query.pb.go diff --git a/internal/pgparse/pgparse.go b/check/postgres/pgparse/pgparse.go similarity index 100% rename from internal/pgparse/pgparse.go rename to check/postgres/pgparse/pgparse.go diff --git a/internal/pgparse/pgparse_test.go b/check/postgres/pgparse/pgparse_test.go similarity index 100% rename from internal/pgparse/pgparse_test.go rename to check/postgres/pgparse/pgparse_test.go diff --git a/internal/pgparse/upgrade.go b/check/postgres/pgparse/upgrade.go similarity index 100% rename from internal/pgparse/upgrade.go rename to check/postgres/pgparse/upgrade.go diff --git a/internal/pgparse/version_test.go b/check/postgres/pgparse/version_test.go similarity index 100% rename from internal/pgparse/version_test.go rename to check/postgres/pgparse/version_test.go diff --git a/internal/pgparse/wasm/build.sh b/check/postgres/pgparse/wasm/build.sh similarity index 96% rename from internal/pgparse/wasm/build.sh rename to check/postgres/pgparse/wasm/build.sh index 78581eb..3b2b122 100755 --- a/internal/pgparse/wasm/build.sh +++ b/check/postgres/pgparse/wasm/build.sh @@ -50,6 +50,6 @@ emcc -O2 -std=gnu99 -fwrapv -fno-strict-aliasing \ ls -la "$out" [ "${2:-}" = pb ] || exit 0 protoc --proto_path="$src/protobuf" --go_out="$here/.." \ - --go_opt=Mpg_query.proto=github.com/kr9ly/sqlshape/internal/pgparse --go_opt=paths=source_relative \ + --go_opt=Mpg_query.proto=github.com/kr9ly/sqlshape/check/postgres/v2/pgparse --go_opt=paths=source_relative \ "$src/protobuf/pg_query.proto" gofmt -w "$here/../pg_query.pb.go" diff --git a/internal/pgparse/wasm/pg_query_17.wasm b/check/postgres/pgparse/wasm/pg_query_17.wasm similarity index 100% rename from internal/pgparse/wasm/pg_query_17.wasm rename to check/postgres/pgparse/wasm/pg_query_17.wasm diff --git a/internal/pgparse/wasm/pg_query_18.wasm b/check/postgres/pgparse/wasm/pg_query_18.wasm similarity index 100% rename from internal/pgparse/wasm/pg_query_18.wasm rename to check/postgres/pgparse/wasm/pg_query_18.wasm diff --git a/internal/pgparse/wasm/shim.c b/check/postgres/pgparse/wasm/shim.c similarity index 100% rename from internal/pgparse/wasm/shim.c rename to check/postgres/pgparse/wasm/shim.c diff --git a/internal/schema/README.md b/check/postgres/schema/README.md similarity index 100% rename from internal/schema/README.md rename to check/postgres/schema/README.md diff --git a/internal/schema/adv_schema_test.go b/check/postgres/schema/adv_schema_test.go similarity index 98% rename from internal/schema/adv_schema_test.go rename to check/postgres/schema/adv_schema_test.go index 71e69e2..fd685ee 100644 --- a/internal/schema/adv_schema_test.go +++ b/check/postgres/schema/adv_schema_test.go @@ -17,8 +17,8 @@ import ( "time" "unicode/utf8" - "github.com/kr9ly/sqlshape/internal/oracle" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/oracle" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" ) // oracleConNames boots a real PG on sql and returns the conname of every constraint diff --git a/check/postgres/schema/contract.go b/check/postgres/schema/contract.go new file mode 100644 index 0000000..5f39d48 --- /dev/null +++ b/check/postgres/schema/contract.go @@ -0,0 +1,131 @@ +package schema + +import ( + "strings" + + "github.com/kr9ly/sqlshape/v2/x/facts" + "github.com/kr9ly/sqlshape/v2/x/obligation" +) + +// Contract is the schema as the contracts (x/obligation) see it: relations by the names the +// facts use, their columns, directives and foreign keys, and what a view column passes +// through. +func (s *Schema) Contract() obligation.Schema { return contractSchema{s} } + +// ByFullName resolves the schema-qualified-unless-public name the facts use as Leaf.Table. +func (s *Schema) ByFullName(name string) *Relation { + sch, n, ok := strings.Cut(name, ".") + if !ok { + return s.Relation("", name) + } + if rel := s.Relation(sch, n); rel != nil { + return rel + } + return s.Relation("", name) +} + +type contractSchema struct{ s *Schema } + +func (c contractSchema) Relations() []obligation.Relation { + out := make([]obligation.Relation, 0, len(c.s.Relations)) + for _, r := range c.s.Relations { + out = append(out, contractRel{c.s, r}) + } + return out +} + +func (c contractSchema) Relation(name string) obligation.Relation { + if r := c.s.ByFullName(name); r != nil { + return contractRel{c.s, r} + } + return nil +} + +type contractRel struct { + s *Schema + r *Relation +} + +func (c contractRel) Name() string { return c.r.Name } +func (c contractRel) FullName() string { return c.r.FullName() } + +func (c contractRel) Kind() facts.RelKind { + switch c.r.Kind { + case View: + return facts.View + case MatView: + return facts.MatView + } + return facts.Table +} + +// HasColumn: a table's column, or a view's frozen output column. +func (c contractRel) HasColumn(col string) bool { + if c.r.Column(col) != nil { + return true + } + for _, vc := range c.r.Frozen { + if vc.Name == col { + return true + } + } + return false +} + +// NotNull: a table column declared NOT NULL; a view's output never counts. +func (c contractRel) NotNull(col string) bool { + if c.r.Kind != Table { + return false + } + cc := c.r.Column(col) + return cc != nil && cc.NotNull +} + +func (c contractRel) Directives() []string { return c.r.Directives } + +// ForeignKeys lists the REFERENCES constraints; a key with no column list references the +// parent's primary key, spelled out here. +func (c contractRel) ForeignKeys() []obligation.ForeignKey { + var out []obligation.ForeignKey + for _, con := range c.r.Constraints { + if con.Kind != ForeignKey { + continue + } + fk := obligation.ForeignKey{Columns: con.Columns, RefTable: con.RefTable, RefColumns: con.RefColumns} + if len(fk.RefColumns) == 0 { + if parent := c.s.ByFullName(con.RefTable); parent != nil { + for _, pc := range parent.Constraints { + if pc.Kind == PrimaryKey { + fk.RefColumns = pc.Columns + break + } + } + } + } + out = append(out, fk) + } + return out +} + +// ViewSource is the base column a view's output column passes through unchanged. +func (c contractRel) ViewSource(col string) (string, string, bool) { + if c.r.Kind != View && c.r.Kind != MatView { + return "", "", false + } + for i := range c.r.Frozen { + vc := &c.r.Frozen[i] + if vc.Name != col { + continue + } + switch { + case vc.SrcRel != nil: + return vc.SrcRel.FullName(), vc.SrcColumn, true + case vc.SrcTable != "": + return vc.SrcTable, vc.SrcColumn, true + } + return "", "", false + } + return "", "", false +} + +func (c contractRel) ForceRowSecurity() bool { return c.r.ForceRowSecurity } diff --git a/internal/schema/ddl.go b/check/postgres/schema/ddl.go similarity index 99% rename from internal/schema/ddl.go rename to check/postgres/schema/ddl.go index 341e956..4177e75 100644 --- a/internal/schema/ddl.go +++ b/check/postgres/schema/ddl.go @@ -5,8 +5,8 @@ import ( "strconv" "strings" - "github.com/kr9ly/sqlshape/internal/catalog" - "github.com/kr9ly/sqlshape/internal/pgparse" + "github.com/kr9ly/sqlshape/check/postgres/v2/catalog" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" ) // The DDL the loader applies beyond CREATE TABLE / VIEW / FUNCTION: inheritance and diff --git a/internal/schema/ddl_test.go b/check/postgres/schema/ddl_test.go similarity index 99% rename from internal/schema/ddl_test.go rename to check/postgres/schema/ddl_test.go index 1270f5f..1badce2 100644 --- a/internal/schema/ddl_test.go +++ b/check/postgres/schema/ddl_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" ) // problems joins a schema's Problems' messages, one per line, for substring assertions. diff --git a/internal/schema/extra_test.go b/check/postgres/schema/extra_test.go similarity index 99% rename from internal/schema/extra_test.go rename to check/postgres/schema/extra_test.go index 0418f3a..e767762 100644 --- a/internal/schema/extra_test.go +++ b/check/postgres/schema/extra_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" ) // --- Load / Apply error paths ------------------------------------------------------- diff --git a/internal/schema/pg18_test.go b/check/postgres/schema/pg18_test.go similarity index 98% rename from internal/schema/pg18_test.go rename to check/postgres/schema/pg18_test.go index 305755d..9da9f41 100644 --- a/internal/schema/pg18_test.go +++ b/check/postgres/schema/pg18_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" ) // PostgreSQL 18 DDL: named NOT NULL constraints, NOT ENFORCED, WITHOUT OVERLAPS / PERIOD, diff --git a/internal/schema/rls.go b/check/postgres/schema/rls.go similarity index 98% rename from internal/schema/rls.go rename to check/postgres/schema/rls.go index b0e3583..d2465f3 100644 --- a/internal/schema/rls.go +++ b/check/postgres/schema/rls.go @@ -3,7 +3,7 @@ package schema import ( "strings" - "github.com/kr9ly/sqlshape/internal/pgparse" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" ) // Row-level security. A table's policies are part of its definition: the loader keeps diff --git a/internal/schema/rls_test.go b/check/postgres/schema/rls_test.go similarity index 98% rename from internal/schema/rls_test.go rename to check/postgres/schema/rls_test.go index 5d77dc5..86ad02a 100644 --- a/internal/schema/rls_test.go +++ b/check/postgres/schema/rls_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" ) func TestPolicies(t *testing.T) { diff --git a/internal/schema/schema.go b/check/postgres/schema/schema.go similarity index 92% rename from internal/schema/schema.go rename to check/postgres/schema/schema.go index d2f62f5..b739ab0 100644 --- a/internal/schema/schema.go +++ b/check/postgres/schema/schema.go @@ -17,8 +17,10 @@ import ( "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" - "github.com/kr9ly/sqlshape/internal/catalog" - "github.com/kr9ly/sqlshape/internal/pgparse" + "github.com/kr9ly/sqlshape/check/postgres/v2/catalog" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" + "github.com/kr9ly/sqlshape/v2/x/dialect" + "github.com/kr9ly/sqlshape/v2/x/obligation" ) // Expr is an unanalyzed expression from the DDL (kept as libpg_query AST). @@ -85,10 +87,14 @@ func (p Problem) String() string { return fmt.Sprintf("@%d: %s", p.Location, p.M // ViewColumn is one frozen output column of a view (see Relation.Frozen). type ViewColumn struct { - Name string - Type TypeRef - Nullable bool - Collation string // the column's collation name, "" for none / default + Name string + Type TypeRef + Nullable bool + // ElemNotNull: for an array-typed column, the view's defining query proved the elements + // are never NULL (dialect.Type.ElemNotNull's meaning); refrozen with Nullable when a + // base table's NOT NULL changes, since the proof may rest on one. + ElemNotNull bool + Collation string // the column's collation name, "" for none / default // SrcRel / Src: the base relation and column this output column is a plain reference // to (writes through the view land there); nil for computed columns. Pointers, so a // later RENAME of the base column is followed the way PG follows attnums. When the @@ -200,6 +206,17 @@ type Relation struct { // PartKeyFuncs: the functions the key expressions call (DROP FUNCTION CASCADE takes // the table). PartKey, PartKeyFuncs []string + // PartStrategy (partitioned tables): "RANGE" / "LIST" / "HASH", the PARTITION BY word. + // PartKeyText is the key's own text as PARTITION BY () + // reads, columns and expressions alike, in declaration order (unlike PartKey, which is + // only the columns referenced, for the "cannot drop" checks). + PartStrategy, PartKeyText string + // PartBound (partitions): the bound FOR VALUES ... / DEFAULT reads after ATTACH + // PARTITION / PARTITION OF, empty for a relation that is not a partition. Set from + // either a direct CREATE TABLE ... PARTITION OF ... FOR VALUES ... (Partbound on the + // CreateStmt) or a later ALTER TABLE ... ATTACH PARTITION ... FOR VALUES ... (the form + // pg_dump always uses); cleared by DETACH PARTITION. + PartBound string // QualifiedRules (views): write commands that have a conditional DO INSTEAD rule // (WHERE ...), which does not make the view take the write but does stop it from // being auto-updatable. @@ -502,10 +519,20 @@ func LoadWithHooks(cat *catalog.Catalog, schemaSQL string, viewHook, notNullHook if err != nil { return nil, fmt.Errorf("parse schema: %w", err) } + // `-- sqlshape: server = ` declares a server variable the judgments + // depend on; the PostgreSQL analyzer reads none yet (its judgments follow the server's + // defaults: search_path is the schema's own SET), so every one is a problem + settings, err := dialect.Settings(schemaSQL) + if err != nil { + return nil, err + } + var extProblems []Problem + for _, st := range settings { + extProblems = append(extProblems, Problem{Location: int32(st.Position), Message: fmt.Sprintf("server %s: not a variable sqlshape reads for PostgreSQL (none is, yet)", st.Name)}) + } // CREATE EXTENSION merges the extension's dumped catalog (types, functions, operators, // casts) under everything else, so it is resolved before any statement is applied var exts []string - var extProblems []Problem for _, raw := range tree.Stmts { ce := raw.Stmt.GetCreateExtensionStmt() if ce == nil { @@ -603,6 +630,11 @@ func leadingComments(text string) string { var directiveLine = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*sqlshape:[ \t]*(.+?)[ \t]*$`) +// validErrorName reports whether a `-- sqlshape: error` directive's name is a Go +// identifier: what vet requires of the `var X = sqlshape.Error(code)` declaration it +// must match. +var validErrorName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`).MatchString + // versionLine is the `-- sqlshape: postgres ` declaration: the one directive that // belongs to the file rather than to the statement below it, read before anything is parsed. var versionLine = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*sqlshape:[ \t]*postgres[ \t]+(\S+)[ \t]*$`) @@ -640,56 +672,20 @@ func supportedVersions() string { return strings.Join(parts, ", ") } -// Waivers reads the list of a `waive` directive: comma-separated (outside parentheses) -// entries of `
[]`, where the obligation is the body as declared -// (`pinned(tenant_id)`, `via view`, a predicate) and its absence waives every obligation -// on the table ("*"). -func Waivers(list string) map[string][]string { - out := map[string][]string{} - depth, start := 0, 0 - items := []string{} - for i := 0; i <= len(list); i++ { - if i == len(list) || (list[i] == ',' && depth == 0) { - items = append(items, strings.TrimSpace(list[start:i])) - start = i + 1 - continue - } - switch list[i] { - case '(': - depth++ - case ')': - depth-- - } - } - for _, it := range items { - if it == "" { - continue - } - table, spec, _ := strings.Cut(it, " ") - spec = strings.Join(strings.Fields(spec), " ") - if spec == "" { - spec = "*" - } - out = AddWaiver(out, table, spec) - } - return out -} +// Waivers reads the list of a `waive` directive (x/obligation's grammar). +func Waivers(list string) map[string][]string { return obligation.Waivers(list) } // AddWaiver appends specs to the table's waivers. func AddWaiver(m map[string][]string, table string, specs ...string) map[string][]string { - if m == nil { - m = map[string][]string{} - } - m[table] = append(m[table], specs...) - return m + return obligation.AddWaiver(m, table, specs...) } // directives extracts the `-- sqlshape: ` comment lines of a text. func directives(text string) []string { var out []string for _, m := range directiveLine.FindAllStringSubmatch(text, -1) { - if versionLine.MatchString(m[0]) { - continue // the file's declaration, read by Load, not the statement's + if versionLine.MatchString(m[0]) || dialect.IsSetting(m[1]) { + continue // the file's declaration and its server settings, read by Load, not the statement's } out = append(out, m[1]) } @@ -729,7 +725,11 @@ func (s *Schema) apply(n *pgparse.Node, loc int32) { defer func() { // remember the text that created what this statement added switch n.Node.(type) { - case *pgparse.Node_CreateFunctionStmt: + case *pgparse.Node_CreateFunctionStmt, *pgparse.Node_DefineStmt: + // DefineStmt: CREATE AGGREGATE is the only DefineStmt form that adds to + // Functions (createAggregate) -- it never set Definition itself (measured: + // the planner's function-alter path replays it via Definition the same way + // as a function/procedure, and found it empty) if len(s.Functions) > nFuncs { s.Functions[len(s.Functions)-1].Definition = s.stmtText } @@ -1125,6 +1125,9 @@ func (s *Schema) createTable(st *pgparse.CreateStmt, loc int32) { continue } s.inherit(rel, parent, st.Partbound != nil, loc) + if st.Partbound != nil { + rel.PartBound = partitionBoundText(st.Partbound) + } } s.pendingUsed = true for _, d := range s.pending { @@ -1145,6 +1148,10 @@ func (s *Schema) createTable(st *pgparse.CreateStmt, loc int32) { } rel.Visible = pred default: + if lower := strings.ToLower(norm); strings.HasPrefix(lower, "unfiltered ") || strings.HasPrefix(lower, "waive ") { + s.problem(loc, "table %s: directive %q is a view's or a statement's opt-out: write it above the CREATE VIEW, or above the statement, whose reads it waives", name, d) + continue + } s.problem(loc, "table %s: unknown directive %q", name, d) } } @@ -1166,17 +1173,82 @@ func (s *Schema) createTable(st *pgparse.CreateStmt, loc int32) { s.removeRelation(rel) return } + var keyParts []string for _, pn := range ps.PartParams { pe := pn.GetPartitionElem() switch { case pe == nil: case pe.Name != "": rel.PartKey = append(rel.PartKey, pe.Name) + keyParts = append(keyParts, pe.Name) case pe.Expr != nil: rel.PartKey = append(rel.PartKey, ColumnRefs(pe.Expr)...) rel.PartKeyFuncs = append(rel.PartKeyFuncs, funcNamesIn(pe.Expr)...) + keyParts = append(keyParts, "("+Deparse(pe.Expr)+")") } } + rel.PartStrategy = partitionStrategyWord(ps.Strategy) + rel.PartKeyText = strings.Join(keyParts, ", ") + } +} + +// partitionStrategyWord is the PARTITION BY word for a strategy (RANGE / LIST / HASH). +func partitionStrategyWord(st pgparse.PartitionStrategy) string { + switch st { + case pgparse.PartitionStrategy_PARTITION_STRATEGY_LIST: + return "LIST" + case pgparse.PartitionStrategy_PARTITION_STRATEGY_HASH: + return "HASH" + default: + return "RANGE" + } +} + +// partitionBoundText renders a PartitionBoundSpec the way PostgreSQL's own ATTACH +// PARTITION / PARTITION OF prints it: `FOR VALUES FROM (...) TO (...)` (RANGE), +// `FOR VALUES IN (...)` (LIST), `FOR VALUES WITH (MODULUS m, REMAINDER n)` (HASH), or +// `DEFAULT`. Measured against pg_dump 17, which always spells ATTACH this way (never the +// single-statement CREATE TABLE ... PARTITION OF ... form) -- the loader's canonical +// round trip depends on matching this text exactly. +func partitionBoundText(b *pgparse.PartitionBoundSpec) string { + if b == nil { + return "" + } + if b.IsDefault { + return "DEFAULT" + } + datum := func(n *pgparse.Node) string { + rd := n.GetPartitionRangeDatum() + if rd == nil { + return Deparse(n) + } + switch rd.Kind { + case pgparse.PartitionRangeDatumKind_PARTITION_RANGE_DATUM_MINVALUE: + return "MINVALUE" + case pgparse.PartitionRangeDatumKind_PARTITION_RANGE_DATUM_MAXVALUE: + return "MAXVALUE" + default: + return Deparse(rd.Value) + } + } + switch b.Strategy { + case "l": + var vals []string + for _, n := range b.Listdatums { + vals = append(vals, Deparse(n)) + } + return "FOR VALUES IN (" + strings.Join(vals, ", ") + ")" + case "h": + return fmt.Sprintf("FOR VALUES WITH (MODULUS %d, REMAINDER %d)", b.Modulus, b.Remainder) + default: // "r", RANGE + var lo, hi []string + for _, n := range b.Lowerdatums { + lo = append(lo, datum(n)) + } + for _, n := range b.Upperdatums { + hi = append(hi, datum(n)) + } + return "FOR VALUES FROM (" + strings.Join(lo, ", ") + ") TO (" + strings.Join(hi, ", ") + ")" } } @@ -1486,6 +1558,15 @@ func (s *Schema) createMatView(st *pgparse.CreateTableAsStmt, loc int32) { } func (s *Schema) alterTable(st *pgparse.AlterTableStmt, loc int32) { + if st.Objtype == pgparse.ObjectType_OBJECT_INDEX { + // ALTER INDEX ... ATTACH PARTITION child_idx: pg_dump's way of tying a partition's + // own index to the parent's partitioned index (both already loaded as ordinary, + // separately named indexes with their own definitions -- the attachment carries no + // property this model diffs), and the other ALTER INDEX forms (SET/RESET storage + // parameters, tablespace) this loader does not model either. A no-op rather than + // "unsupported statement", so it does not block loading a partitioned schema. + return + } schema, name := s.lookupRangeVar(st.Relation) rel := s.relByName[schema+"."+name] if rel == nil { @@ -1496,7 +1577,15 @@ func (s *Schema) alterTable(st *pgparse.AlterTableStmt, loc int32) { } shaping := false for _, cn := range st.Cmds { - if cmd := cn.GetAlterTableCmd(); cmd.GetSubtype() != pgparse.AlterTableType_AT_AddConstraint { + switch cmd := cn.GetAlterTableCmd(); cmd.GetSubtype() { + case pgparse.AlterTableType_AT_AddConstraint: + case pgparse.AlterTableType_AT_AttachPartition, pgparse.AlterTableType_AT_DetachPartition: + // migrate.go synthesizes ATTACH / DETACH PARTITION itself (Relation.Parents / + // PartBound), rather than replaying this verbatim: the statement names the + // *parent* (st.Relation here is the parent), so replaying it as one of the + // parent's own Alters -- right after the parent's CREATE TABLE -- would run + // before a brand-new partition's own CREATE TABLE exists (42P01, measured). + default: shaping = true } } @@ -1664,6 +1753,7 @@ func (s *Schema) alterTable(st *pgparse.AlterTableStmt, loc int32) { if cmd.Subtype == pgparse.AlterTableType_AT_AttachPartition { part.Parents = append(part.Parents, rel) part.IsPartition = true + part.PartBound = partitionBoundText(pc.GetBound()) // a partition's identity columns are the parent's for _, pc := range rel.Columns { if c := part.Column(pc.Name); c != nil && pc.Identity != 0 && pc.Identity != 's' { @@ -1679,6 +1769,7 @@ func (s *Schema) alterTable(st *pgparse.AlterTableStmt, loc int32) { } part.Parents = kept part.IsPartition = false // a detached partition stands on its own + part.PartBound = "" for _, c := range part.Columns { if c.Identity != 0 && c.Identity != 's' { c.Identity = 0 // detaching removes the identity property @@ -1793,7 +1884,12 @@ func (s *Schema) createFunction(st *pgparse.CreateFunctionStmt, loc int32) { s.problem(loc, "function %s: directive %q: SQLSTATE must be 5 characters", name, d) continue } - fn.Raises = append(fn.Raises, RaisedError{Code: code, Name: strings.TrimSpace(errName)}) + errName = strings.TrimSpace(errName) + if !validErrorName(errName) { + s.problem(loc, "function %s: directive %q: %q is not a valid name (a Go identifier)", name, d, errName) + continue + } + fn.Raises = append(fn.Raises, RaisedError{Code: code, Name: errName}) default: s.problem(loc, "function %s: unknown directive %q", name, d) } diff --git a/internal/schema/schema_test.go b/check/postgres/schema/schema_test.go similarity index 97% rename from internal/schema/schema_test.go rename to check/postgres/schema/schema_test.go index 7418ac2..6f69838 100644 --- a/internal/schema/schema_test.go +++ b/check/postgres/schema/schema_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - "github.com/kr9ly/sqlshape/internal/catalog" - "github.com/kr9ly/sqlshape/internal/oracle" + "github.com/kr9ly/sqlshape/check/postgres/v2/catalog" + "github.com/kr9ly/sqlshape/check/postgres/v2/oracle" ) func load(t *testing.T) *Schema { diff --git a/internal/schema/seed.go b/check/postgres/schema/seed.go similarity index 99% rename from internal/schema/seed.go rename to check/postgres/schema/seed.go index df7dbd6..4bf371d 100644 --- a/internal/schema/seed.go +++ b/check/postgres/schema/seed.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/kr9ly/sqlshape/internal/pgparse" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" ) // Seed is the fixed content of a table: the rows the schema text itself gives it with diff --git a/internal/schema/seed_test.go b/check/postgres/schema/seed_test.go similarity index 97% rename from internal/schema/seed_test.go rename to check/postgres/schema/seed_test.go index 4403704..0250843 100644 --- a/internal/schema/seed_test.go +++ b/check/postgres/schema/seed_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" ) func mustLoad(t *testing.T, sql string) *schema.Schema { diff --git a/check/postgres/schema/settings_test.go b/check/postgres/schema/settings_test.go new file mode 100644 index 0000000..06142d4 --- /dev/null +++ b/check/postgres/schema/settings_test.go @@ -0,0 +1,24 @@ +package schema + +import ( + "strings" + "testing" +) + +// A `-- sqlshape: server` line is read as a server setting, never as a statement's +// directive; PostgreSQL reads no variable yet, so each one is a problem of its own line. +func TestServerSettings(t *testing.T) { + s, err := Load("-- sqlshape: postgres 17\n-- sqlshape: server search_path = 'app'\nCREATE TABLE t (a int);") + if err != nil { + t.Fatal(err) + } + if len(s.Problems) != 1 || s.Problems[0].Location != 25 || !strings.Contains(s.Problems[0].Message, "server search_path: not a variable sqlshape reads for PostgreSQL") { + t.Errorf("problems: %v", s.Problems) + } + if r := s.Relation("", "t"); r == nil || len(r.Directives) != 0 { + t.Errorf("the setting became the table's directive: %+v", r) + } + if _, err := Load("-- sqlshape: postgres 17\n-- sqlshape: server search_path\n"); err == nil || !strings.Contains(err.Error(), "want ` = `") { + t.Errorf("malformed: %v", err) + } +} diff --git a/internal/schema/source.go b/check/postgres/schema/source.go similarity index 70% rename from internal/schema/source.go rename to check/postgres/schema/source.go index d1f4c78..32d84ac 100644 --- a/internal/schema/source.go +++ b/check/postgres/schema/source.go @@ -18,17 +18,28 @@ import ( // without one is not judged. Schema text built in code (Load) may leave it out and gets // pgparse.Default. func ReadSource(path string) (string, error) { - text, err := readSource(path) + text, err := ReadText(path) if err != nil { return "", err } - if !versionLine.MatchString(strings.TrimPrefix(text, "\ufeff")) { // a byte order mark is not part of the SQL - return "", fmt.Errorf("%s: declare the PostgreSQL version the schema is written for, as a line `-- sqlshape: postgres 17` (sqlshape supports PostgreSQL %s)", path, supportedVersions()) + if err := RequireVersion(path, text); err != nil { + return "", err } return text, nil } -func readSource(path string) (string, error) { +// RequireVersion is ReadSource's check on a text already read: the PostgreSQL version +// must be declared. +func RequireVersion(path, text string) error { + if !versionLine.MatchString(strings.TrimPrefix(text, "\ufeff")) { // a byte order mark is not part of the SQL + return fmt.Errorf("%s: declare the PostgreSQL version the schema is written for, as a line `-- sqlshape: postgres 17` (sqlshape supports PostgreSQL %s)", path, supportedVersions()) + } + return nil +} + +// ReadText is ReadSource without the version check: the schema text of a file or a +// directory, for a caller that reads the dialect line itself (internal/dialect). +func ReadText(path string) (string, error) { fi, err := os.Stat(path) if err != nil { return "", err diff --git a/internal/schema/source_test.go b/check/postgres/schema/source_test.go similarity index 100% rename from internal/schema/source_test.go rename to check/postgres/schema/source_test.go diff --git a/internal/schema/testdata/schema.sql b/check/postgres/schema/testdata/schema.sql similarity index 100% rename from internal/schema/testdata/schema.sql rename to check/postgres/schema/testdata/schema.sql diff --git a/internal/schema/types.go b/check/postgres/schema/types.go similarity index 99% rename from internal/schema/types.go rename to check/postgres/schema/types.go index 5af78a2..ebc17a9 100644 --- a/internal/schema/types.go +++ b/check/postgres/schema/types.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - "github.com/kr9ly/sqlshape/internal/catalog" + "github.com/kr9ly/sqlshape/check/postgres/v2/catalog" ) // FirstUserOID is where synthetic OIDs for user-defined objects start diff --git a/internal/schema/types_test.go b/check/postgres/schema/types_test.go similarity index 97% rename from internal/schema/types_test.go rename to check/postgres/schema/types_test.go index d849bab..e49300c 100644 --- a/internal/schema/types_test.go +++ b/check/postgres/schema/types_test.go @@ -3,7 +3,7 @@ package schema import ( "testing" - "github.com/kr9ly/sqlshape/internal/catalog" + "github.com/kr9ly/sqlshape/check/postgres/v2/catalog" ) // TestTypesUnknownOID exercises the defensive t == nil guards in renameUser / moveUser / diff --git a/internal/schema/version_test.go b/check/postgres/schema/version_test.go similarity index 95% rename from internal/schema/version_test.go rename to check/postgres/schema/version_test.go index 4dbbc7e..3745a87 100644 --- a/internal/schema/version_test.go +++ b/check/postgres/schema/version_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/pgparse" + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" ) // The `-- sqlshape: postgres ` declaration picks the grammar every statement is parsed diff --git a/internal/schema/waive_test.go b/check/postgres/schema/waive_test.go similarity index 100% rename from internal/schema/waive_test.go rename to check/postgres/schema/waive_test.go diff --git a/internal/verify/verify.go b/check/postgres/verify/verify.go similarity index 93% rename from internal/verify/verify.go rename to check/postgres/verify/verify.go index 82e0745..c265c63 100644 --- a/internal/verify/verify.go +++ b/check/postgres/verify/verify.go @@ -11,10 +11,10 @@ import ( "fmt" "strings" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/expand" - "github.com/kr9ly/sqlshape/internal/oracle" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/check/postgres/v2/oracle" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" + "github.com/kr9ly/sqlshape/v2/x/expand" ) // Mismatch is one expansion the analyzer and PostgreSQL disagree about. diff --git a/internal/verify/verify_test.go b/check/postgres/verify/verify_test.go similarity index 98% rename from internal/verify/verify_test.go rename to check/postgres/verify/verify_test.go index 93162c1..8454279 100644 --- a/internal/verify/verify_test.go +++ b/check/postgres/verify/verify_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/oracle" + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/check/postgres/v2/oracle" ) func TestMatch(t *testing.T) { diff --git a/cmd/sqlshape/LICENSE b/cmd/sqlshape/LICENSE new file mode 100644 index 0000000..d8cf7d4 --- /dev/null +++ b/cmd/sqlshape/LICENSE @@ -0,0 +1,280 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS diff --git a/cmd/sqlshape/go.mod b/cmd/sqlshape/go.mod new file mode 100644 index 0000000..bc18da1 --- /dev/null +++ b/cmd/sqlshape/go.mod @@ -0,0 +1,36 @@ +module github.com/kr9ly/sqlshape/cmd/sqlshape/v2 + +go 1.26.1 + +// The binary is its own module so that it can be GPLv2 (it carries the MySQL parser) while +// the runtime and pgtest stay Apache 2.0 at their import paths. The three modules of the +// repository release in lockstep under one version: scripts/release.sh vX.Y.Z sets the +// requires below to X.Y.Z and tags every module on one commit. A module of the repository +// that has no tag yet is not listed here (a require of a version that does not exist +// breaks module loading even in a workspace); go.work resolves it, and release.sh adds +// the require with the first tag. + +require ( + github.com/go-sql-driver/mysql v1.10.1 + github.com/jackc/pgx/v5 v5.10.0 + github.com/kr9ly/sqlshape/check/mysql/v2 v2.0.0-rc.2 + github.com/kr9ly/sqlshape/check/postgres/v2 v2.0.0-rc.2 + github.com/kr9ly/sqlshape/mysqltest/v2 v2.0.0-rc.2 + github.com/kr9ly/sqlshape/v2 v2.0.0-rc.2 + golang.org/x/tools v0.49.0 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/fergusstrange/embedded-postgres v1.34.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/tetratelabs/wazero v1.12.0 // indirect + github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect + golang.org/x/mod v0.39.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.29.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect +) diff --git a/cmd/sqlshape/go.sum b/cmd/sqlshape/go.sum new file mode 100644 index 0000000..f882cd9 --- /dev/null +++ b/cmd/sqlshape/go.sum @@ -0,0 +1,58 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fergusstrange/embedded-postgres v1.34.0 h1:c6RKhPKFsLVU+Tdxsx8q0UxCHsvZZ/iShAnljRBXs6s= +github.com/fergusstrange/embedded-postgres v1.34.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk= +github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME= +github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr9ly/sqlshape/check/mysql/v2 v2.0.0-rc.2 h1:HZ2cEokBtr6NYe7zdMTMo1QslpKvOXeRE2QpMtNOza0= +github.com/kr9ly/sqlshape/check/mysql/v2 v2.0.0-rc.2/go.mod h1:Ln+Td+WexaCAWrsWs4W2khtbkrvOtitcAbFssNnx1jI= +github.com/kr9ly/sqlshape/check/postgres/v2 v2.0.0-rc.2 h1:1Ja5RV3BlRtgLiwreCX3VdhBbpH6F+YCqerI5XDOk9s= +github.com/kr9ly/sqlshape/check/postgres/v2 v2.0.0-rc.2/go.mod h1:rYnU4foK6jX5Vz/X4OSG78zpmBrnZBWpg40ux6dcCLY= +github.com/kr9ly/sqlshape/mysqltest/v2 v2.0.0-rc.2 h1:XAZTd0FxmvZNA+/FbIw8mLKADI6zYl8mi3zVLcaqvCM= +github.com/kr9ly/sqlshape/mysqltest/v2 v2.0.0-rc.2/go.mod h1:p3sl/SdLlmsZvh6h3Mn5gZcng0O39YGz/Zz9jHazR9s= +github.com/kr9ly/sqlshape/v2 v2.0.0-rc.2 h1:BJLzDY0RI2+U3qcnyuRa6uLktWF+QdaEG2jmytF8Voc= +github.com/kr9ly/sqlshape/v2 v2.0.0-rc.2/go.mod h1:qMnSUVDy+MMtTjCCBq8TKTyPgLvzFCbvawmWFEiix8s= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74= +golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/adv_check_test.go b/cmd/sqlshape/internal/cli/adv_check_test.go similarity index 100% rename from internal/cli/adv_check_test.go rename to cmd/sqlshape/internal/cli/adv_check_test.go diff --git a/internal/cli/apply.go b/cmd/sqlshape/internal/cli/apply.go similarity index 86% rename from internal/cli/apply.go rename to cmd/sqlshape/internal/cli/apply.go index 1bf99c0..76ec78b 100644 --- a/internal/cli/apply.go +++ b/cmd/sqlshape/internal/cli/apply.go @@ -11,9 +11,9 @@ import ( "github.com/jackc/pgx/v5" - "github.com/kr9ly/sqlshape/internal/diff" - "github.com/kr9ly/sqlshape/internal/dump" - "github.com/kr9ly/sqlshape/internal/migrate" + "github.com/kr9ly/sqlshape/check/postgres/v2/diff" + "github.com/kr9ly/sqlshape/check/postgres/v2/dump" + "github.com/kr9ly/sqlshape/check/postgres/v2/migrate" ) // runApply checks a DDL file by its end state — the database's current schema plus the @@ -48,6 +48,11 @@ func runApply(ctx context.Context, args []string, stdout, stderr io.Writer) erro if err != nil { return err } + if text, isMySQL, err := declaresMySQL(schemaPath); err != nil { + return err + } else if isMySQL { + return runApplyMySQL(ctx, schemaPath, text, *db, ddl, *pkgs, *dryRun, *force, stdout, stderr) + } text, version, err := readTarget(schemaPath) if err != nil { return err @@ -82,11 +87,11 @@ func runApply(ctx context.Context, args []string, stdout, stderr io.Writer) erro fmt.Fprintln(stderr, "note: "+strings.ReplaceAll(n.String(), "\n", "\n ")) } if *pkgs != "" { - index, err := indexConsumers(strings.Split(*pkgs, ","), tgt.canonical.Version, currentText) + index, err := indexConsumers(strings.Split(*pkgs, ","), pgDeclared(tgt.canonical.Version, currentText)) if err != nil { return err } - if list := impacts(diff.Compare(current, tgt.canonical), index); len(list) > 0 { + if list := impacts(pgChanges(diff.Compare(current, tgt.canonical)), index); len(list) > 0 { if !*force { return found("statements still depend on what the DDL drops or retypes (-force runs it anyway):\n%s", impactText(list, " ")) } diff --git a/internal/cli/check.go b/cmd/sqlshape/internal/cli/check.go similarity index 91% rename from internal/cli/check.go rename to cmd/sqlshape/internal/cli/check.go index 2d2b818..9443a81 100644 --- a/internal/cli/check.go +++ b/cmd/sqlshape/internal/cli/check.go @@ -9,11 +9,11 @@ import ( "slices" "strings" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/facts" - "github.com/kr9ly/sqlshape/internal/obligation" - "github.com/kr9ly/sqlshape/internal/pgparse" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/analyze" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" + "github.com/kr9ly/sqlshape/v2/x/facts" + "github.com/kr9ly/sqlshape/v2/x/obligation" ) // runCheck judges SQL statements outside Go code -- an operator's UPDATE, a backfill, an @@ -47,7 +47,7 @@ func runCheck(ctx context.Context, args []string, stdout, stderr io.Writer) erro if err := problems(text); err != nil { return fmt.Errorf("%s: %w", schemaPath, err) } - decls, oblProblems := obligation.Declarations(s) + decls, oblProblems := obligation.Declarations(s.Contract()) if len(oblProblems) > 0 { var lines []string for _, p := range oblProblems { @@ -59,7 +59,7 @@ func runCheck(ctx context.Context, args []string, stdout, stderr io.Writer) erro return fmt.Errorf("%s: context %q is not declared (declared: %s)", schemaPath, *ctxName, strings.Join(obligation.Contexts(decls), ", ")) } // the flags are shorthand for declarations: a context's waive lifts them the same way - decls = obligation.InContext(append(decls, obligation.FromFlags(s, *requireCols, *noTables, *noTableReads)...), *ctxName) + decls = obligation.InContext(append(decls, obligation.FromFlags(s.Contract(), *requireCols, *noTables, *noTableReads)...), *ctxName) inputs := fs.Args() if len(inputs) == 0 { @@ -97,7 +97,7 @@ func runCheck(ctx context.Context, args []string, stdout, stderr io.Writer) erro func checkSchemaBodies(s *schema.Schema, decls []obligation.Obligation, name string, quiet bool, w io.Writer) int { failures := 0 judge := func(what string, f *facts.Facts) { - for _, d := range obligation.Check(s, decls, f, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, f, lowerer{s}) { if d.Failed() { failures++ fmt.Fprintf(w, "%s: %s: FAIL %s: %s: %s\n", name, what, d.Leaf.Table, d.Obligation.Source, d.Message) @@ -197,7 +197,7 @@ func checkText(s *schema.Schema, decls []obligation.Obligation, name, text strin fmt.Fprintf(w, "%s:%d: FAIL %v\n", name, line, err) continue } - for _, d := range obligation.Check(s, decls, r.Facts, lowerer{s}) { + for _, d := range obligation.Check(s.Contract(), decls, r.Facts, lowerer{s}) { at := lineAt(d.Position) what := d.Leaf.Table + ": " + d.Obligation.Source switch { @@ -234,6 +234,6 @@ func pathName(p obligation.Path) string { // lowerer is internal/obligation's view of the PostgreSQL analyzer. type lowerer struct{ s *schema.Schema } -func (l lowerer) Lower(expr string, rel *schema.Relation) ([]facts.Pred, error) { - return analyze.Lower(l.s, expr, rel) +func (l lowerer) Lower(expr string, rel obligation.Relation) ([]facts.Pred, error) { + return analyze.Lower(l.s, expr, l.s.ByFullName(rel.FullName())) } diff --git a/internal/cli/check_test.go b/cmd/sqlshape/internal/cli/check_test.go similarity index 100% rename from internal/cli/check_test.go rename to cmd/sqlshape/internal/cli/check_test.go diff --git a/internal/cli/cli.go b/cmd/sqlshape/internal/cli/cli.go similarity index 80% rename from internal/cli/cli.go rename to cmd/sqlshape/internal/cli/cli.go index 241149b..4eed3ec 100644 --- a/internal/cli/cli.go +++ b/cmd/sqlshape/internal/cli/cli.go @@ -20,10 +20,11 @@ import ( "path/filepath" "strings" - "github.com/kr9ly/sqlshape/internal/diff" - "github.com/kr9ly/sqlshape/internal/dump" - "github.com/kr9ly/sqlshape/internal/pgparse" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/diff" + "github.com/kr9ly/sqlshape/check/postgres/v2/dump" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" + "github.com/kr9ly/sqlshape/v2/x/dialect" ) // Subcommands lists the names Main handles; anything else is the checker's business. @@ -93,9 +94,13 @@ const usage = `usage: sqlshape [flags] [arguments] version print the version -schema defaults to schema.sql, or a schema/ directory of *.sql files, in the working -directory or above. -packages names Go packages (go list patterns) whose statements are -indexed as consumers of the columns a change drops or retypes. pg_dump is needed on PATH -(or $SQLSHAPE_PG_DUMP); its major version must be at least the server's. +directory or above; its declaration (postgres / mysql) selects the database the commands +speak to. -packages names Go packages (go list patterns) whose statements are indexed as +consumers of the columns a change drops or retypes. On PostgreSQL, pg_dump is needed on +PATH (or $SQLSHAPE_PG_DUMP); its major version must be at least the server's. On MySQL, the +schema is canonicalized in a scratch database on the -db server (CREATE / DROP DATABASE +privileges), or with -from on a mysqld from PATH; DDL runs statement by statement, since +MySQL's DDL commits implicitly (-no-transaction has no effect). ` // server is the embedded PostgreSQL the comparisons run on. @@ -150,6 +155,26 @@ type target struct { canonical *schema.Schema } +// pgDeclared puts the PostgreSQL declaration in front of a canonical text (pg_dump writes +// none), so the packages are judged against the version the target schema declares. +func pgDeclared(v pgparse.Version, text string) string { + return fmt.Sprintf("-- sqlshape: postgres %d\n%s", int(v.Or()), text) +} + +// declaresMySQL reads the schema at path and reports whether it declares MySQL: the +// migration commands then take the MySQL path (mysql.go). The text is returned for it. +func declaresMySQL(path string) (string, bool, error) { + text, err := dialect.ReadSchema(path) + if err != nil { + return "", false, err + } + d, err := dialect.Declared(text) + if err != nil { + return "", false, err + } + return text, d.Name == "mysql", nil +} + // readTarget reads the schema text at path and the PostgreSQL version it declares, which // the server the command boots must run. func readTarget(path string) (string, pgparse.Version, error) { diff --git a/internal/cli/cli_extra_test.go b/cmd/sqlshape/internal/cli/cli_extra_test.go similarity index 97% rename from internal/cli/cli_extra_test.go rename to cmd/sqlshape/internal/cli/cli_extra_test.go index 8e29477..94d912d 100644 --- a/internal/cli/cli_extra_test.go +++ b/cmd/sqlshape/internal/cli/cli_extra_test.go @@ -11,10 +11,10 @@ import ( "github.com/jackc/pgx/v5" - "github.com/kr9ly/sqlshape/internal/consumers" - "github.com/kr9ly/sqlshape/internal/diff" - "github.com/kr9ly/sqlshape/internal/oracle" - "github.com/kr9ly/sqlshape/internal/pgparse" + "github.com/kr9ly/sqlshape/check/postgres/v2/diff" + "github.com/kr9ly/sqlshape/check/postgres/v2/oracle" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" + "github.com/kr9ly/sqlshape/cmd/sqlshape/v2/internal/consumers" ) // The version subcommand prints "sqlshape "; Version() itself reports the @@ -353,7 +353,7 @@ func TestChangeTargetUnmatched(t *testing.T) { func TestImpactsSkipsUnmatchedKind(t *testing.T) { idx := consumers.New() idx.AddRelation("t", consumers.Site{Owner: "pkg.Foo"}) - got := impacts([]diff.Change{{Op: diff.Drop, Kind: "index", Name: "t_idx"}}, idx) + got := impacts(pgChanges([]diff.Change{{Op: diff.Drop, Kind: "index", Name: "t_idx"}}), idx) if len(got) != 0 { t.Fatalf("impacts = %+v, want none", got) } @@ -364,7 +364,7 @@ func TestImpactsSkipsUnmatchedKind(t *testing.T) { func TestImpactsRelationLevel(t *testing.T) { idx := consumers.New() idx.AddRelation("t", consumers.Site{Owner: "pkg.Baz"}) - got := impacts([]diff.Change{{Op: diff.Drop, Kind: "table", Name: "t"}}, idx) + got := impacts(pgChanges([]diff.Change{{Op: diff.Drop, Kind: "table", Name: "t"}}), idx) if len(got) != 1 || len(got[0].sites) != 1 { t.Fatalf("impacts = %+v", got) } @@ -378,7 +378,7 @@ func TestImpactsAlterTypeColumn(t *testing.T) { Op: diff.Alter, Kind: "column", Name: "t.c", Fields: []diff.Field{{Name: "type", From: "text", To: "int"}}, }} - got := impacts(changes, idx) + got := impacts(pgChanges(changes), idx) if len(got) != 1 || len(got[0].sites) != 1 { t.Fatalf("impacts = %+v", got) } @@ -389,10 +389,10 @@ func TestImpactsAlterTypeColumn(t *testing.T) { // summary line. func TestImpactTextMultilineHead(t *testing.T) { list := []impact{{ - change: diff.Change{ + change: pgChanges([]diff.Change{{ Op: diff.Alter, Kind: "column", Name: "t.c", Fields: []diff.Field{{Name: "type", From: "text", To: "int"}}, - }, + }})[0], sites: []consumers.Site{{Owner: "pkg.Bar"}}, }} text := impactText(list, "-- ") @@ -404,7 +404,7 @@ func TestImpactTextMultilineHead(t *testing.T) { // indexConsumers reports a package pattern that fails to load (packages.PrintErrors) // distinctly from one that loads clean. func TestIndexConsumersBadPackage(t *testing.T) { - _, err := indexConsumers([]string{"./no/such/dir"}, pgparse.Default, "") + _, err := indexConsumers([]string{"./no/such/dir"}, pgDeclared(pgparse.Default, "")) if err == nil || !strings.Contains(err.Error(), "packages did not load") { t.Fatalf("indexConsumers: %v", err) } @@ -414,7 +414,7 @@ func TestIndexConsumersBadPackage(t *testing.T) { // that os.CreateTemp fail. func TestIndexConsumersTempFileError(t *testing.T) { t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "does-not-exist")) - if _, err := indexConsumers([]string{"./no/such/dir"}, pgparse.Default, "x"); err == nil { + if _, err := indexConsumers([]string{"./no/such/dir"}, pgDeclared(pgparse.Default, "x")); err == nil { t.Fatal("indexConsumers: want error from an unwritable TMPDIR") } } diff --git a/internal/cli/cli_test.go b/cmd/sqlshape/internal/cli/cli_test.go similarity index 92% rename from internal/cli/cli_test.go rename to cmd/sqlshape/internal/cli/cli_test.go index c85df62..257d9ca 100644 --- a/internal/cli/cli_test.go +++ b/cmd/sqlshape/internal/cli/cli_test.go @@ -10,9 +10,10 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/dump" - "github.com/kr9ly/sqlshape/internal/oracle" - "github.com/kr9ly/sqlshape/internal/pgparse" + "github.com/kr9ly/sqlshape/check/postgres/v2/dump" + "github.com/kr9ly/sqlshape/check/postgres/v2/oracle" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" + "github.com/kr9ly/sqlshape/mysqltest/v2" ) // one embedded server for every command of the test binary (each command would boot @@ -34,7 +35,7 @@ func TestMain(m *testing.M) { shared = srv newServer = func(context.Context, pgparse.Version) (server, error) { return sharedServer{srv}, nil } } - code := m.Run() + code := mysqltest.Main(m) // the MySQL tests share a mysqld the same way if shared != nil { shared.Close() } @@ -67,7 +68,7 @@ func run(t *testing.T, name string, args ...string) (int, string, string) { func example(t *testing.T, name string) string { t.Helper() - sql, err := os.ReadFile(filepath.Join("../../examples", name, "schema.sql")) + sql, err := os.ReadFile(filepath.Join("../../../../examples", name, "schema.sql")) if err != nil { t.Fatal(err) } @@ -126,7 +127,7 @@ INSERT INTO order_kinds (code, label) VALUES ('retail', 'Retail'), ('bulk', 'Bul t.Fatalf("re-apply: code %d\n%s", code, errs) } // -from compares two schema texts without a database - if code, out, _ := run(t, "diff", "-from", "../../examples/1-tables/schema.sql", "-schema", schemaPath); code != 0 || !strings.Contains(out, "ADD COLUMN") { + if code, out, _ := run(t, "diff", "-from", "../../../../examples/1-tables/schema.sql", "-schema", schemaPath); code != 0 || !strings.Contains(out, "ADD COLUMN") { t.Fatalf("diff -from: code %d\n%s", code, out) } } @@ -137,7 +138,7 @@ func TestConsumers(t *testing.T) { requirePgDump(t) ctx := context.Background() base := example(t, "1-tables") - root, err := filepath.Abs("../..") + root, err := filepath.Abs("../../../..") if err != nil { t.Fatal(err) } diff --git a/cmd/sqlshape/internal/cli/consumers.go b/cmd/sqlshape/internal/cli/consumers.go new file mode 100644 index 0000000..3f6d731 --- /dev/null +++ b/cmd/sqlshape/internal/cli/consumers.go @@ -0,0 +1,163 @@ +package cli + +import ( + "fmt" + "os" + "strings" + + "golang.org/x/tools/go/analysis" + "golang.org/x/tools/go/analysis/checker" + "golang.org/x/tools/go/packages" + + _ "github.com/kr9ly/sqlshape/check/mysql/v2/dialect" // the MySQL dialect: the consumer index over a MySQL schema, and the declaration that selects the MySQL path + mydiff "github.com/kr9ly/sqlshape/check/mysql/v2/diff" + pgdialect "github.com/kr9ly/sqlshape/check/postgres/v2/dialect" + "github.com/kr9ly/sqlshape/check/postgres/v2/diff" + "github.com/kr9ly/sqlshape/cmd/sqlshape/v2/internal/consumers" + "github.com/kr9ly/sqlshape/cmd/sqlshape/v2/internal/vet" +) + +// indexConsumers runs the vet analyzer over the packages matching patterns and merges +// the per-package consumer indexes. The statements are read against currentSQL, the +// schema as it is before the change, with its dialect declaration in front: a column the +// target no longer has cannot be resolved against the target, and it is exactly the +// statements still naming it that matter. +func indexConsumers(patterns []string, currentSQL string) (*consumers.Index, error) { + f, err := os.CreateTemp("", "sqlshape-current-*.sql") + if err != nil { + return nil, err + } + defer os.Remove(f.Name()) + if _, err := f.WriteString(currentSQL); err != nil { + return nil, err + } + if err := f.Close(); err != nil { + return nil, err + } + if err := vet.Analyzer.Flags.Set("schema", f.Name()); err != nil { + return nil, err + } + pkgs, err := packages.Load(&packages.Config{Mode: packages.LoadAllSyntax}, patterns...) + if err != nil { + return nil, fmt.Errorf("load packages: %w", err) + } + if packages.PrintErrors(pkgs) > 0 { + return nil, fmt.Errorf("packages did not load") + } + graph, err := checker.Analyze([]*analysis.Analyzer{vet.Analyzer}, pkgs, nil) + if err != nil { + return nil, err + } + index := consumers.New() + for _, act := range graph.Roots { + if act.Err != nil { + return nil, fmt.Errorf("%s: %w", act.Package.PkgPath, act.Err) + } + if x, ok := act.Result.(*consumers.Index); ok && x != nil { + index.Merge(x) + } + } + return index, nil +} + +// change is what a schema change means to the consumer index, whatever the dialect: the +// relation (and column) it touches, whether it removes or retypes it, and its first line +// for the report. +type change struct { + head string + table, column string + drop, retype bool +} + +// pgChanges reads PostgreSQL diff changes. +func pgChanges(changes []diff.Change) []change { + var out []change + for _, c := range changes { + table, column, ok := changeTarget(c) + if !ok { + continue + } + ch := change{head: firstLine(c.String()), table: table, column: column, drop: c.Op == diff.Drop} + if c.Op == diff.Alter && column != "" { + for _, f := range c.Fields { + if f.Name == "type" { + ch.retype = true + } + } + } + out = append(out, ch) + } + return out +} + +// mysqlChanges reads MySQL diff changes. +func mysqlChanges(changes []mydiff.Change) []change { + var out []change + for _, c := range changes { + ch := change{head: firstLine(c.String()), drop: c.Op == mydiff.Drop, retype: c.Retypes()} + switch c.Kind { + case "table", "view": + ch.table = c.Name + case "column": + i := strings.LastIndex(c.Name, ".") + if i <= 0 { + continue + } + ch.table, ch.column = c.Name[:i], c.Name[i+1:] + default: + continue + } + out = append(out, ch) + } + return out +} + +func firstLine(s string) string { + if i := strings.Index(s, "\n"); i > 0 { + return s[:i] + } + return s +} + +// impact is a change and the Go sites that depend on what it removes or retypes. +type impact struct { + change change + sites []consumers.Site +} + +// impacts lists, for each change that drops a relation or a column or changes a column's +// type, the consumers the index knows. +func impacts(changes []change, index *consumers.Index) []impact { + var out []impact + for _, c := range changes { + if !c.drop && !c.retype { + continue + } + var sites []consumers.Site + if c.column == "" { + sites = index.Relation(c.table) + } else { + sites = index.Column(c.table, c.column) + } + if len(sites) > 0 { + out = append(out, impact{c, sites}) + } + } + return out +} + +// String renders the impacts as a report: the change's first line, then its sites. +func impactText(list []impact, prefix string) string { + var b strings.Builder + for _, im := range list { + fmt.Fprintf(&b, "%s%s reaches %d statement(s):\n", prefix, im.change.head, len(im.sites)) + for _, s := range im.sites { + fmt.Fprintf(&b, "%s %s\n", prefix, s) + } + } + return b.String() +} + +// The consumers index runs the checker over the packages; the checker needs the +// dialects registered, which importing their adapters does (the MySQL one above). +var _ = pgdialect.Load diff --git a/internal/cli/diff.go b/cmd/sqlshape/internal/cli/diff.go similarity index 81% rename from internal/cli/diff.go rename to cmd/sqlshape/internal/cli/diff.go index c8d3c5e..e9e5d21 100644 --- a/internal/cli/diff.go +++ b/cmd/sqlshape/internal/cli/diff.go @@ -8,10 +8,10 @@ import ( "io" "strings" - "github.com/kr9ly/sqlshape/internal/diff" - "github.com/kr9ly/sqlshape/internal/dump" - "github.com/kr9ly/sqlshape/internal/migrate" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/check/postgres/v2/diff" + "github.com/kr9ly/sqlshape/check/postgres/v2/dump" + "github.com/kr9ly/sqlshape/check/postgres/v2/migrate" + "github.com/kr9ly/sqlshape/check/postgres/v2/schema" ) // runDiff prints the DDL from the current state (-db, or the schema text at -from) to @@ -38,6 +38,11 @@ func runDiff(ctx context.Context, args []string, stdout, stderr io.Writer) error if err != nil { return err } + if text, isMySQL, err := declaresMySQL(schemaPath); err != nil { + return err + } else if isMySQL { + return runDiffMySQL(ctx, schemaPath, text, *db, *from, *pkgs, stdout) + } text, version, err := readTarget(schemaPath) if err != nil { return err @@ -81,11 +86,11 @@ func runDiff(ctx context.Context, args []string, stdout, stderr io.Writer) error fmt.Fprintln(stdout, stmt) } if *pkgs != "" { - index, err := indexConsumers(strings.Split(*pkgs, ","), tgt.canonical.Version, currentText) + index, err := indexConsumers(strings.Split(*pkgs, ","), pgDeclared(tgt.canonical.Version, currentText)) if err != nil { return err } - if list := impacts(diff.Compare(current, tgt.canonical), index); len(list) > 0 { + if list := impacts(pgChanges(diff.Compare(current, tgt.canonical)), index); len(list) > 0 { fmt.Fprint(stdout, "\n-- consumers of what this plan drops or retypes:\n"+impactText(list, "-- ")) } } diff --git a/cmd/sqlshape/internal/cli/docs_test.go b/cmd/sqlshape/internal/cli/docs_test.go new file mode 100644 index 0000000..18e8f4d --- /dev/null +++ b/cmd/sqlshape/internal/cli/docs_test.go @@ -0,0 +1,128 @@ +package cli + +// docs_test.go checks docs/checks.md's (and checks.ja.md's) "The same rules for SQL +// outside Go (`sqlshape check`)" section against the real `check` subcommand, the way +// cmd/sqlshape/internal/vet's own docs_part3_test.go checks the vet analyzer against the +// rest of Part 2/3. It does not edit cli_test.go; it reuses that file's own run() helper +// and pg17Decl constant (same package, unexported, already in scope). +// +// The section's own fence is a `$ sqlshape check ...` transcript with no language tag +// (```, not ```go or ```sql), so it is not one of the fences +// cmd/sqlshape/internal/vet/docs_test.go's parseDocBlocks recognizes (go/sql only); this +// file parses it directly with its own minimal helper instead. The transcript is also +// illustrative rather than exhaustive -- it shows three of the run's output lines +// (":2 ok", ":6 waived", ":8 FAIL") and "2 finding(s)", but a real run over the schema and +// script it implies (the same shape check_test.go's own TestCheck already builds) reports +// more lines than that (a second FAIL at :8, for instance) for the same two-finding count +// docs quotes. So each quoted line is checked with Contains against the real output, +// not with a full-output equality -- what the doc quotes must appear verbatim, but the +// doc is not asserted to be the complete transcript. + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +var checkTranscriptFenceRE = regexp.MustCompile("(?s)```\\n(\\$ sqlshape check.*?)\\n```") + +// docsCheckTranscript reads the untagged ``` fence directly under heading "The same rules +// for SQL outside Go (`sqlshape check`)" out of a docs/*.md file. +func docsCheckTranscript(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + m := checkTranscriptFenceRE.FindStringSubmatch(string(data)) + if m == nil { + t.Fatalf("%s: no untagged ```$ sqlshape check ...``` fence found", path) + } + return m[1] +} + +func TestDocsCheckSubcommandTranscript(t *testing.T) { + for _, tc := range []struct { + name string + doc string + }{ + {"en", "checks.md"}, + {"ja", "checks.ja.md"}, + } { + t.Run(tc.name, func(t *testing.T) { + transcript := docsCheckTranscript(t, filepath.Join("..", "..", "..", "..", "docs", tc.doc)) + lines := strings.Split(transcript, "\n") + if len(lines) < 2 || !strings.HasPrefix(lines[0], "$ sqlshape check ") { + t.Fatalf("unexpected transcript shape:\n%s", transcript) + } + + // The schema this transcript implies: an orders table visible where + // deleted_at IS NULL, pinned by tenant_id, with a context "ops" that + // waives the pin -- the same declarations the surrounding prose + // (immediately above this heading, "Different callers, different rules") + // and the ":2 ok ... pinned(tenant_id)" / ":6 waived ... pinned(tenant_id)" + // / ":8 FAIL ... visible where" lines require together. Docs does not + // print the schema itself here, so this harness completes it, the same + // way cmd/sqlshape/internal/vet/docs_part3_test.go completes schema + // fragments elsewhere in Part 2. + dir := t.TempDir() + schema := ` +-- sqlshape: visible where deleted_at IS NULL +-- sqlshape: require pinned(tenant_id) +-- sqlshape: context ops: waive pinned(tenant_id) +CREATE TABLE orders (id bigint PRIMARY KEY, tenant_id bigint NOT NULL, status text NOT NULL, deleted_at timestamptz); +` + if err := os.WriteFile(filepath.Join(dir, "schema.sql"), []byte(pg17Decl+schema), 0o644); err != nil { + t.Fatal(err) + } + ops := `-- nightly cleanup +UPDATE orders SET status = 'archived' + WHERE tenant_id = 7 AND deleted_at IS NULL AND status = 'closed'; + +-- sqlshape: waive orders pinned(tenant_id) +SELECT count(*) FROM orders WHERE deleted_at IS NULL; + +DELETE FROM orders WHERE id = 42; +` + sqlPath := filepath.Join(dir, "ops.sql") + if err := os.WriteFile(sqlPath, []byte(ops), 0o644); err != nil { + t.Fatal(err) + } + + code, out, errs := run(t, "check", "-schema", filepath.Join(dir, "schema.sql"), sqlPath) + if code != 1 { + t.Fatalf("exit %d, want 1; stderr: %s", code, errs) + } + + for _, l := range lines[1:] { + l = strings.TrimSpace(l) + if l == "" { + continue + } + if strings.HasPrefix(l, "sqlshape:") { + // "sqlshape: 2 finding(s)" goes to stderr, not stdout. + if !strings.Contains(errs, strings.TrimPrefix(l, "sqlshape: ")) { + t.Errorf("stderr missing %q; got: %s", l, errs) + } + continue + } + // The transcript's own path is "ops.sql"; the real run's is this + // test's own temp-dir path for the same file. Docs itself trails + // the long FAIL line off with a literal "..."; treat that one as a + // prefix rather than a full line. + want := strings.Replace(l, "ops.sql:", sqlPath+":", 1) + if trimmed, ok := strings.CutSuffix(want, "..."); ok { + if !strings.Contains(out, trimmed) { + t.Errorf("stdout missing a line starting with %q; got:\n%s", trimmed, out) + } + continue + } + if !strings.Contains(out, want) { + t.Errorf("stdout missing %q; got:\n%s", want, out) + } + } + }) + } +} diff --git a/cmd/sqlshape/internal/cli/mysql.go b/cmd/sqlshape/internal/cli/mysql.go new file mode 100644 index 0000000..8115b35 --- /dev/null +++ b/cmd/sqlshape/internal/cli/mysql.go @@ -0,0 +1,218 @@ +package cli + +import ( + "context" + "database/sql" + "errors" + "fmt" + "io" + "strings" + + "github.com/go-sql-driver/mysql" + mydiff "github.com/kr9ly/sqlshape/check/mysql/v2/diff" + mydump "github.com/kr9ly/sqlshape/check/mysql/v2/dump" + mymigrate "github.com/kr9ly/sqlshape/check/mysql/v2/migrate" + "github.com/kr9ly/sqlshape/v2/x/dialect" +) + +// The MySQL side of diff / apply / verify-schema. The target is canonicalized in a scratch +// database on the -db server (dump.Scratch), or on a mysqld from PATH when there is no +// server (-from); the live side is read back the same way (dump.Load). + +// mysqlCanonicalizer is the canonicalizer for a command: the -db server's scratch database +// when there is one, else a local mysqld. Tests replace it. +var mysqlCanonicalizer = func(dsn string) (mydump.Canonicalizer, error) { + if dsn == "" { + return mydump.Local{}, nil + } + return mydump.NewScratch(dsn) +} + +// mysqlTarget canonicalizes the target schema and checks it is clean. +func mysqlTarget(ctx context.Context, c mydump.Canonicalizer, schemaPath, text string) (*mysqlSchema, error) { + if err := mydump.Problems(text); err != nil { + return nil, fmt.Errorf("%s: %w", schemaPath, err) + } + canon, canonText, err := c.Canonical(ctx, text) + if err != nil { + if errors.Is(err, mydump.ErrNoServer) { + return nil, errors.New("no mysqld on PATH to canonicalize the schema: give -db, or put mysqld on PATH") + } + return nil, fmt.Errorf("%s: %w", schemaPath, err) + } + return &mysqlSchema{canon, canonText}, nil +} + +// mysqlSchema is a canonical schema with the text it was read from. +type mysqlSchema struct { + s *mydump.Schema + text string +} + +func runDiffMySQL(ctx context.Context, schemaPath, text, db, from, pkgs string, stdout io.Writer) error { + c, err := mysqlCanonicalizer(db) + if err != nil { + return err + } + tgt, err := mysqlTarget(ctx, c, schemaPath, text) + if err != nil { + return err + } + var current *mysqlSchema + if db != "" { + warnMySQLVersion(ctx, stdout, db, text) + s, currentText, err := mydump.Load(ctx, db, mydump.Header(text)) + if err != nil { + return err + } + current = &mysqlSchema{s, currentText} + } else { + fromText, err := dialect.ReadSchema(from) + if err != nil { + return err + } + s, currentText, err := c.Canonical(ctx, fromText) + if err != nil { + return fmt.Errorf("%s: %w", from, err) + } + current = &mysqlSchema{s, currentText} + } + intents, err := mymigrate.ParseIntents(text) + if err != nil { + return err + } + plan, planErr := mymigrate.Plan(current.s, tgt.s, intents) + for _, stmt := range plan { + fmt.Fprintln(stdout, stmt) + } + if pkgs != "" { + index, err := indexConsumers(strings.Split(pkgs, ","), current.text) + if err != nil { + return err + } + if list := impacts(mysqlChanges(mydiff.Compare(current.s, tgt.s)), index); len(list) > 0 { + fmt.Fprint(stdout, "\n-- consumers of what this plan drops or retypes:\n"+impactText(list, "-- ")) + } + } + if planErr != nil { + return found("the plan needs declarations:\n %s", strings.ReplaceAll(planErr.Error(), "\n", "\n ")) + } + return nil +} + +func runApplyMySQL(ctx context.Context, schemaPath, text, db, ddl, pkgs string, dryRun, force bool, stdout, stderr io.Writer) error { + if db == "" { + return errors.New("-db is required") + } + c, err := mysqlCanonicalizer(db) + if err != nil { + return err + } + tgt, err := mysqlTarget(ctx, c, schemaPath, text) + if err != nil { + return err + } + warnMySQLVersion(ctx, stderr, db, text) + current, currentText, err := mydump.Load(ctx, db, mydump.Header(text)) + if err != nil { + return err + } + changes, err := mymigrate.Verify(ctx, c, currentText, ddl, tgt.s) + if err != nil { + return found("the DDL does not apply to the database's current schema: %v", err) + } + if len(changes) > 0 { + var b strings.Builder + for _, ch := range changes { + fmt.Fprintln(&b, " "+strings.ReplaceAll(ch.String(), "\n", "\n ")) + } + return found("the DDL does not reach %s; after it, the database would still differ:\n%s", schemaPath, b.String()) + } + if pkgs != "" { + index, err := indexConsumers(strings.Split(pkgs, ","), currentText) + if err != nil { + return err + } + if list := impacts(mysqlChanges(mydiff.Compare(current, tgt.s)), index); len(list) > 0 { + if !force { + return found("statements still depend on what the DDL drops or retypes (-force runs it anyway):\n%s", impactText(list, " ")) + } + fmt.Fprint(stderr, "warning: statements still depend on what the DDL drops or retypes:\n"+impactText(list, " ")) + } + } + if dryRun { + fmt.Fprintf(stdout, "ok: the DDL takes the database to %s (dry run, nothing applied)\n", schemaPath) + return nil + } + conn, err := openMySQL(db) + if err != nil { + return err + } + defer conn.Close() + stmts := mymigrate.SplitFor(ddl, tgt.s) + for i, stmt := range stmts { + if _, err := conn.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("apply: statement %d of %d failed, the %d before it are applied (MySQL DDL commits implicitly; `sqlshape diff` from here gives the rest):\n %s\n%v", i+1, len(stmts), i, stmt, err) + } + } + fmt.Fprintf(stdout, "applied: the database now matches %s\n", schemaPath) + return nil +} + +func runVerifyMySQL(ctx context.Context, schemaPath, text, db string, stdout, stderr io.Writer) error { + c, err := mysqlCanonicalizer(db) + if err != nil { + return err + } + tgt, err := mysqlTarget(ctx, c, schemaPath, text) + if err != nil { + return err + } + warnMySQLVersion(ctx, stderr, db, text) + current, _, err := mydump.Load(ctx, db, mydump.Header(text)) + if err != nil { + return err + } + changes := mydiff.Compare(current, tgt.s) + if len(changes) > 0 { + var b strings.Builder + for _, ch := range changes { + fmt.Fprintln(&b, " "+strings.ReplaceAll(ch.String(), "\n", "\n ")) + } + return found("the database differs from %s (database -> schema):\n%s", schemaPath, strings.TrimRight(b.String(), "\n")) + } + fmt.Fprintf(stdout, "ok: the database matches %s\n", schemaPath) + return nil +} + +// warnMySQLVersion tells the user when the server runs another MySQL major.minor than the +// schema declares. +func warnMySQLVersion(ctx context.Context, w io.Writer, dsn, schemaSQL string) { + d, err := dialect.Declared(schemaSQL) + if err != nil || d.Version == "" { + return + } + server, err := mydump.ServerVersion(ctx, dsn) + if err != nil { + return + } + parts := strings.SplitN(server, ".", 3) + if len(parts) < 2 { + return + } + if mm := parts[0] + "." + parts[1]; mm != d.Version { + fmt.Fprintf(w, "sqlshape: warning: the database runs MySQL %s but schema.sql declares %s: the DDL is judged and generated by %s's rules\n", server, d.Version, d.Version) + } +} + +func openMySQL(dsn string) (*sql.DB, error) { + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + return nil, err + } + c, err := mysql.NewConnector(cfg) + if err != nil { + return nil, err + } + return sql.OpenDB(c), nil +} diff --git a/cmd/sqlshape/internal/cli/mysql_test.go b/cmd/sqlshape/internal/cli/mysql_test.go new file mode 100644 index 0000000..c02d0ef --- /dev/null +++ b/cmd/sqlshape/internal/cli/mysql_test.go @@ -0,0 +1,155 @@ +package cli + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// The migration commands on a MySQL schema: diff against a live server (canonicalized in a +// scratch database on it), verify-schema before and after, apply with -dry-run and for real, +// a re-apply refused, and diff -from two texts (canonicalized on a local mysqld). +func TestMySQLDiffApplyVerify(t *testing.T) { + ctx := context.Background() + base := example(t, "5-mysql") + db, err := mysqltest.Start(ctx, base) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + dir := t.TempDir() + schemaPath := filepath.Join(dir, "schema.sql") + edit := base + ` +ALTER TABLE customers ADD COLUMN nickname VARCHAR(50) DEFAULT 'anon'; +CREATE TABLE order_kinds (code VARCHAR(20) PRIMARY KEY, label VARCHAR(50) NOT NULL); +CREATE VIEW paid_orders AS SELECT id, total FROM orders WHERE status = 'paid'; +CREATE EVENT sweep_audit ON SCHEDULE EVERY 1 DAY DO DELETE FROM order_audit WHERE created_at < NOW() - INTERVAL 90 DAY; +` + if err := os.WriteFile(schemaPath, []byte(edit), 0o644); err != nil { + t.Fatal(err) + } + dsn := db.DSN() + + code, out, errs := run(t, "diff", "-db", dsn, "-schema", schemaPath) + if code != 0 || !strings.Contains(out, "ADD COLUMN `nickname`") || !strings.Contains(out, "CREATE TABLE `order_kinds`") || !strings.Contains(out, "VIEW `paid_orders`") || !strings.Contains(out, "CREATE EVENT `sweep_audit` ON SCHEDULE EVERY 1 DAY STARTS '") { + t.Fatalf("diff: code %d\n%s%s", code, out, errs) + } + ddlPath := filepath.Join(dir, "up.sql") + if err := os.WriteFile(ddlPath, []byte(out), 0o644); err != nil { + t.Fatal(err) + } + if code, _, errs := run(t, "verify-schema", "-db", dsn, "-schema", schemaPath); code != 1 || !strings.Contains(errs, "+ table order_kinds") || !strings.Contains(errs, "+ view paid_orders") { + t.Fatalf("verify-schema before apply: code %d\n%s", code, errs) + } + if code, out, _ := run(t, "apply", "-db", dsn, "-schema", schemaPath, "-dry-run", ddlPath); code != 0 || !strings.Contains(out, "dry run") { + t.Fatalf("apply -dry-run: code %d\n%s", code, out) + } + if code, _, _ := run(t, "verify-schema", "-db", dsn, "-schema", schemaPath); code != 1 { + t.Fatalf("dry run must not apply: code %d", code) + } + if code, out, errs := run(t, "apply", "-db", dsn, "-schema", schemaPath, ddlPath); code != 0 || !strings.Contains(out, "applied") { + t.Fatalf("apply: code %d\n%s%s", code, out, errs) + } + // the event's STARTS was left to the server: the live one carries the apply's second, + // the target canonicalized now carries this second -- no drift between them + time.Sleep(1100 * time.Millisecond) + if code, out, errs := run(t, "verify-schema", "-db", dsn, "-schema", schemaPath); code != 0 || !strings.Contains(out, "ok:") { + t.Fatalf("verify-schema after apply: code %d\n%s%s", code, out, errs) + } + // the same DDL again is refused: its end state is no longer the target + if code, _, errs := run(t, "apply", "-db", dsn, "-schema", schemaPath, ddlPath); code != 1 || !strings.Contains(errs, "does not apply") { + t.Fatalf("re-apply: code %d\n%s", code, errs) + } + // an undeclared drop is a finding, the DDL still printed + if err := os.WriteFile(schemaPath, []byte(base), 0o644); err != nil { + t.Fatal(err) + } + if code, out, errs := run(t, "diff", "-db", dsn, "-schema", schemaPath); code != 1 || !strings.Contains(errs, "column customers.nickname is dropped, which no @migrate declares") || !strings.Contains(out, "DROP COLUMN `nickname`") || !strings.Contains(out, "DROP EVENT `sweep_audit`") { + t.Fatalf("diff back: code %d\n%s%s", code, out, errs) + } + // -from compares two schema texts without a database + if code, out, errs := run(t, "diff", "-from", "../../../../examples/5-mysql/schema.sql", "-schema", ddlEditPath(t, dir, edit)); code != 0 || !strings.Contains(out, "ADD COLUMN `nickname`") { + t.Fatalf("diff -from: code %d\n%s%s", code, out, errs) + } + // no scratch database is left on the server + var n int + if err := db.Conn().QueryRowContext(ctx, "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME LIKE 'sqlshape_scratch_%'").Scan(&n); err != nil || n != 0 { + t.Errorf("scratch databases left: %d %v", n, err) + } +} + +func ddlEditPath(t *testing.T, dir, text string) string { + t.Helper() + p := filepath.Join(dir, "edited.sql") + if err := os.WriteFile(p, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +// The -packages path over a MySQL schema: diff lists the Go statements still reading a +// column the target drops (the consumer index is built against the live schema read +// back from the server, with its dialect header, so the MySQL checker is the one indexing), +// apply refuses while they exist, and -force runs the DDL anyway. +func TestMySQLConsumers(t *testing.T) { + ctx := context.Background() + base := example(t, "5-mysql") + root, err := filepath.Abs("../../../..") + if err != nil { + t.Fatal(err) + } + t.Chdir(root) // -packages patterns are relative to the module root + db, err := mysqltest.Start(ctx, base) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH (nix-shell -p mysql84)") + } + if err != nil { + t.Fatal(err) + } + defer db.Close() + dsn := db.DSN() + dir := t.TempDir() + schemaPath := filepath.Join(dir, "schema.sql") + // the target has no orders.note; examples/5-mysql's CreateOrder still inserts it and + // ListOrders still reads it + target := strings.Replace(base, " note TEXT,\n", "", 1) + if target == base { + t.Fatal("examples/5-mysql/schema.sql has no `note TEXT` column to drop") + } + if err := os.WriteFile(schemaPath, []byte(target), 0o644); err != nil { + t.Fatal(err) + } + code, out, errs := run(t, "diff", "-db", dsn, "-schema", schemaPath, "-packages", "./examples/5-mysql") + if code != 1 || !strings.Contains(errs, "column orders.note is dropped, which no @migrate declares") || !strings.Contains(out, "DROP COLUMN `note`") { + t.Fatalf("diff: code %d\n%s%s", code, out, errs) + } + if !strings.Contains(out, "-- - column orders.note reaches 2 statement(s)") || !strings.Contains(out, "5-mysql.CreateOrder") || !strings.Contains(out, "5-mysql.ListOrders") { + t.Fatalf("diff must list the consumers of orders.note:\n%s", out) + } + ddlPath := filepath.Join(dir, "up.sql") + if err := os.WriteFile(ddlPath, []byte("ALTER TABLE orders DROP COLUMN `note`;\n"), 0o644); err != nil { + t.Fatal(err) + } + if code, _, errs := run(t, "apply", "-db", dsn, "-schema", schemaPath, "-packages", "./examples/5-mysql", ddlPath); code != 1 || !strings.Contains(errs, "still depend") || !strings.Contains(errs, "queries.go") { + t.Fatalf("apply must refuse: code %d\n%s", code, errs) + } + if code, _, _ := run(t, "verify-schema", "-db", dsn, "-schema", schemaPath); code != 1 { + t.Fatalf("a refused apply must not touch the database: verify-schema code %d", code) + } + code, out, errs = run(t, "apply", "-db", dsn, "-schema", schemaPath, "-packages", "./examples/5-mysql", "-force", ddlPath) + if code != 0 || !strings.Contains(out, "applied") || !strings.Contains(errs, "warning: statements still depend") { + t.Fatalf("apply -force: code %d\n%s%s", code, out, errs) + } + if code, out, errs := run(t, "verify-schema", "-db", dsn, "-schema", schemaPath); code != 0 || !strings.Contains(out, "ok:") { + t.Fatalf("verify-schema after apply -force: code %d\n%s%s", code, out, errs) + } +} diff --git a/internal/cli/serverversion.go b/cmd/sqlshape/internal/cli/serverversion.go similarity index 93% rename from internal/cli/serverversion.go rename to cmd/sqlshape/internal/cli/serverversion.go index 083082b..ba96bec 100644 --- a/internal/cli/serverversion.go +++ b/cmd/sqlshape/internal/cli/serverversion.go @@ -7,8 +7,8 @@ import ( "strconv" "strings" - "github.com/kr9ly/sqlshape/internal/dump" - "github.com/kr9ly/sqlshape/internal/pgparse" + "github.com/kr9ly/sqlshape/check/postgres/v2/dump" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" ) // warnServerVersion tells the user when the database at connString runs another PostgreSQL diff --git a/internal/cli/serverversion_test.go b/cmd/sqlshape/internal/cli/serverversion_test.go similarity index 93% rename from internal/cli/serverversion_test.go rename to cmd/sqlshape/internal/cli/serverversion_test.go index dac1a96..9488511 100644 --- a/internal/cli/serverversion_test.go +++ b/cmd/sqlshape/internal/cli/serverversion_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/pgparse" + "github.com/kr9ly/sqlshape/check/postgres/v2/pgparse" ) func TestVersionMismatch(t *testing.T) { diff --git a/internal/cli/verify.go b/cmd/sqlshape/internal/cli/verify.go similarity index 87% rename from internal/cli/verify.go rename to cmd/sqlshape/internal/cli/verify.go index 542f932..f3467db 100644 --- a/internal/cli/verify.go +++ b/cmd/sqlshape/internal/cli/verify.go @@ -8,8 +8,8 @@ import ( "io" "strings" - "github.com/kr9ly/sqlshape/internal/diff" - "github.com/kr9ly/sqlshape/internal/dump" + "github.com/kr9ly/sqlshape/check/postgres/v2/diff" + "github.com/kr9ly/sqlshape/check/postgres/v2/dump" ) // runVerify lists where the database differs from the target schema (drift). Column order @@ -32,6 +32,11 @@ func runVerify(ctx context.Context, args []string, stdout, stderr io.Writer) err if err != nil { return err } + if text, isMySQL, err := declaresMySQL(schemaPath); err != nil { + return err + } else if isMySQL { + return runVerifyMySQL(ctx, schemaPath, text, *db, stdout, stderr) + } text, version, err := readTarget(schemaPath) if err != nil { return err diff --git a/internal/cli/version.go b/cmd/sqlshape/internal/cli/version.go similarity index 100% rename from internal/cli/version.go rename to cmd/sqlshape/internal/cli/version.go diff --git a/internal/consumers/consumers.go b/cmd/sqlshape/internal/consumers/consumers.go similarity index 100% rename from internal/consumers/consumers.go rename to cmd/sqlshape/internal/consumers/consumers.go diff --git a/internal/consumers/consumers_test.go b/cmd/sqlshape/internal/consumers/consumers_test.go similarity index 100% rename from internal/consumers/consumers_test.go rename to cmd/sqlshape/internal/consumers/consumers_test.go diff --git a/internal/vet/adv_context_test.go b/cmd/sqlshape/internal/vet/adv_context_test.go similarity index 100% rename from internal/vet/adv_context_test.go rename to cmd/sqlshape/internal/vet/adv_context_test.go diff --git a/internal/vet/adv_obligation_test.go b/cmd/sqlshape/internal/vet/adv_obligation_test.go similarity index 100% rename from internal/vet/adv_obligation_test.go rename to cmd/sqlshape/internal/vet/adv_obligation_test.go diff --git a/internal/vet/adv_param_test.go b/cmd/sqlshape/internal/vet/adv_param_test.go similarity index 100% rename from internal/vet/adv_param_test.go rename to cmd/sqlshape/internal/vet/adv_param_test.go diff --git a/cmd/sqlshape/internal/vet/adv_template3_test.go b/cmd/sqlshape/internal/vet/adv_template3_test.go new file mode 100644 index 0000000..cffd924 --- /dev/null +++ b/cmd/sqlshape/internal/vet/adv_template3_test.go @@ -0,0 +1,33 @@ +package vet + +import ( + "path/filepath" + "testing" + + "golang.org/x/tools/go/analysis/analysistest" +) + +// Lane: template (3rd adversarial round, PostgreSQL). Each `// want` in +// testdata/src/adv_template3/*.go records the diagnostic vet *should* emit and was +// confirmed missing (or, for the control cases in b.go/d.go, confirmed present) by +// actually running analysistest against the current implementation: +// +// - a.go: sqlshape.Query[R,P] assigned to a var and called through the var is never +// recognized as a query call (isQueryCall / calledFunc only unwrap an IndexExpr / +// IndexListExpr directly on the call's own Fun), so a syntactically bad, constant SQL +// string goes completely unanalyzed -- not even -coverage's "unchecked" counter sees +// it. b.go is the control case: the identical bad SQL, called directly, is flagged. +// - c.go: x/expand's control() does not recurse into a condition's nested function-call +// arguments (e.g. `{{if gt (len .Xz) 0}}`), so a typo'd field referenced only that way +// is never checked against the parameter type. d.go is the control case: the same +// typo as a bare `{{if .Xz}}` condition is flagged. +// +// This test therefore fails against the current implementation until isQueryCall widens to +// recognize the var-indirection call shape and control() recurses into nested pipelines. +func TestAdvTemplate3(t *testing.T) { + td := analysistest.TestData() + if err := Analyzer.Flags.Set("schema", filepath.Join(td, "adv_template3_schema.sql")); err != nil { + t.Fatal(err) + } + analysistest.Run(t, td, Analyzer, "adv_template3") +} diff --git a/internal/vet/adv_template_test.go b/cmd/sqlshape/internal/vet/adv_template_test.go similarity index 100% rename from internal/vet/adv_template_test.go rename to cmd/sqlshape/internal/vet/adv_template_test.go diff --git a/cmd/sqlshape/internal/vet/adv_typefit_test.go b/cmd/sqlshape/internal/vet/adv_typefit_test.go new file mode 100644 index 0000000..0f3f8c1 --- /dev/null +++ b/cmd/sqlshape/internal/vet/adv_typefit_test.go @@ -0,0 +1,39 @@ +package vet + +import ( + "path/filepath" + "testing" + + "golang.org/x/tools/go/analysis/analysistest" +) + +// TestAdvTypefit is an adversarial-testing lane test for lane "typefit" (Go type +// acceptance: x/dialect.Type / GoFit / gofit.go / typebind.go / check/postgres/dialect/ +// types.go, against what pgx actually scans / encodes). Each case is reproduced against +// a running PostgreSQL in pgtest/runtime_adv_typefit_test.go; this test only records the +// diagnostic the checker should give. It uses its own schema +// (testdata/adv_typefit_schema.sql) so it does not depend on schema.sql other lanes may +// also be adjusting. +// +// Per the ruling in scratchpad/adv-pg3-fix-brief.md (decision 1), the array-null-element +// finding is a standing note (Advice / Lossy) in both modes, and additionally a +// rejection under -strict; testdata/src/adv_typefit checks the default mode, +// testdata/src/adv_typefit_strict the same three cases under -strict, since the two +// modes report a different diagnostic set for them (and, unrelated to this lane, for the +// binding.go "carries key" advisory too). +func TestAdvTypefit(t *testing.T) { + td := analysistest.TestData() + if err := Analyzer.Flags.Set("schema", filepath.Join(td, "adv_typefit_schema.sql")); err != nil { + t.Fatal(err) + } + t.Run("default", func(t *testing.T) { + analysistest.Run(t, td, Analyzer, "adv_typefit") + }) + if err := Analyzer.Flags.Set("strict", "true"); err != nil { + t.Fatal(err) + } + defer Analyzer.Flags.Set("strict", "false") + t.Run("strict", func(t *testing.T) { + analysistest.Run(t, td, Analyzer, "adv_typefit_strict") + }) +} diff --git a/internal/vet/binding.go b/cmd/sqlshape/internal/vet/binding.go similarity index 67% rename from internal/vet/binding.go rename to cmd/sqlshape/internal/vet/binding.go index f9de8a0..5602e93 100644 --- a/internal/vet/binding.go +++ b/cmd/sqlshape/internal/vet/binding.go @@ -7,12 +7,9 @@ import ( "go/token" "go/types" "sort" - "strconv" "strings" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/pgparse" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/dialect" ) // Interpretation sharing: a Go named type that meets a DB nominal type — an enum, a @@ -69,146 +66,42 @@ func (n nominal) String() string { return "key " + n.key } -// nominalOf classifies a PG type + provenance. -func (c *checker) nominalOf(pg schema.TypeRef, src *analyze.Source) (nominal, bool) { - t := c.s.Types.ByOID(pg.OID) - if t == nil { - return nominal{}, false - } - if t.Elem != 0 && strings.HasPrefix(t.Name, "_") { - t = c.s.Types.ByOID(t.Elem) - if t == nil { - return nominal{}, false - } +// nominalOf classifies a dialect type and its provenance: the enum or domain it is (an +// array of one counts as one), else what the schema says about the column it comes from +// (a CHECK IN value set, a seeded lookup key, a key identity). +func nominalOf(dt dialect.Type, src *dialect.Source) (nominal, bool) { + t := dt + if t.Kind == dialect.Array && t.Elem != nil { + t = *t.Elem } switch t.Kind { - case 'e': - return nominal{kind: 'e', key: t.Name, labels: c.s.Types.Enums[t.OID]}, true - case 'd': - return nominal{kind: 'd', key: t.Name}, true + case dialect.Enum: + return nominal{kind: 'e', key: t.Named, labels: t.Labels}, true + case dialect.Domain: + return nominal{kind: 'd', key: t.Named}, true } if src != nil { - if rel := c.relByFullName(src.Table); rel != nil { - if col := rel.Column(src.Column); col != nil && len(col.Values) > 0 { - return nominal{kind: 'v', key: rel.Name + "." + col.Name, labels: col.Values}, true - } + if src.ValuesFrom == "check" { + return nominal{kind: 'v', key: bareTable(src.Table) + "." + src.Column, labels: src.Values}, true } - if id, ok := c.identity(src.Table, src.Column, 0); ok { + if src.Identity != "" { // a key column of a seeded table (or a column referencing it) carries one of the // declared rows: a value set like enum labels - if labels, ok := c.lookupLabels(id); ok { - return nominal{kind: 'l', key: id, labels: labels}, true + if src.ValuesFrom == "seed" { + return nominal{kind: 'l', key: src.Identity, labels: src.Values}, true } - return nominal{kind: 'k', key: id}, true + return nominal{kind: 'k', key: src.Identity}, true } } return nominal{}, false } -// lookupLabels returns the values of the key column "table.column" when the table is -// seeded with rows identified by that column alone. -func (c *checker) lookupLabels(id string) ([]string, bool) { - dot := strings.LastIndex(id, ".") - if dot < 0 { - return nil, false - } - rel := c.relByFullName(id[:dot]) - if rel == nil || rel.Seed == nil || len(rel.Seed.Key) != 1 || rel.Seed.Key[0] != id[dot+1:] { - return nil, false - } - labels := make([]string, 0, len(rel.Seed.Rows)) - for _, row := range rel.Seed.Rows { - labels = append(labels, constText(row[seedIndex(rel.Seed, id[dot+1:])])) - } - return labels, true -} - -func seedIndex(sd *schema.Seed, col string) int { - for i, c := range sd.Columns { - if c == col { - return i - } - } - return -1 -} - -// constText is the value of a constant as Go constants spell it: the string itself for a -// string, digits for an integer; other expressions as SQL text. -func constText(e schema.Expr) string { - if ac := e.GetAConst(); ac != nil { - switch v := ac.Val.(type) { - case *pgparse.A_Const_Sval: - return v.Sval.GetSval() - case *pgparse.A_Const_Ival: - return strconv.Itoa(int(v.Ival.GetIval())) - } - } - if tc := e.GetTypeCast(); tc != nil { - return constText(tc.Arg) - } - return schema.Deparse(e) -} - -// identity follows single-column PK / FK structure to the root key column. -func (c *checker) identity(table, column string, depth int) (string, bool) { - if depth > 10 { - return "", false - } - rel := c.relByFullName(table) - if rel == nil { - return "", false - } - for _, con := range rel.Constraints { - if con.Kind != schema.ForeignKey { - continue - } - pos := -1 - for i, col := range con.Columns { - if col == column { - pos = i - } - } - if pos < 0 { - continue - } - // a composite FK maps its columns to the referenced key by position - refCols := con.RefColumns - if len(refCols) == 0 { - if ref := c.relByFullName(con.RefTable); ref != nil { - for _, rc := range ref.Constraints { - if rc.Kind == schema.PrimaryKey { - refCols = rc.Columns - } - } - } - } - if pos < len(refCols) { - if id, ok := c.identity(con.RefTable, refCols[pos], depth+1); ok { - return id, true - } - } - } - for _, con := range rel.Constraints { - if con.Kind != schema.PrimaryKey { - continue - } - for _, col := range con.Columns { - if col == column { - // each column of a (possibly composite) primary key is an identity of its own - return table + "." + column, true - } - } - } - return "", false -} - -func (c *checker) relByFullName(name string) *schema.Relation { - for _, r := range c.s.Relations { - if r.FullName() == name { - return r - } +// bareTable is a table's name without its schema. +func bareTable(full string) string { + if i := strings.LastIndexByte(full, '.'); i >= 0 { + return full[i+1:] } - return nil + return full } // namedOf strips pointers / slices / nullable wrappers and returns the Go named type, if any. @@ -234,8 +127,8 @@ func namedOf(t types.Type) *types.Named { // meet records that Go type gt carries a value of PG type pg (from src). Returns the // established binding's key mismatch, if any, as a diagnostic message. -func (c *checker) meet(gt types.Type, pg schema.TypeRef, src *analyze.Source, pos token.Pos, what string) { - n, ok := c.nominalOf(pg, src) +func (c *checker) meet(gt types.Type, dt dialect.Type, src *dialect.Source, pos token.Pos, what string) { + n, ok := nominalOf(dt, src) named := namedOf(gt) if !ok { return diff --git a/internal/vet/comments.go b/cmd/sqlshape/internal/vet/comments.go similarity index 90% rename from internal/vet/comments.go rename to cmd/sqlshape/internal/vet/comments.go index 3109766..c831ed1 100644 --- a/internal/vet/comments.go +++ b/cmd/sqlshape/internal/vet/comments.go @@ -9,7 +9,7 @@ import ( "golang.org/x/tools/go/analysis" - "github.com/kr9ly/sqlshape/internal/analyze" + "github.com/kr9ly/sqlshape/v2/x/dialect" ) // COMMENT ON in schema.sql is the documentation of a column or table. With -sync-comments @@ -18,11 +18,11 @@ import ( // and shows up on hover in the editor. // suggestFieldComment proposes the column's COMMENT as the doc comment of an undocumented field. -func (c *checker) suggestFieldComment(fv *types.Var, col analyze.Column) { +func (c *checker) suggestFieldComment(fv *types.Var, col dialect.Column) { if col.Source == nil { return } - comment := c.s.Comments[col.Source.Table+"."+col.Source.Column] + comment := col.Source.Comment if comment == "" { return } @@ -35,7 +35,7 @@ func (c *checker) suggestFieldComment(fv *types.Var, col analyze.Column) { // suggestTypeComment proposes the table's COMMENT for an undocumented result type whose // rows all come from one relation. -func (c *checker) suggestTypeComment(rType types.Type, r *analyze.Result) { +func (c *checker) suggestTypeComment(rType types.Type, r *dialect.Result) { named, ok := rType.(*types.Named) if !ok || named.Obj().Pkg() != c.pass.Pkg { return @@ -47,10 +47,14 @@ func (c *checker) suggestTypeComment(rType types.Type, r *analyze.Result) { } table = col.Source.Table } - comment := c.s.Comments[table] - if table == "" || comment == "" { + if table == "" { return } + rel := c.sch.Relation(table) + if rel == nil || rel.Comment == "" { + return + } + comment := rel.Comment spec, decl := c.typeSpec(named.Obj().Pos()) if spec == nil || spec.Doc != nil || (decl != nil && decl.Doc != nil) { return diff --git a/internal/vet/consumers_test.go b/cmd/sqlshape/internal/vet/consumers_test.go similarity index 88% rename from internal/vet/consumers_test.go rename to cmd/sqlshape/internal/vet/consumers_test.go index 9173ba3..1d11275 100644 --- a/internal/vet/consumers_test.go +++ b/cmd/sqlshape/internal/vet/consumers_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape/internal/consumers" + "github.com/kr9ly/sqlshape/cmd/sqlshape/v2/internal/consumers" "golang.org/x/tools/go/analysis/analysistest" ) @@ -30,18 +30,18 @@ func TestConsumers(t *testing.T) { return strings.Join(out, " ") } // a column read by a template: the declaration and the position inside the SQL - if got := sites("orders", "note"); !strings.Contains(got, "a.listOrders@34:") { + if got := sites("orders", "note"); !strings.Contains(got, "a.listOrders@37:") { t.Errorf("orders.note consumers: %s", got) } // a view is consumed as itself; its base table's readers are not the statement's - if got := sites("live_memos", "id"); got != "a.viaLiveView@244:68" { + if got := sites("live_memos", "id"); got != "a.viaLiveView@251:68" { t.Errorf("live_memos.id consumers: %s", got) } if got := sites("memos", "deleted_at"); strings.Contains(got, "viaLiveView") { t.Errorf("memos.deleted_at reached through the view: %s", got) } // COPY names its columns - if got := sites("hosts", "mac"); got != "a.hosts@351:52" { + if got := sites("hosts", "mac"); got != "a.hosts@358:52" { t.Errorf("hosts.mac consumers: %s", got) } // relation-level: every statement touching the table diff --git a/internal/vet/copy.go b/cmd/sqlshape/internal/vet/copy.go similarity index 81% rename from internal/vet/copy.go rename to cmd/sqlshape/internal/vet/copy.go index b5e1c7d..35d5730 100644 --- a/internal/vet/copy.go +++ b/cmd/sqlshape/internal/vet/copy.go @@ -8,8 +8,8 @@ import ( "golang.org/x/tools/go/analysis" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/dialect" + "github.com/kr9ly/sqlshape/v2/x/facts" ) // sqlshape.Copy[R](table, cols...) declares a bulk load. The checker verifies the table @@ -32,7 +32,7 @@ func isCopyCall(pass *analysis.Pass, call *ast.CallExpr) bool { return false } obj, ok := pass.TypesInfo.Uses[sel.Sel].(*types.Func) - return ok && obj.Pkg() != nil && obj.Pkg().Path() == sqlshapePkg && obj.Name() == "Copy" + return ok && obj.Pkg() != nil && obj.Pkg().Path() == postgresPkg && obj.Name() == "Copy" } func (c *checker) checkCopy(call *ast.CallExpr) { @@ -67,31 +67,27 @@ func (c *checker) checkCopy(call *ast.CallExpr) { return } table, cols := strs[0], strs[1:] - sch, n := "public", table - if i := strings.LastIndex(table, "."); i >= 0 { - sch, n = table[:i], table[i+1:] - } - rel := c.s.Relation(sch, n) + rel := c.sch.Relation(table) if rel == nil { report("Copy: table %q does not exist", table) return } - if rel.Kind != schema.Table { + if rel.Kind != facts.Table { report("Copy: %q is not a table (COPY FROM loads tables)", table) return } for i, name := range cols { if col := rel.Column(name); col != nil { - c.index.AddColumn(rel.FullName(), col.Name, c.site(call, call.Args[i+1].Pos())) + c.index.AddColumn(rel.Name, col.Name, c.site(call, call.Args[i+1].Pos())) } } if len(cols) == 0 { for _, col := range rel.Columns { - c.index.AddColumn(rel.FullName(), col.Name, c.site(call, call.Args[0].Pos())) + c.index.AddColumn(rel.Name, col.Name, c.site(call, call.Args[0].Pos())) } } if noTables { - report("table %s is written directly; with -no-tables application code reads views and calls functions only", rel.FullName()) + report("table %s is written directly; with -no-tables application code reads views and calls functions only", rel.Name) } if schemasFlag != "" { allowed := false @@ -101,7 +97,7 @@ func (c *checker) checkCopy(call *ast.CallExpr) { } } if !allowed { - report("%s is outside the schemas this code may reference (%s)", rel.FullName(), schemasFlag) + report("%s is outside the schemas this code may reference (%s)", rel.Name, schemasFlag) } } // fields of R @@ -142,7 +138,7 @@ func (c *checker) checkCopy(call *ast.CallExpr) { report("Copy into %s: column %q does not exist", table, name) continue } - if col.Generated != nil { + if col.Generated { report("Copy into %s: column %q is generated and cannot be copied into", table, name) continue } @@ -152,7 +148,7 @@ func (c *checker) checkCopy(call *ast.CallExpr) { continue } fed[f.col] = true - c.meet(f.v.Type(), col.Type, &analyze.Source{Table: rel.FullName(), Column: col.Name, NotNull: col.NotNull, Assigned: true}, call.Pos(), "field "+f.name) + c.meet(f.v.Type(), col.Type, c.sch.Source(rel.Name, col.Name), call.Pos(), "field "+f.name) c.copyFit(report, col, f.v.Type(), "field "+f.name) } for _, f := range flat { @@ -164,15 +160,15 @@ func (c *checker) checkCopy(call *ast.CallExpr) { } // copyFit checks one column against the Go type feeding it (parameter direction). -func (c *checker) copyFit(report func(string, ...any), col *schema.Column, gt types.Type, what string) { - f := c.paramFit(col.Type, gt) +func (c *checker) copyFit(report func(string, ...any), col *dialect.SchemaColumn, gt types.Type, what string) { + f := c.fitPG(col.Type, gt, true) switch { case !f.ok: - report("Copy: %s is %s but column %q is %s", what, gt, col.Name, c.s.Types.Format(col.Type)) + report("Copy: %s is %s but column %q is %s", what, gt, col.Name, col.Type.Name) case f.lossy != "": report("Copy: %s: %s", what, f.lossy) case f.unknown: - report("Copy: %s: no known Go mapping for %s, not checked", what, c.s.Types.Format(col.Type)) + report("Copy: %s: no known Go mapping for %s, not checked", what, col.Type.Name) } if f.ok && col.NotNull && f.nullable { report("Copy: %s is %s but column %q is NOT NULL: a nil value fails the load", what, gt, col.Name) @@ -181,16 +177,16 @@ func (c *checker) copyFit(report func(string, ...any), col *schema.Column, gt ty // copyOmitted reports the columns the load leaves to their defaults that have none and // forbid NULL: COPY fills them with NULL and the first row fails. -func (c *checker) copyOmitted(report func(string, ...any), rel *schema.Relation, cols []string) { +func (c *checker) copyOmitted(report func(string, ...any), rel *dialect.Relation, cols []string) { listed := map[string]bool{} for _, n := range cols { listed[n] = true } for _, col := range rel.Columns { - if listed[col.Name] || !col.NotNull || col.Default != nil || col.Identity != 0 || col.Generated != nil { + if listed[col.Name] || !col.NotNull || col.HasDefault || col.Identity || col.Generated { continue } - report("Copy into %s: column %q is NOT NULL without a default and is not copied", rel.FullName(), col.Name) + report("Copy into %s: column %q is NOT NULL without a default and is not copied", rel.Name, col.Name) } } diff --git a/cmd/sqlshape/internal/vet/dialect.go b/cmd/sqlshape/internal/vet/dialect.go new file mode 100644 index 0000000..f951905 --- /dev/null +++ b/cmd/sqlshape/internal/vet/dialect.go @@ -0,0 +1,30 @@ +package vet + +import ( + "go/ast" +) + +// The dialect path: a schema that declares a dialect other than PostgreSQL is judged +// through internal/dialect. It checks what every dialect's Result can say — the statement +// analyzes, its result columns fit R by name, type and nullability, its parameters fit P — +// and, over its Facts, the One proof (x/cardinality). Obligations, violations, bindings and +// the quick fixes stay on the PostgreSQL path until the Result carries what they need. + +// runDialect is run's tail for a schema whose dialect gives the checker no schema contract +// yet: the statements are checked like any other, MatView and Copy are not available. +func (c *checker) runDialect(calls, matviews []*ast.CallExpr) { + for _, call := range calls[:len(calls)-len(matviews)] { + c.checkCall(call) + } + for _, call := range matviews { + what := "MatView" + if isCopyCall(c.pass, call) { + what = "Copy" + } + c.pass.Reportf(call.Pos(), "sqlshape: %s is not supported for %s", what, c.ls.dialectName) + } + if coverageFlag { + n := len(calls) - len(matviews) + c.pass.Reportf(calls[0].Pos(), "sqlshape: coverage: %d of %d statements checked, %d unchecked (non-constant templates)", n-c.unchecked, n, c.unchecked) + } +} diff --git a/cmd/sqlshape/internal/vet/dialect_test.go b/cmd/sqlshape/internal/vet/dialect_test.go new file mode 100644 index 0000000..f310ce4 --- /dev/null +++ b/cmd/sqlshape/internal/vet/dialect_test.go @@ -0,0 +1,53 @@ +package vet + +import ( + "path/filepath" + "strings" + "testing" + + "golang.org/x/tools/go/analysis/analysistest" + + "github.com/kr9ly/sqlshape/v2/x/dialect" +) + +// stubDialect answers for a fixed table things(id, name, price, at) without parsing +// anything: the test is about the checker's dialect path, not about an analyzer. +type stubDialect struct{} + +var stubColumns = map[string]dialect.Column{ + "id": {Name: "id", Type: dialect.Type{Name: "bigint", Result: []dialect.GoFit{{Go: "int64"}}, Param: []dialect.GoFit{{Go: "int64"}}}}, + "name": {Name: "name", Type: dialect.Type{Name: "varchar(20)", Result: []dialect.GoFit{{Go: "string"}}, Param: []dialect.GoFit{{Go: "string"}}}}, + "price": {Name: "price", Type: dialect.Type{Name: "decimal(10,2)", Result: []dialect.GoFit{{Go: "string"}}, Param: []dialect.GoFit{{Go: "string"}}}, Nullable: true}, + "at": {Name: "at", Type: dialect.Type{Name: "datetime", Result: []dialect.GoFit{{Go: "time.Time"}}, Param: []dialect.GoFit{{Go: "time.Time"}}}}, +} + +func (stubDialect) Problems() []string { return []string{"3: a problem with the schema"} } +func (stubDialect) Traits() dialect.Traits { return dialect.Traits{} } + +func (stubDialect) Analyze(sql string) (*dialect.Result, error) { + if strings.HasPrefix(sql, "SELECT boom") { + return nil, &dialect.Error{Message: "no such thing as boom", Code: "TD001", Position: 7} + } + r := &dialect.Result{} + rest := strings.TrimPrefix(sql, "SELECT ") + list, _, _ := strings.Cut(rest, " FROM ") + for _, name := range strings.Split(list, ",") { + r.Columns = append(r.Columns, stubColumns[strings.TrimSpace(name)]) + } + if i := strings.Index(sql, "WHERE id = $1"); i >= 0 { + r.Params = []dialect.Param{{Type: stubColumns["id"].Type}} + } + return r, nil +} + +func init() { + dialect.Register("testdb", func(string) (dialect.Analyzer, error) { return stubDialect{}, nil }) +} + +func TestDialectPath(t *testing.T) { + td := analysistest.TestData() + if err := Analyzer.Flags.Set("schema", filepath.Join(td, "dialect_schema.sql")); err != nil { + t.Fatal(err) + } + analysistest.Run(t, td, Analyzer, "dialect") +} diff --git a/cmd/sqlshape/internal/vet/dialects_test.go b/cmd/sqlshape/internal/vet/dialects_test.go new file mode 100644 index 0000000..8ab7e21 --- /dev/null +++ b/cmd/sqlshape/internal/vet/dialects_test.go @@ -0,0 +1,4 @@ +package vet + +// The tests check PostgreSQL schemas: the dialect registers itself as the binary does. +import _ "github.com/kr9ly/sqlshape/check/postgres/v2/dialect" diff --git a/cmd/sqlshape/internal/vet/docs_part1_test.go b/cmd/sqlshape/internal/vet/docs_part1_test.go new file mode 100644 index 0000000..5dd8014 --- /dev/null +++ b/cmd/sqlshape/internal/vet/docs_part1_test.go @@ -0,0 +1,788 @@ +package vet + +// docs_part1_test.go covers the rest of docs/checks.md's ("Part 1 — Checks on every +// statement") Result-columns and Passing-parameters groups that docs_test.go's own +// harness does not already exercise: see that file's package doc for the general +// approach (parseDocBlocks/nthBlock/docLine read the .md files at test time; a docs edit +// changes what this test compiles and expects without a second copy to keep in sync). +// This file reuses every helper docs_test.go defines (same package) and defines none of +// its own with the same name; identifiers introduced here that are not test functions +// carry a p1 prefix to stay clear of docs_part2_test.go / docs_part3_test.go. +// +// Sections covered (docs/checks.md heading, in file order): +// - Unnamed and duplicate columns need an alias +// - A column only some branches select needs a field that can hold NULL +// - Column and field types follow the table below +// - Nested rows are received by structs +// - A single-column statement can be received by a scalar +// - Embedded structs are flattened +// - The Go type table (excluded: prose only, no fences) +// - A parameter's type follows where it is used +// - A parameter that may be NULL is a pointer (-strict) +// - Nested paths and `range` +// - Composite parameters are structs +// - Do not leave unused fields in `P` (-strict) +// - Do not always send a value into a column with a DEFAULT (-strict) +// +// The "Nested rows" Passes example and both -strict examples that touch an enum column +// each get one diagnostic beyond the one docs' prose leads with; docs itself now says so +// (the array-element NULL note has no way to be proven absent yet; the enum-column +// advisory is a separate, correct finding about the column rather than the parameter), +// so each expect list below names the extra diagnostic explicitly rather than treating it +// as an unexplained mismatch. + +import ( + "strings" + "testing" +) + +// p1SetStrict turns -strict on for the calling test and restores it on cleanup, the way +// vet_test.go's TestStrict does; docs_test.go's own tests never need -strict, so it adds +// no such helper. +func p1SetStrict(t *testing.T) { + t.Helper() + if err := Analyzer.Flags.Set("strict", "true"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { Analyzer.Flags.Set("strict", "false") }) +} + +// p1DropBracket cuts s at the first " [" (a branch-location marker such as +// "[if@11:else]"): the byte offset docs' own comment bakes in is the offset within its +// own copy of the template text, which need not match this harness's -- it is not part +// of what the doc is actually promising (the wording around it is), so a caller matches +// only the text on one side of it. +func p1DropBracket(s string) string { + if i := strings.Index(s, " ["); i >= 0 { + return s[:i] + } + return s +} + +// p1Paragraphs splits a fenced sql block's body on blank lines: docs sometimes shows two +// independent statements (each with its own trailing "-- ^ ..." comment) in one fence, +// separated by a blank line, rather than as two fences. +func p1Paragraphs(body string) []string { + var out []string + var buf []string + flush := func() { + if len(buf) > 0 { + out = append(out, strings.Join(buf, "\n")) + buf = nil + } + } + for _, l := range strings.Split(body, "\n") { + if strings.TrimSpace(l) == "" { + flush() + continue + } + buf = append(buf, l) + } + flush() + return out +} + +// --------------------------------------------------------------------------------------- +// "Unnamed and duplicate columns need an alias" +// --------------------------------------------------------------------------------------- + +func TestDocsP1Alias(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Unnamed and duplicate columns need an alias"}, + {"ja", "checks.ja.md", "名前の無い列と同名の列には別名を付ける"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ng := nthBlock(t, blocks, tc.head, "sql", 1) + ok := nthBlock(t, blocks, tc.head, "sql", 2) + + ngStmts := p1Paragraphs(ng.body) + if len(ngStmts) != 2 { + t.Fatalf("expected 2 Rejected statements, got %d:\n%s", len(ngStmts), ng.body) + } + okLines := strings.Split(strings.TrimSpace(ok.body), "\n") + if len(okLines) != 2 { + t.Fatalf("expected 2 Passes statements, got %d:\n%s", len(okLines), ok.body) + } + + td := t.TempDir() + writeFile(t, td, "src/docsex_p1_alias_ng_group/ng.go", `package docsex_p1_alias_ng_group + +import "github.com/kr9ly/sqlshape/v2" + +type OrderCount struct { + ID int64 +} + +var Q1 = sqlshape.Query[OrderCount, struct{}](`+backtick(oneLineSQL(ngStmts[0]))+`) + +type OrderCustomer struct { + ID int64 +} + +var Q2 = sqlshape.Query[OrderCustomer, struct{}](`+backtick(oneLineSQL(ngStmts[1]))+`) +`) + writeFile(t, td, "src/docsex_p1_alias_ok_group/ok.go", `package docsex_p1_alias_ok_group + +import "github.com/kr9ly/sqlshape/v2" + +type OrderCount struct { + ID int64 + N int64 +} + +var Q1 = sqlshape.Query[OrderCount, struct{}](`+backtick(okLines[0])+`) + +type OrderCustomer struct { + ID int64 + CustomerID int64 +} + +var Q2 = sqlshape.Query[OrderCustomer, struct{}](`+backtick(okLines[1])+`) +`) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_colbind_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p1_alias_ng_group"}, []string{ + docLine(t, ngStmts[0], "has no name"), + docLine(t, ngStmts[1], "both named"), + }) + assertDiagnostics(t, td, []string{"docsex_p1_alias_ok_group"}, nil) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "A column only some branches select needs a field that can hold NULL" +// --------------------------------------------------------------------------------------- + +func TestDocsP1BranchNullable(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "A column only some branches select needs a field that can hold NULL"}, + {"ja", "checks.ja.md", "一部の分岐だけが選ぶ列はNULLを受けられる型で受ける"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ngGo := nthBlock(t, blocks, tc.head, "go", 1) + sql := nthBlock(t, blocks, tc.head, "sql", 1) + okGo := nthBlock(t, blocks, tc.head, "go", 2) + + plainNG := dropInlineComment(ngGo.body, "is not selected in every branch") + + td := t.TempDir() + writeFile(t, td, "src/docsex_p1_branch_ng/ng.go", `package docsex_p1_branch_ng + +import "github.com/kr9ly/sqlshape/v2" + +`+plainNG+` + +type Params struct { + WithTotal bool +} + +var Q = sqlshape.Query[Order, Params](`+backtick(oneLineSQL(sql.body))+`) +`) + writeFile(t, td, "src/docsex_p1_branch_ok/ok.go", `package docsex_p1_branch_ok + +import "github.com/kr9ly/sqlshape/v2" + +`+okGo.body+` + +type Params struct { + WithTotal bool +} + +var Q = sqlshape.Query[Order, Params](`+backtick(oneLineSQL(sql.body))+`) +`) + copyRuntime(t, td) + + // the shared schema.sql's orders.total (numeric(12,2) NOT NULL) already + // matches what this example needs. + setSchema(t, absPath(t, "testdata/schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p1_branch_ng"}, []string{ + // dropping the "[if@N:else]" branch-location suffix: see p1DropBracket. + p1DropBracket(docLine(t, ngGo.body, "is not selected in every branch")), + }) + assertDiagnostics(t, td, []string{"docsex_p1_branch_ok"}, nil) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Column and field types follow the table below" +// --------------------------------------------------------------------------------------- + +// A numeric(12,2) column received into a float64 field is accepted with a precision-loss +// note, not rejected: float64 is a lossy but permitted receiver for numeric, not an +// incompatible one (see the type table in docs/postgres.md). +func TestDocsP1ColumnTypeTable(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Column and field types follow the table below"}, + {"ja", "checks.ja.md", "列の型とフィールドの型は下の表に従う"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + lossyGo := nthBlock(t, blocks, tc.head, "go", 1) + sql := nthBlock(t, blocks, tc.head, "sql", 1) + silentGo := nthBlock(t, blocks, tc.head, "go", 2) + + plainLossy := dropInlineComment(lossyGo.body, "loses precision") + + td := t.TempDir() + writeFile(t, td, "src/docsex_p1_coltype_lossy/lossy.go", `package docsex_p1_coltype_lossy + +import "github.com/kr9ly/sqlshape/v2" + +`+plainLossy+` + +var Q = sqlshape.Query[Order, struct{}](`+backtick(oneLineSQL(sql.body))+`) +`) + writeFile(t, td, "src/docsex_p1_coltype_silent/silent.go", `package docsex_p1_coltype_silent + +import "github.com/kr9ly/sqlshape/v2" + +`+silentGo.body+` + +var Q = sqlshape.Query[Order, struct{}](`+backtick(oneLineSQL(sql.body))+`) +`) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p1_coltype_lossy"}, []string{ + docLine(t, lossyGo.body, "loses precision"), + }) + assertDiagnostics(t, td, []string{"docsex_p1_coltype_silent"}, nil) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Nested rows are received by structs" +// +// Docs now name both of the diagnostics the checker actually raises here, so the expect +// lists below are the documented behaviour, not an unexplained mismatch: +// +// 1. Swapping two adjacent fields (docs' Rejected Item{Qty, Sku}) puts *both* fields at +// the wrong position, not one: checkNested (nested.go) compares struct field i +// against row column i for every i, so both "Qty is at position 1, row column 1 is +// sku" and "Sku is at position 2, row column 2 is qty" fire. +// 2. docs' Passes example receives a composite array_agg column with a plain +// (non-pointer) struct slice, []Item, and carries no "may contain a NULL element" +// note: array_agg's own argument here, (i.sku, i.qty)::order_item, is a row +// constructor, never NULL itself regardless of its fields' own nullability, and +// dialect.Type.ElemNotNull now lets the checker say so (unlike a plain scalar +// array_agg, which still carries the note -- see docs/postgres.md's `T[]` row). +// --------------------------------------------------------------------------------------- + +func TestDocsP1NestedRows(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Nested rows are received by structs"}, + {"ja", "checks.ja.md", "ネストした行は構造体で受ける"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ngGo := nthBlock(t, blocks, tc.head, "go", 1) + sql := nthBlock(t, blocks, tc.head, "sql", 2) // sql#1 is the "-- schema.sql" CREATE TYPE excerpt + okGo := nthBlock(t, blocks, tc.head, "go", 2) + + plainNG := dropInlineComment(ngGo.body, "is at position 1") + + // docs' okGo only redeclares Item (Sku before Qty); Order (with []Item) + // carries over from the Rejected example. + td := t.TempDir() + writeFile(t, td, "src/docsex_p1_nested_ng/ng.go", `package docsex_p1_nested_ng + +import "github.com/kr9ly/sqlshape/v2" + +`+plainNG+` + +var Q = sqlshape.Query[Order, struct{}](`+backtick(oneLineSQL(sql.body))+`) +`) + writeFile(t, td, "src/docsex_p1_nested_ok/ok.go", `package docsex_p1_nested_ok + +import "github.com/kr9ly/sqlshape/v2" + +`+okGo.body+` + +type Order struct { + ID int64 + Items []Item +} + +var Q = sqlshape.Query[Order, struct{}](`+backtick(oneLineSQL(sql.body))+`) +`) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p1_nested_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p1_nested_ng"}, []string{ + docLine(t, ngGo.body, "is at position 1"), + // real behaviour, not docs' own words: the same swap also misplaces + // Sku (see the mismatch note above). + `field Items.Sku is at position 2 but the row type's column 2 is "qty" (fields are scanned in order)`, + }) + assertDiagnostics(t, td, []string{"docsex_p1_nested_ok"}, nil) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "A single-column statement can be received by a scalar" +// --------------------------------------------------------------------------------------- + +func TestDocsP1ScalarResult(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "A single-column statement can be received by a scalar"}, + {"ja", "checks.ja.md", "1列だけ返すSQLはスカラーで受けられる"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ok := nthBlock(t, blocks, tc.head, "go", 1) + ng := nthBlock(t, blocks, tc.head, "go", 2) + + plainNG := stripTrailingLineComment(ng.body, "R is int64") + + td := t.TempDir() + writeFile(t, td, "src/docsex_p1_scalar_ok/ok.go", `package docsex_p1_scalar_ok + +import "github.com/kr9ly/sqlshape/v2" + +`+ok.body+` +`) + writeFile(t, td, "src/docsex_p1_scalar_ng/ng.go", `package docsex_p1_scalar_ng + +import "github.com/kr9ly/sqlshape/v2" + +`+plainNG+` +`) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p1_scalar_ok"}, nil) + assertDiagnostics(t, td, []string{"docsex_p1_scalar_ng"}, []string{ + docLine(t, ng.body, "R is int64"), + }) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Embedded structs are flattened" +// --------------------------------------------------------------------------------------- + +func TestDocsP1EmbeddedFlatten(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Embedded structs are flattened"}, + {"ja", "checks.ja.md", "埋め込み構造体は平坦化される"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + okGo := nthBlock(t, blocks, tc.head, "go", 1) + sql := nthBlock(t, blocks, tc.head, "sql", 1) + ngGo := nthBlock(t, blocks, tc.head, "go", 2) + + plainNG := dropInlineComment(ngGo.body, "both bind to column") + + td := t.TempDir() + writeFile(t, td, "src/docsex_p1_embed_ok/ok.go", `package docsex_p1_embed_ok + +import "time" +import "github.com/kr9ly/sqlshape/v2" + +`+okGo.body+` + +var Q = sqlshape.Query[Order, struct{}](`+backtick(oneLineSQL(sql.body))+`) +`) + writeFile(t, td, "src/docsex_p1_embed_ng/ng.go", `package docsex_p1_embed_ng + +import "time" +import "github.com/kr9ly/sqlshape/v2" + +type Base struct { + ID int64 + CreatedAt time.Time +} + +`+plainNG+` + +var Q = sqlshape.Query[Order, struct{}](`+backtick(oneLineSQL(sql.body))+`) +`) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p1_embed_ok"}, nil) + assertDiagnostics(t, td, []string{"docsex_p1_embed_ng"}, []string{ + docLine(t, ngGo.body, "both bind to column"), + }) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "The Go type table" -- prose only (a cross-reference to postgres.md / mysql.md's own +// tables), no fenced go/sql example of its own to run. +// --------------------------------------------------------------------------------------- + +func TestDocsP1GoTypeTableExcluded(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "The Go type table"}, + {"ja", "checks.ja.md", "Go型の表"}, + } { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + n := 0 + for _, b := range blocks { + if b.heading == tc.head { + n++ + } + } + if n != 0 { + t.Errorf("%s: %q now has %d fenced block(s); this test's exclusion is stale, wire it up", tc.name, tc.head, n) + } + t.Logf("excluded %s %q: prose only, cross-references postgres.md/mysql.md's own Go type tables, no runnable fence", tc.name, tc.head) + } +} + +// --------------------------------------------------------------------------------------- +// "A parameter's type follows where it is used" +// --------------------------------------------------------------------------------------- + +func TestDocsP1ParamTypeUse(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "A parameter's type follows where it is used"}, + {"ja", "checks.ja.md", "パラメータの型は使われる場所の型に合わせる"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ngGo := nthBlock(t, blocks, tc.head, "go", 1) + sql := nthBlock(t, blocks, tc.head, "sql", 1) + okGo := nthBlock(t, blocks, tc.head, "go", 2) + + plainNG := dropInlineComment(ngGo.body, "SQL expects uuid") + + td := t.TempDir() + writeFile(t, td, "src/docsex_p1_paramtype_ng/ng.go", `package docsex_p1_paramtype_ng + +import ( + "github.com/google/uuid" + "github.com/kr9ly/sqlshape/v2" +) + +type User struct { + ID uuid.UUID + Email string +} + +`+plainNG+` + +var Q = sqlshape.Query[User, Params](`+backtick(oneLineSQL(sql.body))+`) +`) + writeFile(t, td, "src/docsex_p1_paramtype_ok/ok.go", `package docsex_p1_paramtype_ok + +import ( + "github.com/google/uuid" + "github.com/kr9ly/sqlshape/v2" +) + +type User struct { + ID uuid.UUID + Email string +} + +`+okGo.body+` + +var Q = sqlshape.Query[User, Params](`+backtick(oneLineSQL(sql.body))+`) +`) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p1_paramtype_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p1_paramtype_ng"}, []string{ + docLine(t, ngGo.body, "SQL expects uuid"), + }) + assertDiagnostics(t, td, []string{"docsex_p1_paramtype_ok"}, nil) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "A parameter that may be NULL is a pointer" (-strict) +// +// Mismatch found: this schema's status column is an enum, and -strict's schema.Advice() +// (check/postgres/dialect/schema.go) always reports "
.is enum : a +// seeded lookup table ... is easier to change" for every enum column in every table, +// once per package under -strict, regardless of whether any query touches it. Docs' +// Passes example is therefore not actually diagnostic-free under -strict either. Filed +// as a docs suggestion; both expect lists below name the advisory explicitly. +// --------------------------------------------------------------------------------------- + +func TestDocsP1ParamNullPointer(t *testing.T) { + p1SetStrict(t) + enumAdvice := "orders.status is enum order_status: a seeded lookup table" + + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "A parameter that may be NULL is a pointer"}, + {"ja", "checks.ja.md", "NULLを渡しうるパラメータはポインタにする"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ngGo := nthBlock(t, blocks, tc.head, "go", 1) + okGo := nthBlock(t, blocks, tc.head, "go", 2) + + plainNG := dropInlineComment(ngGo.body, "zero value") + + td := t.TempDir() + writeFile(t, td, "src/docsex_p1_paramnull_ng/ng.go", `package docsex_p1_paramnull_ng + +import "github.com/kr9ly/sqlshape/v2" + +type OrderStatus string + +`+plainNG+` + +var Q = sqlshape.Query[int64, Params](`+backtick("SELECT count(*) FROM orders WHERE status = {{.Status}}")+`) +`) + writeFile(t, td, "src/docsex_p1_paramnull_ok/ok.go", `package docsex_p1_paramnull_ok + +import "github.com/kr9ly/sqlshape/v2" + +type OrderStatus string + +`+okGo.body+` + +var Q = sqlshape.Query[int64, Params](`+backtick("SELECT count(*) FROM orders WHERE status = {{.Status}}")+`) +`) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p1_paramnull_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p1_paramnull_ng"}, []string{ + docLine(t, ngGo.body, "zero value"), + enumAdvice, + }) + assertDiagnostics(t, td, []string{"docsex_p1_paramnull_ok"}, []string{ + enumAdvice, + }) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Nested paths and `range`" +// --------------------------------------------------------------------------------------- + +func TestDocsP1RangePaths(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Nested paths and `range`"}, + {"ja", "checks.ja.md", "ネストしたパスと`range`"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + okGo := nthBlock(t, blocks, tc.head, "go", 1) + sql := nthBlock(t, blocks, tc.head, "sql", 1) + + td := t.TempDir() + writeFile(t, td, "src/docsex_p1_rangepath_ok/ok.go", `package docsex_p1_rangepath_ok + +import "github.com/kr9ly/sqlshape/v2" + +`+okGo.body+` + +var Q = sqlshape.Query[struct{ ID int64 }, Params](`+backtick(oneLineSQL(sql.body))+`) +`) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p1_rangepath_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p1_rangepath_ok"}, nil) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Composite parameters are structs" +// --------------------------------------------------------------------------------------- + +func TestDocsP1CompositeParams(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Composite parameters are structs"}, + {"ja", "checks.ja.md", "複合型のパラメータは構造体で渡す"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + okGo := nthBlock(t, blocks, tc.head, "go", 1) + sql := nthBlock(t, blocks, tc.head, "sql", 2) // sql#1 is the "-- schema.sql" excerpt + + td := t.TempDir() + writeFile(t, td, "src/docsex_p1_compositeparam_ok/ok.go", `package docsex_p1_compositeparam_ok + +import "github.com/kr9ly/sqlshape/v2" + +`+okGo.body+` + +var Q = sqlshape.Query[int64, Params](`+backtick(oneLineSQL(sql.body))+`) +`) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p1_compositeparam_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p1_compositeparam_ok"}, nil) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Do not leave unused fields in `P` (-strict)" +// --------------------------------------------------------------------------------------- + +func TestDocsP1UnusedParamField(t *testing.T) { + p1SetStrict(t) + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Do not leave unused fields in `P` (`-strict`)"}, + {"ja", "checks.ja.md", "使っていないフィールドを`P`に残さない(`-strict`)"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ngGo := nthBlock(t, blocks, tc.head, "go", 1) + sql := nthBlock(t, blocks, tc.head, "sql", 1) + + plainNG := dropInlineComment(ngGo.body, "never used by the template") + + td := t.TempDir() + writeFile(t, td, "src/docsex_p1_unusedfield_ng/ng.go", `package docsex_p1_unusedfield_ng + +import "github.com/kr9ly/sqlshape/v2" + +`+plainNG+` + +var Q = sqlshape.Query[struct{ ID int64 }, Params](`+backtick(oneLineSQL(sql.body))+`) +`) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p1_unusedfield_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p1_unusedfield_ng"}, []string{ + docLine(t, ngGo.body, "never used by the template"), + }) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Do not always send a value into a column with a DEFAULT (-strict)" +// +// Mismatch found: NewOrder.Status here is the same OrderStatus/order_status pair as "A +// parameter that may be NULL is a pointer", so both that section's non-pointer-enum +// note and this schema's unconditional enum-lookup-table advice (see that test's +// comment) accompany the DEFAULT diagnostic docs' comment names -- three diagnostics, +// not one, for the Rejected example; the Passes example gets the enum advice alone, not +// zero. Filed as a docs suggestion; expect lists name both extra diagnostics explicitly. +// --------------------------------------------------------------------------------------- + +func TestDocsP1ParamDefault(t *testing.T) { + p1SetStrict(t) + enumAdvice := "orders.status is enum order_status: a seeded lookup table" + + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Do not always send a value into a column with a DEFAULT (`-strict`)"}, + {"ja", "checks.ja.md", "既定値のある列に常に値を送らない(`-strict`)"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ngGo := nthBlock(t, blocks, tc.head, "go", 1) + ngSQL := nthBlock(t, blocks, tc.head, "sql", 1) + okSQL := nthBlock(t, blocks, tc.head, "sql", 2) + + plainNG := dropInlineComment(ngGo.body, "always sends a value") + nonPointerEnumNote := "parameter .Status is a non-pointer OrderStatus: its zero value \"\" is not a label of enum order_status and fails at runtime (SQLSTATE 22P02) when unset" + + td := t.TempDir() + writeFile(t, td, "src/docsex_p1_paramdefault_ng/ng.go", `package docsex_p1_paramdefault_ng + +import "github.com/kr9ly/sqlshape/v2" + +type OrderStatus string + +`+plainNG+` + +var Q = sqlshape.Query[struct{}, NewOrder](`+backtick(oneLineSQL(ngSQL.body))+`) +`) + writeFile(t, td, "src/docsex_p1_paramdefault_ok/ok.go", `package docsex_p1_paramdefault_ok + +import "github.com/kr9ly/sqlshape/v2" + +type OrderStatus string + +type NewOrder struct { + CustomerID int64 + Status *OrderStatus +} + +var Q = sqlshape.Query[struct{}, NewOrder](`+backtick(oneLineSQL(okSQL.body))+`) +`) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p1_paramdefault_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p1_paramdefault_ng"}, []string{ + docLine(t, ngGo.body, "always sends a value"), + nonPointerEnumNote, + enumAdvice, + }) + + // Docs' Passes SQL guards the same {{if .Status}} test twice, once in the + // column list and once in the VALUES list; branch-state exploration now + // follows an if/with/range on an already-decided plain path within the + // same lineage, so the two occurrences are treated as one decision, not + // as four independently-variable combinations, and no spurious + // syntax-error diagnostic is produced. + assertDiagnostics(t, td, []string{"docsex_p1_paramdefault_ok"}, []string{ + enumAdvice, + }) + }) + } +} diff --git a/cmd/sqlshape/internal/vet/docs_part2_test.go b/cmd/sqlshape/internal/vet/docs_part2_test.go new file mode 100644 index 0000000..c2842fc --- /dev/null +++ b/cmd/sqlshape/internal/vet/docs_part2_test.go @@ -0,0 +1,556 @@ +package vet + +// docs_part2_test.go covers the milestone's own slice of docs/checks.md (and its ja +// translation): "Giving types a meaning" through "Returning one row" minus the sections +// docs_test.go already owns ("Fix a unique key by equality", "Bulk loading with COPY", +// "Name the errors a trigger raises"). It reuses every helper docs_test.go already +// defines (parseDocBlocks, nthBlock, docLine, assertDiagnostics, setSchema, docsMD, +// writeFile, copyRuntime, absPath, backtick, oneLineSQL, dropInlineComment, +// stripTrailingLineComment) and adds only what is missing here, under a p2 prefix. +// +// Exclusions (sections in this milestone's scope with nothing to compile): +// - "Constraint names": prose plus two cross-doc links, no go/sql fence at all. +// - "PL/pgSQL statements that fail on their own (PostgreSQL)": a bullet list describing +// SQLSTATEs (P0002/P0003/20000/P0004) and how RAISE ... USING ERRCODE resolves +// statically; no fence. +// - Under "Declare the constraints a write can violate", the third fence +// (`_, err := postgres.First(ctx, db, CreateCustomer, p); if postgres.Violates(...) { +// ... }`) is a runtime call-site illustration: ctx/db/p/CreateCustomer are all +// undefined and the snippet asserts nothing a diagnostic could check. The two sql +// fences either side of it (the bare INSERT and the same INSERT with its expect line) +// are exercised on their own below. +// +// A few fences in this milestone's sections are prose demonstrations rather than +// complete, standalone programs; each such case is called out at its own Test function +// with what was supplied to make it compile and why that supply does not change the +// diagnostic wording being checked. +import ( + "fmt" + "strings" + "testing" +) + +// setStrictP2 turns on the -strict flag for the duration of the subtest, mirroring the +// Set/defer-Set("false") shape vet_test.go's TestStrict already uses (Analyzer.Flags is +// package-level state, not scoped to a single subtest on its own). +func setStrictP2(t *testing.T) { + t.Helper() + if err := Analyzer.Flags.Set("strict", "true"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { Analyzer.Flags.Set("strict", "false") }) +} + +// p2Rename replaces the first occurrence of from with to in body. It exists for the two +// fences in this milestone (see TestDocsP2MeaningFromUse and TestDocsP2NotAnotherTablesID) +// that write `var User = sqlshape.One[User, ...]`: naming the var the same as the type +// parameter it instantiates is fine in isolation (the doc never declares `type User` in +// the same fence, so there is no real clash there), but this harness must supply that +// missing `type User` itself to let the snippet compile at all, and doing so turns the +// doc's own var name into a genuine redeclaration. Renaming only the var side leaves the +// diagnostic wording (which never mentions the var, only the type UserID and the columns +// it meets) untouched. +func p2Rename(body, from, to string) string { + return strings.Replace(body, from, to, 1) +} + +// --------------------------------------------------------------------------------------- +// "Meaning comes from use, not from a registry" +// --------------------------------------------------------------------------------------- + +// TestDocsP2MeaningFromUse runs the section's own two-statement illustration (UserID binds +// to users.id on the first meeting, then is checked -- and found wanting -- on the +// second). The doc never declares `type User`; that struct belongs to an earlier, +// unrelated section of the same page (a plain users(id, email) shape matches the SQL +// here), so this harness supplies it, and renames the doc's `var User` to `var ByID` to +// avoid redeclaring the type it just added (see p2Rename). +func TestDocsP2MeaningFromUse(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Meaning comes from use, not from a registry"}, + {"ja", "checks.ja.md", "意味は登録ではなく使用箇所から決まる"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + typeDecl := nthBlock(t, blocks, tc.head, "go", 1) // type UserID int64 + twoStmts := nthBlock(t, blocks, tc.head, "go", 2) // User query, then Total query + + body := stripTrailingLineComment(twoStmts.body, "elsewhere, but here meets") + body = p2Rename(body, "var User =", "var ByID =") + + td := t.TempDir() + writeFile(t, td, "src/docsex_p2_meaning/meaning.go", fmt.Sprintf(`package docsex_p2_meaning + +import "github.com/kr9ly/sqlshape/v2" + +%s + +type User struct { + ID UserID + Email string +} + +%s +`, typeDecl.body, body)) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p2_meaning_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p2_meaning"}, []string{ + docLine(t, twoStmts.body, "elsewhere, but here meets"), + }) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Enum and lookup values agree with the named type's constants" +// --------------------------------------------------------------------------------------- + +func TestDocsP2EnumConstants(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Enum and lookup values agree with the named type's constants"}, + {"ja", "checks.ja.md", "enumやlookupテーブルの値はnamed typeの定数と一致させる"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + bind := nthBlock(t, blocks, tc.head, "go", 1) // var ByStatus = sqlshape.Query[Order, struct{ Status OrderStatus }](...) + ng := nthBlock(t, blocks, tc.head, "go", 2) // type OrderStatus string; const (... Canceled ...) + ok := nthBlock(t, blocks, tc.head, "go", 3) // const (... Shipped ...) + + ngConsts := dropInlineComment(ng.body, "is not a label of value set") + ngConsts = stripTrailingLineComment(ngConsts, "has no constant for it") + + td := t.TempDir() + writeFile(t, td, "src/docsex_p2_enum_ng/ng.go", fmt.Sprintf(`package docsex_p2_enum_ng + +import "github.com/kr9ly/sqlshape/v2" + +type Order struct { + ID int64 + Total string +} + +%s + +%s +`, ngConsts, bind.body)) + writeFile(t, td, "src/docsex_p2_enum_ok/ok.go", fmt.Sprintf(`package docsex_p2_enum_ok + +import "github.com/kr9ly/sqlshape/v2" + +type OrderStatus string + +type Order struct { + ID int64 + Total string +} + +%s + +%s +`, ok.body, bind.body)) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p2_enum_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p2_enum_ng"}, []string{ + docLine(t, ng.body, "is not a label of value set"), + docLine(t, ng.body, "has no constant for it"), + }) + assertDiagnostics(t, td, []string{"docsex_p2_enum_ok"}, nil) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Do not pass another table's ID" +// --------------------------------------------------------------------------------------- + +// TestDocsP2NotAnotherTablesID runs the Rejected/Passes Params pair, primed by the +// section's own opening illustration (UserID bound to users.id). As in +// TestDocsP2MeaningFromUse, that opening fence writes `var User = sqlshape.One[User, +// ...]` without ever declaring `type User` in the fence itself; this harness supplies the +// type and renames the var (p2Rename) so both meanings of "User" do not collide. +func TestDocsP2NotAnotherTablesID(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Do not pass another table's ID"}, + {"ja", "checks.ja.md", "別のテーブルのIDを渡さない"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + prime := nthBlock(t, blocks, tc.head, "go", 1) // type UserID/OrderID int64; var User = ... + ngGo := nthBlock(t, blocks, tc.head, "go", 2) // type Params struct{ ID UserID // ... } + ngSQL := nthBlock(t, blocks, tc.head, "sql", 1) // SELECT total FROM orders WHERE id = {{.ID}} + okGo := nthBlock(t, blocks, tc.head, "go", 3) // type Params struct{ ID OrderID } + + primeBody := p2Rename(prime.body, "var User =", "var ByID =") + ngParams := dropInlineComment(ngGo.body, "elsewhere, but here meets") + + td := t.TempDir() + writeFile(t, td, "src/docsex_p2_tableid_ng/ng.go", fmt.Sprintf(`package docsex_p2_tableid_ng + +import "github.com/kr9ly/sqlshape/v2" + +%s + +type User struct { + ID UserID + Email string +} + +%s + +var Q = sqlshape.Query[struct{ Total string }, Params](%s) +`, primeBody, ngParams, backtick(oneLineSQL(ngSQL.body)))) + writeFile(t, td, "src/docsex_p2_tableid_ok/ok.go", fmt.Sprintf(`package docsex_p2_tableid_ok + +import "github.com/kr9ly/sqlshape/v2" + +%s + +type User struct { + ID UserID + Email string +} + +%s + +var Q = sqlshape.Query[struct{ Total string }, Params](%s) +`, primeBody, okGo.body, backtick(oneLineSQL(ngSQL.body)))) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p2_tableid_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p2_tableid_ng"}, []string{ + docLine(t, ngGo.body, "elsewhere, but here meets"), + }) + assertDiagnostics(t, td, []string{"docsex_p2_tableid_ok"}, nil) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Do not mix domains of different units (PostgreSQL)" +// --------------------------------------------------------------------------------------- + +// TestDocsP2DomainMismatch splits the section's two independent illustrations: a plain +// SQL expression mixing two domains (no parameter involved), and a -strict advisory about +// a parameter carrying a domain through a plain int64 versus a named type. A parameter +// compared against a domain column is resolved by PostgreSQL to the domain, so both the +// plain-SQL and the parameter advisory fire, as docs claim. +func TestDocsP2DomainMismatch(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Do not mix domains of different units (PostgreSQL)"}, + {"ja", "checks.ja.md", "単位の違うドメインを混ぜない(PostgreSQL)"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ngSQL := nthBlock(t, blocks, tc.head, "sql", 2) // SELECT id FROM products WHERE price + weight > 1000 + ngGo := nthBlock(t, blocks, tc.head, "go", 1) // Params{ Max int64 // ... } + okGo := nthBlock(t, blocks, tc.head, "go", 2) // type Yen int64; Params{ Max Yen } + okSQL := nthBlock(t, blocks, tc.head, "sql", 3) // SELECT id FROM products WHERE price > {{.Max}} + + td := t.TempDir() + // R is a named ProductID, not a bare int64: products.id is itself a key + // column, and under -strict a bare int64 receiving it would add its own + // "carries key products.id as a plain int64" advisory alongside the domain + // one this test is actually about. + writeFile(t, td, "src/docsex_p2_domainsql_ng/ng.go", fmt.Sprintf(`package docsex_p2_domainsql_ng + +import "github.com/kr9ly/sqlshape/v2" + +type ProductID int64 + +var Q = sqlshape.Query[struct{ ID ProductID }, struct{}](%s) +`, backtick(oneLineSQL(ngSQL.body)))) + copyRuntime(t, td) + + ngParams := dropInlineComment(ngGo.body, "declare a named type to have it checked") + + writeFile(t, td, "src/docsex_p2_domainparam_ng/ng.go", fmt.Sprintf(`package docsex_p2_domainparam_ng + +import "github.com/kr9ly/sqlshape/v2" + +type ProductID int64 + +%s + +var Q = sqlshape.Query[struct{ ID ProductID }, Params](%s) +`, ngParams, backtick(oneLineSQL(okSQL.body)))) + writeFile(t, td, "src/docsex_p2_domainparam_ok/ok.go", fmt.Sprintf(`package docsex_p2_domainparam_ok + +import "github.com/kr9ly/sqlshape/v2" + +type ProductID int64 + +%s + +var Q = sqlshape.Query[struct{ ID ProductID }, Params](%s) +`, okGo.body, backtick(oneLineSQL(okSQL.body)))) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p2_domain_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p2_domainsql_ng"}, []string{ + "domain mismatch: yen + gram: operands must share the domain (cast to the base type to drop it)", + }) + + setStrictP2(t) + assertDiagnostics(t, td, []string{"docsex_p2_domainparam_ng"}, []string{ + "parameter .Max carries domain yen as a plain int64; declare a named type to have it checked", + }) + assertDiagnostics(t, td, []string{"docsex_p2_domainparam_ok"}, nil) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Do not receive with a type that loses information (`-strict`)" +// --------------------------------------------------------------------------------------- + +// TestDocsP2LossyReceive runs the section's one struct verbatim; the doc shows no SELECT +// for it (the point is made on the fields alone), so this harness supplies a query whose +// result columns match At/Day exactly ("SELECT at, day FROM events" against an +// events(at timestamp, day date) table, both NOT NULL so neither field's other advisory, +// "may be NULL", can appear and crowd out the ones the doc claims). +func TestDocsP2LossyReceive(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Do not receive with a type that loses information (`-strict`)"}, + {"ja", "checks.ja.md", "情報が落ちる型で受けない(`-strict`)"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + event := nthBlock(t, blocks, tc.head, "go", 1) + + td := t.TempDir() + writeFile(t, td, "src/docsex_p2_event/event.go", fmt.Sprintf(`package docsex_p2_event + +import ( + "time" + + "github.com/kr9ly/sqlshape/v2" +) + +%s + +var Q = sqlshape.Query[Event, struct{}](`+"`SELECT at, day FROM events`"+`) +`, event.body)) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p2_event_schema.sql")) + setStrictP2(t) + assertDiagnostics(t, td, []string{"docsex_p2_event"}, []string{ + docLine(t, event.body, "which zone the value is in"), + docLine(t, event.body, "a zone conversion can move the day"), + }) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Declare the constraints a write can violate" +// --------------------------------------------------------------------------------------- + +func TestDocsP2DeclareViolations(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Declare the constraints a write can violate"}, + {"ja", "checks.ja.md", "違反しうる制約はexpect行に宣言する"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ngSQL := nthBlock(t, blocks, tc.head, "sql", 1) // INSERT ... RETURNING id, no expect line + okSQL := nthBlock(t, blocks, tc.head, "sql", 2) // same, with expect customers_email_key + // The third fence under this heading (postgres.First/Violates call-site + // illustration) is a runtime usage snippet, not a diagnostic to check -- + // see the file-level exclusion note. + + td := t.TempDir() + writeFile(t, td, "src/docsex_p2_writefail_ng/ng.go", fmt.Sprintf(`package docsex_p2_writefail_ng + +import "github.com/kr9ly/sqlshape/v2" + +var CreateCustomer = sqlshape.Query[int64, struct { + Email string + Name string +}](%s) +`, backtick(oneLineSQL(ngSQL.body)))) + writeFile(t, td, "src/docsex_p2_writefail_ok/ok.go", fmt.Sprintf(`package docsex_p2_writefail_ok + +import "github.com/kr9ly/sqlshape/v2" + +var CreateCustomer = sqlshape.Query[int64, struct { + Email string + Name string +}](%s) +`, backtick(oneLineSQL(okSQL.body)))) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p2_writefail_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p2_writefail_ng"}, []string{ + docLine(t, ngSQL.body, "may violate customers_email_key"), + }) + assertDiagnostics(t, td, []string{"docsex_p2_writefail_ok"}, nil) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Do not declare a violation that cannot happen" +// --------------------------------------------------------------------------------------- + +func TestDocsP2OverdeclaredExpect(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Do not declare a violation that cannot happen"}, + {"ja", "checks.ja.md", "起こりえない違反を宣言しない"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ng := nthBlock(t, blocks, tc.head, "sql", 1) + + td := t.TempDir() + writeFile(t, td, "src/docsex_p2_overdeclare/ng.go", fmt.Sprintf(`package docsex_p2_overdeclare + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct{}, struct { + Note string + ID int64 +}](%s) +`, backtick(oneLineSQL(ng.body)))) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p2_overdeclare_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p2_overdeclare"}, []string{ + docLine(t, ng.body, "expects orders_total_check but no expansion can violate it"), + }) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "A statement that calls a function declares the function's failure modes too" +// --------------------------------------------------------------------------------------- + +// TestDocsP2CallDeclaresFailureModes runs both dialects' Rejected call. Neither shows a +// schema for the PostgreSQL half (it assumes the customers/orders pair from elsewhere on +// the page); the MySQL half's schema fence declares place_order() itself but likewise +// assumes customers/orders already exist. Both are supplied in docsex_p2_call_pg_schema.sql +// / docsex_p2_call_mysql_schema.sql (the latter carries the doc's function fence +// verbatim). This section has no ja run: it is PostgreSQL/MySQL-specific prose that +// checks.ja.md mirrors under the same heading translated, so the same two dialect cases +// cover it; running the loop would just duplicate assertions against the same fences +// under a second heading string. +func TestDocsP2CallDeclaresFailureModes(t *testing.T) { + blocks := parseDocBlocks(t, docsMD("checks.md")) + head := "A statement that calls a function declares the function's failure modes too" + pgCall := nthBlock(t, blocks, head, "sql", 1) // SELECT place_order({{.CustomerID}}, {{.Note}}) + mysqlCall := nthBlock(t, blocks, head, "sql", 3) // SELECT place_order({{.CustomerID}}, {{.Total}}) + + td := t.TempDir() + writeFile(t, td, "src/docsex_p2_call_pg/pg.go", fmt.Sprintf(`package docsex_p2_call_pg + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[int64, struct { + CustomerID int64 + Note string +}](%s) +`, backtick(oneLineSQL(pgCall.body)))) + // mysqlCall's caret comment (unlike every other doc example in this milestone) spans + // two "--" lines ("^ may violate ..." then a continuation "add `-- sqlshape: expect + // ...` ..."); oneLineSQL only recognizes a caret line by "^" on that same line, so it + // keeps the continuation as a real SQL comment -- and that continuation's own + // backtick-quoted directive breaks out of the Go raw string this harness wraps it in. + // The statement itself is a single line, so this harness takes just that line instead. + mysqlCallSQL := strings.SplitN(mysqlCall.body, "\n", 2)[0] + writeFile(t, td, "src/docsex_p2_call_mysql/mysql.go", fmt.Sprintf(`package docsex_p2_call_mysql + +import "github.com/kr9ly/sqlshape/v2" + +// declared so the "no var declares P0401/30001 anywhere this program can reach" finding +// ("Name the errors a trigger raises") does not crowd out the one this test is about. +var OrderTooLarge = sqlshape.Error("30001") + +var Q = sqlshape.Query[int64, struct { + CustomerID uint64 + Total string +}](%s) +`, backtick(mysqlCallSQL))) + copyRuntime(t, td) + + t.Run("pg", func(t *testing.T) { + setSchema(t, absPath(t, "testdata/docsex_p2_call_pg_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p2_call_pg"}, []string{ + docLine(t, pgCall.body, "may violate orders_customer_id_fkey"), + }) + }) + t.Run("mysql", func(t *testing.T) { + setSchema(t, absPath(t, "testdata/docsex_p2_call_mysql_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p2_call_mysql"}, []string{ + docLine(t, mysqlCall.body, "may violate 30001"), + }) + }) +} + +// --------------------------------------------------------------------------------------- +// "Every branch must be provable" +// --------------------------------------------------------------------------------------- + +func TestDocsP2EveryBranchProvable(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Every branch must be provable"}, + {"ja", "checks.ja.md", "すべての分岐で証明できなければならない"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ng := nthBlock(t, blocks, tc.head, "go", 1) + + td := t.TempDir() + writeFile(t, td, "src/docsex_p2_branch/branch.go", fmt.Sprintf(`package docsex_p2_branch + +import "github.com/kr9ly/sqlshape/v2" + +type User struct { + ID int64 + Email string +} + +%s +`, stripTrailingLineComment(ng.body, "One: cannot prove"))) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p2_branch_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p2_branch"}, []string{ + docLine(t, ng.body, "One: cannot prove"), + }) + }) + } +} diff --git a/cmd/sqlshape/internal/vet/docs_part3_test.go b/cmd/sqlshape/internal/vet/docs_part3_test.go new file mode 100644 index 0000000..b6fb5d2 --- /dev/null +++ b/cmd/sqlshape/internal/vet/docs_part3_test.go @@ -0,0 +1,1066 @@ +package vet + +// docs_part3_test.go extends docs_test.go's harness (parseDocBlocks / nthBlock / docLine / +// assertDiagnostics / setSchema / writeFile / copyRuntime / absPath / backtick / oneLineSQL +// / stripTrailingLineComment / dropInlineComment, all defined there and reused here +// unchanged) over docs/checks.md's Part 2 ("How a declaration works" through "Different +// callers, different rules (`context`)") and the headings the recipe groups into "Part 3" +// ("The same rules for SQL outside Go" through "Problems in the schema itself"). This file +// does not edit docs_test.go; helpers this file adds of its own are named with a p3 prefix +// to stay out of its way, and its own testdata lives under testdata/docsex_p3_*. +// +// "How a declaration works" is excluded: it has one fenced block, the bare directive +// grammar (`-- sqlshape: require [on ]`), not an instance of it -- there is +// no schema or statement to complete. + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" +) + +// p3Entry is one SQL statement plus the comment line(s) immediately following it in a +// docs fence, split out where a single fence holds several independent statements (docs' +// "Passes, all three" / "Rejected: ..." fences, and a Passes/Rejected pair sharing one +// fence via a leading "-- passes; ..." / trailing message comment) rather than the +// one-fence-per-statement shape the rest of docs_test.go's helpers assume. +type p3Entry struct { + SQL string + Comment string +} + +// p3SplitEntries walks body line by line: a line that is not a "--" comment starts a new +// entry (its own text kept as written, trailing inline "-- ..." included -- harmless as a +// real SQL comment); every "--" line immediately after belongs to that entry's Comment +// ("^" caret lines, which point at the line above rather than adding wording, are +// dropped). +func p3SplitEntries(body string) []p3Entry { + var out []p3Entry + var cur *p3Entry + for _, l := range strings.Split(body, "\n") { + t := strings.TrimSpace(l) + if t == "" { + continue + } + if strings.HasPrefix(t, "--") { + c := strings.TrimSpace(strings.TrimPrefix(t, "--")) + if strings.HasPrefix(c, "^") { + continue + } + if cur != nil { + if cur.Comment != "" { + cur.Comment += " " + } + cur.Comment += c + } + continue + } + out = append(out, p3Entry{SQL: l}) + cur = &out[len(out)-1] + } + return out +} + +// p3Quote is a Go double-quoted string literal (strconv.Quote) for doc text that may +// itself contain a backtick (the visible-where Rejected comment quotes `-- sqlshape: +// unfiltered memos` inline): docs_test.go's own backtick() helper assumes the text has +// none, which holds for every fence that helper is used on there but not for this one. +func p3Quote(s string) string { + return strconv.Quote(s) +} + +// p3NoDiagnostics is assertDiagnostics(t, dir, pkgs, nil): a Passes package must produce +// none. +func p3NoDiagnostics(t *testing.T, dir string, pkgs ...string) { + t.Helper() + assertDiagnostics(t, dir, pkgs, nil) +} + +// p3SomeDiagnostic checks only that the package produced at least one diagnostic, for the +// cases in this file where docs describes a statement as Rejected but does not spell the +// checker's exact wording anywhere in the fence (the EXISTS witness and sensitive-column +// examples both label statements "fails" / "Rejected" in prose or an inline comment, +// without a "-- " line docLine could read) -- an exact-substring check is not +// possible there without retyping wording docs itself does not give, which is exactly what +// this harness must not do (see docs_test.go's package doc). Logged in NOTES.local.md. +func p3SomeDiagnostic(t *testing.T, dir string, pkgs ...string) { + t.Helper() + diags := runDocs(t, dir, pkgs...) + if len(diags) == 0 { + t.Errorf("docs says this is rejected, but the checker produced no diagnostic for %v", pkgs) + } +} + +// --------------------------------------------------------------------------------------- +// "Every read carries the visibility predicate (`visible where`)" +// --------------------------------------------------------------------------------------- + +func TestDocsVisibleWhere(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Every read carries the visibility predicate (`visible where`)"}, + {"ja", "checks.ja.md", "必ず付ける読み取り条件(`visible where`)"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ng := nthBlock(t, blocks, tc.head, "sql", 2) + ok := nthBlock(t, blocks, tc.head, "sql", 3) + unfiltered := nthBlock(t, blocks, tc.head, "sql", 4) + + td := t.TempDir() + writeFile(t, td, "src/docsex_p3_visible_ng/ng.go", fmt.Sprintf(`package docsex_p3_visible_ng + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct { + ID int64 + Body string +}, struct{ UserID int64 }](%s) +`, p3Quote(oneLineSQL(ng.body)))) + writeFile(t, td, "src/docsex_p3_visible_ok/ok.go", fmt.Sprintf(`package docsex_p3_visible_ok + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct { + ID int64 + Body string +}, struct{ UserID int64 }](%s) +`, backtick(oneLineSQL(ok.body)))) + writeFile(t, td, "src/docsex_p3_visible_unfiltered/uf.go", fmt.Sprintf(`package docsex_p3_visible_unfiltered + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct { + ID int64 + Body string +}, struct{ ID int64 }](%s) +`, backtick(oneLineSQL(unfiltered.body)))) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p3_visiblewhere_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p3_visible_ng"}, []string{ + docLine(t, ng.body, "add that predicate"), + }) + p3NoDiagnostics(t, td, "docsex_p3_visible_ok") + p3NoDiagnostics(t, td, "docsex_p3_visible_unfiltered") + }) + } +} + +// --------------------------------------------------------------------------------------- +// "A column is pinned on every statement (`pinned`, `-require-columns`)" +// +// Only the tenant_id example is exercised: the "optimistic locking" follow-on shares a +// single fence between a Passes statement and the wording for a Rejected variant docs +// describes only in prose ("without `AND version = ...`"), and reaching it would mean +// synthesizing SQL text of our own rather than reading it out of the doc, plus a +// bump_version() trigger the fence never shows either -- lower value for what it costs; +// noted in NOTES.local.md instead of silently dropped. +// --------------------------------------------------------------------------------------- + +func TestDocsPinnedColumn(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "A column is pinned on every statement (`pinned`, `-require-columns`)"}, + {"ja", "checks.ja.md", "列を必ず固定する(`pinned`、`-require-columns`)"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ng := nthBlock(t, blocks, tc.head, "sql", 2) + ok := nthBlock(t, blocks, tc.head, "sql", 3) + + td := t.TempDir() + writeFile(t, td, "src/docsex_p3_pinned_ng/ng.go", fmt.Sprintf(`package docsex_p3_pinned_ng + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct { + ID int64 + Total string +}, struct{ ID int64 }](%s) +`, backtick(oneLineSQL(ng.body)))) + writeFile(t, td, "src/docsex_p3_pinned_ok/ok.go", fmt.Sprintf(`package docsex_p3_pinned_ok + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct { + ID int64 + Total string +}, struct { + ID int64 + TenantID int64 +}](%s) +`, backtick(oneLineSQL(ok.body)))) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p3_pinned_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p3_pinned_ng"}, []string{ + docLine(t, ng.body, "is not pinned"), + }) + p3NoDiagnostics(t, td, "docsex_p3_pinned_ok") + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Tables are read through views (`via view`, `-no-table-reads` / `-no-tables`)" +// --------------------------------------------------------------------------------------- + +func TestDocsViaView(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Tables are read through views (`via view`, `-no-table-reads` / `-no-tables`)"}, + {"ja", "checks.ja.md", "テーブルを直接読まない(`via view`、`-no-table-reads` / `-no-tables`)"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ng := nthBlock(t, blocks, tc.head, "sql", 2) + ok := nthBlock(t, blocks, tc.head, "sql", 3) + + td := t.TempDir() + writeFile(t, td, "src/docsex_p3_viaview_ng/ng.go", fmt.Sprintf(`package docsex_p3_viaview_ng + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct{ ID int64; Name string }, struct{}](%s) +`, backtick(oneLineSQL(ng.body)))) + writeFile(t, td, "src/docsex_p3_viaview_ok/ok.go", fmt.Sprintf(`package docsex_p3_viaview_ok + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct { + ID int64 + CustomerName string +}, struct{}](%s) +`, backtick(oneLineSQL(ok.body)))) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p3_viaview_schema.sql")) + if err := Analyzer.Flags.Set("no-table-reads", "true"); err != nil { + t.Fatal(err) + } + defer Analyzer.Flags.Set("no-table-reads", "false") + + assertDiagnostics(t, td, []string{"docsex_p3_viaview_ng"}, []string{ + docLine(t, ng.body, "is read directly"), + // docs' own comment quotes only the orders half of this message; the + // query also joins customers, which -no-table-reads flags the same way + // (the wording is the same rule's, applied to the other table docs' + // example references but does not spell out). + "table customers is read directly; with -no-table-reads application code reads views (tables are written, not read)", + }) + p3NoDiagnostics(t, td, "docsex_p3_viaview_ok") + }) + } +} + +// --------------------------------------------------------------------------------------- +// "A predicate across tables has a witness (`EXISTS`)" +// +// Docs labels these three statements "Passes, all three" and two more "Rejected" without +// giving either group's diagnostic wording (there is no "-- ..." message line to read for +// any of the five): p3SomeDiagnostic checks the Rejected pair produced something, and +// p3NoDiagnostics checks the Passes trio produced nothing, which is what docs actually +// claims here -- not a specific message. +// --------------------------------------------------------------------------------------- + +func TestDocsExistsWitness(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "A predicate across tables has a witness (`EXISTS`)"}, + {"ja", "checks.ja.md", "表をまたぐ述語には証人が要る(`EXISTS`)"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + pass := nthBlock(t, blocks, tc.head, "sql", 2) + reject := nthBlock(t, blocks, tc.head, "sql", 3) + + passEntries := p3SplitEntries(pass.body) + if len(passEntries) != 3 { + t.Fatalf("expected 3 passing statements, got %d", len(passEntries)) + } + rejectEntries := p3SplitEntries(reject.body) + if len(rejectEntries) != 2 { + t.Fatalf("expected 2 rejected statements, got %d", len(rejectEntries)) + } + + td := t.TempDir() + for i, e := range passEntries { + writeFile(t, td, fmt.Sprintf("src/docsex_p3_exists_ok%d/ok.go", i), fmt.Sprintf(`package docsex_p3_exists_ok%d + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct{ Carrier string }, struct{ T int64 }](%s) +`, i, backtick(e.SQL))) + } + for i, e := range rejectEntries { + writeFile(t, td, fmt.Sprintf("src/docsex_p3_exists_ng%d/ng.go", i), fmt.Sprintf(`package docsex_p3_exists_ng%d + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct{ Carrier string }, struct{ T int64 }](%s) +`, i, backtick(e.SQL))) + } + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p3_exists_schema.sql")) + for i := range passEntries { + p3NoDiagnostics(t, td, fmt.Sprintf("docsex_p3_exists_ok%d", i)) + } + for i := range rejectEntries { + p3SomeDiagnostic(t, td, fmt.Sprintf("docsex_p3_exists_ng%d", i)) + } + }) + } +} + +// --------------------------------------------------------------------------------------- +// "An aggregate is reached through its root, one per statement (`aggregate`)" +// +// Only the primary Rejected example (reading across aggregates) is exercised; the +// `lock ` follow-on is a bare illustrative UPDATE, not itself labelled Rejected or +// Passes, and adds a second schema shape (a version column, a foreign-key witness) for one +// more statement -- represented well enough by the primary example for this heading's +// purpose here. Noted in NOTES.local.md. +// --------------------------------------------------------------------------------------- + +func TestDocsAggregate(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "An aggregate is reached through its root, one per statement (`aggregate`)"}, + {"ja", "checks.ja.md", "集約にはルート経由で触り、1文で1つだけ触る(`aggregate`)"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ng := nthBlock(t, blocks, tc.head, "sql", 2) + + td := t.TempDir() + writeFile(t, td, "src/docsex_p3_aggregate_ng/ng.go", fmt.Sprintf(`package docsex_p3_aggregate_ng + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct { + Status string + ID int64 +}, struct{ ID int64 }](%s) +`, backtick(oneLineSQL(ng.body)))) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p3_aggregate_schema.sql")) + // docs quotes only orders' side of the message; the checker reports the + // same violation from each table's own aggregate too, so invoices' side + // (the same wording with the two names swapped) is unclaimed otherwise. + ordersMsg := docLine(t, ng.body, "one statement, one aggregate") + invoicesMsg := strings.NewReplacer("orders", "invoices", "invoices", "orders").Replace(ordersMsg) + assertDiagnostics(t, td, []string{"docsex_p3_aggregate_ng"}, []string{ + ordersMsg, + invoicesMsg, + }) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "A status column moves along its declared transitions (`transitions`)" +// --------------------------------------------------------------------------------------- + +func TestDocsTransitions(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "A status column moves along its declared transitions (`transitions`)"}, + {"ja", "checks.ja.md", "ステータス列は宣言した遷移でしか動かさない(`transitions`)"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ok := nthBlock(t, blocks, tc.head, "sql", 2) + ng := nthBlock(t, blocks, tc.head, "sql", 3) + + ngEntries := p3SplitEntries(ng.body) + if len(ngEntries) != 2 { + t.Fatalf("expected 2 rejected statements, got %d", len(ngEntries)) + } + + td := t.TempDir() + writeFile(t, td, "src/docsex_p3_transitions_ok/ok.go", fmt.Sprintf(`package docsex_p3_transitions_ok + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct{}, struct{ ID int64 }](%s) +`, backtick(ok.body))) + for i, e := range ngEntries { + writeFile(t, td, fmt.Sprintf("src/docsex_p3_transitions_ng%d/ng.go", i), fmt.Sprintf(`package docsex_p3_transitions_ng%d + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct{}, struct{ ID int64 }](%s) +`, i, backtick(e.SQL))) + } + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p3_transitions_schema.sql")) + p3NoDiagnostics(t, td, "docsex_p3_transitions_ok") + for i, e := range ngEntries { + assertDiagnostics(t, td, []string{fmt.Sprintf("docsex_p3_transitions_ng%d", i)}, []string{e.Comment}) + } + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Append-only tables, paired writes, single-row deletes (`never`, `paired`, `single`)" +// +// `never` itself has no fenced Rejected/Passes statement (its message is quoted inline in +// prose, not shown as a checked example): only `paired` and `single` are exercised here. +// --------------------------------------------------------------------------------------- + +func TestDocsNeverPairedSingle(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Append-only tables, paired writes, single-row deletes (`never`, `paired`, `single`)"}, + {"ja", "checks.ja.md", "追記専用、対になる書き込み、1行だけの削除(`never`、`paired`、`single`)"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + paired := nthBlock(t, blocks, tc.head, "sql", 2) + single := nthBlock(t, blocks, tc.head, "sql", 3) + + // single's two DELETE statements and single's own trailing message line: + // unlike every other fence in this file, its two SQL lines are each a + // complete standalone statement (no continuation), so p3SplitEntries + // (which cannot tell that apart from the paired fence's two-line WITH + // statement below) is not used here. + var singleLines []string + for _, l := range strings.Split(single.body, "\n") { + if strings.TrimSpace(l) != "" { + singleLines = append(singleLines, l) + } + } + if len(singleLines) != 3 { + t.Fatalf("expected 2 statements + 1 comment line in the single fence, got %d lines", len(singleLines)) + } + singleOK, singleNG, singleNGComment := singleLines[0], singleLines[1], docLine(t, single.body, "One proof") + + td := t.TempDir() + // paired: docs' own WITH statement has literal "(...)" placeholders (its + // point is the shape, not real columns), so this harness completes it with + // orders' and outbox's actual columns rather than compiling the ellipsis + // verbatim; the bare "INSERT INTO orders" docs describes only in prose ("a + // bare INSERT INTO orders:") is completed the same way. + writeFile(t, td, "src/docsex_p3_paired_ok/ok.go", `package docsex_p3_paired_ok + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct{}, struct{}]("-- sqlshape: expect outbox_pkey\nWITH o AS (INSERT INTO orders (status) VALUES ('new') RETURNING id)\nINSERT INTO outbox (id, payload) SELECT id, 'created' FROM o") +`) + writeFile(t, td, "src/docsex_p3_paired_ng/ng.go", `package docsex_p3_paired_ng + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct{}, struct{}]("INSERT INTO orders (status) VALUES ('new')") +`) + // single: the fence's first statement passes, the second is rejected with + // the wording the fence's own trailing comment gives. + writeFile(t, td, "src/docsex_p3_single_ok/ok.go", fmt.Sprintf(`package docsex_p3_single_ok + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct{}, struct{ ID int64 }](%s) +`, backtick(singleOK))) + writeFile(t, td, "src/docsex_p3_single_ng/ng.go", fmt.Sprintf(`package docsex_p3_single_ng + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct{}, struct{}](%s) +`, backtick(singleNG))) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p3_neverpairedsingle_schema.sql")) + p3NoDiagnostics(t, td, "docsex_p3_paired_ok") + assertDiagnostics(t, td, []string{"docsex_p3_paired_ng"}, []string{ + docLine(t, paired.body, "must also write outbox"), + }) + p3NoDiagnostics(t, td, "docsex_p3_single_ok") + assertDiagnostics(t, td, []string{"docsex_p3_single_ng"}, []string{singleNGComment}) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Labelled columns are read only where allowed (`sensitive`, `may read`)" +// +// The first statement's comment gives an exact message (docLine picks it up); the second +// is only described in prose ("fails too: ...", not the checker's own wording), so it is +// checked with p3SomeDiagnostic instead of an exact match, as with the EXISTS section +// above. +// --------------------------------------------------------------------------------------- + +func TestDocsSensitiveColumns(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Labelled columns are read only where allowed (`sensitive`, `may read`)"}, + {"ja", "checks.ja.md", "ラベル付きの列は許された文脈でしか読まない(`sensitive`、`may read`)"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + block := nthBlock(t, blocks, tc.head, "sql", 2) + entries := p3SplitEntries(block.body) + if len(entries) != 3 { + t.Fatalf("expected 3 statements, got %d", len(entries)) + } + + td := t.TempDir() + writeFile(t, td, "src/docsex_p3_sensitive_direct/direct.go", fmt.Sprintf(`package docsex_p3_sensitive_direct + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct{ Email string }, struct{ ID int64 }](%s) +`, backtick(entries[0].SQL))) + writeFile(t, td, "src/docsex_p3_sensitive_view/view.go", fmt.Sprintf(`package docsex_p3_sensitive_view + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct{ Email string }, struct{ ID int64 }](%s) +`, backtick(entries[1].SQL))) + writeFile(t, td, "src/docsex_p3_sensitive_masked/masked.go", fmt.Sprintf(`package docsex_p3_sensitive_masked + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct{ PhoneMasked string }, struct{}](%s) +`, backtick(entries[2].SQL))) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p3_sensitive_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p3_sensitive_direct"}, []string{ + docLine(t, block.body, "is pii"), + }) + p3SomeDiagnostic(t, td, "docsex_p3_sensitive_view") + p3NoDiagnostics(t, td, "docsex_p3_sensitive_masked") + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Different callers, different rules (`context`)" +// +// Docs shows this rule's schema and package-comment syntax but no Rejected/Passes SQL +// example with the checker's own wording; per the recipe, a declaration-syntax section +// like this one is completed with one statement of our own that discharges the context's +// own obligation (`require id = $1 on delete`, the tenant pin waived), checking only that +// the declaration reads with no Problems -- not any specific diagnostic docs does not +// give. +// --------------------------------------------------------------------------------------- + +func TestDocsContext(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Different callers, different rules (`context`)"}, + {"ja", "checks.ja.md", "呼び出し元ごとに規約を変える(`context`)"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + pkg := nthBlock(t, blocks, tc.head, "go", 1) // the `package ops` comment fence + + td := t.TempDir() + writeFile(t, td, "src/docsex_p3_context_ops/ops.go", fmt.Sprintf(`%s + +import "github.com/kr9ly/sqlshape/v2" + +var deleteByID = sqlshape.Query[struct{}, struct{ ID int64 }](`+"`DELETE FROM orders WHERE id = {{.ID}}`"+`) +`, pkg.body)) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p3_context_schema.sql")) + p3NoDiagnostics(t, td, "docsex_p3_context_ops") + }) + } +} + +// --------------------------------------------------------------------------------------- +// "The schema names its PostgreSQL version (`postgres`)" +// +// Docs' fence is the declaration syntax itself; the Rejected/OK cases are a bullet list, +// not fenced examples, so this is exercised directly through loadSchema (same function +// loadschema_test.go's own TestLoadSchema* tests use), one call per bullet, rather than +// through analysistest. +// --------------------------------------------------------------------------------------- + +func TestDocsPostgresVersionDeclaration(t *testing.T) { + write := func(t *testing.T, sql string) string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "schema.sql") + if err := os.WriteFile(p, []byte(sql), 0o644); err != nil { + t.Fatal(err) + } + return p + } + + t.Run("no-declaration-anywhere", func(t *testing.T) { + if _, err := loadSchema(write(t, "CREATE TABLE t (id bigint PRIMARY KEY);\n")); err == nil { + t.Fatal("expected an error") + } + }) + t.Run("unsupported-version", func(t *testing.T) { + if _, err := loadSchema(write(t, "-- sqlshape: postgres 99\nCREATE TABLE t (id bigint PRIMARY KEY);\n")); err == nil { + t.Fatal("expected an error") + } + }) + t.Run("two-declarations-disagree", func(t *testing.T) { + if _, err := loadSchema(write(t, "-- sqlshape: postgres 17\n-- sqlshape: postgres 18\nCREATE TABLE t (id bigint PRIMARY KEY);\n")); err == nil { + t.Fatal("expected an error") + } + }) + t.Run("ok-repeated-same-value", func(t *testing.T) { + if _, err := loadSchema(write(t, "-- sqlshape: postgres 17\n-- sqlshape: postgres 17\nCREATE TABLE t (id bigint PRIMARY KEY);\n")); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +// --------------------------------------------------------------------------------------- +// "The schema names its MySQL version (`mysql`)" +// --------------------------------------------------------------------------------------- + +func TestDocsMySQLVersionDeclaration(t *testing.T) { + write := func(t *testing.T, sql string) string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "schema.sql") + if err := os.WriteFile(p, []byte(sql), 0o644); err != nil { + t.Fatal(err) + } + return p + } + + t.Run("unsupported-version", func(t *testing.T) { + if _, err := loadSchema(write(t, "-- sqlshape: mysql 5.7\nCREATE TABLE t (id bigint PRIMARY KEY);\n")); err == nil { + t.Fatal("expected an error") + } + }) + t.Run("also-names-postgres", func(t *testing.T) { + if _, err := loadSchema(write(t, "-- sqlshape: postgres 17\n-- sqlshape: mysql 8.4\nCREATE TABLE t (id bigint PRIMARY KEY);\n")); err == nil { + t.Fatal("expected an error") + } + }) + t.Run("ok", func(t *testing.T) { + if _, err := loadSchema(write(t, "-- sqlshape: mysql 8.4\nCREATE TABLE t (id bigint PRIMARY KEY);\n")); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +// --------------------------------------------------------------------------------------- +// "The schema names the server's settings (`server`)" +// +// Only MySQL's two variables are checked at all (docs: "for PostgreSQL every variable, +// for now"); a bad one is a schema Problem, not a load error. +// --------------------------------------------------------------------------------------- + +func TestDocsServerSettings(t *testing.T) { + write := func(t *testing.T, sql string) string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "schema.sql") + if err := os.WriteFile(p, []byte(sql), 0o644); err != nil { + t.Fatal(err) + } + return p + } + + t.Run("ok", func(t *testing.T) { + ls, err := loadSchema(write(t, "-- sqlshape: mysql 8.4\n-- sqlshape: server sql_mode = 'ANSI,STRICT_ALL_TABLES'\n-- sqlshape: server lower_case_table_names = 1\nCREATE TABLE t (id bigint PRIMARY KEY);\n")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(ls.problems) != 0 { + t.Fatalf("unexpected problems: %v", ls.problems) + } + }) + t.Run("unknown-variable", func(t *testing.T) { + ls, err := loadSchema(write(t, "-- sqlshape: mysql 8.4\n-- sqlshape: server nonexistent_var = 1\nCREATE TABLE t (id bigint PRIMARY KEY);\n")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !p3ContainsSubstring(ls.problems, "not a variable sqlshape reads for MySQL") { + t.Errorf("problems missing the expected wording; got: %v", ls.problems) + } + }) + t.Run("bad-lower-case-table-names", func(t *testing.T) { + ls, err := loadSchema(write(t, "-- sqlshape: mysql 8.4\n-- sqlshape: server lower_case_table_names = 3\nCREATE TABLE t (id bigint PRIMARY KEY);\n")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !p3ContainsSubstring(ls.problems, "want 0, 1 or 2") { + t.Errorf("problems missing the expected wording; got: %v", ls.problems) + } + }) +} + +func p3ContainsSubstring(list []string, substr string) bool { + for _, s := range list { + if strings.Contains(s, substr) { + return true + } + } + return false +} + +// --------------------------------------------------------------------------------------- +// "Do not run SQL that bypasses sqlshape (`-raw-sql`)" +// --------------------------------------------------------------------------------------- + +func TestDocsRawSQL(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Do not run SQL that bypasses sqlshape (`-raw-sql`)"}, + {"ja", "checks.ja.md", "sqlshapeを通さないSQLを書かない(`-raw-sql`)"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ng := nthBlock(t, blocks, tc.head, "go", 1) + ok := nthBlock(t, blocks, tc.head, "go", 2) + + td := t.TempDir() + writeFile(t, td, "src/docsex_p3_rawsql_ng/ng.go", fmt.Sprintf(`package docsex_p3_rawsql_ng + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func f(ctx context.Context, pool *pgxpool.Pool, where string) { + %s + _, _ = rows, err +} +`, dropInlineComment(ng.body, "sqlshape:"))) + writeFile(t, td, "src/docsex_p3_rawsql_ok/ok.go", fmt.Sprintf(`package docsex_p3_rawsql_ok + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func f(ctx context.Context, pool *pgxpool.Pool, status int64) { + %s + _, _ = rows, err +} +`, ok.body)) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p3_rawsql_ng"}, []string{ + docLine(t, ng.body, "SQL passed to Query must be a constant"), + }) + p3NoDiagnostics(t, td, "docsex_p3_rawsql_ok") + }) + } +} + +// --------------------------------------------------------------------------------------- +// "A package references only its schemas (`-schemas`, PostgreSQL)" +// --------------------------------------------------------------------------------------- + +func TestDocsSchemasBoundary(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "A package references only its schemas (`-schemas`, PostgreSQL)"}, + {"ja", "checks.ja.md", "パッケージは自分のスキーマだけを参照する(`-schemas`、PostgreSQL)"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ng := nthBlock(t, blocks, tc.head, "sql", 1) + + td := t.TempDir() + writeFile(t, td, "src/docsex_p3_schemas_ng/ng.go", fmt.Sprintf(`package docsex_p3_schemas_ng + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[int64, struct{}](%s) +`, backtick(oneLineSQL(ng.body)))) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p3_schemas_schema.sql")) + if err := Analyzer.Flags.Set("schemas", "a_api,b_private"); err != nil { + t.Fatal(err) + } + defer Analyzer.Flags.Set("schemas", "") + + assertDiagnostics(t, td, []string{"docsex_p3_schemas_ng"}, []string{ + docLine(t, ng.body, "is outside the schemas"), + }) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Problems in the schema itself" +// +// Both fences embed the checker's own wording as a trailing comment inside the schema +// text they show; loadSchema (the same function loadschema_test.go's own tests use) is +// exercised directly, one problem per fence, rather than through analysistest. +// --------------------------------------------------------------------------------------- + +func TestDocsSchemaProblems(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Problems in the schema itself"}, + {"ja", "checks.ja.md", "スキーマ自体の問題"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + execFence := nthBlock(t, blocks, tc.head, "sql", 1) + + td := t.TempDir() + // the schema problem is reported at the package's first Query, the way + // TestPLpgSQL (vet_test.go) and its plpgsql/plpgsql.go fixture already + // exercise this same mechanism; -strict is needed for the dynamic EXECUTE + // note (an advisory), as it is there. + writeFile(t, td, "src/docsex_p3_schemaproblems/q.go", `package docsex_p3_schemaproblems + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[int64, struct{}](`+"`SELECT id FROM orders`"+`) +`) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p3_schemaproblems_schema.sql")) + if err := Analyzer.Flags.Set("strict", "true"); err != nil { + t.Fatal(err) + } + defer Analyzer.Flags.Set("strict", "false") + + diags := runDocs(t, td, "docsex_p3_schemaproblems") + joined := formatDiags(diags) + if want := docLine(t, execFence.body, "EXECUTE runs SQL built at run time"); !strings.Contains(joined, want) { + t.Errorf("diagnostics missing %q; got: %s", want, joined) + } + // docs' fence writes the byte offset as a placeholder (""): the real offset is where "nmae" falls in the whole + // schema.sql the package under test loads, not in this fence's own excerpt, + // so it cannot be pinned to one literal number here. Everything else in + // docs' line (the "42703: column c.nmae does not exist (at ...)" shape) is + // asserted below. + if !strings.Contains(joined, "order_summary") || !strings.Contains(joined, `42703: column c.nmae does not exist (at`) { + t.Errorf("diagnostics missing a schema problem for order_summary's column typo (42703); got: %s", joined) + } + }) + } +} + +// --------------------------------------------------------------------------------------- +// postgres.md / mysql.md +// +// docs_test.go's parseDocBlocks only recognizes a fence whose ``` sits at column 0 +// ("^```(go|sql)\\s*$"); postgres.md's Copy and MatView examples (and mysql.md's version +// declaration fragment) are nested two spaces under a bullet list item instead, so they +// need their own, separate extraction rather than reusing that regex (which this file +// does not edit). p3IndentedBlock finds the n'th such fence of the given language in +// file order. +// --------------------------------------------------------------------------------------- + +var p3IndentedFenceRE = regexp.MustCompile(`^[ \t]+` + "```" + `(go|sql)\s*$`) + +func p3IndentedBlock(t *testing.T, path, lang string, occurrence int) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(string(data), "\n") + n := 0 + for i := 0; i < len(lines); i++ { + m := p3IndentedFenceRE.FindStringSubmatch(lines[i]) + if m == nil || m[1] != lang { + continue + } + indent := lines[i][:strings.Index(lines[i], "```")] + j := i + 1 + var body []string + for j < len(lines) && strings.TrimSpace(lines[j]) != "```" { + body = append(body, strings.TrimPrefix(lines[j], indent)) + j++ + } + n++ + if n == occurrence { + return strings.Join(body, "\n") + } + i = j + } + t.Fatalf("%s: no indented %s block #%d (found %d)", path, lang, occurrence, n) + return "" +} + +// --------------------------------------------------------------------------------------- +// postgres.md "The Go type table": `// sqlshape: type ` binds a Go type to a +// PostgreSQL type not in the built-in table (here, a domain). The fence's own struct body +// is an ellipsis (its point is the doc-comment syntax, not a specific struct), completed +// here with a type that actually implements sql.Scanner / driver.Valuer, backed by the +// vendored pgtype-free test stub (testdata/src/github.com/... has no real pgx, so Money +// only needs to compile, not actually scan). +// --------------------------------------------------------------------------------------- + +func TestDocsPostgresTypeBinding(t *testing.T) { + block := p3IndentedBlock(t, filepath.Join("..", "..", "..", "..", "docs", "postgres.md"), "go", 1) + _ = block // the fence is a syntax fragment (`type Money struct{ ... }`); see below + + td := t.TempDir() + writeFile(t, td, "src/docsex_p3_pgtype_ok/ok.go", `package docsex_p3_pgtype_ok + +import "github.com/kr9ly/sqlshape/v2" + +// sqlshape: type money_amount +type Money struct{ Cents int64 } + +func (m *Money) Scan(src any) error { return nil } + +var Q = sqlshape.Query[struct{ Amount Money }, struct{ ID int64 }](`+"`SELECT amount FROM orders WHERE id = {{.ID}}`"+`) +`) + // Elsewhere in the same package, receiving the domain with a plain Go type the table + // does not cover (and no binding) is reported -- the "anywhere else" half of what + // postgres.md's prose claims for a bound type. + writeFile(t, td, "src/docsex_p3_pgtype_ng/ng.go", `package docsex_p3_pgtype_ng + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct{ Amount bool }, struct{ ID int64 }](`+"`SELECT amount FROM orders WHERE id = {{.ID}}`"+`) +`) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p3_pgtype_schema.sql")) + p3NoDiagnostics(t, td, "docsex_p3_pgtype_ok") + p3SomeDiagnostic(t, td, "docsex_p3_pgtype_ng") +} + +// --------------------------------------------------------------------------------------- +// postgres.md "The runtime: pgx", the Copy example. Only the declaration +// (`postgres.Copy[Item](...)`) is checked, as docsex_copy_ok already does for +// checks.md's own COPY section -- the `.From` / `.FromSeq` runtime calls the fence also +// shows are not: the vendored test stub (testdata/src/github.com/kr9ly/sqlshape/postgres/v2, +// shared with every other test in this package and out of scope to edit here) declares +// only postgres.Copy and postgres.MatView, the two calls vet itself inspects, not the +// runtime methods around them. +// --------------------------------------------------------------------------------------- + +func TestDocsPostgresCopyExample(t *testing.T) { + td := t.TempDir() + writeFile(t, td, "src/docsex_p3_pgcopy_ok/ok.go", `package docsex_p3_pgcopy_ok + +import "github.com/kr9ly/sqlshape/postgres/v2" + +var loadItems = postgres.Copy[Item]("order_items", "order_id", "line_no", "sku", "qty") + +type Item struct { + OrderID int64 + LineNo int16 + Sku string + Qty int32 +} +`) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p3_pgcopy_schema.sql")) + p3NoDiagnostics(t, td, "docsex_p3_pgcopy_ok") +} + +// --------------------------------------------------------------------------------------- +// postgres.md "The runtime: pgx", the MatView example: only the declaration +// (`postgres.MatView("order_stats")`), for the same reason as Copy above -- `Refresh` / +// `RefreshConcurrently` are not part of the vendored stub. +// --------------------------------------------------------------------------------------- + +func TestDocsPostgresMatViewExample(t *testing.T) { + td := t.TempDir() + writeFile(t, td, "src/docsex_p3_pgmatview_ok/ok.go", `package docsex_p3_pgmatview_ok + +import "github.com/kr9ly/sqlshape/postgres/v2" + +var OrderStats = postgres.MatView("order_stats") +`) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p3_pgmatview_schema.sql")) + p3NoDiagnostics(t, td, "docsex_p3_pgmatview_ok") +} + +// --------------------------------------------------------------------------------------- +// mysql.md "Grouping": the ONLY_FULL_GROUP_BY example, the one concrete Rejected/Passes +// SQL fence on this page. +// --------------------------------------------------------------------------------------- + +func TestDocsMySQLGroupBy(t *testing.T) { + blocks := parseDocBlocks(t, docsMD("mysql.md")) + block := nthBlock(t, blocks, "Grouping", "sql", 1) + entries := p3SplitEntries(block.body) + if len(entries) != 2 { + t.Fatalf("expected 2 statements, got %d", len(entries)) + } + + td := t.TempDir() + writeFile(t, td, "src/docsex_p3_mysqlgroupby_ng/ng.go", fmt.Sprintf(`package docsex_p3_mysqlgroupby_ng + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct { + Email string + N int64 +}, struct{}](%s) +`, backtick(entries[0].SQL))) + writeFile(t, td, "src/docsex_p3_mysqlgroupby_ok/ok.go", fmt.Sprintf(`package docsex_p3_mysqlgroupby_ok + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct { + Name string + N int64 `+"`col:\"count(*)\"`"+` +}, struct{}](%s) +`, backtick(entries[1].SQL))) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_p3_mysqlgroupby_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_p3_mysqlgroupby_ng"}, []string{ + "MySQL error 1055", + }) + p3NoDiagnostics(t, td, "docsex_p3_mysqlgroupby_ok") +} diff --git a/cmd/sqlshape/internal/vet/docs_test.go b/cmd/sqlshape/internal/vet/docs_test.go new file mode 100644 index 0000000..46eb7e2 --- /dev/null +++ b/cmd/sqlshape/internal/vet/docs_test.go @@ -0,0 +1,695 @@ +package vet + +// docs_test.go makes sure that what docs/*.md promises the checker will say, the checker +// actually says. Each Rejected/Passes fence in those files is prose first: most fences +// show only the half of a query that the point is about (the R struct, say, but not the +// P it is paired with, or vice versa), and lean on tables (orders, customers, ...) that +// are named across many sections but never declared once, in full, in one place. So a +// fence cannot be dropped into a Go file and run unmodified -- something has to complete +// it into a compiling program: an R or P this fence doesn't show, a schema.sql these +// tables didn't get. +// +// That completion lives here as ordinary Go source under testdata/src/docsex_*, one +// package per Rejected or Passes example, following the same testdata/src/ + +// analysistest shape every other test in this package already uses. What must NOT be +// hand-copied is the SQL/Go text the fence shows and the diagnostic wording its trailing +// comment (`// field ... has no result column`, `-- ^ ...`, `-- sqlshape: ...`) claims the +// checker will produce: both are read out of the .md file at test time by +// parseDocBlocks/docLine below, so a docs edit changes what this test compiles and +// expects without anyone updating a second copy. +// +// The comparison itself does not use analysistest's own "// want" mechanism: "// want" +// matches a regexp against the single line a diagnostic's position falls on, and several +// of these diagnostics (the ones that map into a `-- sqlshape: expect` line inside a +// multi-line template, in particular) do not share a line with anything a Go comment could +// sit on. Instead, assertDiagnostics below runs the analyzer through analysistest with a +// Testing stub that swallows its own pass/fail verdict, reads every analysis.Diagnostic +// out of the result, and checks -- regardless of position -- that each string docs claims +// is a literal substring (not a regexp: regexp.QuoteMeta is not involved) of exactly one +// diagnostic, with none left over on either side. That is what "the docs' words are the +// diagnostic" means operationally, and it is the same check the recipe asked for: a docs +// rewrite that drifts from the checker's actual wording fails here, on either side -- +// fix whichever is wrong. +// +// Coverage: docs/checks.md (114 go/sql fences) is overwhelmingly Part 1 (statement-level +// checks); this harness currently exercises a representative slice of it -- the section +// that prompted this task (naming a trigger's error, both dialects) plus several +// self-contained Part 1 examples -- not the full 114. NOTES.local.md lists what is not +// wired up yet and why (mostly: Part 2/3 fences are schema-declaration syntax with `...` +// ellipses, not complete SQL, or CLI/runtime-only snippets -- see the exclusion notes in +// each Test function below for the ones adjacent to what is covered). + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "golang.org/x/tools/go/analysis" + "golang.org/x/tools/go/analysis/analysistest" + + // registers the MySQL dialect with x/dialect for this test binary, the way + // dialects_test.go does for PostgreSQL; docs' MySQL examples need it. + _ "github.com/kr9ly/sqlshape/check/mysql/v2/dialect" +) + +// docBlock is one ```go or ```sql fenced code block lifted from a docs/*.md file. +type docBlock struct { + lang string // "go" or "sql" + heading string // the nearest preceding ## / ### / #### heading text + body string // the fence's content, unindented, fences themselves excluded + line int // 1-based line number of the opening fence, for error messages +} + +var ( + docHeadingRE = regexp.MustCompile(`^(#{2,4})\s+(.*)`) + docFenceRE = regexp.MustCompile("^```(go|sql)\\s*$") +) + +// parseDocBlocks reads path (a docs/*.md file) and returns every fenced go/sql block in +// file order, each tagged with the heading text of the section it appears under. +func parseDocBlocks(t *testing.T, path string) []docBlock { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(string(data), "\n") + var blocks []docBlock + heading := "" + for i := 0; i < len(lines); i++ { + if m := docHeadingRE.FindStringSubmatch(lines[i]); m != nil { + heading = m[2] + continue + } + if m := docFenceRE.FindStringSubmatch(lines[i]); m != nil { + lang := m[1] + j := i + 1 + var body []string + for j < len(lines) && strings.TrimSpace(lines[j]) != "```" { + body = append(body, lines[j]) + j++ + } + blocks = append(blocks, docBlock{lang: lang, heading: heading, body: strings.Join(body, "\n"), line: i + 1}) + i = j + } + } + return blocks +} + +// nthBlock returns the occurrence'th (1-based, in file order) block of the given +// language directly under heading. It fails the test loudly if the doc no longer has +// that many -- a section being edited out from under this harness is a finding, not a +// silent skip. +func nthBlock(t *testing.T, blocks []docBlock, heading, lang string, occurrence int) docBlock { + t.Helper() + n := 0 + for _, b := range blocks { + if b.heading == heading && b.lang == lang { + n++ + if n == occurrence { + return b + } + } + } + t.Fatalf("heading %q: no %s block #%d (found %d)", heading, lang, occurrence, n) + return docBlock{} +} + +// docLine returns the text of the line in body containing marker, with its comment +// syntax (a leading Go "//" or SQL "--", and a "^" caret pointing up at the line above) +// trimmed off -- the diagnostic wording the docs use, verbatim, not retyped here. +func docLine(t *testing.T, body, marker string) string { + t.Helper() + for _, l := range strings.Split(body, "\n") { + if !strings.Contains(l, marker) { + continue + } + s := l + // take only the comment portion: a struct field's diagnostic is a trailing + // "// text" after real code, not the whole line. + if i := strings.Index(s, "//"); i >= 0 { + s = s[i+2:] + } else if i := strings.Index(s, "--"); i >= 0 { + s = s[i+2:] + } + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "^") + return strings.TrimSpace(s) + } + t.Fatalf("no line containing %q in:\n%s", marker, body) + return "" +} + +// quietT is an analysistest.Testing that records rather than fails: analysistest.Run +// otherwise reports every diagnostic as "unexpected" the moment a file has no "// want" +// comments at all, which is always, here (see the package doc above for why this harness +// does not use "// want"). assertDiagnostics reads the real diagnostics out of the +// Result instead and ignores q entirely. +type quietT struct{ errs []string } + +func (q *quietT) Errorf(format string, args ...any) { + q.errs = append(q.errs, fmt.Sprintf(format, args...)) +} + +// runDocs runs Analyzer over dir/pkgs and returns every diagnostic it produced, in the +// order the packages were given. Each package's own name, as a qualifier +// (".TypeName"), is stripped from messages first: docs write the bare "TypeName" +// (the snippet has no package of its own to speak of), while go/types always prints +// a Named type's diagnostics package-qualified. +func runDocs(t *testing.T, dir string, pkgs ...string) []analysis.Diagnostic { + t.Helper() + var q quietT + res := analysistest.Run(&q, dir, Analyzer, pkgs...) + var diags []analysis.Diagnostic + for _, r := range res { + diags = append(diags, r.Diagnostics...) + } + for i := range diags { + for _, pkg := range pkgs { + diags[i].Message = strings.ReplaceAll(diags[i].Message, pkg+".", "") + } + } + return diags +} + +// assertDiagnostics checks that the analyzer's diagnostics over dir/pkgs are exactly what +// docs claims: each string in expect is a literal substring (strings.Contains, not a +// regexp) of exactly one diagnostic message, and no diagnostic is left unclaimed. Passing +// examples call this with expect == nil: any diagnostic at all is then a failure. +func assertDiagnostics(t *testing.T, dir string, pkgs []string, expect []string) { + t.Helper() + diags := runDocs(t, dir, pkgs...) + used := make([]bool, len(diags)) + for _, e := range expect { + found := false + for i, d := range diags { + if used[i] { + continue + } + if strings.Contains(d.Message, e) { + used[i] = true + found = true + break + } + } + if !found { + t.Errorf("docs promises a diagnostic containing %q, but the checker did not produce one; got: %s", e, formatDiags(diags)) + } + } + for i, d := range diags { + if !used[i] { + t.Errorf("checker produced a diagnostic docs does not claim: %s", d.Message) + } + } +} + +func formatDiags(diags []analysis.Diagnostic) string { + if len(diags) == 0 { + return "(none)" + } + var parts []string + for _, d := range diags { + parts = append(parts, d.Message) + } + return strings.Join(parts, " | ") +} + +func setSchema(t *testing.T, path string) { + t.Helper() + if err := Analyzer.Flags.Set("schema", path); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { Analyzer.Flags.Set("schema", "") }) +} + +func docsMD(name string) string { + return filepath.Join("..", "..", "..", "..", "docs", name) +} + +// --------------------------------------------------------------------------------------- +// "Result columns bind to fields by name" +// --------------------------------------------------------------------------------------- + +// TestDocsColumnBindByName runs docs/checks.md's and docs/checks.ja.md's "Result columns +// bind to fields by name" example: an unaliased join column with no matching field +// (Rejected), and the same query with the column aliased and the field tagged (Passes). +func TestDocsColumnBindByName(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Result columns bind to fields by name"}, + {"ja", "checks.ja.md", "結果列とフィールドは名前で対応する"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ngGo := nthBlock(t, blocks, tc.head, "go", 1) + ngSQL := nthBlock(t, blocks, tc.head, "sql", 1) + okSQL := nthBlock(t, blocks, tc.head, "sql", 2) // the column aliased + okGo := nthBlock(t, blocks, tc.head, "go", 2) // the field tagged instead + + // docs shows two independent fixes ("alias the column, or name the column + // in the tag"), not one aligned pair: okSQL (aliased) is meant to pair with + // the original, untagged struct (ngGo minus its Rejected comment), and okGo + // (tagged `col:"name"`) is meant to pair with the original, unaliased query + // (ngSQL). Pairing okSQL with okGo instead (aliased column + a tag for the + // unaliased name) would not bind at all. + plainGo := dropInlineComment(ngGo.body, "has no result column") + + td := t.TempDir() + writeFile(t, td, "src/docsex_colbind_ng/ng.go", fmt.Sprintf(`package docsex_colbind_ng + +import "github.com/kr9ly/sqlshape/v2" + +%s + +var Q = sqlshape.Query[Order, struct{}](%s) +`, ngGo.body, backtick(oneLineSQL(ngSQL.body)))) + writeFile(t, td, "src/docsex_colbind_ok_alias/ok.go", fmt.Sprintf(`package docsex_colbind_ok_alias + +import "github.com/kr9ly/sqlshape/v2" + +%s + +var Q = sqlshape.Query[Order, struct{}](%s) +`, plainGo, backtick(oneLineSQL(okSQL.body)))) + writeFile(t, td, "src/docsex_colbind_ok_tag/ok.go", fmt.Sprintf(`package docsex_colbind_ok_tag + +import "github.com/kr9ly/sqlshape/v2" + +%s + +var Q = sqlshape.Query[Order, struct{}](%s) +`, okGo.body, backtick(oneLineSQL(ngSQL.body)))) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_colbind_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_colbind_ng"}, []string{ + docLine(t, ngGo.body, "has no result column"), + docLine(t, ngSQL.body, "has no field in Order"), + }) + assertDiagnostics(t, td, []string{"docsex_colbind_ok_alias"}, nil) + assertDiagnostics(t, td, []string{"docsex_colbind_ok_tag"}, nil) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "A column that may be NULL needs a field that can hold NULL" +// --------------------------------------------------------------------------------------- + +func TestDocsNullableColumn(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "A column that may be NULL needs a field that can hold NULL"}, + {"ja", "checks.ja.md", "NULLになりうる列はNULLを受けられる型で受ける"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ngGo := nthBlock(t, blocks, tc.head, "go", 1) + sql := nthBlock(t, blocks, tc.head, "sql", 1) + okGo := nthBlock(t, blocks, tc.head, "go", 2) + + td := t.TempDir() + writeFile(t, td, "src/docsex_null_ng/ng.go", fmt.Sprintf(`package docsex_null_ng + +import ( + "time" + + "github.com/kr9ly/sqlshape/v2" +) + +%s + +var Q = sqlshape.Query[User, struct{}](%s) +`, ngGo.body, backtick(oneLineSQL(sql.body)))) + writeFile(t, td, "src/docsex_null_ok/ok.go", fmt.Sprintf(`package docsex_null_ok + +import ( + "time" + + "github.com/kr9ly/sqlshape/v2" +) + +%s + +var Q = sqlshape.Query[User, struct{}](%s) +`, okGo.body, backtick(oneLineSQL(sql.body)))) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_null_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_null_ng"}, []string{ + docLine(t, ngGo.body, "may be NULL"), + }) + assertDiagnostics(t, td, []string{"docsex_null_ok"}, nil) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Fix a unique key by equality" (the `One` proof) +// --------------------------------------------------------------------------------------- + +func TestDocsOneProof(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Fix a unique key by equality"}, + {"ja", "checks.ja.md", "一意キーを等値で固定する"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ng := nthBlock(t, blocks, tc.head, "go", 1) + ok := nthBlock(t, blocks, tc.head, "go", 2) + + td := t.TempDir() + writeFile(t, td, "src/docsex_one_ng/ng.go", fmt.Sprintf(`package docsex_one_ng + +import "github.com/kr9ly/sqlshape/v2" + +type User struct { + ID int64 + Email string +} + +%s +`, stripTrailingLineComment(ng.body, "One: cannot prove"))) + writeFile(t, td, "src/docsex_one_ok/ok.go", fmt.Sprintf(`package docsex_one_ok + +import "github.com/kr9ly/sqlshape/v2" + +type User struct { + ID int64 + Email string +} + +%s +`, ok.body)) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_oneproof_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_one_ng"}, []string{ + docLine(t, ng.body, "One: cannot prove"), + }) + assertDiagnostics(t, td, []string{"docsex_one_ok"}, nil) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Bulk loading with COPY (PostgreSQL)" +// --------------------------------------------------------------------------------------- + +// TestDocsCopy reuses the shared testdata/schema.sql, whose order_items table already +// matches this example exactly (order_id, line_no NOT NULL with no default, sku): no +// docs-specific schema needed. +func TestDocsCopy(t *testing.T) { + for _, tc := range []struct { + name string + doc string + head string + }{ + {"en", "checks.md", "Bulk loading with COPY (PostgreSQL)"}, + {"ja", "checks.ja.md", "COPYで一括ロードする(PostgreSQL)"}, + } { + t.Run(tc.name, func(t *testing.T) { + blocks := parseDocBlocks(t, docsMD(tc.doc)) + ng := nthBlock(t, blocks, tc.head, "go", 1) + ok := nthBlock(t, blocks, tc.head, "go", 2) + + td := t.TempDir() + writeFile(t, td, "src/docsex_copy_ng/ng.go", fmt.Sprintf(`package docsex_copy_ng + +import "github.com/kr9ly/sqlshape/postgres/v2" + +%s +`, ng.body)) + writeFile(t, td, "src/docsex_copy_ok/ok.go", fmt.Sprintf(`package docsex_copy_ok + +import "github.com/kr9ly/sqlshape/postgres/v2" + +%s +`, ok.body)) + copyRuntime(t, td) + + setSchema(t, absPath(t, "testdata/docsex_copy_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_copy_ng"}, []string{ + docLine(t, ng.body, "is not copied"), + }) + assertDiagnostics(t, td, []string{"docsex_copy_ok"}, nil) + }) + } +} + +// --------------------------------------------------------------------------------------- +// "Name the errors a trigger raises" -- the section that prompted this task: docs' +// `-- sqlshape: expect OrderTooLarge` (naming an error by the Name a schema's +// `-- sqlshape: error = ` line gives it) must be exactly what the checker +// accepts, in both directions (a correct declaration, a wrong one, a duplicate, an +// unknown code, a missing declaration) and both dialects (PostgreSQL RAISE ... ERRCODE, +// MySQL SIGNAL ... MYSQL_ERRNO). +// --------------------------------------------------------------------------------------- + +func TestDocsErrorNames(t *testing.T) { + blocks := parseDocBlocks(t, docsMD("checks.md")) + head := "Name the errors a trigger raises" + declOK := nthBlock(t, blocks, head, "go", 1) // var OrderTooLarge = sqlshape.Error("P0401") + okSQL := nthBlock(t, blocks, head, "sql", 2) // -- sqlshape: expect OrderTooLarge, orders_customer_id_fkey + wrong := nthBlock(t, blocks, head, "go", 2) // var Wrong = ... + dup := nthBlock(t, blocks, head, "go", 3) // var A, B = ... + unknown := nthBlock(t, blocks, head, "go", 4) // var Ghost = ... + missing := nthBlock(t, blocks, head, "go", 5) // no var at all; comment only + mysqlOK := nthBlock(t, blocks, head, "sql", 4) // -- sqlshape: expect OrderTooLarge, fk_orders_customer + + td := t.TempDir() + writeFile(t, td, "src/docsex_errname_ok/ok.go", fmt.Sprintf(`package docsex_errname_ok + +import "github.com/kr9ly/sqlshape/v2" + +%s + +var Q = sqlshape.Query[struct{}, struct { + CustomerID int64 + Total string +}](%s) +`, stripTrailingLineComment(declOK.body, ""), backtick(oneLineSQL(okSQL.body)))) + + // wrong/dup/unknown each need at least one sqlshape.Query call in the package for + // vet to run its error-declaration checks at all (see errnamewrong/errnamedup/ + // errnameunknown in testdata/src for the same requirement); a plain read is enough + // and is not itself part of what the doc's comment claims. + writeFile(t, td, "src/docsex_errname_wrong/wrong.go", fmt.Sprintf(`package docsex_errname_wrong + +import "github.com/kr9ly/sqlshape/v2" + +%s + +var read = sqlshape.Query[int64, struct{}]("SELECT id FROM orders") +`, wrong.body)) + + writeFile(t, td, "src/docsex_errname_dup/dup.go", fmt.Sprintf(`package docsex_errname_dup + +import "github.com/kr9ly/sqlshape/v2" + +%s + +var read = sqlshape.Query[int64, struct{}]("SELECT id FROM orders") +`, dup.body)) + + writeFile(t, td, "src/docsex_errname_unknown/unknown.go", fmt.Sprintf(`package docsex_errname_unknown + +import "github.com/kr9ly/sqlshape/v2" + +%s + +var read = sqlshape.Query[int64, struct{}]("SELECT id FROM orders") +`, unknown.body)) + + // missing: docs shows no code at all for this case (only the comment describing + // what the checker says), so the query is this harness's own completion -- the + // same expect line as the ok/mysql examples, in a package that declares no var at + // all for P0401. + writeFile(t, td, "src/docsex_errname_missing/missing.go", `package docsex_errname_missing + +import "github.com/kr9ly/sqlshape/v2" + +var Q = sqlshape.Query[struct{}, struct { + CustomerID int64 + Total string +}]("-- sqlshape: expect OrderTooLarge, orders_customer_id_fkey\nINSERT INTO orders (customer_id, total) VALUES ({{.CustomerID}}, {{.Total}})") +`) + + writeFile(t, td, "src/docsex_errname_mysql_ok/ok.go", fmt.Sprintf(`package docsex_errname_mysql_ok + +import "github.com/kr9ly/sqlshape/v2" + +var OrderTooLarge = sqlshape.Error("30001") + +var Q = sqlshape.Query[struct{}, struct { + CustomerID int64 + Total string +}](%s) +`, backtick(oneLineSQL(mysqlOK.body)))) + copyRuntime(t, td) + + t.Run("pg-ok", func(t *testing.T) { + setSchema(t, absPath(t, "testdata/docsex_errname_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_errname_ok"}, nil) + }) + t.Run("pg-wrong-name", func(t *testing.T) { + setSchema(t, absPath(t, "testdata/docsex_errname_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_errname_wrong"}, []string{ + docLine(t, wrong.body, "schema names"), + }) + }) + t.Run("pg-duplicate", func(t *testing.T) { + setSchema(t, absPath(t, "testdata/docsex_errname_schema.sql")) + // docs' A/B example is a duplicate-declaration illustration only: neither A + // nor B is the schema's own name for P0401 ("OrderTooLarge"), so a real + // compile also raises the wrong-name diagnostic for each -- a true side + // effect of the names docs chose to illustrate "both declare", not a claim + // docs makes or a mismatch to fix. wrongNameLine (from the "wrong" example, + // same schema) gives the message's shape; substituting the name it blames + // keeps this literal rather than hand-typed. + wrongNameLine := docLine(t, wrong.body, "schema names") + assertDiagnostics(t, td, []string{"docsex_errname_dup"}, []string{ + docLine(t, dup.body, "both declare"), + strings.Replace(wrongNameLine, `"Wrong"`, `"A"`, 1), + strings.Replace(wrongNameLine, `"Wrong"`, `"B"`, 1), + }) + }) + t.Run("pg-unknown-code", func(t *testing.T) { + setSchema(t, absPath(t, "testdata/docsex_errname_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_errname_unknown"}, []string{ + docLine(t, unknown.body, "declares no error"), + }) + }) + t.Run("pg-missing-declaration", func(t *testing.T) { + setSchema(t, absPath(t, "testdata/docsex_errname_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_errname_missing"}, []string{ + docLine(t, missing.body, "has no `var"), + }) + }) + t.Run("mysql-ok", func(t *testing.T) { + setSchema(t, absPath(t, "testdata/docsex_errname_mysql_schema.sql")) + assertDiagnostics(t, td, []string{"docsex_errname_mysql_ok"}, nil) + }) +} + +// --------------------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------------------- + +// oneLineSQL joins a fenced sql block into as few physical lines as possible: a "--" +// documentation caret (`-- ^ ...`, pointing at the line above) is dropped entirely, a +// real SQL comment or `-- sqlshape:` directive is kept on its own line (a SQL line +// comment runs to end of line, so it cannot be joined with what follows), and +// consecutive plain SQL lines between comments are joined with a space. +func oneLineSQL(body string) string { + var out []string + var buf []string + flush := func() { + if len(buf) > 0 { + out = append(out, strings.Join(buf, " ")) + buf = nil + } + } + for _, l := range strings.Split(body, "\n") { + s := strings.TrimSpace(l) + if s == "" { + continue + } + if strings.HasPrefix(s, "--") { + if strings.Contains(s, "^") { + continue + } + flush() + out = append(out, s) + continue + } + buf = append(buf, s) + } + flush() + return strings.Join(out, "\n") +} + +// stripTrailingLineComment drops the line containing marker entirely (used where a +// docs snippet's diagnostic-bearing comment is on a line of its own, appended after +// real code, and must not appear in the compiled source). marker == "" strips nothing. +func stripTrailingLineComment(body, marker string) string { + if marker == "" { + return body + } + var out []string + for _, l := range strings.Split(body, "\n") { + if strings.Contains(l, marker) { + continue + } + out = append(out, l) + } + return strings.Join(out, "\n") +} + +// dropInlineComment finds the line in body containing marker and truncates it at the +// start of its trailing "// ..." comment, keeping the code before it (used where the +// comment sits on the same line as real code, e.g. a struct field, rather than as a +// line of its own). +func dropInlineComment(body, marker string) string { + var out []string + for _, l := range strings.Split(body, "\n") { + if strings.Contains(l, marker) { + if i := strings.Index(l, "//"); i >= 0 { + l = strings.TrimRight(l[:i], " \t") + } + } + out = append(out, l) + } + return strings.Join(out, "\n") +} + +func backtick(s string) string { + return "`" + s + "`" +} + +func writeFile(t *testing.T, root, rel, content string) { + t.Helper() + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// copyRuntime symlinks the vet package's own vendored sqlshape/postgres runtime stubs +// (testdata/src/github.com/...) into td/src, the same GOPATH layout analysistest.TestData +// already uses, so packages built under td can import them. +func copyRuntime(t *testing.T, td string) { + t.Helper() + src := absPath(t, "testdata/src/github.com") + if err := os.MkdirAll(filepath.Join(td, "src"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(src, filepath.Join(td, "src", "github.com")); err != nil && !os.IsExist(err) { + t.Fatal(err) + } +} + +func absPath(t *testing.T, rel string) string { + t.Helper() + p, err := filepath.Abs(rel) + if err != nil { + t.Fatal(err) + } + return p +} diff --git a/internal/vet/dto.go b/cmd/sqlshape/internal/vet/dto.go similarity index 75% rename from internal/vet/dto.go rename to cmd/sqlshape/internal/vet/dto.go index 90249b9..9452858 100644 --- a/internal/vet/dto.go +++ b/cmd/sqlshape/internal/vet/dto.go @@ -11,10 +11,8 @@ import ( "golang.org/x/tools/go/analysis" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/catalog" - "github.com/kr9ly/sqlshape/internal/expand" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/dialect" + "github.com/kr9ly/sqlshape/v2/x/expand" ) // Declaring R and P from the query. Every diagnostic about the result struct not fitting @@ -29,15 +27,15 @@ import ( // dtoColumn is one result column seen across the expansions of a call. type dtoColumn struct { - col analyze.Column + col dialect.Column count int // expansions that select it } // dtoParam is one parameter path seen across the expansions. type dtoParam struct { path expand.Path - pg schema.TypeRef - src *analyze.Source + typ dialect.Type + src *dialect.Source } // heldDiag is a diagnostic waiting for its quick fix. @@ -63,10 +61,10 @@ func newDTO() *dto { } // addResult records the columns of one analyzed expansion. -func (d *dto) addResult(r *analyze.Result) { +func (d *dto) addResult(r *dialect.Result) { d.analyzed++ for _, col := range r.Columns { - if col.Name == "?column?" || col.Type.OID == catalog.Void { + if col.Name == "" || col.Type.Kind == dialect.Void { continue } if dc, ok := d.cols[col.Name]; ok { @@ -79,7 +77,7 @@ func (d *dto) addResult(r *analyze.Result) { } // addParams records the parameters of one expansion. -func (d *dto) addParams(e *expand.Expansion, r *analyze.Result) { +func (d *dto) addParams(e *expand.Expansion, r *dialect.Result) { for _, p := range e.Params { if p.N-1 >= len(r.Params) { continue @@ -88,7 +86,8 @@ func (d *dto) addParams(e *expand.Expansion, r *analyze.Result) { if _, ok := d.params[key]; ok { continue } - d.params[key] = &dtoParam{path: p.Path, pg: r.Params[p.N-1], src: r.ParamSources[p.N-1]} + prm := r.Params[p.N-1] + d.params[key] = &dtoParam{path: p.Path, typ: prm.Type, src: prm.Source} d.prmOrder = append(d.prmOrder, key) } } @@ -276,7 +275,7 @@ func (c *checker) resultStruct(d *dto, cur *types.Struct, node *ast.StructType) if f.node.Doc != nil { doc = strings.TrimSpace(f.node.Doc.Text()) } - if fit := c.match(dc.col.Type, f.v.Type()); fit.ok && fit.lossy == "" && !fit.unknown && (!nullable || fit.nullable) { + if fit := c.fitPG(dc.col.Type, f.v.Type(), false); fit.ok && fit.lossy == "" && !fit.unknown && (!nullable || fit.nullable) { typ = types.ExprString(f.node.Type) if f.node.Tag != nil { tag = " " + f.node.Tag.Value @@ -290,7 +289,7 @@ func (c *checker) resultStruct(d *dto, cur *types.Struct, node *ast.StructType) } } if doc == "" && dc.col.Source != nil { - doc = c.s.Comments[dc.col.Source.Table+"."+dc.col.Source.Column] + doc = dc.col.Source.Comment } g.fields = append(g.fields, withDoc(doc, fname+" "+typ+tag)) } @@ -380,11 +379,7 @@ func (c *checker) pathType(g *generated, n *pathNode, param bool) string { b.WriteString("}") return b.String() case n.leaf != nil: - col := analyze.Column{Name: "", Type: n.leaf.pg, Source: n.leaf.src} - if nested := c.paramColumn(n.leaf.pg); nested != nil { - col.Fields = nested.Fields - } - return c.goType(g, col, n.control, true) + return c.goType(g, dialect.Column{Type: n.leaf.typ, Source: n.leaf.src}, n.control, true) default: return "bool" } @@ -399,7 +394,7 @@ func (c *checker) pathsFit(t types.Type, n *pathNode, prefix expand.Path) bool { return false } if n.leaf != nil { - fit := c.paramFit(n.leaf.pg, gt) + fit := c.fitPG(n.leaf.typ, gt, true) if !fit.ok || fit.lossy != "" || fit.unknown || (n.control && !fit.nullable) { return false } @@ -470,9 +465,10 @@ func exportedName(col string) string { return name } -// goType renders the Go type for a column: the reverse of the type table. nullable -// wraps in a pointer unless the type carries NULL itself (slices, maps, pgtype.*). -func (c *checker) goType(g *generated, col analyze.Column, nullable, param bool) string { +// goType renders the Go type for a column: the dialect's first choice, a bound type where +// the column carries a nominal type. nullable wraps in a pointer unless the type carries +// NULL itself (slices, maps, pgtype.*). +func (c *checker) goType(g *generated, col dialect.Column, nullable, param bool) string { typ, selfNullable := c.goTypeValue(g, col, param) if nullable && !selfNullable { return "*" + typ @@ -480,141 +476,94 @@ func (c *checker) goType(g *generated, col analyze.Column, nullable, param bool) return typ } -const pgtypePkg = "github.com/jackc/pgx/v5/pgtype" - -func (c *checker) goTypeValue(g *generated, col analyze.Column, param bool) (typ string, selfNullable bool) { - pg := col.Type +func (c *checker) goTypeValue(g *generated, col dialect.Column, param bool) (typ string, selfNullable bool) { // a Go type already bound to this enum / value set / domain / key is the one to use - if n, ok := c.nominalOf(pg, col.Source); ok && n.kind != 'k' { + if n, ok := nominalOf(col.Type, col.Source); ok && n.kind != 'k' { if name := c.boundTypeName(g, n); name != "" { return name, false } } - base := c.s.Types.BaseOf(pg) - pt := c.s.Types.ByOID(base.OID) - if pt == nil { - return "any", false + return c.renderType(g, col.Type, param) +} + +// renderType renders the Go type the dialect lists first for the type — or, among the +// listed options, the one whose package the program already imports (a decimal type, a +// uuid type), so that a generated struct follows the program's own choices. +func (c *checker) renderType(g *generated, dt dialect.Type, param bool) (string, bool) { + // the value's canonical Go type is the result list's first, in either direction (the + // parameter list is ordered by what fits, narrow types first) + list := dt.Result + if len(list) == 0 { + list = dt.Param + } + if len(list) == 0 { + return "any /* " + dt.Name + ": no known mapping */", true } - // arrays - if pt.Elem != 0 && strings.HasPrefix(pt.Name, "_") { - elem := analyze.Column{Name: col.Name, Type: schema.TypeRef{OID: pt.Elem, Typmod: -1}, Fields: col.Fields} - et, _ := c.goTypeValue(g, elem, param) - return "[]" + et, true + chosen := list[0].Go + for _, opt := range list { + if path := spellingPkg(opt.Go); path != "" && strings.Contains(path, ".") && !strings.HasSuffix(path, "pgtype") && c.imports(path) { + chosen = opt.Go + break + } } - pgt := func(name string) (string, bool) { - g.need(pgtypePkg) - return "pgtype." + name, true - } - switch base.OID { - case catalog.Int2: - return "int16", false - case catalog.Int4: - return "int32", false - case catalog.Int8: - return "int64", false - case catalog.OIDType: - return "uint32", false - case catalog.Float4: - return "float32", false - case catalog.Float8: - return "float64", false - case catalog.Numeric: - if c.imports("github.com/shopspring/decimal") { - g.need("github.com/shopspring/decimal") - return "decimal.Decimal", false - } - return pgt("Numeric") - case catalog.Bool: - return "bool", false - case catalog.Text, catalog.Varchar, catalog.BPChar, catalog.Name, catalog.Char, catalog.Cstring: - return "string", false - case catalog.Bytea: + return c.renderSpelling(g, chosen, dt, param) +} + +// renderSpelling writes one GoSpelling as Go source, importing what it needs; the second +// result says whether the type carries NULL itself (a slice, a map, a Valid-bearing value). +func (c *checker) renderSpelling(g *generated, spell string, dt dialect.Type, param bool) (string, bool) { + switch { + case spell == "struct": + return c.rowStruct(g, dt.Fields, param), false + case spell == "json": + return "any", true + case spell == "[]byte": return "[]byte", true - case catalog.UUID: - if c.imports("github.com/google/uuid") { - g.need("github.com/google/uuid") - return "uuid.UUID", false - } - return "string", false - case catalog.Date, catalog.Timestamp, catalog.TimestampTZ, catalog.Time: - g.need("time") - return "time.Time", false - case catalog.TimeTZ: - return "string", false - case catalog.Interval: - g.need("time") - return "time.Duration", false - case catalog.JSON, catalog.JSONB: - g.need("encoding/json") - return "json.RawMessage", true // a slice: nil for NULL - case catalog.Record: - return c.rowStruct(g, col, param), false - } - switch pt.Kind { - case 'e': - return "string", false - case 'c': - return c.rowStruct(g, col, param), false - case 'r': - if rng := c.s.Types.RangeOf(base.OID); rng != nil { - st, _ := c.goTypeValue(g, analyze.Column{Type: schema.TypeRef{OID: rng.Subtype, Typmod: -1}}, param) - g.need(pgtypePkg) - return "pgtype.Range[" + st + "]", true - } - case 'm': - if rng := c.s.Types.RangeOfMulti(base.OID); rng != nil { - st, _ := c.goTypeValue(g, analyze.Column{Type: schema.TypeRef{OID: rng.Subtype, Typmod: -1}}, param) - g.need(pgtypePkg) - return "pgtype.Multirange[pgtype.Range[" + st + "]]", true - } - } - if pt.Schema == "" || pt.Schema == "pg_catalog" { - switch pt.Name { - case "inet", "cidr": - g.need("net/netip") - return "netip.Prefix", false - case "macaddr", "macaddr8": - g.need("net") - return "net.HardwareAddr", true - case "bit", "varbit": - return pgt("Bits") - case "point": - return pgt("Point") - case "lseg": - return pgt("Lseg") - case "path": - return pgt("Path") - case "box": - return pgt("Box") - case "polygon": - return pgt("Polygon") - case "line": - return pgt("Line") - case "circle": - return pgt("Circle") - case "tsvector": - return pgt("TSVector") - case "xml", "money", "tsquery", "jsonpath", "tid", "pg_lsn", "txid_snapshot", "pg_snapshot", "aclitem", "regclass", "regtype", "regproc", "regprocedure", "regoper", "regoperator", "regnamespace", "regrole", "regconfig", "regdictionary", "regcollation": - return "string", false - } - } - switch pt.Name { - case "hstore": - return "map[string]*string", true - case "citext", "ltree", "lquery", "ltxtquery": - return "string", false - } - return "any /* " + c.s.Types.Format(pg) + ": no known mapping */", true + case strings.HasPrefix(spell, "map["): + return spell, true + case spell == "[]$elem": + if dt.Elem == nil { + return "[]any", true + } + et, _ := c.renderType(g, *dt.Elem, param) + return "[]" + et, true + case strings.HasSuffix(spell, "[$elem]"): + base, _ := c.renderSpelling(g, strings.TrimSuffix(spell, "[$elem]"), dt, param) + et := "any" + if dt.Elem != nil { + et, _ = c.renderType(g, *dt.Elem, param) + } + return base + "[" + et + "]", true + case strings.HasPrefix(spell, "[") && strings.HasSuffix(spell, "]byte"): + return spell, false + } + if path := spellingPkg(spell); path != "" { + g.need(path) + name := spell[strings.LastIndexByte(spell, '.')+1:] + pkg := path[strings.LastIndexByte(path, '/')+1:] + self := strings.HasSuffix(path, "pgtype") || spell == "encoding/json.RawMessage" || spell == "net.HardwareAddr" + return pkg + "." + name, self + } + return spell, false +} + +// spellingPkg is the package path of a named-type spelling ("net/netip.Addr" → "net/netip"), +// "" for anything else. +func spellingPkg(spell string) string { + if strings.HasPrefix(spell, "[") || strings.HasPrefix(spell, "map[") || !strings.ContainsAny(spell, "./") { + return "" + } + return spell[:strings.LastIndexByte(spell, '.')] } // rowStruct renders a record / composite column as a nested struct of its fields. -func (c *checker) rowStruct(g *generated, col analyze.Column, param bool) string { - if len(col.Fields) == 0 { +func (c *checker) rowStruct(g *generated, fields []dialect.Column, param bool) string { + if len(fields) == 0 { return "struct{}" } var b strings.Builder b.WriteString("struct {\n") - for _, f := range col.Fields { + for _, f := range fields { b.WriteString("\t" + exportedName(f.Name) + " " + c.goType(g, f, f.Nullable, param) + "\n") } b.WriteString("}") diff --git a/cmd/sqlshape/internal/vet/errornames.go b/cmd/sqlshape/internal/vet/errornames.go new file mode 100644 index 0000000..43acb6d --- /dev/null +++ b/cmd/sqlshape/internal/vet/errornames.go @@ -0,0 +1,177 @@ +package vet + +import ( + "go/ast" + "go/constant" + "go/token" + "go/types" + "sort" +) + +// Error names. A schema's `-- sqlshape: error = ` annotation (a trigger's +// or routine's own raised failure mode) gets a Go name by: +// +// var OrderTooLarge = sqlshape.Error("30001") +// +// vet checks both directions: the declaration's own identifier must be the schema's Name +// for that code, and every code the schema declares must have a Go declaration for it +// somewhere in the program. The binding travels across packages the way a declared type's +// PG name does (typebind.go): a Fact on the var's object, exported here and imported by +// whatever other package refers to the var (typically a Violates(err, OrderTooLarge) +// call) -- so a "no Go declaration" diagnostic is only caught where a reference reaches +// this package, the same reach a declared type's binding has. A single-package program +// (every example in this repository, and vet's own testdata) is unaffected: every +// declaration and every reference to it are in the one package this analysis sees. + +// ErrorDeclFact records a sqlshape.Error(code) declaration's code, for another package +// that refers to the var to check its own schema against. +type ErrorDeclFact struct{ Code string } + +func (*ErrorDeclFact) AFact() {} +func (f *ErrorDeclFact) String() string { return "sqlshape.Error(" + f.Code + ")" } + +func init() { + Analyzer.FactTypes = append(Analyzer.FactTypes, (*ErrorDeclFact)(nil)) +} + +// errorDecl is one `var X = sqlshape.Error(code)` declaration in this package. +type errorDecl struct { + name string + code string + pos token.Pos +} + +// collectErrorDecls finds this package's own sqlshape.Error(code) declarations +// (package-level `var X = sqlshape.Error("code")`; a const is not recognized, since +// sqlshape.Error is not constant), exports each as a fact on its var object, and, when a +// schema is loaded, validates it: the code must be one the schema declares, under the +// schema's own Name for it, and no two declarations may claim the same code. +func (c *checker) collectErrorDecls() { + c.errorDecls = map[*types.Var]errorDecl{} + byCode := map[string]errorDecl{} + for _, f := range c.pass.Files { + for _, d := range f.Decls { + gd, ok := d.(*ast.GenDecl) + if !ok || gd.Tok != token.VAR { + continue + } + for _, sp := range gd.Specs { + vs := sp.(*ast.ValueSpec) + if len(vs.Names) != 1 || len(vs.Values) != 1 { + continue + } + code, ok := c.errorCallCode(vs.Values[0]) + if !ok { + continue + } + name := vs.Names[0] + obj, ok := c.pass.TypesInfo.Defs[name].(*types.Var) + if !ok { + continue + } + decl := errorDecl{name: name.Name, code: code, pos: name.Pos()} + if prev, dup := byCode[code]; dup { + c.pass.Reportf(decl.pos, "sqlshape: %s and %s both declare sqlshape.Error(%q)", prev.name, decl.name, code) + } else { + byCode[code] = decl + } + c.errorDecls[obj] = decl + c.pass.ExportObjectFact(obj, &ErrorDeclFact{Code: code}) + if c.sch != nil { + c.checkErrorDecl(decl) + } + } + } + } +} + +// errorCallCode recognizes sqlshape.Error("code") and returns the constant string +// argument. +func (c *checker) errorCallCode(expr ast.Expr) (string, bool) { + call, ok := expr.(*ast.CallExpr) + if !ok || len(call.Args) != 1 { + return "", false + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return "", false + } + fn, ok := c.pass.TypesInfo.Uses[sel.Sel].(*types.Func) + if !ok || fn.Pkg() == nil || fn.Pkg().Path() != sqlshapePkg || fn.Name() != "Error" { + return "", false + } + tv, ok := c.pass.TypesInfo.Types[call.Args[0]] + if !ok || tv.Value == nil || tv.Value.Kind() != constant.String { + return "", false + } + return constant.StringVal(tv.Value), true +} + +// checkErrorDecl validates one declaration against the schema's own Errors(). +func (c *checker) checkErrorDecl(decl errorDecl) { + for _, e := range c.sch.Errors() { + if e.Code != decl.code { + continue + } + if e.Name != decl.name { + c.pass.Reportf(decl.pos, "sqlshape: schema names %s's error %s %q, not %q", e.Subject, e.Code, e.Name, decl.name) + } + return + } + c.pass.Reportf(decl.pos, "sqlshape: the schema declares no error %q", decl.code) +} + +// checkErrorDeclsCovered reports a named violation possible in this package (Name != "", +// so it is a `-- sqlshape: error` annotation, not a plain constraint) with no Go +// declaration this analysis can find: neither this package's own (collectErrorDecls) nor +// one declared elsewhere and referred to somewhere in this package (an ErrorDeclFact +// imported off any identifier this package resolves to another package's var). A code the +// schema declares that no statement in this package can ever raise is not checked -- the +// way an enum label nothing binds is not (binding.go) -- so a schema shared with unrelated +// packages (a test fixture, say) does not force every one of them to declare every error. +// Reported at an expect line that names the code or its Name, when this package has one; +// the fallback position otherwise (there is nothing better to point at without one). +func (c *checker) checkErrorDeclsCovered(fallback token.Pos) { + if len(c.possibleErrors) == 0 { + return + } + covered := map[string]bool{} + for _, d := range c.errorDecls { + covered[d.code] = true + } + for _, f := range c.pass.Files { + ast.Inspect(f, func(n ast.Node) bool { + id, ok := n.(*ast.Ident) + if !ok { + return true + } + v, ok := c.pass.TypesInfo.Uses[id].(*types.Var) + if !ok || v.Pkg() == nil || v.Pkg() == c.pass.Pkg { + return true + } + var fct ErrorDeclFact + if c.pass.ImportObjectFact(v, &fct) { + covered[fct.Code] = true + } + return true + }) + } + var codes []string + for k := range c.possibleErrors { + codes = append(codes, k) + } + sort.Strings(codes) + for _, k := range codes { + if covered[k] { + continue + } + v := c.possibleErrors[k] + pos := fallback + if p, ok := c.expectPos[k]; ok { + pos = p + } else if p, ok := c.expectPos[v.Name]; ok { + pos = p + } + c.pass.Reportf(pos, "sqlshape: %s (%s) has no `var %s = sqlshape.Error(%q)` declared in this program", k, v.Detail, v.Name, k) + } +} diff --git a/internal/vet/escape_test.go b/cmd/sqlshape/internal/vet/escape_test.go similarity index 100% rename from internal/vet/escape_test.go rename to cmd/sqlshape/internal/vet/escape_test.go diff --git a/internal/vet/expect.go b/cmd/sqlshape/internal/vet/expect.go similarity index 58% rename from internal/vet/expect.go rename to cmd/sqlshape/internal/vet/expect.go index 65be9c6..c812dcd 100644 --- a/internal/vet/expect.go +++ b/cmd/sqlshape/internal/vet/expect.go @@ -7,8 +7,8 @@ import ( "sort" "strings" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/expand" + "github.com/kr9ly/sqlshape/v2/x/dialect" + "github.com/kr9ly/sqlshape/v2/x/expand" ) // The failure contract of a statement is written in its template: @@ -58,20 +58,33 @@ func directiveItems(text string, re *regexp.Regexp) (map[string]int, int) { } // possibleViolations filters an expansion's violations by what P can actually send: -// a NOT NULL violation carried by a parameter is dropped when its Go type cannot be NULL. -func (c *checker) possibleViolations(e *expand.Expansion, r *analyze.Result, pType types.Type) []analyze.Violation { - var out []analyze.Violation +// a NOT NULL violation carried by a parameter is dropped when its Go type cannot be NULL, +// or when the parameter's path was proven non-nil in this expansion by a plain +// {{if .X}} / {{with .X}} whose then branch was taken to produce it (see +// expand.Expansion.Guarded): a pointer read that way is guaranteed non-nil along this +// expansion's lineage, whatever the pointer's zero-value nullability would otherwise say. +// Only a bare pointer field gets this treatment -- a `{{if .X}}` is true exactly when X is +// not the zero value, which for a pointer means non-nil, but for a nullable wrapper +// (sql.Null*, pgtype.*) a struct is truthy the moment it's non-zero, which does not agree +// with its own Valid flag, so those are left to their ordinary nullability. +func (c *checker) possibleViolations(e *expand.Expansion, r *dialect.Result, pType types.Type) []dialect.Violation { + var out []dialect.Violation for _, v := range r.Violations { if v.Param > 0 { nullable := true for _, p := range e.Params { - if int32(p.N) == v.Param { + if p.N == v.Param { if gt, err := c.resolvePath(pType, p.Path); err == nil { _, nullable = unwrapNullable(gt) switch gt.Underlying().(type) { case *types.Slice, *types.Map: nullable = true // a nil slice / map is sent as NULL } + if nullable { + if _, isPtr := gt.(*types.Pointer); isPtr && e.Guarded(p.Path) { + nullable = false + } + } } } } @@ -87,22 +100,59 @@ func (c *checker) possibleViolations(e *expand.Expansion, r *analyze.Result, pTy // checkExpectations reports the diff between the template's expect line and the // violations possible in any expansion (possible: key → violation, with the branch it // was first seen in). -func (c *checker) checkExpectations(lit literal, possible map[string]analyze.Violation, branch map[string]string, report func(token.Pos, string, ...any)) { +// +// An expect line item satisfies a violation by matching either its Key (the SQLSTATE / +// constraint the runtime's Violates judges by) or its Name (the `-- sqlshape: error +// key = Name` annotation, when the violation carries one): the two are interchangeable +// on the expect line, so a template may write whichever reads better, and a template +// that writes both for the same violation (`expect P0401, OrderTooLarge`) has both +// items satisfied by that one violation, neither left over as unmatched. +func (c *checker) checkExpectations(lit literal, possible map[string]dialect.Violation, branch map[string]string, report func(token.Pos, string, ...any)) { expected, at := expectations(lit.text) + if c.expectPos == nil { + c.expectPos = map[string]token.Pos{} + } + for k, off := range expected { + if _, ok := c.expectPos[k]; !ok { + c.expectPos[k] = lit.pos(off) + } + } + if c.possibleErrors == nil { + c.possibleErrors = map[string]dialect.Violation{} + } + for k, v := range possible { + if v.Name != "" { + c.possibleErrors[k] = v + } + } + satisfied := map[string]bool{} var keys []string - for k := range possible { - if _, ok := expected[k]; !ok { + for k, v := range possible { + _, byKey := expected[k] + _, byName := expected[v.Name] + byName = byName && v.Name != "" + if byKey { + satisfied[k] = true + } + if byName { + satisfied[v.Name] = true + } + if !byKey && !byName { keys = append(keys, k) } } sort.Strings(keys) for _, k := range keys { v := possible[k] - report(lit.pos(at), "may violate %s (%s); add `-- sqlshape: expect %s` to the template or make it impossible%s", k, describeViolation(v), k, branch[k]) + suggest := k + if v.Name != "" { + suggest = v.Name + } + report(lit.pos(at), "may violate %s (%s); add `-- sqlshape: expect %s` to the template or make it impossible%s", k, v.Detail, suggest, branch[k]) } keys = keys[:0] for k := range expected { - if _, ok := possible[k]; !ok { + if !satisfied[k] { keys = append(keys, k) } } @@ -112,41 +162,6 @@ func (c *checker) checkExpectations(lit literal, possible map[string]analyze.Vio } } -func describeViolation(v analyze.Violation) string { - s := describeViolationAt(v) - if v.Function != "" { - s += ", through " + v.Function + "()" - } - return s -} - -func describeViolationAt(v analyze.Violation) string { - cols := strings.Join(v.Columns, ", ") - switch v.Code { - case "23505": - return "UNIQUE (" + cols + ") on " + v.Table + ", SQLSTATE 23505" - case "23503": - return "FOREIGN KEY (" + cols + ") on " + v.Table + " REFERENCES " + v.RefTable + ", SQLSTATE 23503" - case "23514": - return "CHECK on " + v.Table + " (" + cols + "), SQLSTATE 23514" - case "23502": - return "NOT NULL on " + v.Table + "." + cols + ", SQLSTATE 23502" - case "23P01": - return "EXCLUDE (" + cols + ") on " + v.Table + ", SQLSTATE 23P01" - } - if v.Trigger != "" { - s := "raised by trigger " + v.Trigger + " on " + v.Table - if v.Name != "" { - s += " as " + v.Name - } - return s + ", SQLSTATE " + v.Code - } - if v.Name != "" { - return "raised as " + v.Name + ", SQLSTATE " + v.Code - } - return "SQLSTATE " + v.Code -} - var notNullLine = directive("not null") // notNullOverrides parses `-- sqlshape: not null col, col` lines: result columns the diff --git a/internal/vet/findschema_test.go b/cmd/sqlshape/internal/vet/findschema_test.go similarity index 100% rename from internal/vet/findschema_test.go rename to cmd/sqlshape/internal/vet/findschema_test.go diff --git a/cmd/sqlshape/internal/vet/gofit.go b/cmd/sqlshape/internal/vet/gofit.go new file mode 100644 index 0000000..1c8836c --- /dev/null +++ b/cmd/sqlshape/internal/vet/gofit.go @@ -0,0 +1,245 @@ +package vet + +import ( + "fmt" + "go/types" + "strconv" + "strings" + + "github.com/kr9ly/sqlshape/v2/x/dialect" +) + +// The Go side of a dialect type: dialect.Type spells, in GoSpelling, the Go types that +// carry a value; this file matches a go/types type against those spellings. Nothing here +// knows a database: what fits is the dialect's table, how a spelling reads is this grammar. + +// fitType decides whether Go type t carries a value of dt, as a result column (param +// false) or as a parameter (param true), under the dialect's traits. +func fitType(dt dialect.Type, t types.Type, param bool, tr dialect.Traits) fit { + inner, nullable := unwrapNullable(t) + if inner == nil { + return fit{ok: true, nullable: true} + } + if n, ok := inner.(*types.Named); ok && n.Obj().Pkg() != nil && n.TypeArgs() == nil { + for _, w := range tr.NullWrappers { + if strings.HasSuffix(n.Obj().Pkg().Path(), w) { + return fit{ok: true, nullable: true} + } + } + } + switch inner.Underlying().(type) { + case *types.Slice, *types.Map: + nullable = true + } + if dt.Unknown() { + return fit{ok: true, unknown: true, nullable: nullable} + } + // a Go type that decodes / encodes itself receives any column / encodes as any parameter + if !param && implementsScanner(inner) { + return fit{ok: true, nullable: true} + } + if param && implementsValuer(inner) { + return fit{ok: true, nullable: nullable} + } + f := fitValue(dt, inner, param, tr) + f.nullable = nullable + return f +} + +// fitValue matches the value itself (nullability already stripped) against the dialect's +// list for the direction. +func fitValue(dt dialect.Type, t types.Type, param bool, tr dialect.Traits) fit { + if dt.Unknown() { + return fit{ok: true, unknown: true} + } + if param && tr.TextParams { + if k, ok := basicKind(t); ok && k == types.String { + return fit{ok: true} + } + } + list := dt.Result + if param { + list = dt.Param + } + for _, g := range list { + if ok, sub := matchSpelling(g.Go, dt, t, param, tr); ok { + f := fit{ok: true, lossy: g.Lossy, advice: g.Advice} + if sub.lossy != "" { + f.lossy = sub.lossy + } + if sub.advice != "" { + f.advice = sub.advice + } + return f + } + } + return fit{} +} + +// matchSpelling reads one GoSpelling against t. For a compound spelling ([]$elem, a +// generic over $elem) the element's fit is Elem's, and its lossiness and advice are +// carried up in sub. +func matchSpelling(spell string, dt dialect.Type, t types.Type, param bool, tr dialect.Traits) (ok bool, sub fit) { + switch { + case spell == "struct": + _, isStruct := t.Underlying().(*types.Struct) + return isStruct, fit{} + case spell == "json": + if k, isBasic := basicKind(t); isBasic && k != types.String { + return false, fit{} + } + return true, fit{} + case spell == "[]byte": + return isByteSlice(t), fit{} + case strings.HasPrefix(spell, "map["): + return matchMap(spell, t), fit{} + case strings.HasSuffix(spell, "$elem") && (strings.HasPrefix(spell, "[]") || strings.HasPrefix(spell, "[")): + // []$elem or [N]$elem + var et types.Type + switch u := t.Underlying().(type) { + case *types.Slice: + if !strings.HasPrefix(spell, "[]") { + return false, fit{} + } + et = u.Elem() + case *types.Array: + n, err := strconv.ParseInt(strings.TrimSuffix(strings.TrimPrefix(spell, "["), "]$elem"), 10, 64) + if strings.HasPrefix(spell, "[]") { + et = u.Elem() + } else if err != nil || n != u.Len() { + return false, fit{} + } else { + et = u.Elem() + } + default: + return false, fit{} + } + if dt.Elem == nil { + return false, fit{} + } + ef := fitType(*dt.Elem, et, param, tr) + if ef.unknown { + return true, fit{} + } + // A dialect's own column type never guarantees an array's elements are themselves + // not null: PostgreSQL's own NOT NULL only forbids the array value as a whole from + // being NULL. A dialect that has proved the elements can never be NULL (dt.ElemNotNull, + // e.g. PostgreSQL's array_agg() of a value it knows is never NULL, a literal + // ARRAY[...] whose elements are all not NULL, an ARRAY(SELECT ...) over a not-null + // column) skips the note below entirely. Otherwise a Go element type that cannot + // itself carry NULL (not already a pointer / nullable wrapper) is accepted by + // default with a standing note, and additionally flagged as a rejection under + // -strict (matchColumns/checkNested still run against it: the array is accepted, + // only the missing NULL-safety is being called out). + if ef.ok && !param && dt.Kind == dialect.Array && !ef.nullable && !dt.ElemNotNull { + addArrayNullElemNotes(&ef, dt, t, et) + } + return ef.ok, ef + case strings.HasSuffix(spell, "[$elem]"): + // pkg.Name[$elem] + n, isNamed := t.(*types.Named) + if !isNamed || !sameNamed(n, strings.TrimSuffix(spell, "[$elem]")) { + return false, fit{} + } + args := n.TypeArgs() + if args == nil || args.Len() != 1 || dt.Elem == nil { + return false, fit{} + } + ef := fitType(*dt.Elem, args.At(0), param, tr) + return ef.ok, ef + case strings.HasPrefix(spell, "[") && strings.HasSuffix(spell, "]byte"): + arr, isArr := t.Underlying().(*types.Array) + if !isArr { + return false, fit{} + } + n, err := strconv.ParseInt(strings.TrimSuffix(strings.TrimPrefix(spell, "["), "]byte"), 10, 64) + if err != nil || n != arr.Len() { + return false, fit{} + } + k, isBasic := basicKind(arr.Elem()) + return isBasic && (k == types.Byte || k == types.Uint8), fit{} + } + if strings.ContainsAny(spell, "./") { + n, isNamed := t.(*types.Named) + return isNamed && sameNamed(n, spell), fit{} + } + // a basic type, by its own or its underlying spelling (a named string carries text) + b, isBasic := t.Underlying().(*types.Basic) + if !isBasic { + return false, fit{} + } + return b.Name() == spell || (spell == "byte" && b.Kind() == types.Uint8), fit{} +} + +// addArrayNullElemNotes attaches the array-null-element note to ef.lossy (shown in both +// modes, so the checker always says elements can be NULL and a non-pointer element +// cannot receive one) and a stronger, -strict-only rejection note to ef.advice (shown +// only under -strict, per vet.go's own advice/strict gating). t is the full Go type +// received (a slice or a fixed-size array), et its element type. +func addArrayNullElemNotes(ef *fit, dt dialect.Type, t, et types.Type) { + arrGo := t.String() + elemGo := et.String() + ptrArrGo := "[]*" + elemGo + if arr, isArr := t.Underlying().(*types.Array); isArr { + ptrArrGo = fmt.Sprintf("[%d]*%s", arr.Len(), elemGo) + } + if dt.Elem != nil && (dt.Elem.Kind == dialect.Composite || dt.Elem.Kind == dialect.Record) { + short := typeName(et) + appendNote(&ef.lossy, fmt.Sprintf("%s may contain a NULL element even though the column is not NULL; %s silently receives it as a zero-valued %s with no error (use %s)", dt.Name, arrGo, short, ptrArrGo)) + appendNote(&ef.advice, fmt.Sprintf("-strict rejects %s for %s: a NULL element is silently decoded as a zero-valued %s instead of an error (use %s)", arrGo, dt.Name, short, ptrArrGo)) + return + } + appendNote(&ef.lossy, fmt.Sprintf("%s may contain a NULL element even though the column is not NULL; %s cannot receive one (use %s)", dt.Name, arrGo, ptrArrGo)) + appendNote(&ef.advice, fmt.Sprintf("-strict rejects %s for %s: pgx errors scanning a NULL element into %s (use %s)", arrGo, dt.Name, elemGo, ptrArrGo)) +} + +func appendNote(dst *string, note string) { + if *dst == "" { + *dst = note + return + } + *dst += "; " + note +} + +// sameNamed: the named type's package path and name spell "path.Name" (the path matched +// by suffix, so a vendored or forked module still fits). +func sameNamed(n *types.Named, spell string) bool { + if n.Obj().Pkg() == nil { + return false + } + i := strings.LastIndexByte(spell, '.') + if i < 0 { + return false + } + path, name := spell[:i], spell[i+1:] + return n.Obj().Name() == name && (n.Obj().Pkg().Path() == path || strings.HasSuffix(n.Obj().Pkg().Path(), "/"+path) || strings.HasSuffix(n.Obj().Pkg().Path(), path)) +} + +// matchMap reads map[K]V with K, V basic or *basic. +func matchMap(spell string, t types.Type) bool { + m, ok := t.Underlying().(*types.Map) + if !ok { + return false + } + rest := strings.TrimPrefix(spell, "map[") + k, v, ok := strings.Cut(rest, "]") + if !ok { + return false + } + kb, isBasic := basicKind(m.Key()) + if !isBasic || types.Typ[kb].Name() != k { + return false + } + vt := m.Elem() + if strings.HasPrefix(v, "*") { + p, isPtr := vt.(*types.Pointer) + if !isPtr { + return false + } + vt, v = p.Elem(), v[1:] + } else if _, isPtr := vt.(*types.Pointer); isPtr { + return false + } + vb, isBasic := basicKind(vt) + return isBasic && types.Typ[vb].Name() == v +} diff --git a/cmd/sqlshape/internal/vet/gotypes.go b/cmd/sqlshape/internal/vet/gotypes.go new file mode 100644 index 0000000..1f6438e --- /dev/null +++ b/cmd/sqlshape/internal/vet/gotypes.go @@ -0,0 +1,234 @@ +package vet + +import ( + "fmt" + "go/types" + "strings" + "unicode" + + "github.com/kr9ly/sqlshape/v2/x/dialect" +) + +// fit is the verdict of matching a Go type against a PostgreSQL type. +type fit struct { + ok bool // the Go type can carry the PG value + nullable bool // the Go type can carry NULL (pointer, sql.Null*, pgtype.*) + lossy string // non-empty when the mapping loses information (e.g. numeric → float64) + advice string // what a faithful mapping still leaves to the application (-strict) + unknown bool // PG type has no known Go mapping; accepted with a note +} + +// unwrapNullable strips one level of nullability wrapper and reports it. +func unwrapNullable(t types.Type) (types.Type, bool) { + if p, ok := t.(*types.Pointer); ok { + return p.Elem(), true + } + if n, ok := t.(*types.Named); ok { + obj := n.Obj() + if obj.Pkg() != nil { + switch { + case obj.Pkg().Path() == "database/sql" && strings.HasPrefix(obj.Name(), "Null"): + return nil, true // sql.NullString etc.: type checked loosely + case strings.HasSuffix(obj.Pkg().Path(), "jackc/pgx/v5/pgtype"): + if n.TypeArgs() != nil { + return t, true // Range[T] / Multirange[T]: nullable, and T is checked + } + return nil, true // pgtype.* all carry Valid + } + } + } + return t, false +} + +// isNamed reports whether t is the named type pkg.name (pkg matched by suffix). +func isNamed(t types.Type, pkgSuffix, name string) bool { + n, ok := t.(*types.Named) + if !ok || n.Obj().Pkg() == nil { + return false + } + return strings.HasSuffix(n.Obj().Pkg().Path(), pkgSuffix) && n.Obj().Name() == name +} + +func basicKind(t types.Type) (types.BasicKind, bool) { + b, ok := t.Underlying().(*types.Basic) + if !ok { + return 0, false + } + return b.Kind(), true +} + +func isByteSlice(t types.Type) bool { + s, ok := t.Underlying().(*types.Slice) + if !ok { + return false + } + k, ok := basicKind(s.Elem()) + return ok && (k == types.Byte || k == types.Uint8) +} + +// fitPG is fitType with this package's declared type bindings applied first. +func (c *checker) fitPG(dt dialect.Type, t types.Type, param bool) fit { + if inner, nullable := unwrapNullable(t); inner != nil { + if d, ok := c.declaredOf(inner); ok { + f := c.matchDeclared(d, inner, dt, param) + switch inner.Underlying().(type) { + case *types.Slice, *types.Map: + nullable = true + } + f.nullable = nullable || implementsScanner(inner) + return f + } + } + return fitType(dt, t, param, c.traits()) +} + +// traits are the loaded dialect's conventions for Go types. +func (c *checker) traits() dialect.Traits { + if c.ls != nil && c.ls.dialect != nil { + return c.ls.dialect.Traits() + } + return dialect.Traits{} +} + +// structField is one column-bearing field of a result struct, embedded structs flattened. +type structField struct { + v *types.Var + name string // Go name, dotted through embedded structs (Base.ID) + col string // result column it binds to + opts []string // tag options after the name (notnull) +} + +// structFields lists the exported, non-skipped fields of st in declaration order. An +// embedded struct (anonymous field without a col / db tag) is flattened: its fields count +// as the parent's, the same way the runtime mapper scans them. A named struct field is a +// nested row instead. dups lists the columns two fields bind to ("A and B both bind to x"). +// Kept in sync with the runtime's flatFields. +func structFields(st *types.Struct) (fields []structField, dups []string) { + seen := map[string]string{} + var walk func(st *types.Struct, prefix string) + walk = func(st *types.Struct, prefix string) { + for i := 0; i < st.NumFields(); i++ { + fv := st.Field(i) + if fv.Embedded() { + if inner := embeddedStruct(fv, st.Tag(i)); inner != nil { + walk(inner, prefix+fv.Name()+".") + continue + } + } + if !fv.Exported() { + continue + } + col, opts := columnName(fv, st.Tag(i)) + if col == "-" { + continue + } + if prev, dup := seen[col]; dup { + dups = append(dups, fmt.Sprintf("%s and %s both bind to column %q", prev, prefix+fv.Name(), col)) + continue + } + seen[col] = prefix + fv.Name() + fields = append(fields, structField{v: fv, name: prefix + fv.Name(), col: col, opts: opts}) + } + } + walk(st, "") + return fields, dups +} + +// embeddedStruct returns the struct an embedded field flattens into, or nil when it is a +// leaf: not a struct, time.Time, a Scanner, or tagged with a column name. +func embeddedStruct(fv *types.Var, tag string) *types.Struct { + if _, ok := lookupTag(tag, "col"); ok { + return nil + } + if _, ok := lookupTag(tag, "db"); ok { + return nil + } + t := fv.Type() + if p, ok := t.(*types.Pointer); ok { + t = p.Elem() + } + if n, ok := t.(*types.Named); ok && n.Obj().Pkg() != nil { + switch p := n.Obj().Pkg().Path(); { + case p == "time", p == "net/netip", strings.HasSuffix(p, "jackc/pgx/v5/pgtype"): + return nil // scalars pgx decodes itself, as the runtime's scalarStruct + } + } + st, ok := t.Underlying().(*types.Struct) + if !ok { + return nil + } + if types.NewMethodSet(types.NewPointer(t)).Lookup(nil, "Scan") != nil { + return nil + } + return st +} + +// columnName derives the result column a struct field binds to: `col:"name"` / `db:"name"` +// tag first, else snake_case of the field name. +func columnName(f *types.Var, tag string) (name string, opts []string) { + for _, key := range []string{"col", "db"} { + if v, ok := lookupTag(tag, key); ok { + parts := strings.Split(v, ",") + if parts[0] == "" { + parts[0] = snake(f.Name()) + } + return parts[0], parts[1:] + } + } + return snake(f.Name()), nil +} + +func lookupTag(tag, key string) (string, bool) { + // minimal reflect.StructTag.Lookup + for tag != "" { + i := 0 + for i < len(tag) && tag[i] == ' ' { + i++ + } + tag = tag[i:] + if tag == "" { + break + } + i = 0 + for i < len(tag) && tag[i] > ' ' && tag[i] != ':' && tag[i] != '"' { + i++ + } + if i == 0 || i+1 >= len(tag) || tag[i] != ':' || tag[i+1] != '"' { + break + } + name := tag[:i] + tag = tag[i+1:] + i = 1 + for i < len(tag) && tag[i] != '"' { + if tag[i] == '\\' { + i++ + } + i++ + } + if i >= len(tag) { + break + } + val := tag[1:i] + tag = tag[i+1:] + if name == key { + return val, true + } + } + return "", false +} + +func snake(s string) string { + var b strings.Builder + rs := []rune(s) + for i, r := range rs { + if unicode.IsUpper(r) { + if i > 0 && (unicode.IsLower(rs[i-1]) || (i+1 < len(rs) && unicode.IsLower(rs[i+1]) && unicode.IsUpper(rs[i-1]))) { + b.WriteByte('_') + } + b.WriteRune(unicode.ToLower(r)) + } else { + b.WriteRune(r) + } + } + return b.String() +} diff --git a/cmd/sqlshape/internal/vet/guarded_pointer_test.go b/cmd/sqlshape/internal/vet/guarded_pointer_test.go new file mode 100644 index 0000000..229f5b0 --- /dev/null +++ b/cmd/sqlshape/internal/vet/guarded_pointer_test.go @@ -0,0 +1,23 @@ +package vet + +import ( + "path/filepath" + "testing" + + "golang.org/x/tools/go/analysis/analysistest" +) + +// TestGuardedPointer covers m12e-guarded-pointer: a pointer parameter read inside the +// then branch of a plain {{if .X}} / {{with .X}} on itself (or on a path it is nested +// under) is proven non-nil for that expansion, so no NOT NULL violation is reported for +// it there; outside that branch (unconditional, or in the else branch), the ordinary +// nullable-pointer violation still fires. It uses its own schema +// (testdata/guarded_pointer_schema.sql) so it does not depend on the shared schema.sql +// other lanes may also be adjusting. +func TestGuardedPointer(t *testing.T) { + td := analysistest.TestData() + if err := Analyzer.Flags.Set("schema", filepath.Join(td, "guarded_pointer_schema.sql")); err != nil { + t.Fatal(err) + } + analysistest.Run(t, td, Analyzer, "guarded_pointer") +} diff --git a/internal/vet/hazards.go b/cmd/sqlshape/internal/vet/hazards.go similarity index 69% rename from internal/vet/hazards.go rename to cmd/sqlshape/internal/vet/hazards.go index 60562e9..6aea33c 100644 --- a/internal/vet/hazards.go +++ b/cmd/sqlshape/internal/vet/hazards.go @@ -6,9 +6,7 @@ import ( "strconv" "strings" - "github.com/kr9ly/sqlshape/internal/expand" - "github.com/kr9ly/sqlshape/internal/pgparse" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/expand" ) // Template hazards: places where a template action does not do what it looks like. @@ -138,47 +136,3 @@ var dollarTag = regexp.MustCompile(`^\$[A-Za-z_][A-Za-z_0-9]*\$|^\$\$`) func isIdentByte(c byte) bool { return c == '_' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' } - -// checkBareOrderBy reports a parameter standing alone as an ORDER BY / GROUP BY / window -// PARTITION BY item: PostgreSQL sorts/groups by the constant value it received, never -// looking at what column name (if any) the value names, so branching on the value's -// content is the only way to select a column dynamically. This walks the parsed AST of -// the expansion's rendered SQL (the same parse analyze.Analyze itself would produce) so a -// bare parameter is caught in any position of the list, decorated with ASC/DESC/NULLS -// FIRST/LAST or not, inside a window definition's PARTITION BY or ORDER BY too — not only -// when it is the leading item of a plain ORDER BY / GROUP BY. -func checkBareOrderBy(v pgparse.Version, e *expand.Expansion, lit literal, report func(token.Pos, string, ...any), where string) { - tree, err := v.Parse(e.SQL) - if err != nil { - return // analyze.Analyze reports the parse failure itself - } - byN := make(map[int32]*expand.Param, len(e.Params)) - for i := range e.Params { - byN[int32(e.Params[i].N)] = &e.Params[i] - } - flag := func(kw string, target *pgparse.Node) { - ref := target.GetParamRef() - if ref == nil { - return - } - p, ok := byN[ref.Number] - if !ok { - return - } - report(lit.pos(e.TemplatePos(int(ref.Location))), "%s BY {{%s}} sorts by a constant, not by the column the value names: branch on it instead ({{if eq %s \"total\"}} total {{else}} id {{end}})%s", kw, p.Path, p.Path, where) - } - schema.WalkNodes(tree, func(n *pgparse.Node) { - switch v := n.Node.(type) { - case *pgparse.Node_SortBy: - flag("ORDER", v.SortBy.Node) - case *pgparse.Node_SelectStmt: - for _, g := range v.SelectStmt.GroupClause { - flag("GROUP", g) - } - case *pgparse.Node_WindowDef: - for _, p := range v.WindowDef.PartitionClause { - flag("PARTITION", p) - } - } - }) -} diff --git a/internal/vet/loadschema_test.go b/cmd/sqlshape/internal/vet/loadschema_test.go similarity index 100% rename from internal/vet/loadschema_test.go rename to cmd/sqlshape/internal/vet/loadschema_test.go diff --git a/internal/vet/nested.go b/cmd/sqlshape/internal/vet/nested.go similarity index 59% rename from internal/vet/nested.go rename to cmd/sqlshape/internal/vet/nested.go index 59ccfc4..06e4a3b 100644 --- a/internal/vet/nested.go +++ b/cmd/sqlshape/internal/vet/nested.go @@ -5,9 +5,7 @@ import ( "go/types" "strconv" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/catalog" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/dialect" ) // Nested rows. A record / composite column (or an array of them) is received by a Go @@ -19,8 +17,9 @@ import ( // checkNested matches the fields of a record column against the Go struct receiving it // (param = false), or of a composite parameter against the struct passed for it (param = // true: the runtime encodes the struct's fields positionally as pgtype.CompositeFields). -func (c *checker) checkNested(col analyze.Column, gt types.Type, at token.Pos, what string, report func(token.Pos, string, ...any), where string, param bool) { - if len(col.Fields) == 0 { +func (c *checker) checkNested(col dialect.Column, gt types.Type, at token.Pos, what string, report func(token.Pos, string, ...any), where string, param bool) { + rowFields, named := nestedFields(col.Type) + if len(rowFields) == 0 { return } inner, _ := unwrapNullable(gt) @@ -44,8 +43,6 @@ func (c *checker) checkNested(col analyze.Column, gt types.Type, at token.Pos, w if _, declared := c.declaredOf(inner); declared || implementsScanner(inner) { return // the type decodes the value itself; its fields are not columns } - et := c.s.Types.ByOID(elemOID(c, col)) - named := et != nil && et.Kind == 'c' flat, dups := structFields(st) for _, d := range dups { report(at, "%s: %s: fields %s%s", what, inner, d, where) @@ -62,11 +59,11 @@ func (c *checker) checkNested(col analyze.Column, gt types.Type, at token.Pos, w } } } - if len(fields) != len(col.Fields) { - report(at, "%s: %s has %d fields but the row type has %d (%s)%s", what, inner, len(fields), len(col.Fields), rowShape(c, col), where) + if len(fields) != len(rowFields) { + report(at, "%s: %s has %d fields but the row type has %d (%s)%s", what, inner, len(fields), len(rowFields), rowShape(rowFields), where) return } - for i, f := range col.Fields { + for i, f := range rowFields { fv := fields[i] sub := what + "." + goNames[i] if named && names[i] != f.Name { @@ -74,62 +71,37 @@ func (c *checker) checkNested(col analyze.Column, gt types.Type, at token.Pos, w continue } c.meet(fv.Type(), f.Type, f.Source, at, sub) - fit := c.matchDir(f.Type, fv.Type(), param) if notnull[i] { f.Nullable = false + f.Type.ElemNotNull = true // the elements too, see matchColumns } + fit := c.fitPG(f.Type, fv.Type(), param) c.reportFit(report, at, sub, f, fv.Type(), fit, where) c.checkNested(f, fv.Type(), at, sub, report, where, param) } } -// paramColumn describes a composite parameter (or an array of composites) like a result -// column, so checkNested can match the struct passed for it: the fields are the declared -// columns of the relation whose row type it is, recursively. Nil for other types. -func (c *checker) paramColumn(pg schema.TypeRef) *analyze.Column { - oid := c.s.Types.BaseOf(pg).OID - if t := c.s.Types.ByOID(oid); t != nil && t.IsArray() { - oid = t.Elem +// nestedFields are the columns of a row-typed value (a composite, a record, or an array +// of either; a domain over one), and whether the row type is named (a composite: the +// field names must agree, not only the positions). +func nestedFields(t dialect.Type) ([]dialect.Column, bool) { + for t.Kind == dialect.Domain && t.Base != nil { + t = *t.Base } - rel := c.relByRowType(oid) - if rel == nil { - return nil + if t.Kind == dialect.Array && t.Elem != nil { + t = *t.Elem } - col := &analyze.Column{Type: pg} - for _, rc := range rel.Columns { - f := analyze.Column{Name: rc.Name, Type: rc.Type} - if sub := c.paramColumn(rc.Type); sub != nil { - f.Fields = sub.Fields - } - col.Fields = append(col.Fields, f) - } - return col -} - -func (c *checker) relByRowType(oid catalog.OID) *schema.Relation { - for _, r := range c.s.Relations { - if r.RowType == oid { - return r - } - } - return nil -} - -func elemOID(c *checker, col analyze.Column) catalog.OID { - oid := col.Type.OID - if t := c.s.Types.ByOID(oid); t != nil && t.IsArray() { - oid = t.Elem - } - return oid + return t.Fields, t.Kind == dialect.Composite } -func rowShape(c *checker, col analyze.Column) string { +// rowShape spells a row type's columns for a diagnostic. +func rowShape(fields []dialect.Column) string { s := "" - for i, f := range col.Fields { + for i, f := range fields { if i > 0 { s += ", " } - s += f.Name + " " + c.s.Types.Format(f.Type) + s += f.Name + " " + f.Type.Name } return strconv.Quote(s) } diff --git a/internal/vet/rawsql.go b/cmd/sqlshape/internal/vet/rawsql.go similarity index 97% rename from internal/vet/rawsql.go rename to cmd/sqlshape/internal/vet/rawsql.go index 6ada1fa..e1a23ff 100644 --- a/internal/vet/rawsql.go +++ b/cmd/sqlshape/internal/vet/rawsql.go @@ -59,7 +59,7 @@ func checkRawSQL(pass *analysis.Pass, calls []*ast.CallExpr) { } if mode == "forbid" { if !allowed { - pass.Reportf(call.Pos(), "sqlshape: %s.%s executes SQL outside sqlshape; with -raw-sql=forbid every statement goes through sqlshape.Query / One / Copy (or list the package in -raw-sql-allow)", fn.Pkg().Name(), rc.method) + pass.Reportf(call.Pos(), "sqlshape: %s.%s executes SQL outside sqlshape; with -raw-sql=forbid every statement goes through sqlshape.Query / One / postgres.Copy (or list the package in -raw-sql-allow)", fn.Pkg().Name(), rc.method) } continue } diff --git a/internal/vet/testdata/adv_context_schema.sql b/cmd/sqlshape/internal/vet/testdata/adv_context_schema.sql similarity index 100% rename from internal/vet/testdata/adv_context_schema.sql rename to cmd/sqlshape/internal/vet/testdata/adv_context_schema.sql diff --git a/internal/vet/testdata/adv_obligation_schema.sql b/cmd/sqlshape/internal/vet/testdata/adv_obligation_schema.sql similarity index 100% rename from internal/vet/testdata/adv_obligation_schema.sql rename to cmd/sqlshape/internal/vet/testdata/adv_obligation_schema.sql diff --git a/internal/vet/testdata/adv_param_schema.sql b/cmd/sqlshape/internal/vet/testdata/adv_param_schema.sql similarity index 100% rename from internal/vet/testdata/adv_param_schema.sql rename to cmd/sqlshape/internal/vet/testdata/adv_param_schema.sql diff --git a/cmd/sqlshape/internal/vet/testdata/adv_template3_schema.sql b/cmd/sqlshape/internal/vet/testdata/adv_template3_schema.sql new file mode 100644 index 0000000..8684d93 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/adv_template3_schema.sql @@ -0,0 +1,5 @@ +-- sqlshape: postgres 17 +CREATE TABLE widgets ( + id bigint PRIMARY KEY, + name text NOT NULL +); diff --git a/cmd/sqlshape/internal/vet/testdata/adv_typefit_schema.sql b/cmd/sqlshape/internal/vet/testdata/adv_typefit_schema.sql new file mode 100644 index 0000000..9e64e68 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/adv_typefit_schema.sql @@ -0,0 +1,14 @@ +-- sqlshape: postgres 17 +-- Adversarial-testing lane "typefit". Own schema so it does not depend on schema.sql +-- other lanes may also be touching. +CREATE TYPE item AS ( + sku text, + qty integer +); + +CREATE TABLE t ( + id bigint PRIMARY KEY, + tags integer[], + items item[], + span interval +); diff --git a/internal/vet/testdata/ctx_schema.sql b/cmd/sqlshape/internal/vet/testdata/ctx_schema.sql similarity index 100% rename from internal/vet/testdata/ctx_schema.sql rename to cmd/sqlshape/internal/vet/testdata/ctx_schema.sql diff --git a/cmd/sqlshape/internal/vet/testdata/dialect_schema.sql b/cmd/sqlshape/internal/vet/testdata/dialect_schema.sql new file mode 100644 index 0000000..4427605 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/dialect_schema.sql @@ -0,0 +1,3 @@ +-- sqlshape: testdb 1 +-- a stub dialect (dialect_test.go): the loader ignores the DDL and answers from a fixed table +CREATE TABLE things (id, name, price, note); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_colbind_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_colbind_schema.sql new file mode 100644 index 0000000..e051eb2 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_colbind_schema.sql @@ -0,0 +1,14 @@ +-- sqlshape: postgres 17 +-- The "Result columns bind to fields by name" group in docs/checks.md (and its ja +-- translation): a minimal customers/orders pair, just enough for the join the example +-- runs. +CREATE TABLE customers ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL, + email text +); + +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_id bigint NOT NULL REFERENCES customers(id) +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_copy_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_copy_schema.sql new file mode 100644 index 0000000..8a0cc93 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_copy_schema.sql @@ -0,0 +1,12 @@ +-- sqlshape: postgres 17 +-- "Bulk loading with COPY (PostgreSQL)": order_items exactly as the example implies -- +-- line_no is NOT NULL with no default, so a Copy that omits it is rejected. +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY +); + +CREATE TABLE order_items ( + order_id bigint NOT NULL REFERENCES orders(id), + line_no smallint NOT NULL, + sku varchar(32) NOT NULL +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_errname_mysql_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_errname_mysql_schema.sql new file mode 100644 index 0000000..efd5902 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_errname_mysql_schema.sql @@ -0,0 +1,21 @@ +-- sqlshape: mysql 8.4 +-- "Name the errors a trigger raises" (MySQL half): fk_orders_customer is named +-- explicitly, matching the doc's expect line. +CREATE TABLE customers ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY +); + +CREATE TABLE orders ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + customer_id BIGINT UNSIGNED NOT NULL, + total DECIMAL(12,2) NOT NULL, + CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers(id) +); + +-- sqlshape: error 30001 = OrderTooLarge +CREATE TRIGGER order_size BEFORE INSERT ON orders FOR EACH ROW +BEGIN + IF NEW.total > 1000000 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'order too large', MYSQL_ERRNO = 30001; + END IF; +END; diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_errname_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_errname_schema.sql new file mode 100644 index 0000000..ada8817 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_errname_schema.sql @@ -0,0 +1,21 @@ +-- sqlshape: postgres 17 +-- "Name the errors a trigger raises" (PostgreSQL half): customers/orders with the +-- doc's own trigger, so its default constraint name (orders_customer_id_fkey) and +-- trigger/function names (order_size / check_order_size) match the prose exactly. +CREATE TABLE customers ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY +); + +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_id bigint NOT NULL REFERENCES customers(id), + total numeric(12,2) NOT NULL +); + +-- sqlshape: error P0401 = OrderTooLarge +CREATE FUNCTION check_order_size() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.total > 1000000 THEN RAISE EXCEPTION 'order too large' USING ERRCODE = 'P0401'; END IF; + RETURN NEW; +END $$; +CREATE TRIGGER order_size BEFORE INSERT OR UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION check_order_size(); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_null_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_null_schema.sql new file mode 100644 index 0000000..02c41ff --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_null_schema.sql @@ -0,0 +1,6 @@ +-- sqlshape: postgres 17 +-- "A column that may be NULL needs a field that can hold NULL". +CREATE TABLE users ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + deleted_at timestamptz +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_oneproof_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_oneproof_schema.sql new file mode 100644 index 0000000..f702d6d --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_oneproof_schema.sql @@ -0,0 +1,7 @@ +-- sqlshape: postgres 17 +-- "Fix a unique key by equality" (the `One` proof). +CREATE TABLE users ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email text UNIQUE NOT NULL, + name text +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p1_compositeparam_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p1_compositeparam_schema.sql new file mode 100644 index 0000000..03dbfef --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p1_compositeparam_schema.sql @@ -0,0 +1,7 @@ +-- sqlshape: postgres 17 +-- "Composite parameters are structs". +CREATE TYPE order_item AS (sku text, qty integer); + +-- sqlshape: not null +CREATE FUNCTION place_order(customer_id bigint, items order_item[]) RETURNS bigint +LANGUAGE sql AS $$ SELECT 1::bigint $$; diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p1_nested_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p1_nested_schema.sql new file mode 100644 index 0000000..8774b60 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p1_nested_schema.sql @@ -0,0 +1,11 @@ +-- sqlshape: postgres 17 +-- "Nested rows are received by structs". +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY +); +CREATE TABLE order_items ( + order_id bigint NOT NULL REFERENCES orders(id), + sku text NOT NULL, + qty integer NOT NULL +); +CREATE TYPE order_item AS (sku text, qty integer); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p1_paramdefault_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p1_paramdefault_schema.sql new file mode 100644 index 0000000..82f4ed9 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p1_paramdefault_schema.sql @@ -0,0 +1,8 @@ +-- sqlshape: postgres 17 +-- "Do not always send a value into a column with a DEFAULT" (-strict). +CREATE TYPE order_status AS ENUM ('pending', 'paid', 'shipped', 'cancelled'); +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_id bigint NOT NULL, + status order_status NOT NULL DEFAULT 'pending' +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p1_paramnull_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p1_paramnull_schema.sql new file mode 100644 index 0000000..5adf240 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p1_paramnull_schema.sql @@ -0,0 +1,10 @@ +-- sqlshape: postgres 17 +-- "A parameter that may be NULL is a pointer" (-strict): the status column is indexed so +-- the example's one diagnostic is the non-pointer-enum note this section is about, not +-- also an unrelated "no index" advisory. +CREATE TYPE order_status AS ENUM ('pending', 'paid', 'shipped', 'cancelled'); +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + status order_status NOT NULL DEFAULT 'pending' +); +CREATE INDEX orders_status_idx ON orders (status); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p1_paramtype_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p1_paramtype_schema.sql new file mode 100644 index 0000000..c38d5c2 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p1_paramtype_schema.sql @@ -0,0 +1,6 @@ +-- sqlshape: postgres 17 +-- "A parameter's type follows where it is used". +CREATE TABLE users ( + id uuid PRIMARY KEY, + email text NOT NULL +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p1_rangepath_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p1_rangepath_schema.sql new file mode 100644 index 0000000..e07a8f8 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p1_rangepath_schema.sql @@ -0,0 +1,7 @@ +-- sqlshape: postgres 17 +-- "Nested paths and `range`". +CREATE TABLE products ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text, + sku varchar(32) NOT NULL +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p1_unusedfield_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p1_unusedfield_schema.sql new file mode 100644 index 0000000..9c0b7d4 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p1_unusedfield_schema.sql @@ -0,0 +1,9 @@ +-- sqlshape: postgres 17 +-- "Do not leave unused fields in `P` (-strict)": a plain, unkeyed orders.id column (no +-- PRIMARY KEY), so -strict's "carries key ... as a plain int64" advisory (a different +-- rule) does not also fire for this example's own R{ID int64} / P.ID int64; indexed, so +-- -strict's "no index on orders leads with any of (id)" advisory does not fire either. +CREATE TABLE orders ( + id bigint NOT NULL +); +CREATE INDEX orders_id_idx ON orders (id); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p2_branch_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p2_branch_schema.sql new file mode 100644 index 0000000..302dfab --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p2_branch_schema.sql @@ -0,0 +1,9 @@ +-- sqlshape: postgres 17 +-- "Every branch must be provable": same users shape as "Fix a unique key by equality" +-- (docsex_oneproof_schema.sql) -- duplicated here rather than shared, per this +-- milestone's file-ownership rule. +CREATE TABLE users ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email text UNIQUE NOT NULL, + name text +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p2_call_mysql_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p2_call_mysql_schema.sql new file mode 100644 index 0000000..c2c05be --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p2_call_mysql_schema.sql @@ -0,0 +1,26 @@ +-- sqlshape: mysql 8.4 +-- "A statement that calls a function declares the function's failure modes too" +-- (MySQL half): the doc's own place_order() function fence, verbatim, plus a minimal +-- orders table it writes to. customer_id/total are left nullable and orders carries no +-- foreign key here (neither is shown in the doc's fence, and the point of the example is +-- the SIGNAL alone, not a second, unrelated violation this harness would have to invent a +-- schema reason for). Like PostgreSQL's schema.sql / docsex_p2_call_pg_schema.sql, the +-- function is annotated "-- sqlshape: not null" (mysql.md documents the same directive +-- for a MySQL CREATE FUNCTION), so the test package receives place_order's result as +-- plain int64 rather than *int64. +CREATE TABLE orders ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + customer_id BIGINT UNSIGNED, + total DECIMAL(12,2) +); + +-- sqlshape: error 30001 = OrderTooLarge +-- sqlshape: not null +CREATE FUNCTION place_order(cust_id BIGINT UNSIGNED, amount DECIMAL(10,2)) RETURNS BIGINT +BEGIN + IF amount > 1000000 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'order too large', MYSQL_ERRNO = 30001; + END IF; + INSERT INTO orders (customer_id, total) VALUES (cust_id, amount); + RETURN LAST_INSERT_ID(); +END; diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p2_call_pg_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p2_call_pg_schema.sql new file mode 100644 index 0000000..da3331f --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p2_call_pg_schema.sql @@ -0,0 +1,19 @@ +-- sqlshape: postgres 17 +-- "A statement that calls a function declares the function's failure modes too" +-- (PostgreSQL half): a place_order() that inserts into orders, whose customer_id foreign +-- key the caller can violate through the function. Annotated "not null" (like +-- order_count in schema.sql) so the RETURNING id value does not add an unrelated NULL +-- advisory of its own. +CREATE TABLE customers ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY +); + +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_id bigint NOT NULL REFERENCES customers(id), + note text +); + +-- sqlshape: not null +CREATE FUNCTION place_order(p_customer_id bigint, p_note text) RETURNS bigint +LANGUAGE sql AS $$ INSERT INTO orders (customer_id, note) VALUES (p_customer_id, p_note) RETURNING id $$; diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p2_domain_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p2_domain_schema.sql new file mode 100644 index 0000000..59fa253 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p2_domain_schema.sql @@ -0,0 +1,8 @@ +-- sqlshape: postgres 17 +-- "Do not mix domains of different units (PostgreSQL)": the doc's own schema fence, +-- verbatim, plus an index on price so the -strict param case does not pick up an +-- unrelated "no index ... scans the whole table" advisory alongside the domain one. +CREATE DOMAIN yen AS bigint; +CREATE DOMAIN gram AS integer; +CREATE TABLE products (id bigint PRIMARY KEY, price yen NOT NULL, weight gram NOT NULL); +CREATE INDEX products_price_idx ON products (price); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p2_enum_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p2_enum_schema.sql new file mode 100644 index 0000000..a08d76a --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p2_enum_schema.sql @@ -0,0 +1,11 @@ +-- sqlshape: postgres 17 +-- "Enum and lookup values agree with the named type's constants": order_statuses/orders +-- exactly as the doc's own schema fence shows. +CREATE TABLE order_statuses (code text PRIMARY KEY, label text NOT NULL); +INSERT INTO order_statuses VALUES ('pending', 'Pending'), ('paid', 'Paid'), ('shipped', 'Shipped'); + +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + status text NOT NULL REFERENCES order_statuses(code), + total numeric(12,2) NOT NULL +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p2_event_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p2_event_schema.sql new file mode 100644 index 0000000..eb7aa60 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p2_event_schema.sql @@ -0,0 +1,8 @@ +-- sqlshape: postgres 17 +-- "Do not receive with a type that loses information (-strict)": an events table with a +-- plain timestamp and a date column, neither nullable (so the only advisories are the +-- ones the doc's Event struct itself calls out). +CREATE TABLE events ( + at timestamp without time zone NOT NULL, + day date NOT NULL +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p2_meaning_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p2_meaning_schema.sql new file mode 100644 index 0000000..34684f1 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p2_meaning_schema.sql @@ -0,0 +1,15 @@ +-- sqlshape: postgres 17 +-- "Meaning comes from use, not from a registry": a users/orders pair, just enough for +-- UserID to bind to users.id on its first meeting and then meet orders.id on its second. +-- orders.total is bigint (not numeric) so the second statement's R (int64) does not pick +-- up an unrelated "drops the fraction" advisory alongside the key-mismatch diagnostic +-- the example is about. +CREATE TABLE users ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email text NOT NULL +); + +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + total bigint NOT NULL +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p2_overdeclare_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p2_overdeclare_schema.sql new file mode 100644 index 0000000..4b2f906 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p2_overdeclare_schema.sql @@ -0,0 +1,8 @@ +-- sqlshape: postgres 17 +-- "Do not declare a violation that cannot happen": an orders table whose total has a +-- named CHECK the doc's UPDATE (which never touches total) cannot violate. +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + note text, + total numeric(12,2) NOT NULL CONSTRAINT orders_total_check CHECK (total >= 0) +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p2_tableid_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p2_tableid_schema.sql new file mode 100644 index 0000000..703e4f6 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p2_tableid_schema.sql @@ -0,0 +1,12 @@ +-- sqlshape: postgres 17 +-- "Do not pass another table's ID": users/orders, just enough for UserID to bind to +-- users.id and for the example's Params.ID to meet orders.id instead. +CREATE TABLE users ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email text NOT NULL +); + +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + total numeric(12,2) NOT NULL +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p2_writefail_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p2_writefail_schema.sql new file mode 100644 index 0000000..e7bd6e8 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p2_writefail_schema.sql @@ -0,0 +1,8 @@ +-- sqlshape: postgres 17 +-- "Declare the constraints a write can violate": a customers table with the unique email +-- the doc's INSERT can violate. +CREATE TABLE customers ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email text UNIQUE NOT NULL, + name text NOT NULL +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p3_aggregate_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p3_aggregate_schema.sql new file mode 100644 index 0000000..db26457 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p3_aggregate_schema.sql @@ -0,0 +1,20 @@ +-- sqlshape: postgres 17 +-- "An aggregate is reached through its root, one per statement (`aggregate`)": orders is +-- the aggregate root of order_items; invoices is a root of its own (no children), so a +-- statement touching both belongs to two aggregates, as the Rejected example says. +-- sqlshape: aggregate orders (order_items) +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + status text NOT NULL +); + +CREATE TABLE order_items ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + order_id bigint NOT NULL REFERENCES orders(id) +); + +-- sqlshape: aggregate invoices () +CREATE TABLE invoices ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + order_id bigint NOT NULL REFERENCES orders(id) +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p3_context_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p3_context_schema.sql new file mode 100644 index 0000000..3e1811b --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p3_context_schema.sql @@ -0,0 +1,10 @@ +-- sqlshape: postgres 17 +-- "Different callers, different rules (`context`)": orders pinned by tenant_id in the +-- base context; the ops context waives the pin and requires id = $1 on delete instead. +-- sqlshape: require pinned(tenant_id) +-- sqlshape: context ops: waive pinned(tenant_id); require id = $1 on delete +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + tenant_id bigint NOT NULL, + status text NOT NULL +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p3_exists_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p3_exists_schema.sql new file mode 100644 index 0000000..333172f --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p3_exists_schema.sql @@ -0,0 +1,14 @@ +-- sqlshape: postgres 17 +-- "A predicate across tables has a witness (`EXISTS`)": orders/shipments exactly as the +-- example's three Passes and two Rejected statements reference. +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + tenant_id bigint NOT NULL +); + +-- sqlshape: require EXISTS (SELECT 1 FROM orders o WHERE o.id = order_id AND o.tenant_id = $1) +CREATE TABLE shipments ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + order_id bigint NOT NULL, + carrier text NOT NULL +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p3_mysqlgroupby_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p3_mysqlgroupby_schema.sql new file mode 100644 index 0000000..046daaa --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p3_mysqlgroupby_schema.sql @@ -0,0 +1,7 @@ +-- sqlshape: mysql 8.4 +-- mysql.md "Grouping": users, exactly as the example's two statements need. +CREATE TABLE users ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p3_neverpairedsingle_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p3_neverpairedsingle_schema.sql new file mode 100644 index 0000000..0898e45 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p3_neverpairedsingle_schema.sql @@ -0,0 +1,20 @@ +-- sqlshape: postgres 17 +-- "Append-only tables, paired writes, single-row deletes (`never`, `paired`, `single`)": +-- ledger (never), orders (paired(outbox), single), outbox as the pairing target. +-- sqlshape: require never on update, delete +CREATE TABLE ledger ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + amount numeric(12,2) NOT NULL +); + +-- sqlshape: require paired(outbox) on insert +-- sqlshape: require single on delete +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + status text NOT NULL +); + +CREATE TABLE outbox ( + id bigint PRIMARY KEY, + payload text NOT NULL +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p3_pgcopy_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p3_pgcopy_schema.sql new file mode 100644 index 0000000..06086f0 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p3_pgcopy_schema.sql @@ -0,0 +1,14 @@ +-- sqlshape: postgres 17 +-- postgres.md "The runtime: pgx", the Copy example: order_items with every column the +-- fence's postgres.Copy call lists. +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY +); + +CREATE TABLE order_items ( + order_id bigint NOT NULL REFERENCES orders(id), + line_no smallint NOT NULL, + sku varchar(32) NOT NULL, + qty integer NOT NULL, + discount numeric(5,2) NOT NULL DEFAULT 0 +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p3_pgmatview_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p3_pgmatview_schema.sql new file mode 100644 index 0000000..2c45212 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p3_pgmatview_schema.sql @@ -0,0 +1,9 @@ +-- sqlshape: postgres 17 +-- postgres.md "The runtime: pgx", the MatView example. +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + total numeric(12,2) NOT NULL +); + +CREATE MATERIALIZED VIEW order_stats AS +SELECT count(*) AS n, sum(total) AS total FROM orders; diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p3_pgtype_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p3_pgtype_schema.sql new file mode 100644 index 0000000..7cbea10 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p3_pgtype_schema.sql @@ -0,0 +1,9 @@ +-- sqlshape: postgres 17 +-- postgres.md "The Go type table": a domain over numeric, bound to a Go type via +-- `// sqlshape: type money_amount` on the Go type (below, in the test package). +CREATE DOMAIN money_amount AS numeric(12,2); + +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + amount money_amount NOT NULL +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p3_pinned_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p3_pinned_schema.sql new file mode 100644 index 0000000..010f435 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p3_pinned_schema.sql @@ -0,0 +1,9 @@ +-- sqlshape: postgres 17 +-- "A column is pinned on every statement (`pinned`, `-require-columns`)": orders, +-- completed with the columns the example's SELECT needs. +-- sqlshape: require pinned(tenant_id) +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + tenant_id bigint NOT NULL, + total numeric(12,2) NOT NULL +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p3_schemaproblems_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p3_schemaproblems_schema.sql new file mode 100644 index 0000000..ac979ea --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p3_schemaproblems_schema.sql @@ -0,0 +1,21 @@ +-- sqlshape: postgres 17 +-- "Problems in the schema itself": a dynamic EXECUTE inside a PL/pgSQL function (a +-- -strict advisory) and a view referencing a misspelled column (a hard schema error), +-- exactly as the heading's two fences show. +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_id bigint NOT NULL +); + +CREATE TABLE customers ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL +); + +CREATE FUNCTION purge(tbl text) RETURNS void LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE 'DELETE FROM ' || quote_ident(tbl); +END $$; + +CREATE VIEW order_summary AS +SELECT o.id, c.nmae AS customer_name FROM orders o JOIN customers c ON c.id = o.customer_id; diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p3_schemas_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p3_schemas_schema.sql new file mode 100644 index 0000000..b2f6021 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p3_schemas_schema.sql @@ -0,0 +1,5 @@ +-- sqlshape: postgres 17 +-- "A package references only its schemas (`-schemas`, PostgreSQL)": c_private, outside +-- the -schemas=a_api,b_private set the test applies. +CREATE SCHEMA c_private; +CREATE TABLE c_private.orders (id bigint PRIMARY KEY); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p3_sensitive_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p3_sensitive_schema.sql new file mode 100644 index 0000000..a5962ca --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p3_sensitive_schema.sql @@ -0,0 +1,13 @@ +-- sqlshape: postgres 17 +-- "Labelled columns are read only where allowed (`sensitive`, `may read`)": orders with +-- pii columns, order_contacts a view that masks phone and passes email through. +-- sqlshape: sensitive pii: email, phone +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email text NOT NULL, + phone text NOT NULL +); + +-- sqlshape: context billing: may read pii +CREATE VIEW order_contacts AS +SELECT id, email, left(phone, 3) || '***' AS phone_masked FROM orders; diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p3_transitions_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p3_transitions_schema.sql new file mode 100644 index 0000000..a6bef25 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p3_transitions_schema.sql @@ -0,0 +1,7 @@ +-- sqlshape: postgres 17 +-- "A status column moves along its declared transitions (`transitions`)". +-- sqlshape: transitions status: draft -> submitted, submitted -> paid | cancelled, paid -> refunded +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + status text NOT NULL +); diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p3_viaview_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p3_viaview_schema.sql new file mode 100644 index 0000000..b38a259 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p3_viaview_schema.sql @@ -0,0 +1,18 @@ +-- sqlshape: postgres 17 +-- "Tables are read through views (`via view`, `-no-table-reads` / `-no-tables`)": +-- orders/customers as the direct-read Rejected example needs, and order_summary as the +-- Passes example's view. The concrete example demonstrates the -no-table-reads flag on +-- its own (no persistent `require via view` declared here -- that directive's syntax is +-- shown separately, just above this heading's example, as the general form). +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_id bigint NOT NULL +); + +CREATE TABLE customers ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL +); + +CREATE VIEW order_summary AS +SELECT o.id, c.name AS customer_name FROM orders o JOIN customers c ON c.id = o.customer_id; diff --git a/cmd/sqlshape/internal/vet/testdata/docsex_p3_visiblewhere_schema.sql b/cmd/sqlshape/internal/vet/testdata/docsex_p3_visiblewhere_schema.sql new file mode 100644 index 0000000..69bc2c4 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/docsex_p3_visiblewhere_schema.sql @@ -0,0 +1,11 @@ +-- sqlshape: postgres 17 +-- "Every read carries the visibility predicate (`visible where`)": memos, completed with +-- the columns the example's SELECT and its predicate need (the doc's fence has only +-- `CREATE TABLE memos (...)`). +-- sqlshape: visible where deleted_at IS NULL +CREATE TABLE memos ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL, + body text NOT NULL, + deleted_at timestamptz +); diff --git a/cmd/sqlshape/internal/vet/testdata/errname_schema.sql b/cmd/sqlshape/internal/vet/testdata/errname_schema.sql new file mode 100644 index 0000000..8d3e818 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/errname_schema.sql @@ -0,0 +1,15 @@ +-- sqlshape: postgres 17 +CREATE TABLE widgets ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + qty integer NOT NULL +); + +-- sqlshape: error P0501 = TooManyWidgets +CREATE FUNCTION widgets_check() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.qty > 100 THEN + RAISE EXCEPTION 'too many widgets' USING ERRCODE = 'P0501'; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER widgets_check_trigger BEFORE INSERT ON widgets FOR EACH ROW EXECUTE FUNCTION widgets_check(); diff --git a/cmd/sqlshape/internal/vet/testdata/guarded_pointer_schema.sql b/cmd/sqlshape/internal/vet/testdata/guarded_pointer_schema.sql new file mode 100644 index 0000000..d1c384d --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/guarded_pointer_schema.sql @@ -0,0 +1,9 @@ +-- sqlshape: postgres 17 +-- m12e-guarded-pointer: a plain {{if .X}} / {{with .X}} that takes the then branch +-- proves a pointer field non-nil for the rest of that expansion; the checker should not +-- report a NOT NULL violation for a parameter read that way. +CREATE TABLE t ( + id bigint PRIMARY KEY, + name text NOT NULL DEFAULT 'x', + extra text NOT NULL DEFAULT 'y' +); diff --git a/internal/vet/testdata/loaderrors_schema.sql b/cmd/sqlshape/internal/vet/testdata/loaderrors_schema.sql similarity index 100% rename from internal/vet/testdata/loaderrors_schema.sql rename to cmd/sqlshape/internal/vet/testdata/loaderrors_schema.sql diff --git a/internal/vet/testdata/otherschema_schema.sql b/cmd/sqlshape/internal/vet/testdata/otherschema_schema.sql similarity index 100% rename from internal/vet/testdata/otherschema_schema.sql rename to cmd/sqlshape/internal/vet/testdata/otherschema_schema.sql diff --git a/internal/vet/testdata/plpgsql_schema.sql b/cmd/sqlshape/internal/vet/testdata/plpgsql_schema.sql similarity index 100% rename from internal/vet/testdata/plpgsql_schema.sql rename to cmd/sqlshape/internal/vet/testdata/plpgsql_schema.sql diff --git a/internal/vet/testdata/rls_schema.sql b/cmd/sqlshape/internal/vet/testdata/rls_schema.sql similarity index 100% rename from internal/vet/testdata/rls_schema.sql rename to cmd/sqlshape/internal/vet/testdata/rls_schema.sql diff --git a/cmd/sqlshape/internal/vet/testdata/schema-mysql.sql b/cmd/sqlshape/internal/vet/testdata/schema-mysql.sql new file mode 100644 index 0000000..6a2f8d7 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/schema-mysql.sql @@ -0,0 +1,16 @@ +-- sqlshape: mysql 8.4 + +CREATE TABLE logs ( + id INT NOT NULL PRIMARY KEY, + note VARCHAR(10), + CONSTRAINT c CHECK (id > 0) NOT ENFORCED +) ENGINE=MyISAM; + +CREATE TABLE orders ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + total INT NOT NULL +); + +-- sqlshape: require pinned(tenant_id) +CREATE VIEW v_orders AS SELECT id, tenant_id, total FROM orders; diff --git a/internal/vet/testdata/schema.sql b/cmd/sqlshape/internal/vet/testdata/schema.sql similarity index 100% rename from internal/vet/testdata/schema.sql rename to cmd/sqlshape/internal/vet/testdata/schema.sql diff --git a/internal/vet/testdata/src/a/a.go b/cmd/sqlshape/internal/vet/testdata/src/a/a.go similarity index 88% rename from internal/vet/testdata/src/a/a.go rename to cmd/sqlshape/internal/vet/testdata/src/a/a.go index 183befe..a1f911d 100644 --- a/internal/vet/testdata/src/a/a.go +++ b/cmd/sqlshape/internal/vet/testdata/src/a/a.go @@ -8,14 +8,17 @@ import ( "time" "b" - "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" - "github.com/kr9ly/sqlshape" + "github.com/kr9ly/sqlshape/postgres/v2" + "github.com/kr9ly/sqlshape/v2" ) type OrderStatus string // want OrderStatus:`bound e order_status` +// OrderTooLarge names the trigger's P0401: staleExpect and branchViolation below may raise it. +var OrderTooLarge = sqlshape.Error("P0401") // want OrderTooLarge:`sqlshape.Error\(P0401\)` + type OrderRow struct { ID int64 Status OrderStatus @@ -52,7 +55,7 @@ var badTypes = sqlshape.Query[BadRow, struct{}](`SELECT id, total, note, status type P2 struct{ Email int } -var badParam = sqlshape.Query[int64, P2](`SELECT id FROM users WHERE email = {{.Email}}`) // want `parameter .Email is int but SQL expects text` +var badParam = sqlshape.Query[int64, P2](`SELECT id FROM users WHERE email = {{.Email}}`) // want `parameter .Email is int but SQL expects email` var badColumn = sqlshape.Query[int64, struct{}](`SELECT idd FROM users`) // want `column "idd" does not exist \(SQLSTATE 42703\)` @@ -136,7 +139,7 @@ var insertUser = sqlshape.Query[int64, NewUser]("-- sqlshape: expect users_email var insertUserOK = sqlshape.Query[int64, NewUser]("-- sqlshape: expect users_email_key, email_check, users_pkey\nINSERT INTO users (email, name) VALUES ({{.Email}}, {{.Name}}) RETURNING id") // want `expects users_pkey but no expansion can violate it` -var nullableEmail = sqlshape.Query[int64, struct{ Email *string }]("-- sqlshape: expect users_email_key, email_check\nINSERT INTO users (email) VALUES ({{.Email}}) RETURNING id") // want "may violate users.email \\(NOT NULL on users.email, SQLSTATE 23502\\)" +var nullableEmail = sqlshape.Query[int64, struct{ Email *string }]("-- sqlshape: expect users_email_key, email_check\nINSERT INTO users (email) VALUES ({{.Email}}) RETURNING id") // want "may violate email \\(NOT NULL on users.email, SQLSTATE 23502\\)" var staleExpect = sqlshape.Query[struct{}, struct{ ID int64 }]("-- sqlshape: expect orders_user_note_key, P0401\nUPDATE orders SET status = 'paid' WHERE id = {{.ID}}") // want "expects orders_user_note_key but no expansion can violate it" @@ -148,9 +151,13 @@ var escapedTemplate = sqlshape.Query[int64, struct{}]("SELECT id FROM users -- \ var branchViolation = sqlshape.Query[struct{}, struct { ID int64 Note *string -}](`UPDATE orders SET status = 'paid' {{if .Note}}, note = {{.Note}} {{end}} WHERE id = {{.ID}}`) // want `may violate P0401 \(raised by trigger orders_size on orders as OrderTooLarge, SQLSTATE P0401\)` "may violate orders_user_note_key \\(UNIQUE \\(user_id, note\\) on orders, SQLSTATE 23505\\); add `-- sqlshape: expect orders_user_note_key` to the template or make it impossible \\[if@\\d+:then\\]" +}](`UPDATE orders SET status = 'paid' {{if .Note}}, note = {{.Note}} {{end}} WHERE id = {{.ID}}`) // want "may violate P0401 \\(raised by trigger orders_size on orders as OrderTooLarge, SQLSTATE P0401\\); add `-- sqlshape: expect OrderTooLarge` to the template or make it impossible" "may violate orders_user_note_key \\(UNIQUE \\(user_id, note\\) on orders, SQLSTATE 23505\\); add `-- sqlshape: expect orders_user_note_key` to the template or make it impossible \\[if@\\d+:then\\]" -// nested rows: array_agg(row(...)) is positional, array_agg(t) / composite columns follow the type's column order +// nested rows: array_agg(row(...)) is positional, array_agg(t) / composite columns follow +// the type's column order. array_agg(row(...))'s argument is a row constructor, never +// NULL itself, so these carry no array-null-element note regardless of the row's own +// fields' nullability (dialect.Type.ElemNotNull, see adv_typefit for the case that still +// does: array_agg of a plain scalar column). type OrderBrief struct { ID int64 Total string @@ -211,17 +218,17 @@ var notNullTypo = sqlshape.Query[NotedOrder, struct{}]("-- sqlshape: not null no var countByFunc = sqlshape.Query[int64, struct{ U int64 }](`SELECT order_count({{.U}})`) // materialized view handles are checked against the schema -var orderStats = sqlshape.MatView("order_stats") +var orderStats = postgres.MatView("order_stats") -var noSuchView = sqlshape.MatView("order_statz") // want `materialized view "order_statz" does not exist` +var noSuchView = postgres.MatView("order_statz") // want `materialized view "order_statz" does not exist` -var notAMatView = sqlshape.MatView("order_summary") // want `"order_summary" is not a materialized view` +var notAMatView = postgres.MatView("order_summary") // want `"order_summary" is not a materialized view` -var qualifiedMatView = sqlshape.MatView("public.plan_counts") +var qualifiedMatView = postgres.MatView("public.plan_counts") var dynamicMatViewName = "order_stats" -var nonConstMatView = sqlshape.MatView(dynamicMatViewName) // want `MatView name must be a string constant` +var nonConstMatView = postgres.MatView(dynamicMatViewName) // want `MatView name must be a string constant` // CHECK (role IN (...)) is a value set like an enum: constants are diffed both ways type Role string // want Role:`consts admin,guest,member` Role:`bound v users.role` @@ -348,7 +355,7 @@ type Host struct { Flags pgtype.Bits } -var hosts = sqlshape.Query[Host, struct{}](`SELECT h.*, u.flags FROM hosts h JOIN users u ON u.id = h.id`) +var hosts = sqlshape.Query[Host, struct{}](`SELECT h.*, u.flags FROM hosts h JOIN users u ON u.id = h.id`) // want `field Uptime: interval into time\.Duration approximates months as 30 days; PostgreSQL's own calendar arithmetic on the same interval can land on a different day` type BadHost struct { Addr string @@ -391,7 +398,7 @@ var hostParams = sqlshape.Query[struct{ ID int32 }, struct { Span pgtype.Range[int64] Spans string Rel int64 -}](`SELECT id FROM hosts WHERE addr = {{.Addr}} AND net = {{.Net}} AND uptime > {{.Uptime}} AND attrs @> {{.Attrs}} AND span && {{.Span}} AND spans && {{.Spans}} AND rel = {{.Rel}}`) // want `parameter .Span: int64 into integer may overflow` +}](`SELECT id FROM hosts WHERE addr = {{.Addr}} AND net = {{.Net}} AND uptime > {{.Uptime}} AND attrs @> {{.Attrs}} AND span && {{.Span}} AND spans && {{.Spans}} AND rel = {{.Rel}}`) // want `parameter .Span: int64 into integer may overflow` `parameter .Uptime: interval into time\.Duration approximates months as 30 days; PostgreSQL's own calendar arithmetic on the same interval can land on a different day` var badHostParams = sqlshape.Query[struct{ ID int32 }, struct { Addr int64 @@ -452,17 +459,17 @@ var dupCol = sqlshape.Query[TwoIDs, struct{}](`SELECT o.id, u.id FROM orders o J // bulk loads: table and columns exist, each column is fed by the field of its name, and // what is left out must have a default -var loadItems = sqlshape.Copy[ItemIn]("order_items") +var loadItems = postgres.Copy[ItemIn]("order_items") -var loadItemsQualified = sqlshape.Copy[ItemIn]("public.order_items", "order_id", "line_no", "sku", "qty", "discount") +var loadItemsQualified = postgres.Copy[ItemIn]("public.order_items", "order_id", "line_no", "sku", "qty", "discount") -var loadItemsPartial = sqlshape.Copy[ItemIn]("order_items", "order_id", "line_no", "sku") // want `Copy into order_items: field Qty \(column "qty"\) is not copied` `Copy into order_items: field Discount \(column "discount"\) is not copied` `Copy into order_items: column "discount" is NOT NULL without a default and is not copied` +var loadItemsPartial = postgres.Copy[ItemIn]("order_items", "order_id", "line_no", "sku") // want `Copy into order_items: field Qty \(column "qty"\) is not copied` `Copy into order_items: field Discount \(column "discount"\) is not copied` `Copy into order_items: column "discount" is NOT NULL without a default and is not copied` -var loadNoSuchTable = sqlshape.Copy[ItemIn]("order_itemz") // want `Copy: table "order_itemz" does not exist` +var loadNoSuchTable = postgres.Copy[ItemIn]("order_itemz") // want `Copy: table "order_itemz" does not exist` -var loadView = sqlshape.Copy[ItemIn]("order_summary") // want `Copy: "order_summary" is not a table` +var loadView = postgres.Copy[ItemIn]("order_summary") // want `Copy: "order_summary" is not a table` -var loadNoSuchColumn = sqlshape.Copy[ItemIn]("order_items", "order_id", "line_no", "skoo", "qty", "discount") // want `Copy into order_items: column "skoo" does not exist` `field Sku \(column "sku"\) is not copied` `column "sku" is NOT NULL without a default and is not copied` +var loadNoSuchColumn = postgres.Copy[ItemIn]("order_items", "order_id", "line_no", "skoo", "qty", "discount") // want `Copy into order_items: column "skoo" does not exist` `field Sku \(column "sku"\) is not copied` `column "sku" is NOT NULL without a default and is not copied` type BadItem struct { OrderID int64 @@ -472,15 +479,15 @@ type BadItem struct { Discount string } -var loadBadItems = sqlshape.Copy[BadItem]("order_items") // want `Copy: field Sku is \*string but column "sku" is NOT NULL: a nil value fails the load` `Copy: field Qty is bool but column "qty" is integer` +var loadBadItems = postgres.Copy[BadItem]("order_items") // want `Copy: field Sku is \*string but column "sku" is NOT NULL: a nil value fails the load` `Copy: field Qty is bool but column "qty" is integer` -var loadNames = sqlshape.Copy[string]("users", "name", "alias") // want `Copy\[string\] into users: a scalar R feeds exactly one column, 2 given` +var loadNames = postgres.Copy[string]("users", "name", "alias") // want `Copy\[string\] into users: a scalar R feeds exactly one column, 2 given` // scalar R that fits exactly one column: no error, and the columns left to their // defaults that have none (NOT NULL, no default) are still reported -var loadScalarOK = sqlshape.Copy[bool]("flags_test", "active") // want `Copy into flags_test: column "id" is NOT NULL without a default and is not copied` `Copy into flags_test: column "tiny" is NOT NULL without a default and is not copied` +var loadScalarOK = postgres.Copy[bool]("flags_test", "active") // want `Copy into flags_test: column "id" is NOT NULL without a default and is not copied` `Copy into flags_test: column "tiny" is NOT NULL without a default and is not copied` -var loadScalarMissing = sqlshape.Copy[bool]("flags_test", "bogus") // want `Copy into flags_test: column "bogus" does not exist` +var loadScalarMissing = postgres.Copy[bool]("flags_test", "bogus") // want `Copy into flags_test: column "bogus" does not exist` // two fields binding to the same column type DupCols struct { @@ -492,7 +499,7 @@ type DupCols struct { Discount string `col:"discount"` } -var loadDup = sqlshape.Copy[DupCols]("order_items") // want `a.DupCols: fields SkuA and SkuB both bind to column "sku"` +var loadDup = postgres.Copy[DupCols]("order_items") // want `a.DupCols: fields SkuA and SkuB both bind to column "sku"` // a column named in the call that exists but has no field in R type MissingFieldItem struct { @@ -501,7 +508,7 @@ type MissingFieldItem struct { Sku string } -var loadMissingField = sqlshape.Copy[MissingFieldItem]("order_items", "order_id", "line_no", "sku", "qty") // want `Copy into order_items: column "qty" has no field in a.MissingFieldItem` `Copy into order_items: column "discount" is NOT NULL without a default and is not copied` +var loadMissingField = postgres.Copy[MissingFieldItem]("order_items", "order_id", "line_no", "sku", "qty") // want `Copy into order_items: column "qty" has no field in a.MissingFieldItem` `Copy into order_items: column "discount" is NOT NULL without a default and is not copied` // a generated column named in the call cannot be copied into type FlagsCopy struct { @@ -510,7 +517,7 @@ type FlagsCopy struct { Tiny int16 } -var loadGenerated = sqlshape.Copy[FlagsCopy]("flags_test", "id", "active", "tiny", "computed") // want `Copy into flags_test: column "computed" is generated and cannot be copied into` +var loadGenerated = postgres.Copy[FlagsCopy]("flags_test", "id", "active", "tiny", "computed") // want `Copy into flags_test: column "computed" is generated and cannot be copied into` // a lossy struct-field conversion (parameter direction): a wider Go integer into a // narrower PG column may overflow @@ -522,12 +529,12 @@ type LossyCopy struct { Discount string } -var loadLossy = sqlshape.Copy[LossyCopy]("order_items") // want `Copy: field LineNo: int32 into smallint may overflow` +var loadLossy = postgres.Copy[LossyCopy]("order_items") // want `Copy: field LineNo: int32 into smallint may overflow` // a non-constant table name var dynamicTable = "order_items" -var loadDynamicTable = sqlshape.Copy[ItemIn](dynamicTable) // want `Copy table and column names must be string constants` +var loadDynamicTable = postgres.Copy[ItemIn](dynamicTable) // want `Copy table and column names must be string constants` // shared fragments: a const concatenated into the template; diagnostics land in the fragment const userFrag = " AND o.user_id = {{.UserID}}" // want `parameter .UserID is bool but SQL expects bigint` `parameter .UserID is bool but SQL expects bigint` @@ -676,7 +683,7 @@ func planName(p Plan) string { // Go array against a PG array, integer/float/numeric narrowing, UUID variants, a plain // string reading a time column, and mismatches). -var fixedArrayOK = sqlshape.Query[struct{ Tags [4]string }, struct{}](`SELECT tags FROM users`) +var fixedArrayOK = sqlshape.Query[struct{ Tags [4]string }, struct{}](`SELECT tags FROM users`) // want `field Tags: text\[\] may contain a NULL element even though the column is not NULL; \[4\]string cannot receive one \(use \[4\]\*string\)` var fixedArrayMismatch = sqlshape.Query[struct{ Tags bool }, struct{}](`SELECT tags FROM users`) // want `field Tags is bool but column "tags" is text\[\]` @@ -873,10 +880,40 @@ type PricesArrField struct { Prices [1]MoneyFields } -var nestedFixedArray = sqlshape.Query[PricesArrField, struct{}](`SELECT array_agg(price) AS prices FROM orders`) // want `field Prices is \[1\]a.MoneyFields but column "prices" may be NULL` `field Prices.Amount is string but column "amount" may be NULL` `field Prices.Currency is string but column "currency" may be NULL` +var nestedFixedArray = sqlshape.Query[PricesArrField, struct{}](`SELECT array_agg(price) AS prices FROM orders`) // want `field Prices is \[1\]a.MoneyFields but column "prices" may be NULL` `field Prices.Amount is string but column "amount" may be NULL` `field Prices.Currency is string but column "currency" may be NULL` `field Prices: money_amount\[\] may contain a NULL element even though the column is not NULL; \[1\]a\.MoneyFields silently receives it as a zero-valued MoneyFields with no error \(use \[1\]\*a\.MoneyFields\)` type PricesPgtypeField struct { Prices []pgtype.Numeric } var nestedPgtypeElem = sqlshape.Query[PricesPgtypeField, struct{}](`SELECT array_agg(price) AS prices FROM orders`) + +// dialect.Type.ElemNotNull: the analyzer proves some array shapes can never hold a NULL +// element, and the checker skips the note (and the -strict rejection) for those; a plain +// array column and array_agg of a value that can be NULL still carry it (see +// TestNestedRowConstructorNeverNullButArrayAggPassesScalarNullsThrough in +// check/postgres/analyze for the measured PostgreSQL facts these forms rely on). + +// array_agg(row(...)) already covered above (userOrders etc.); array_agg of a NOT NULL +// scalar column is provably not-null the same way. +var elemNotNullAggScalar = sqlshape.Query[struct{ Ids []int64 }, struct{}](`SELECT array_agg(id) AS ids FROM orders`) + +// array_agg of a nullable scalar column still carries the note. +var elemNullableAggScalar = sqlshape.Query[struct{ Notes []string }, struct{}](`SELECT array_agg(note) AS notes FROM orders`) // want `field Notes: text\[\] may contain a NULL element even though the column is not NULL; \[\]string cannot receive one \(use \[\]\*string\)` + +// `col:",notnull"` asserts the elements too: the author knows no NULL is ever aggregated +var elemTaggedNotNull = sqlshape.Query[struct { + Notes []string `col:",notnull"` +}, struct{}](`SELECT array_agg(note) AS notes FROM orders`) + +// a literal ARRAY[...] whose elements are all not null. +var elemNotNullLiteral = sqlshape.Query[struct{ Xs []int32 }, struct{}](`SELECT ARRAY[1, 2, 3] AS xs`) + +// a literal ARRAY[...] with a NULL element still carries the note. +var elemNullableLiteral = sqlshape.Query[struct{ Xs []int32 }, struct{}](`SELECT ARRAY[1, NULL, 3] AS xs`) // want `field Xs: integer\[\] may contain a NULL element even though the column is not NULL; \[\]int32 cannot receive one \(use \[\]\*int32\)` + +// ARRAY(SELECT ...) over a NOT NULL column. +var elemNotNullSubquery = sqlshape.Query[struct{ Ids []int64 }, struct{}](`SELECT ARRAY(SELECT id FROM orders) AS ids`) + +// ARRAY(SELECT ...) over a nullable column still carries the note. +var elemNullableSubquery = sqlshape.Query[struct{ Notes []string }, struct{}](`SELECT ARRAY(SELECT note FROM orders) AS notes`) // want `field Notes: text\[\] may contain a NULL element even though the column is not NULL; \[\]string cannot receive one \(use \[\]\*string\)` diff --git a/internal/vet/testdata/src/adv_context/adv_context.go b/cmd/sqlshape/internal/vet/testdata/src/adv_context/adv_context.go similarity index 93% rename from internal/vet/testdata/src/adv_context/adv_context.go rename to cmd/sqlshape/internal/vet/testdata/src/adv_context/adv_context.go index 4d26ae5..fe1ade3 100644 --- a/internal/vet/testdata/src/adv_context/adv_context.go +++ b/cmd/sqlshape/internal/vet/testdata/src/adv_context/adv_context.go @@ -4,7 +4,7 @@ // sqlshape: context ops (temporary, remove after the migration) package adv_context -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" // The trailing text after the context name is not a recognized context directive, so // no context is selected: the base obligation applies, and the unrecognized directive diff --git a/internal/vet/testdata/src/adv_obligation/adv_obligation.go b/cmd/sqlshape/internal/vet/testdata/src/adv_obligation/adv_obligation.go similarity index 95% rename from internal/vet/testdata/src/adv_obligation/adv_obligation.go rename to cmd/sqlshape/internal/vet/testdata/src/adv_obligation/adv_obligation.go index 5ebd733..5bbe1c1 100644 --- a/internal/vet/testdata/src/adv_obligation/adv_obligation.go +++ b/cmd/sqlshape/internal/vet/testdata/src/adv_obligation/adv_obligation.go @@ -1,6 +1,6 @@ package adv_obligation -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" type Row struct{ ID int64 } diff --git a/internal/vet/testdata/src/adv_param/adv_param.go b/cmd/sqlshape/internal/vet/testdata/src/adv_param/adv_param.go similarity index 97% rename from internal/vet/testdata/src/adv_param/adv_param.go rename to cmd/sqlshape/internal/vet/testdata/src/adv_param/adv_param.go index 846019c..a01e025 100644 --- a/internal/vet/testdata/src/adv_param/adv_param.go +++ b/cmd/sqlshape/internal/vet/testdata/src/adv_param/adv_param.go @@ -1,6 +1,6 @@ package adv_param -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" // A scalar int64 parameter into a smallint column: the checker warns (control case, // proves paramFit's overflow note works at the top level). diff --git a/internal/vet/testdata/src/adv_template/a.go b/cmd/sqlshape/internal/vet/testdata/src/adv_template/a.go similarity index 98% rename from internal/vet/testdata/src/adv_template/a.go rename to cmd/sqlshape/internal/vet/testdata/src/adv_template/a.go index c172679..749417d 100644 --- a/internal/vet/testdata/src/adv_template/a.go +++ b/cmd/sqlshape/internal/vet/testdata/src/adv_template/a.go @@ -1,6 +1,6 @@ package adv_template -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" // P1 exercises an if/else where the then-branch leaves an odd number of quotes in the // raw template text (closed only by text shared after {{end}}); checkActionPlacement scans diff --git a/cmd/sqlshape/internal/vet/testdata/src/adv_template3/a.go b/cmd/sqlshape/internal/vet/testdata/src/adv_template3/a.go new file mode 100644 index 0000000..caa5f96 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/src/adv_template3/a.go @@ -0,0 +1,24 @@ +package adv_template3 + +import "github.com/kr9ly/sqlshape/v2" + +// isQueryCall (cmd/sqlshape/internal/vet/vet.go) recognizes a statement only when the +// call expression's Fun is (after unwrapping a generic instantiation's IndexExpr / +// IndexListExpr) a *ast.SelectorExpr, i.e. literally `sqlshape.Query[...](...)` or +// `sqlshape.One[...](...)` written at the call site. Assigning the instantiated generic +// function to a package-level var first, then calling the var, makes call.Fun an +// *ast.Ident -- calledFunc's type switch has no case for that shape, so markerOf never +// matches and the call is skipped entirely: it is never added to `calls` in run(), so the +// SQL text is never analyzed, in any mode, with or without -coverage. This is a genuine +// escape route, not the documented "non-constant template" gap (-coverage's "unchecked" +// counter): the template argument here is a constant string literal, and the SQL is +// syntactically wrong (a column that does not exist) -- the same statement written as +// `sqlshape.Query[int64, struct{}](...)` directly is correctly flagged (see b.go). Bound +// through a var, sqlshape has nothing to say about it at all: no diagnostic, and no +// -coverage counter change either (unchecked is only incremented inside checkCall, which +// this call never reaches). +var runBadQuery = sqlshape.Query[int64, struct{}] + +func UseIt() { + _ = runBadQuery(`SELECT nope FROM widgets`) // want `sqlshape: column "nope" does not exist` +} diff --git a/cmd/sqlshape/internal/vet/testdata/src/adv_template3/b.go b/cmd/sqlshape/internal/vet/testdata/src/adv_template3/b.go new file mode 100644 index 0000000..6ca5ba5 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/src/adv_template3/b.go @@ -0,0 +1,9 @@ +package adv_template3 + +import "github.com/kr9ly/sqlshape/v2" + +// Control case for a.go: the same bad column, called directly through +// `sqlshape.Query[...](...)` instead of through a var, is correctly analyzed and flagged. +// This proves the gap in a.go is specifically about the var-indirection call shape, not +// about the schema or the SQL itself. +var direct = sqlshape.Query[int64, struct{}](`SELECT nope2 FROM widgets`) // want `sqlshape: column "nope2" does not exist` diff --git a/cmd/sqlshape/internal/vet/testdata/src/adv_template3/c.go b/cmd/sqlshape/internal/vet/testdata/src/adv_template3/c.go new file mode 100644 index 0000000..c29df5e --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/src/adv_template3/c.go @@ -0,0 +1,22 @@ +package adv_template3 + +import "github.com/kr9ly/sqlshape/v2" + +// x/expand's control() (x/expand/expand.go) records the field paths an {{if}} / {{with}} +// / {{range}} condition reads, so vet's "control paths must exist on P" check +// (cmd/sqlshape/internal/vet/vet.go, `for _, ctl := range res.Controls`) can catch a typo +// in a condition even when the field is never used as a `$n` parameter. control() only +// recognizes a *parse.FieldNode or *parse.DotNode as a direct argument of the condition's +// pipeline; a condition built from a nested function call -- `{{if gt (len .Xz) 0}}` -- +// has `(len .Xz)` as a nested *parse.PipeNode argument, which control()'s switch has no +// case for, so `.Xz` is never added to Controls and the typo (Xz instead of Xs, the real +// field on CondParams below) is never checked against P. +// +// The same typo written as the bare condition `{{if .Xz}}` (CondParams2/d.go) is correctly +// flagged as "has no field Xz" -- proving the gap is specifically about a condition wrapped +// in a function call, not about typo detection on conditions in general. +type CondParams struct { + Xs []int64 +} + +var withNestedCond = sqlshape.Query[int64, CondParams](`SELECT id FROM widgets {{if gt (len .Xz) 0}}WHERE id = ANY({{.Xs}}){{end}}`) // want `sqlshape: adv_template3\.CondParams has no field Xz` diff --git a/cmd/sqlshape/internal/vet/testdata/src/adv_template3/d.go b/cmd/sqlshape/internal/vet/testdata/src/adv_template3/d.go new file mode 100644 index 0000000..14377e7 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/src/adv_template3/d.go @@ -0,0 +1,11 @@ +package adv_template3 + +import "github.com/kr9ly/sqlshape/v2" + +// Control case for c.go: the same typo'd field name, used as a bare condition instead of +// wrapped in a function call, is correctly flagged. +type CondParams2 struct { + Xs []int64 +} + +var directCond = sqlshape.Query[int64, CondParams2](`SELECT id FROM widgets {{if .Xz}}WHERE id = ANY({{.Xs}}){{end}}`) // want `sqlshape: adv_template3\.CondParams2 has no field Xz` diff --git a/cmd/sqlshape/internal/vet/testdata/src/adv_typefit/adv_typefit.go b/cmd/sqlshape/internal/vet/testdata/src/adv_typefit/adv_typefit.go new file mode 100644 index 0000000..d7312dd --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/src/adv_typefit/adv_typefit.go @@ -0,0 +1,63 @@ +package adv_typefit + +import ( + "time" + + "github.com/kr9ly/sqlshape/v2" +) + +// Adversarial-testing lane "typefit": each case below is reproduced against a real +// PostgreSQL in pgtest/runtime_adv_typefit_test.go, so this file only records the +// diagnostic the checker should have given and currently does not; it fails until +// gofit.go / types.go grow the check. +// +// This file is checked in the default (non-strict) mode: the array-null-element note is +// standing advice (Advice / Lossy), not a rejection -- []int32 / []Item are still +// accepted. testdata/src/adv_typefit_strict holds the same three cases checked with +// -strict, where the note also becomes an explicit rejection (per the ruling in +// scratchpad/adv-pg3-fix-brief.md, decision 1). "field ID carries key" is a plain-type +// key-binding advisory (binding.go), unrelated to this lane, and is strict-only -- it +// does not appear here. + +// PostgreSQL never guarantees an array's elements are themselves not null: a column's +// own NOT NULL only forbids the array value as a whole from being NULL -- +// `ARRAY[1,NULL,3]` is a perfectly legal value for an ordinary `integer[]` column, NOT +// NULL or not. `[]int32` for `integer[]` is still accepted here (default mode), but now +// carries a standing note that pgx errors scanning a real NULL element into *int32 +// (TestAdvTypefit_ScalarArrayNullElement in pgtest/runtime_adv_typefit_test.go). +type ScalarArrayRow struct { + ID int64 + Tags []int32 +} + +var scalarArray = sqlshape.Query[ScalarArrayRow, struct{}](`SELECT id, tags FROM t`) // want `field Tags: integer\[\] may contain a NULL element even though the column is not NULL; \[\]int32 cannot receive one \(use \[\]\*int32\)` + +// The same gap for an array of a composite type is worse: a NULL item element is not +// merely a decode error, pgx silently turns it into a zero-valued Item +// (Item{Sku:"", Qty:0}), indistinguishable from a real all-empty-fields row +// (TestAdvTypefit_CompositeArrayNullElement in pgtest/runtime_adv_typefit_test.go). +type Item struct { + Sku string + Qty int32 +} + +type CompositeArrayRow struct { + ID int64 + Items []Item +} + +var compositeArray = sqlshape.Query[CompositeArrayRow, struct{}](`SELECT id, items FROM t`) // want `field Items\.Sku is string but column "sku" may be NULL` `field Items\.Qty is int32 but column "qty" may be NULL` `field Items: item\[\] may contain a NULL element even though the column is not NULL; \[\]adv_typefit\.Item silently receives it as a zero-valued Item with no error \(use \[\]\*adv_typefit\.Item\)` + +// interval keeps months, days and microseconds as separate components and PostgreSQL +// applies them to a calendar date one at a time; pgx's Duration codec instead flattens +// interval to a fixed months*30days + days*24h + microseconds, the same as any other +// non-faithful GoFit (numeric->float64, ...): a standing Lossy note, in both modes +// (TestAdvTypefit_IntervalCalendar in pgtest/runtime_adv_typefit_test.go: adding +// `interval '1 month'` to 2024-02-01, a leap February, lands one day apart between +// PostgreSQL and time.Time.Add(span)). +type IntervalRow struct { + ID int64 + Span time.Duration +} + +var intervalRow = sqlshape.Query[IntervalRow, struct{}](`SELECT id, span FROM t`) // want `field Span is time\.Duration but column "span" may be NULL` `field Span: interval into time\.Duration approximates months as 30 days; PostgreSQL's own calendar arithmetic on the same interval can land on a different day` diff --git a/cmd/sqlshape/internal/vet/testdata/src/adv_typefit_strict/adv_typefit_strict.go b/cmd/sqlshape/internal/vet/testdata/src/adv_typefit_strict/adv_typefit_strict.go new file mode 100644 index 0000000..5640b0a --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/src/adv_typefit_strict/adv_typefit_strict.go @@ -0,0 +1,57 @@ +package adv_typefit_strict + +import ( + "time" + + "github.com/kr9ly/sqlshape/v2" +) + +// Adversarial-testing lane "typefit", checked under -strict: the same three cases as +// testdata/src/adv_typefit, so the standing note (Advice / Lossy) is not the only thing +// -strict changes -- it also turns the array-null-element gap into an explicit +// rejection, per the ruling in scratchpad/adv-pg3-fix-brief.md (decision 1). "field ID +// carries key" is unrelated to this lane (binding.go's plain-type key-binding advisory) +// and only fires under -strict, which is why it appears here and not in adv_typefit. + +// PostgreSQL never guarantees an array's elements are themselves not null: a column's +// own NOT NULL only forbids the array value as a whole from being NULL -- +// `ARRAY[1,NULL,3]` is a perfectly legal value for an ordinary `integer[]` column, NOT +// NULL or not. `[]int32` for `integer[]` is still accepted (no field is rewritten), but +// -strict additionally reports it as rejected: pgx errors scanning a real NULL element +// into *int32 (TestAdvTypefit_ScalarArrayNullElement in pgtest/runtime_adv_typefit_test.go). +type ScalarArrayRow struct { + ID int64 + Tags []int32 +} + +var scalarArray = sqlshape.Query[ScalarArrayRow, struct{}](`SELECT id, tags FROM t`) // want `field ID carries key t\.id` `field Tags: integer\[\] may contain a NULL element even though the column is not NULL; \[\]int32 cannot receive one \(use \[\]\*int32\)` `field Tags: -strict rejects \[\]int32 for integer\[\]: pgx errors scanning a NULL element into int32 \(use \[\]\*int32\)` + +// The same gap for an array of a composite type is worse: a NULL item element is not +// merely a decode error, pgx silently turns it into a zero-valued Item +// (Item{Sku:"", Qty:0}), indistinguishable from a real all-empty-fields row +// (TestAdvTypefit_CompositeArrayNullElement in pgtest/runtime_adv_typefit_test.go). +type Item struct { + Sku string + Qty int32 +} + +type CompositeArrayRow struct { + ID int64 + Items []Item +} + +var compositeArray = sqlshape.Query[CompositeArrayRow, struct{}](`SELECT id, items FROM t`) // want `field ID carries key t\.id` `field Items\.Sku is string but column "sku" may be NULL` `field Items\.Qty is int32 but column "qty" may be NULL` `field Items: item\[\] may contain a NULL element even though the column is not NULL; \[\]adv_typefit_strict\.Item silently receives it as a zero-valued Item with no error \(use \[\]\*adv_typefit_strict\.Item\)` `field Items: -strict rejects \[\]adv_typefit_strict\.Item for item\[\]: a NULL element is silently decoded as a zero-valued Item instead of an error \(use \[\]\*adv_typefit_strict\.Item\)` + +// interval keeps months, days and microseconds as separate components and PostgreSQL +// applies them to a calendar date one at a time; pgx's Duration codec instead flattens +// interval to a fixed months*30days + days*24h + microseconds, the same as any other +// non-faithful GoFit (numeric->float64, ...): a standing Lossy note, in both modes +// (TestAdvTypefit_IntervalCalendar in pgtest/runtime_adv_typefit_test.go: adding +// `interval '1 month'` to 2024-02-01, a leap February, lands one day apart between +// PostgreSQL and time.Time.Add(span)). +type IntervalRow struct { + ID int64 + Span time.Duration +} + +var intervalRow = sqlshape.Query[IntervalRow, struct{}](`SELECT id, span FROM t`) // want `field ID carries key t\.id` `field Span is time\.Duration but column "span" may be NULL` `field Span: interval into time\.Duration approximates months as 30 days; PostgreSQL's own calendar arithmetic on the same interval can land on a different day` diff --git a/internal/vet/testdata/src/b/b.go b/cmd/sqlshape/internal/vet/testdata/src/b/b.go similarity index 100% rename from internal/vet/testdata/src/b/b.go rename to cmd/sqlshape/internal/vet/testdata/src/b/b.go diff --git a/internal/vet/testdata/src/cov/cov.go b/cmd/sqlshape/internal/vet/testdata/src/cov/cov.go similarity index 88% rename from internal/vet/testdata/src/cov/cov.go rename to cmd/sqlshape/internal/vet/testdata/src/cov/cov.go index 0c124b3..5a5d28a 100644 --- a/internal/vet/testdata/src/cov/cov.go +++ b/cmd/sqlshape/internal/vet/testdata/src/cov/cov.go @@ -1,6 +1,6 @@ package cov -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" var dynamic string diff --git a/internal/vet/testdata/src/ctx/ctx.go b/cmd/sqlshape/internal/vet/testdata/src/ctx/ctx.go similarity index 92% rename from internal/vet/testdata/src/ctx/ctx.go rename to cmd/sqlshape/internal/vet/testdata/src/ctx/ctx.go index cf3b6b7..6b9bd08 100644 --- a/internal/vet/testdata/src/ctx/ctx.go +++ b/cmd/sqlshape/internal/vet/testdata/src/ctx/ctx.go @@ -3,7 +3,7 @@ // sqlshape: context ops package ctx -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" var count = sqlshape.Query[int64, struct{}](`SELECT count(*) FROM orders`) diff --git a/cmd/sqlshape/internal/vet/testdata/src/dialect/dialect.go b/cmd/sqlshape/internal/vet/testdata/src/dialect/dialect.go new file mode 100644 index 0000000..ad5fd53 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/src/dialect/dialect.go @@ -0,0 +1,62 @@ +package dialect + +import ( + "time" + + "github.com/kr9ly/sqlshape/v2" + "github.com/kr9ly/sqlshape/postgres/v2" +) + +// The stub dialect (vet/dialect_test.go) knows one table: things(id bigint not null, +// name varchar not null, price decimal null, at datetime not null). A parameter compared +// with a column takes its type; the statement "SELECT boom" is a database error at byte 7. +// Diagnostics about R land on the call, like the PostgreSQL path's. + +type Thing struct { + ID int64 + Name string + Price *string + At time.Time +} + +type ByID struct{ ID int64 } + +var ok = sqlshape.Query[Thing, ByID](`SELECT id, name, price, at FROM things WHERE id = {{.ID}}`) // want `schema .*dialect_schema.sql: 3: a problem with the schema` + +type Wrong struct { + ID string + Name string + Price string + At time.Time + Extra int +} + +var wrong = sqlshape.Query[Wrong, struct{ ID string }](`SELECT id, name, price, at FROM things WHERE id = {{.ID}}`) // want `field ID is string but column "id" is bigint` `field Price is string but column "price" may be NULL` `field Wrong.Extra has no result column` `parameter .ID is string but SQL expects bigint` + +var scalar = sqlshape.Query[int64, struct{}](`SELECT id FROM things`) + +var twoCols = sqlshape.Query[int64, struct{}](`SELECT id, name FROM things`) // want `R is int64 but the query returns 2 columns` + +var boom = sqlshape.Query[int64, struct{}](`SELECT boom`) // want `no such thing as boom \(TD001\)` + +type Opt struct { + ID int64 + Name string +} + +var branches = sqlshape.Query[Opt, struct{ Named bool }](`SELECT id{{if .Named}}, name{{end}} FROM things`) // want `field Opt.Name is not selected in every branch \[if@14:else\]: make it a pointer` + +type Status string + +func (*Status) Scan(any) error { return nil } + +type Typed struct { + ID Status // a Scanner receives any column + Name Status // a named string carries a varchar through its underlying type +} + +var typed = sqlshape.Query[Typed, struct{}](`SELECT id, name FROM things`) + +var one = sqlshape.One[int64, struct{}](`SELECT id FROM things`) // want `One: cannot prove at most one row: the statement.s shape is not analyzed for its cardinality` + +var mv = postgres.MatView("x") // want `MatView is not supported for testdb` diff --git a/internal/vet/testdata/src/docs/docs.go b/cmd/sqlshape/internal/vet/testdata/src/docs/docs.go similarity index 97% rename from internal/vet/testdata/src/docs/docs.go rename to cmd/sqlshape/internal/vet/testdata/src/docs/docs.go index 8034418..e722719 100644 --- a/internal/vet/testdata/src/docs/docs.go +++ b/cmd/sqlshape/internal/vet/testdata/src/docs/docs.go @@ -1,6 +1,6 @@ package docs -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" // -sync-comments: COMMENT ON TABLE orders / COLUMN orders.status become doc comment suggestions diff --git a/internal/vet/testdata/src/docs/docs2.go b/cmd/sqlshape/internal/vet/testdata/src/docs/docs2.go similarity index 92% rename from internal/vet/testdata/src/docs/docs2.go rename to cmd/sqlshape/internal/vet/testdata/src/docs/docs2.go index dafdefd..70e867c 100644 --- a/internal/vet/testdata/src/docs/docs2.go +++ b/cmd/sqlshape/internal/vet/testdata/src/docs/docs2.go @@ -1,6 +1,6 @@ package docs -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" // A second file: fieldDecl / typeSpec must skip the first file (whose range does not // contain this type's position) before finding the match here. diff --git a/internal/vet/testdata/src/dto/dto.go b/cmd/sqlshape/internal/vet/testdata/src/dto/dto.go similarity index 98% rename from internal/vet/testdata/src/dto/dto.go rename to cmd/sqlshape/internal/vet/testdata/src/dto/dto.go index d8568c3..e93870b 100644 --- a/internal/vet/testdata/src/dto/dto.go +++ b/cmd/sqlshape/internal/vet/testdata/src/dto/dto.go @@ -6,7 +6,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" - "github.com/kr9ly/sqlshape" + "github.com/kr9ly/sqlshape/v2" ) var _, _, _ = json.RawMessage{}, time.Time{}, pgtype.Numeric{} diff --git a/internal/vet/testdata/src/dto/dto.go.golden b/cmd/sqlshape/internal/vet/testdata/src/dto/dto.go.golden similarity index 99% rename from internal/vet/testdata/src/dto/dto.go.golden rename to cmd/sqlshape/internal/vet/testdata/src/dto/dto.go.golden index 37ac997..207f192 100644 --- a/internal/vet/testdata/src/dto/dto.go.golden +++ b/cmd/sqlshape/internal/vet/testdata/src/dto/dto.go.golden @@ -6,7 +6,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" - "github.com/kr9ly/sqlshape" + "github.com/kr9ly/sqlshape/v2" ) var _, _, _ = json.RawMessage{}, time.Time{}, pgtype.Numeric{} diff --git a/internal/vet/testdata/src/dto/flagrow.go b/cmd/sqlshape/internal/vet/testdata/src/dto/flagrow.go similarity index 88% rename from internal/vet/testdata/src/dto/flagrow.go rename to cmd/sqlshape/internal/vet/testdata/src/dto/flagrow.go index daedd31..1499343 100644 --- a/internal/vet/testdata/src/dto/flagrow.go +++ b/cmd/sqlshape/internal/vet/testdata/src/dto/flagrow.go @@ -1,6 +1,6 @@ package dto -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" // The lone boolean and smallint columns the rest of the package never exercises. type FlagRow struct{} diff --git a/internal/vet/testdata/src/dto/flagrow.go.golden b/cmd/sqlshape/internal/vet/testdata/src/dto/flagrow.go.golden similarity index 89% rename from internal/vet/testdata/src/dto/flagrow.go.golden rename to cmd/sqlshape/internal/vet/testdata/src/dto/flagrow.go.golden index 9bdf455..32b137a 100644 --- a/internal/vet/testdata/src/dto/flagrow.go.golden +++ b/cmd/sqlshape/internal/vet/testdata/src/dto/flagrow.go.golden @@ -1,6 +1,6 @@ package dto -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" // The lone boolean and smallint columns the rest of the package never exercises. type FlagRow struct { diff --git a/internal/vet/testdata/src/dto/grouped.go b/cmd/sqlshape/internal/vet/testdata/src/dto/grouped.go similarity index 100% rename from internal/vet/testdata/src/dto/grouped.go rename to cmd/sqlshape/internal/vet/testdata/src/dto/grouped.go diff --git a/internal/vet/testdata/src/dto/grouped.go.golden b/cmd/sqlshape/internal/vet/testdata/src/dto/grouped.go.golden similarity index 100% rename from internal/vet/testdata/src/dto/grouped.go.golden rename to cmd/sqlshape/internal/vet/testdata/src/dto/grouped.go.golden diff --git a/internal/vet/testdata/src/dto/hostrow.go b/cmd/sqlshape/internal/vet/testdata/src/dto/hostrow.go similarity index 96% rename from internal/vet/testdata/src/dto/hostrow.go rename to cmd/sqlshape/internal/vet/testdata/src/dto/hostrow.go index 2abc162..3106502 100644 --- a/internal/vet/testdata/src/dto/hostrow.go +++ b/cmd/sqlshape/internal/vet/testdata/src/dto/hostrow.go @@ -1,6 +1,6 @@ package dto -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" // A fresh struct covering the reverse mapping's exotic branches: network / hardware // address types, an interval, hstore, a range and multirange, geometry, tsvector, and diff --git a/internal/vet/testdata/src/dto/hostrow.go.golden b/cmd/sqlshape/internal/vet/testdata/src/dto/hostrow.go.golden similarity index 97% rename from internal/vet/testdata/src/dto/hostrow.go.golden rename to cmd/sqlshape/internal/vet/testdata/src/dto/hostrow.go.golden index f528387..2dd7e1d 100644 --- a/internal/vet/testdata/src/dto/hostrow.go.golden +++ b/cmd/sqlshape/internal/vet/testdata/src/dto/hostrow.go.golden @@ -2,7 +2,7 @@ package dto import ( "github.com/jackc/pgx/v5/pgtype" - "github.com/kr9ly/sqlshape" + "github.com/kr9ly/sqlshape/v2" "net" "net/netip" "time" diff --git a/internal/vet/testdata/src/dto/imports.go b/cmd/sqlshape/internal/vet/testdata/src/dto/imports.go similarity index 89% rename from internal/vet/testdata/src/dto/imports.go rename to cmd/sqlshape/internal/vet/testdata/src/dto/imports.go index ce6e2ef..287b0a2 100644 --- a/internal/vet/testdata/src/dto/imports.go +++ b/cmd/sqlshape/internal/vet/testdata/src/dto/imports.go @@ -1,6 +1,6 @@ package dto -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" // A fix that needs imports the file lacks adds them (a single import becomes a block). type Stamped struct{} diff --git a/internal/vet/testdata/src/dto/imports.go.golden b/cmd/sqlshape/internal/vet/testdata/src/dto/imports.go.golden similarity index 93% rename from internal/vet/testdata/src/dto/imports.go.golden rename to cmd/sqlshape/internal/vet/testdata/src/dto/imports.go.golden index c7b29e7..5e43e21 100644 --- a/internal/vet/testdata/src/dto/imports.go.golden +++ b/cmd/sqlshape/internal/vet/testdata/src/dto/imports.go.golden @@ -1,7 +1,7 @@ package dto import ( - "github.com/kr9ly/sqlshape" + "github.com/kr9ly/sqlshape/v2" "github.com/jackc/pgx/v5/pgtype" "time" ) diff --git a/internal/vet/testdata/src/dto/noimport.go b/cmd/sqlshape/internal/vet/testdata/src/dto/noimport.go similarity index 100% rename from internal/vet/testdata/src/dto/noimport.go rename to cmd/sqlshape/internal/vet/testdata/src/dto/noimport.go diff --git a/internal/vet/testdata/src/dto/noimport.go.golden b/cmd/sqlshape/internal/vet/testdata/src/dto/noimport.go.golden similarity index 100% rename from internal/vet/testdata/src/dto/noimport.go.golden rename to cmd/sqlshape/internal/vet/testdata/src/dto/noimport.go.golden diff --git a/internal/vet/testdata/src/dto/queries2.go b/cmd/sqlshape/internal/vet/testdata/src/dto/queries2.go similarity index 89% rename from internal/vet/testdata/src/dto/queries2.go rename to cmd/sqlshape/internal/vet/testdata/src/dto/queries2.go index 8eaea22..3bea49a 100644 --- a/internal/vet/testdata/src/dto/queries2.go +++ b/cmd/sqlshape/internal/vet/testdata/src/dto/queries2.go @@ -1,6 +1,6 @@ package dto -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" var blank = sqlshape.Query[Blank, struct{}](`SELECT id, created_at FROM orders`) // want `result column "id" has no field` `result column "created_at" has no field` diff --git a/internal/vet/testdata/src/dto/rolerow.go b/cmd/sqlshape/internal/vet/testdata/src/dto/rolerow.go similarity index 93% rename from internal/vet/testdata/src/dto/rolerow.go rename to cmd/sqlshape/internal/vet/testdata/src/dto/rolerow.go index a381a13..218441f 100644 --- a/internal/vet/testdata/src/dto/rolerow.go +++ b/cmd/sqlshape/internal/vet/testdata/src/dto/rolerow.go @@ -1,6 +1,6 @@ package dto -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" // role is a CHECK-based value set no Go type in this package is bound to: it falls // through to the plain string mapping. tags is a plain array; score / ratio the two diff --git a/internal/vet/testdata/src/dto/rolerow.go.golden b/cmd/sqlshape/internal/vet/testdata/src/dto/rolerow.go.golden similarity index 95% rename from internal/vet/testdata/src/dto/rolerow.go.golden rename to cmd/sqlshape/internal/vet/testdata/src/dto/rolerow.go.golden index d7d7f80..703832e 100644 --- a/internal/vet/testdata/src/dto/rolerow.go.golden +++ b/cmd/sqlshape/internal/vet/testdata/src/dto/rolerow.go.golden @@ -2,7 +2,7 @@ package dto import ( "github.com/jackc/pgx/v5/pgtype" - "github.com/kr9ly/sqlshape" + "github.com/kr9ly/sqlshape/v2" ) // role is a CHECK-based value set no Go type in this package is bound to: it falls diff --git a/internal/vet/testdata/src/dtouuid/dtouuid.go b/cmd/sqlshape/internal/vet/testdata/src/dtouuid/dtouuid.go similarity index 92% rename from internal/vet/testdata/src/dtouuid/dtouuid.go rename to cmd/sqlshape/internal/vet/testdata/src/dtouuid/dtouuid.go index a136899..3f87904 100644 --- a/internal/vet/testdata/src/dtouuid/dtouuid.go +++ b/cmd/sqlshape/internal/vet/testdata/src/dtouuid/dtouuid.go @@ -2,7 +2,7 @@ package dtouuid import ( "github.com/google/uuid" - "github.com/kr9ly/sqlshape" + "github.com/kr9ly/sqlshape/v2" "github.com/shopspring/decimal" ) diff --git a/internal/vet/testdata/src/dtouuid/dtouuid.go.golden b/cmd/sqlshape/internal/vet/testdata/src/dtouuid/dtouuid.go.golden similarity index 93% rename from internal/vet/testdata/src/dtouuid/dtouuid.go.golden rename to cmd/sqlshape/internal/vet/testdata/src/dtouuid/dtouuid.go.golden index d21cf1d..c99dacd 100644 --- a/internal/vet/testdata/src/dtouuid/dtouuid.go.golden +++ b/cmd/sqlshape/internal/vet/testdata/src/dtouuid/dtouuid.go.golden @@ -2,7 +2,7 @@ package dtouuid import ( "github.com/google/uuid" - "github.com/kr9ly/sqlshape" + "github.com/kr9ly/sqlshape/v2" "github.com/shopspring/decimal" ) diff --git a/cmd/sqlshape/internal/vet/testdata/src/errnamedup/errnamedup.go b/cmd/sqlshape/internal/vet/testdata/src/errnamedup/errnamedup.go new file mode 100644 index 0000000..4489fe0 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/src/errnamedup/errnamedup.go @@ -0,0 +1,13 @@ +package errnamedup + +import "github.com/kr9ly/sqlshape/v2" + +// Two declarations claiming the same code: a duplicate, whichever one is right about the +// schema's own Name for it (this one is). +var TooManyWidgets = sqlshape.Error("P0501") // want TooManyWidgets:`sqlshape.Error\(P0501\)` + +// TooManyWidgetsAgain both duplicates TooManyWidgets's code and gets the schema's Name for +// it wrong (the schema calls P0501 TooManyWidgets, not this). +var TooManyWidgetsAgain = sqlshape.Error("P0501") // want `TooManyWidgets and TooManyWidgetsAgain both declare sqlshape.Error\("P0501"\)` `schema names function widgets_check's error P0501 "TooManyWidgets", not "TooManyWidgetsAgain"` TooManyWidgetsAgain:`sqlshape.Error\(P0501\)` + +var insert = sqlshape.Query[struct{}, struct{ Qty int32 }]("-- sqlshape: expect TooManyWidgets\nINSERT INTO widgets (qty) VALUES ({{.Qty}})") diff --git a/cmd/sqlshape/internal/vet/testdata/src/errnamemissing/errnamemissing.go b/cmd/sqlshape/internal/vet/testdata/src/errnamemissing/errnamemissing.go new file mode 100644 index 0000000..5692bd5 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/src/errnamemissing/errnamemissing.go @@ -0,0 +1,7 @@ +package errnamemissing + +import "github.com/kr9ly/sqlshape/v2" + +// No var declares P0501 anywhere in this package: the expect line covers the violation +// for checkExpectations' own purposes, but vet still requires a Go binding for it. +var insert = sqlshape.Query[struct{}, struct{ Qty int32 }]("-- sqlshape: expect P0501\nINSERT INTO widgets (qty) VALUES ({{.Qty}})") // want `P0501 \(raised by trigger widgets_check_trigger on widgets as TooManyWidgets, SQLSTATE P0501\) has no .var TooManyWidgets = sqlshape.Error\("P0501"\). declared in this program` diff --git a/cmd/sqlshape/internal/vet/testdata/src/errnameok/errnameok.go b/cmd/sqlshape/internal/vet/testdata/src/errnameok/errnameok.go new file mode 100644 index 0000000..42882ae --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/src/errnameok/errnameok.go @@ -0,0 +1,16 @@ +package errnameok + +import "github.com/kr9ly/sqlshape/v2" + +// TooManyWidgets names the trigger's own P0501 -- the schema's Name for it, so this +// declaration is exactly what vet requires. +var TooManyWidgets = sqlshape.Error("P0501") // want TooManyWidgets:`sqlshape.Error\(P0501\)` + +// byCode: the expect line spells the raw code. +var insertByCode = sqlshape.Query[struct{}, struct{ Qty int32 }]("-- sqlshape: expect P0501\nINSERT INTO widgets (qty) VALUES ({{.Qty}})") + +// byName: the expect line spells the schema's Name instead -- interchangeable with the code. +var insertByName = sqlshape.Query[struct{}, struct{ Qty int32 }]("-- sqlshape: expect TooManyWidgets\nINSERT INTO widgets (qty) VALUES ({{.Qty}})") + +// byBoth: writing both leaves neither unmatched. +var insertByBoth = sqlshape.Query[struct{}, struct{ Qty int32 }]("-- sqlshape: expect P0501, TooManyWidgets\nINSERT INTO widgets (qty) VALUES ({{.Qty}})") diff --git a/cmd/sqlshape/internal/vet/testdata/src/errnameunknown/errnameunknown.go b/cmd/sqlshape/internal/vet/testdata/src/errnameunknown/errnameunknown.go new file mode 100644 index 0000000..2a3a98d --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/src/errnameunknown/errnameunknown.go @@ -0,0 +1,10 @@ +package errnameunknown + +import "github.com/kr9ly/sqlshape/v2" + +// Ghost names a code the schema does not declare at all. +var Ghost = sqlshape.Error("P9999") // want `the schema declares no error "P9999"` Ghost:`sqlshape.Error\(P9999\)` + +// a read, so the trigger's own P0501 is not itself a possible violation to report here +// (that is errnamemissing's own case): this file is only about Ghost's own code. +var read = sqlshape.Query[int64, struct{}]("SELECT id FROM widgets") diff --git a/cmd/sqlshape/internal/vet/testdata/src/errnamewrong/errnamewrong.go b/cmd/sqlshape/internal/vet/testdata/src/errnamewrong/errnamewrong.go new file mode 100644 index 0000000..fe35122 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/src/errnamewrong/errnamewrong.go @@ -0,0 +1,8 @@ +package errnamewrong + +import "github.com/kr9ly/sqlshape/v2" + +// Wrong: the schema names P0501 TooManyWidgets, not this. +var Wrong = sqlshape.Error("P0501") // want `schema names function widgets_check's error P0501 "TooManyWidgets", not "Wrong"` Wrong:`sqlshape.Error\(P0501\)` + +var insert = sqlshape.Query[struct{}, struct{ Qty int32 }]("-- sqlshape: expect P0501\nINSERT INTO widgets (qty) VALUES ({{.Qty}})") diff --git a/internal/vet/testdata/src/github.com/google/uuid/uuid.go b/cmd/sqlshape/internal/vet/testdata/src/github.com/google/uuid/uuid.go similarity index 100% rename from internal/vet/testdata/src/github.com/google/uuid/uuid.go rename to cmd/sqlshape/internal/vet/testdata/src/github.com/google/uuid/uuid.go diff --git a/internal/vet/testdata/src/github.com/jackc/pgx/v5/pgtype/pgtype.go b/cmd/sqlshape/internal/vet/testdata/src/github.com/jackc/pgx/v5/pgtype/pgtype.go similarity index 100% rename from internal/vet/testdata/src/github.com/jackc/pgx/v5/pgtype/pgtype.go rename to cmd/sqlshape/internal/vet/testdata/src/github.com/jackc/pgx/v5/pgtype/pgtype.go diff --git a/internal/vet/testdata/src/github.com/jackc/pgx/v5/pgx.go b/cmd/sqlshape/internal/vet/testdata/src/github.com/jackc/pgx/v5/pgx.go similarity index 100% rename from internal/vet/testdata/src/github.com/jackc/pgx/v5/pgx.go rename to cmd/sqlshape/internal/vet/testdata/src/github.com/jackc/pgx/v5/pgx.go diff --git a/internal/vet/testdata/src/github.com/jackc/pgx/v5/pgxpool/pgxpool.go b/cmd/sqlshape/internal/vet/testdata/src/github.com/jackc/pgx/v5/pgxpool/pgxpool.go similarity index 100% rename from internal/vet/testdata/src/github.com/jackc/pgx/v5/pgxpool/pgxpool.go rename to cmd/sqlshape/internal/vet/testdata/src/github.com/jackc/pgx/v5/pgxpool/pgxpool.go diff --git a/cmd/sqlshape/internal/vet/testdata/src/github.com/kr9ly/sqlshape/postgres/v2/postgres.go b/cmd/sqlshape/internal/vet/testdata/src/github.com/kr9ly/sqlshape/postgres/v2/postgres.go new file mode 100644 index 0000000..247396f --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/src/github.com/kr9ly/sqlshape/postgres/v2/postgres.go @@ -0,0 +1,15 @@ +// Package postgres is the vet test stub of the PostgreSQL runtime: the declarations the +// checker recognizes (MatView, Copy), without pgx. +package postgres + +// MatView is a handle on a materialized view. +type MatView string + +type Copier[R any] struct { + Table string + Columns []string +} + +func Copy[R any](table string, columns ...string) Copier[R] { + return Copier[R]{Table: table, Columns: columns} +} diff --git a/cmd/sqlshape/internal/vet/testdata/src/github.com/kr9ly/sqlshape/v2/sqlshape.go b/cmd/sqlshape/internal/vet/testdata/src/github.com/kr9ly/sqlshape/v2/sqlshape.go new file mode 100644 index 0000000..f1511f3 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/src/github.com/kr9ly/sqlshape/v2/sqlshape.go @@ -0,0 +1,44 @@ +// Package sqlshape makes SQL a first-class, statically checked citizen of a Go +// program. A query is a text/template over a parameter struct P whose finite set +// of expansions is checked at lint time against schema.sql; R describes the +// result row. +// +// var listOrders = sqlshape.Query[OrderRow, ListOrdersParams](` +// SELECT o.id, o.status, o.total +// FROM orders o +// WHERE true +// {{if .Status}} AND o.status = {{.Status}} {{end}} +// LIMIT {{.Limit}} +// `) +// +// The runtime half (executing a Stmt against pgx) is not implemented yet; the +// analyzer in cmd/sqlshape checks every expansion of every Query in a program. +package sqlshape + +// Stmt is a checked SQL template. R is the result row type, P the parameter type. +type Stmt[R, P any] struct { + Template string +} + +// Query declares a statement. The argument must be a string literal so the +// analyzer can expand and check it. +func Query[R, P any](template string) Stmt[R, P] { + return Stmt[R, P]{Template: template} +} + +// Single is a checked SQL template proven to return at most one row. +type Single[R, P any] struct { + stmt Stmt[R, P] +} + +// One declares a single-row statement. +func One[R, P any](template string) Single[R, P] { + return Single[R, P]{stmt: Stmt[R, P]{Template: template}} +} + +// Failure is a statement's failure mode as the program names it (see Error). +type Failure string + +// Error declares a program's Go name for a schema's `-- sqlshape: error = ` +// annotation. +func Error(code string) Failure { return Failure(code) } diff --git a/internal/vet/testdata/src/github.com/shopspring/decimal/decimal.go b/cmd/sqlshape/internal/vet/testdata/src/github.com/shopspring/decimal/decimal.go similarity index 100% rename from internal/vet/testdata/src/github.com/shopspring/decimal/decimal.go rename to cmd/sqlshape/internal/vet/testdata/src/github.com/shopspring/decimal/decimal.go diff --git a/cmd/sqlshape/internal/vet/testdata/src/guarded_pointer/guarded_pointer.go b/cmd/sqlshape/internal/vet/testdata/src/guarded_pointer/guarded_pointer.go new file mode 100644 index 0000000..a509d0e --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/src/guarded_pointer/guarded_pointer.go @@ -0,0 +1,65 @@ +package guarded_pointer + +import "github.com/kr9ly/sqlshape/v2" + +// (a) A pointer parameter read inside the then branch of a plain {{if .Name}} on itself +// is proven non-nil for the rest of this expansion: no NOT NULL violation, in either +// expansion (the else branch sends nothing, and t.name has a DEFAULT). +type PA struct { + ID int64 + Name *string +} + +var qa = sqlshape.Query[struct{}, PA]("-- sqlshape: expect t_pkey\nINSERT INTO t (id{{if .Name}}, name{{end}}) VALUES ({{.ID}}{{if .Name}}, {{.Name}}{{end}})") + +// (b) Control case: the same pointer used unconditionally (no guard) still reports the +// NOT NULL violation -- the guard only applies to a use inside the condition that +// proved it non-nil. +type PB struct { + ID int64 + Name *string +} + +var qb = sqlshape.Query[struct{}, PB]("-- sqlshape: expect t_pkey\nINSERT INTO t (id, name) VALUES ({{.ID}}, {{.Name}})") // want `may violate t.name \(NOT NULL on t.name, SQLSTATE 23502\)` + +// (c) The else branch of {{if .Name}} does not guard: reading Name.Name there still +// reports the violation, even though .Name is only reached when the condition is false +// (the checker does not reason about "known false", only "known true"). +type PC struct { + ID int64 + Name *string +} + +var qc = sqlshape.Query[struct{}, PC]("-- sqlshape: expect t_pkey\nINSERT INTO t (id, name) VALUES ({{.ID}}, {{if .Name}}'literal'{{else}}{{.Name}}{{end}})") // want `may violate t.name \(NOT NULL on t.name, SQLSTATE 23502\).*\[if@\d+:else\]` + +// (d) {{with .Order}} guards .Order itself only, not a path nested under it: .Name here +// resolves (dot-relative) to the absolute path .Order.Name, a *different* path from the +// guarded .Order, so it gets no benefit from the guard. +// +// (d1) When Name is not a pointer, there was never a NOT NULL violation to begin with +// (its own zero value, not NULL, is what would be sent) -- this case is unaffected by +// guarding either way. +type OrderD1 struct { + Name string +} + +type PD1 struct { + ID int64 + Order *OrderD1 +} + +var qd1 = sqlshape.Query[struct{}, PD1]("-- sqlshape: expect t_pkey\nINSERT INTO t (id{{with .Order}}, name{{end}}) VALUES ({{.ID}}{{with .Order}}, {{.Name}}{{end}})") + +// (d2) When Name is itself a pointer, .Order being non-nil (proven by the with) says +// nothing about .Order.Name's own nilness: the violation still fires, because only a +// condition on .Order.Name itself (not .Order) would prove it. +type OrderD2 struct { + Name *string +} + +type PD2 struct { + ID int64 + Order *OrderD2 +} + +var qd2 = sqlshape.Query[struct{}, PD2]("-- sqlshape: expect t_pkey\nINSERT INTO t (id{{with .Order}}, name{{end}}) VALUES ({{.ID}}{{with .Order}}, {{.Name}}{{end}})") // want `may violate t.name \(NOT NULL on t.name, SQLSTATE 23502\)` diff --git a/cmd/sqlshape/internal/vet/testdata/src/marker/marker.go b/cmd/sqlshape/internal/vet/testdata/src/marker/marker.go new file mode 100644 index 0000000..3f6afa6 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/src/marker/marker.go @@ -0,0 +1,16 @@ +package marker + +import "mydb" + +// -query=mydb.Query,mydb.One:one: the program's own markers are checked like sqlshape's + +type Order struct { + ID int64 + Total string +} + +var ok = mydb.Query[Order, struct{}](`SELECT id, total FROM orders`) + +var badColumn = mydb.Query[Order, struct{}](`SELECT id, totl FROM orders`) // want `column "totl" does not exist` + +var notOne = mydb.One[Order, struct{}](`SELECT id, total FROM orders`) // want `cannot prove at most one row` diff --git a/cmd/sqlshape/internal/vet/testdata/src/mydb/mydb.go b/cmd/sqlshape/internal/vet/testdata/src/mydb/mydb.go new file mode 100644 index 0000000..14f4dd1 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/src/mydb/mydb.go @@ -0,0 +1,11 @@ +// Package mydb is a program's own statement marker: a generic function over the template, +// registered with -query=mydb.Query,mydb.One:one. The checker reads it like sqlshape.Query. +package mydb + +type Stmt[R, P any] struct{ SQL string } + +func Query[R, P any](sql string) Stmt[R, P] { return Stmt[R, P]{SQL: sql} } + +type Single[R, P any] struct{ SQL string } + +func One[R, P any](sql string) Single[R, P] { return Single[R, P]{SQL: sql} } diff --git a/cmd/sqlshape/internal/vet/testdata/src/mysqlstrict/mysqlstrict.go b/cmd/sqlshape/internal/vet/testdata/src/mysqlstrict/mysqlstrict.go new file mode 100644 index 0000000..58b3451 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/src/mysqlstrict/mysqlstrict.go @@ -0,0 +1,9 @@ +// Package mysqlstrict reads the -strict advisories of a MySQL schema: an engine that +// enforces no constraints, and a CHECK declared NOT ENFORCED. +package mysqlstrict + +import ( + "github.com/kr9ly/sqlshape/v2" +) + +var lastLog = sqlshape.One[struct{ ID int32 }, struct{ ID int32 }](`SELECT id FROM logs WHERE id = {{.ID}}`) // want `schema: table logs uses ENGINE=MyISAM: foreign keys are not enforced, and a statement is not atomic under it` `schema: table logs: CHECK c is NOT ENFORCED, so it documents an intent the server does not check` `parameter .ID carries key logs.id as a plain int32` diff --git a/cmd/sqlshape/internal/vet/testdata/src/mysqlvet/mysqlvet.go b/cmd/sqlshape/internal/vet/testdata/src/mysqlvet/mysqlvet.go new file mode 100644 index 0000000..f2136b0 --- /dev/null +++ b/cmd/sqlshape/internal/vet/testdata/src/mysqlvet/mysqlvet.go @@ -0,0 +1,28 @@ +// Package mysqlvet runs the analyzer against a MySQL schema in-process: the obligation +// declared on a view, and the type-binding directive MySQL has nothing to bind to. +package mysqlvet + +import ( + "github.com/kr9ly/sqlshape/v2" +) + +// sqlshape: type point +type Geo string // want `type Geo: .*binds to a named type of the schema, and mysql has none` Geo:`carries point` + +var pinned = sqlshape.Query[uint64, struct{ Tenant uint64 }](`SELECT id FROM v_orders WHERE tenant_id = {{.Tenant}}`) + +var unpinned = sqlshape.Query[uint64, struct{}](`SELECT id FROM v_orders`) // want `v_orders.tenant_id is not pinned: every statement on v_orders must fix tenant_id by equality \(or assign it\)` + +var insertAssigns = sqlshape.Query[struct{}, struct { + ID uint64 + Tenant uint64 + Total int32 +}](`-- sqlshape: expect PRIMARY +INSERT INTO v_orders (id, tenant_id, total) VALUES ({{.ID}}, {{.Tenant}}, {{.Total}})`) + +// A construct the AST layer does not fold (CAST ... AT TIME ZONE) is reported with the +// statement's own text quoted, not silently accepted. +var unsupported = sqlshape.Query[struct{ X string }, struct{}](`SELECT CAST(NOW() AT TIME ZONE '+00:00' AS DATETIME) AS x FROM v_orders WHERE tenant_id = 1`) // want `unsupported construct simple_expr/\d+ at byte 7: "CAST\(NOW\(\) AT TIME ZONE '\+00:00' AS DATETIME\)"` + +// A syntax error carries MySQL's own number, positioned at the offending token. +var syntaxError = sqlshape.Query[uint64, struct{}](`SELECT id FROM v_orders WHERE ORDER BY id`) // want `syntax error \(MySQL error 1064\)` diff --git a/internal/vet/testdata/src/otherschema/otherschema.go b/cmd/sqlshape/internal/vet/testdata/src/otherschema/otherschema.go similarity index 85% rename from internal/vet/testdata/src/otherschema/otherschema.go rename to cmd/sqlshape/internal/vet/testdata/src/otherschema/otherschema.go index 12091e6..3e5da8d 100644 --- a/internal/vet/testdata/src/otherschema/otherschema.go +++ b/cmd/sqlshape/internal/vet/testdata/src/otherschema/otherschema.go @@ -1,6 +1,6 @@ package otherschema -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" // A relation outside the public schema: checkReferences qualifies its name with the // schema, unlike a public-schema reference. diff --git a/internal/vet/testdata/src/owner/owner.go b/cmd/sqlshape/internal/vet/testdata/src/owner/owner.go similarity index 87% rename from internal/vet/testdata/src/owner/owner.go rename to cmd/sqlshape/internal/vet/testdata/src/owner/owner.go index f470b7b..edd55f1 100644 --- a/internal/vet/testdata/src/owner/owner.go +++ b/cmd/sqlshape/internal/vet/testdata/src/owner/owner.go @@ -1,6 +1,9 @@ package owner -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" + +// OrderTooLarge names the trigger's P0401: the INSERT below may raise it. +var OrderTooLarge = sqlshape.Error("P0401") // want OrderTooLarge:`sqlshape.Error\(P0401\)` // -require-columns=user_id: every statement on a table with user_id must pin it diff --git a/internal/vet/testdata/src/plpgsql/plpgsql.go b/cmd/sqlshape/internal/vet/testdata/src/plpgsql/plpgsql.go similarity index 92% rename from internal/vet/testdata/src/plpgsql/plpgsql.go rename to cmd/sqlshape/internal/vet/testdata/src/plpgsql/plpgsql.go index 6a7f4ef..e580dd3 100644 --- a/internal/vet/testdata/src/plpgsql/plpgsql.go +++ b/cmd/sqlshape/internal/vet/testdata/src/plpgsql/plpgsql.go @@ -1,9 +1,12 @@ package plpgsql -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" type AccountID int64 // want AccountID:`bound k accounts.id` +// Overdrawn names withdraw()'s own AC001. +var Overdrawn = sqlshape.Error("AC001") // want Overdrawn:`sqlshape.Error\(AC001\)` + type Move struct { ID AccountID Amount int64 diff --git a/internal/vet/testdata/src/policy/policy.go b/cmd/sqlshape/internal/vet/testdata/src/policy/policy.go similarity index 76% rename from internal/vet/testdata/src/policy/policy.go rename to cmd/sqlshape/internal/vet/testdata/src/policy/policy.go index 4d3e9e0..b166a86 100644 --- a/internal/vet/testdata/src/policy/policy.go +++ b/cmd/sqlshape/internal/vet/testdata/src/policy/policy.go @@ -1,6 +1,12 @@ package policy -import "github.com/kr9ly/sqlshape" +import ( + "github.com/kr9ly/sqlshape/v2" + "github.com/kr9ly/sqlshape/postgres/v2" +) + +// OrderTooLarge names the trigger's P0401: the UPDATE below may raise it. +var OrderTooLarge = sqlshape.Error("P0401") // want OrderTooLarge:`sqlshape.Error\(P0401\)` // -no-tables: application code reads views and calls functions only @@ -15,4 +21,4 @@ var write = sqlshape.Query[struct{}, struct{ ID int64 }]("-- sqlshape: expect P0 // interpreted string: the diagnostic lands on the reference inside the template var escaped = sqlshape.Query[int64, struct{}]("SELECT id\n FROM orders") // want `table orders is referenced directly` -var bulk = sqlshape.Copy[struct{ ID int64 }]("users", "id") // want `table users is written directly; with -no-tables application code reads views and calls functions only` +var bulk = postgres.Copy[struct{ ID int64 }]("users", "id") // want `table users is written directly; with -no-tables application code reads views and calls functions only` diff --git a/internal/vet/testdata/src/raw/raw.go b/cmd/sqlshape/internal/vet/testdata/src/raw/raw.go similarity index 100% rename from internal/vet/testdata/src/raw/raw.go rename to cmd/sqlshape/internal/vet/testdata/src/raw/raw.go diff --git a/internal/vet/testdata/src/rawforbid/rawforbid.go b/cmd/sqlshape/internal/vet/testdata/src/rawforbid/rawforbid.go similarity index 84% rename from internal/vet/testdata/src/rawforbid/rawforbid.go rename to cmd/sqlshape/internal/vet/testdata/src/rawforbid/rawforbid.go index f5619b2..1cf38d7 100644 --- a/internal/vet/testdata/src/rawforbid/rawforbid.go +++ b/cmd/sqlshape/internal/vet/testdata/src/rawforbid/rawforbid.go @@ -11,7 +11,7 @@ import ( const byID = "SELECT id FROM users WHERE id = $1" func f(ctx context.Context, c *pgx.Conn, id int64) { - c.Query(ctx, byID, id) // want `pgx.Query executes SQL outside sqlshape; with -raw-sql=forbid every statement goes through sqlshape.Query / One / Copy \(or list the package in -raw-sql-allow\)` + c.Query(ctx, byID, id) // want `pgx.Query executes SQL outside sqlshape; with -raw-sql=forbid every statement goes through sqlshape.Query / One / postgres.Copy \(or list the package in -raw-sql-allow\)` c.CopyFrom(ctx, pgx.Identifier{"users"}, []string{"id"}, nil) // want `pgx.CopyFrom executes SQL outside sqlshape` var b pgx.Batch b.Queue(byID, id) // want `pgx.Queue executes SQL outside sqlshape` diff --git a/internal/vet/testdata/src/rawok/rawok.go b/cmd/sqlshape/internal/vet/testdata/src/rawok/rawok.go similarity index 100% rename from internal/vet/testdata/src/rawok/rawok.go rename to cmd/sqlshape/internal/vet/testdata/src/rawok/rawok.go diff --git a/internal/vet/testdata/src/reads/reads.go b/cmd/sqlshape/internal/vet/testdata/src/reads/reads.go similarity index 85% rename from internal/vet/testdata/src/reads/reads.go rename to cmd/sqlshape/internal/vet/testdata/src/reads/reads.go index 3bb2e35..4acae46 100644 --- a/internal/vet/testdata/src/reads/reads.go +++ b/cmd/sqlshape/internal/vet/testdata/src/reads/reads.go @@ -1,6 +1,9 @@ package reads -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" + +// OrderTooLarge names the trigger's P0401: the writes below may raise it. +var OrderTooLarge = sqlshape.Error("P0401") // want OrderTooLarge:`sqlshape.Error\(P0401\)` // -no-table-reads: reads go through views; tables are written, not read diff --git a/internal/vet/testdata/src/rls/rls.go b/cmd/sqlshape/internal/vet/testdata/src/rls/rls.go similarity index 55% rename from internal/vet/testdata/src/rls/rls.go rename to cmd/sqlshape/internal/vet/testdata/src/rls/rls.go index 6876497..4d05f32 100644 --- a/internal/vet/testdata/src/rls/rls.go +++ b/cmd/sqlshape/internal/vet/testdata/src/rls/rls.go @@ -1,7 +1,11 @@ package rls -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" -// Row-level security: the schema problems and the -strict advisories about it are -// reported once, on the first query of the package. +// Row-level security: the schema problems (always reported) and the -strict advisories +// about a relation (reported only for a package that actually touches that relation -- +// vault below is claimed because vaultCount reads vault, not merely because -strict is +// on) are all attached to the first query of the package. var docs = sqlshape.Query[*string, struct{}](`SELECT body FROM docs`) // want `schema .*: drafts has policies but row level security is not enabled, so they do not apply: ALTER TABLE drafts ENABLE ROW LEVEL SECURITY` `schema: vault has row level security enabled and no policy: every role but the owner sees no rows` `schema: policy docs_tenant on docs reads current_setting\("app.tenant", true\): a session that never set it gets NULL` `schema: function all_docs is SECURITY DEFINER and reaches docs, whose policies do not bind the owner` + +var vaultCount = sqlshape.Query[int64, struct{}](`SELECT count(*) FROM vault`) diff --git a/internal/vet/testdata/src/rlspin/rlspin.go b/cmd/sqlshape/internal/vet/testdata/src/rlspin/rlspin.go similarity index 88% rename from internal/vet/testdata/src/rlspin/rlspin.go rename to cmd/sqlshape/internal/vet/testdata/src/rlspin/rlspin.go index f796f18..ca6b36a 100644 --- a/internal/vet/testdata/src/rlspin/rlspin.go +++ b/cmd/sqlshape/internal/vet/testdata/src/rlspin/rlspin.go @@ -1,6 +1,6 @@ package rlspin -import "github.com/kr9ly/sqlshape" +import "github.com/kr9ly/sqlshape/v2" // -require-columns=tenant_id with -strict: a row-level security policy that fixes the // column pins it; when row security is not forced the pin holds for non-owner roles only. @@ -9,6 +9,10 @@ type TenantID string // want TenantID:`bound k tenants.id` var sealed = sqlshape.Query[int64, struct{}](`SELECT count(*) FROM sealed`) // want `schema .*: drafts has policies but row level security is not enabled` `schema: vault has row level security enabled and no policy` `schema: policy docs_tenant on docs reads current_setting` `schema: function all_docs is SECURITY DEFINER` +// vaultCount makes the vault advisory above appear: it is scoped to a package that +// actually touches vault (reportAdvice, vet.go), not to -strict alone. +var vaultCount = sqlshape.Query[int64, struct{}](`SELECT count(*) FROM vault`) + var docs = sqlshape.Query[int64, struct{}](`SELECT count(*) FROM docs`) // want `docs.tenant_id is pinned by policy docs_tenant for roles subject to row security, not for the table's owner: FORCE ROW LEVEL SECURITY if the application connects as the owner` var drafts = sqlshape.Query[int64, struct{}](`SELECT count(*) FROM drafts`) // want `drafts.tenant_id is not pinned: every statement on drafts must fix tenant_id by equality` diff --git a/internal/vet/testdata/src/schemas/schemas.go b/cmd/sqlshape/internal/vet/testdata/src/schemas/schemas.go similarity index 75% rename from internal/vet/testdata/src/schemas/schemas.go rename to cmd/sqlshape/internal/vet/testdata/src/schemas/schemas.go index 6a1c2c8..0e09ef8 100644 --- a/internal/vet/testdata/src/schemas/schemas.go +++ b/cmd/sqlshape/internal/vet/testdata/src/schemas/schemas.go @@ -1,13 +1,16 @@ package schemas -import "github.com/kr9ly/sqlshape" +import ( + "github.com/kr9ly/sqlshape/v2" + "github.com/kr9ly/sqlshape/postgres/v2" +) // -schemas=other: this package's statements and Copy calls read/write "public", which is // outside the allowed set. var q = sqlshape.Query[int64, struct{}](`SELECT id FROM orders`) // want `orders is outside the schemas this code may reference \(other\)` -var bulk = sqlshape.Copy[ItemIn]("order_items") // want `order_items is outside the schemas this code may reference \(other\)` +var bulk = postgres.Copy[ItemIn]("order_items") // want `order_items is outside the schemas this code may reference \(other\)` type ItemIn struct { OrderID int64 diff --git a/internal/vet/testdata/src/schemasok/schemasok.go b/cmd/sqlshape/internal/vet/testdata/src/schemasok/schemasok.go similarity index 71% rename from internal/vet/testdata/src/schemasok/schemasok.go rename to cmd/sqlshape/internal/vet/testdata/src/schemasok/schemasok.go index 533f86c..7f91ace 100644 --- a/internal/vet/testdata/src/schemasok/schemasok.go +++ b/cmd/sqlshape/internal/vet/testdata/src/schemasok/schemasok.go @@ -1,13 +1,16 @@ package schemasok -import "github.com/kr9ly/sqlshape" +import ( + "github.com/kr9ly/sqlshape/v2" + "github.com/kr9ly/sqlshape/postgres/v2" +) // -schemas=public: this package's statements and Copy calls read/write "public", which is // in the allowed set, so nothing is reported. var q = sqlshape.Query[int64, struct{}](`SELECT id FROM orders`) -var bulk = sqlshape.Copy[ItemIn]("order_items") +var bulk = postgres.Copy[ItemIn]("order_items") type ItemIn struct { OrderID int64 diff --git a/internal/vet/testdata/src/strict/strict.go b/cmd/sqlshape/internal/vet/testdata/src/strict/strict.go similarity index 86% rename from internal/vet/testdata/src/strict/strict.go rename to cmd/sqlshape/internal/vet/testdata/src/strict/strict.go index 7f28663..5a79ba5 100644 --- a/internal/vet/testdata/src/strict/strict.go +++ b/cmd/sqlshape/internal/vet/testdata/src/strict/strict.go @@ -4,7 +4,7 @@ import ( "time" "github.com/jackc/pgx/v5/pgtype" - "github.com/kr9ly/sqlshape" + "github.com/kr9ly/sqlshape/v2" ) type OrderStatus string // want OrderStatus:`bound e order_status` @@ -13,12 +13,24 @@ type OrderID int64 // want OrderID:`bound k orders.id` type UserID int64 // want UserID:`bound k users.id` +// OrderTooLarge names the trigger's P0401: this package's INSERT below may raise it. +var OrderTooLarge = sqlshape.Error("P0401") // want OrderTooLarge:`sqlshape.Error\(P0401\)` + // advisory findings, reported only with -strict +// Schema.Advice's "materialized view order_stats has no unique index" advisory is about +// order_stats specifically: reportAdvice (vet.go) reports it for this package only because +// matviewOrderStats below actually reads order_stats -- every schema-level advisory lands +// at this package's first statement's position (calls[0]), not at the statement that +// triggered it -- not merely because -strict is on. A package that never mentions +// order_stats would not get it, which is what plainEnumParam and pointerEnumParam below no +// longer claim. var plainEnumParam = sqlshape.Query[OrderID, struct{ S OrderStatus }](`SELECT id FROM orders WHERE status = {{.S}}`) // want `schema: materialized view order_stats has no unique index, so REFRESH MATERIALIZED VIEW CONCURRENTLY is not possible` `schema: orders.status is enum order_status: a seeded lookup table .* is easier to change` `no index on orders leads with any of \(status\): this predicate scans the whole table` `parameter .S is a non-pointer strict.OrderStatus: its zero value "" is not a label of enum order_status and fails at runtime \(SQLSTATE 22P02\) when unset` var pointerEnumParam = sqlshape.Query[OrderID, struct{ S *OrderStatus }](`SELECT id FROM orders WHERE status = {{.S}}`) // want `no index on orders leads with any of \(status\)` +var matviewOrderStats = sqlshape.Query[struct{ N int64 }, struct{}](`SELECT n FROM order_stats`) + type Times struct { UpdatedAt *time.Time Born *time.Time diff --git a/internal/vet/typebind.go b/cmd/sqlshape/internal/vet/typebind.go similarity index 73% rename from internal/vet/typebind.go rename to cmd/sqlshape/internal/vet/typebind.go index 896dc41..fbf1c9a 100644 --- a/internal/vet/typebind.go +++ b/cmd/sqlshape/internal/vet/typebind.go @@ -5,9 +5,8 @@ import ( "go/token" "go/types" "regexp" - "strings" - "github.com/kr9ly/sqlshape/internal/catalog" + "github.com/kr9ly/sqlshape/v2/x/dialect" ) // Declared type bindings. A Go type can name the PostgreSQL type it carries in its doc @@ -35,8 +34,8 @@ func init() { } type declaredType struct { - pg string - oid catalog.OID // 0 when the PG type does not exist (reported at the declaration) + pg string + named string // the type's canonical name (dialect.Type.Named); "" when it does not exist (reported at the declaration) } var typeDirective = regexp.MustCompile(`(?m)^\s*sqlshape:\s*type\s+(\S+)\s*$`) @@ -69,15 +68,16 @@ func (c *checker) collectDeclaredTypes() { continue } dt := declaredType{pg: m[1]} - sch, name := "", m[1] - if i := strings.LastIndex(name, "."); i >= 0 { - sch, name = name[:i], name[i+1:] - } - if c.s != nil { - if t := c.s.Types.Lookup(sch, name); t != nil { - dt.oid = t.OID - } else { + if c.sch != nil { + if t, ok := c.sch.Type(m[1]); ok { + dt.named = t.Named + } else if c.ls.dialectName == dialect.Postgres { c.pass.Reportf(ts.Name.Pos(), "sqlshape: type %s: PostgreSQL type %q does not exist in the schema", obj.Name(), m[1]) + } else { + // MySQL (and any dialect without named types) has nothing for the + // directive to bind to; saying "type %q does not exist" would send + // the reader hunting for a spelling + c.pass.Reportf(ts.Name.Pos(), "sqlshape: type %s: `// sqlshape: type` binds to a named type of the schema, and %s has none", obj.Name(), c.ls.dialectName) } } c.declared[obj] = dt @@ -101,26 +101,33 @@ func (c *checker) declaredOf(t types.Type) (declaredType, bool) { return declaredType{}, false } dt := declaredType{pg: f.PG} - sch, name := "", f.PG - if i := strings.LastIndex(name, "."); i >= 0 { - sch, name = name[:i], name[i+1:] - } - if pt := c.s.Types.Lookup(sch, name); pt != nil { - dt.oid = pt.OID + if c.sch != nil { + if t, ok := c.sch.Type(f.PG); ok { + dt.named = t.Named + } } c.declared[named.Obj()] = dt return dt, true } -// matchDeclared decides a declared type against a PG type: the same type (domains over it -// included), and, unless the PG type is a composite fed by the struct's fields, the Go -// type must do its own decoding / encoding. -func (c *checker) matchDeclared(dt declaredType, t types.Type, pg, base catalog.OID, param bool) fit { - if dt.oid == 0 || (pg != dt.oid && base != dt.oid) { +// matchDeclared decides a declared type against a dialect type: the same named type (a +// domain over it included), and, unless the type is a composite fed by the struct's +// fields, the Go type must do its own decoding / encoding. +func (c *checker) matchDeclared(dt declaredType, t types.Type, dtype dialect.Type, param bool) fit { + if dt.named == "" { + return fit{} + } + var matched *dialect.Type + for cur := &dtype; cur != nil; cur = cur.Base { + if cur.Named == dt.named { + matched = cur + break + } + } + if matched == nil { return fit{} } - pt := c.s.Types.ByOID(dt.oid) - if _, isStruct := t.Underlying().(*types.Struct); isStruct && (pt == nil || pt.Kind != 'c') { + if _, isStruct := t.Underlying().(*types.Struct); isStruct && matched.Kind != dialect.Composite { if !param && !implementsScanner(t) { return fit{ok: true, lossy: t.String() + " carries " + dt.pg + " but does not implement sql.Scanner: pgx cannot decode into it"} } diff --git a/internal/vet/typebind_test.go b/cmd/sqlshape/internal/vet/typebind_test.go similarity index 100% rename from internal/vet/typebind_test.go rename to cmd/sqlshape/internal/vet/typebind_test.go diff --git a/internal/vet/vet.go b/cmd/sqlshape/internal/vet/vet.go similarity index 67% rename from internal/vet/vet.go rename to cmd/sqlshape/internal/vet/vet.go index 3f6ed1c..2d8bc46 100644 --- a/internal/vet/vet.go +++ b/cmd/sqlshape/internal/vet/vet.go @@ -24,16 +24,18 @@ import ( "golang.org/x/tools/go/analysis/passes/inspect" "golang.org/x/tools/go/ast/inspector" - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/catalog" - "github.com/kr9ly/sqlshape/internal/consumers" - "github.com/kr9ly/sqlshape/internal/expand" - "github.com/kr9ly/sqlshape/internal/facts" - "github.com/kr9ly/sqlshape/internal/obligation" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/cmd/sqlshape/v2/internal/consumers" + "github.com/kr9ly/sqlshape/v2/x/cardinality" + "github.com/kr9ly/sqlshape/v2/x/dialect" + "github.com/kr9ly/sqlshape/v2/x/expand" + "github.com/kr9ly/sqlshape/v2/x/facts" + "github.com/kr9ly/sqlshape/v2/x/obligation" ) -const sqlshapePkg = "github.com/kr9ly/sqlshape" +const sqlshapePkg = "github.com/kr9ly/sqlshape/v2" + +// postgresPkg is the PostgreSQL runtime: MatView and Copy are declared there. +const postgresPkg = "github.com/kr9ly/sqlshape/postgres/v2" // Analyzer is the sqlshape checker. var Analyzer = &analysis.Analyzer{ @@ -69,6 +71,7 @@ func init() { Analyzer.Flags.StringVar(&requireCols, "require-columns", "", "comma-separated columns (e.g. tenant_id) every statement must pin by equality on each table that has them (row ownership); INSERTs must assign them") Analyzer.Flags.BoolVar(&coverageFlag, "coverage", false, "report per package how many Query / One declarations were checked and how many could not be (non-constant templates)") Analyzer.Flags.BoolVar(&syncComments, "sync-comments", false, "suggest doc comments for result struct fields and types from the schema's COMMENT ON (apply with sqlshape -fix)") + Analyzer.Flags.StringVar(&queryFlag, "query", "", "comma-separated marker functions of your own the checker treats like sqlshape.Query, as import/path.Func (append :one for a single-row marker like One): a generic F[R, P any](string) T whose argument is the template") Analyzer.Flags.BoolVar(&strictFlag, "strict", false, "also report advisory findings: enum / domain / key columns carried by unnamed Go types, timestamp / date received as time.Time, non-pointer enum parameters (zero value is no label), parameters that always override a column DEFAULT, LIMIT without ORDER BY, enum ordering") } @@ -79,14 +82,11 @@ var ( // loadedSchema is a schema plus the findings about the schema itself, reported once per package. type loadedSchema struct { - s *schema.Schema problems []string - // fnRefs are the relations each analyzable function body references (adviseSchema: - // SECURITY DEFINER functions past row-level security) - fnRefs map[*schema.Function][]analyze.RelationRef - // fnAdvice are the advisory notes of function bodies (a PL/pgSQL EXECUTE of a string - // built at run time), reported with -strict - fnAdvice map[*schema.Function][]analyze.Note + // dialect judges the statements; dialectName is what the schema declared (postgres + // when it declared nothing) + dialect dialect.Analyzer + dialectName string // decls are the obligations schema.sql declares (`visible where`, `require ...`); // the flags' obligations are added per run, since flags change between runs decls []obligation.Obligation @@ -95,18 +95,31 @@ type loadedSchema struct { caveats []string } -// lowerer is internal/obligation's view of the PostgreSQL analyzer. -type lowerer struct{ s *schema.Schema } +// contract is the dialect's schema contract for the obligations, nil when it has none. +func (ls *loadedSchema) contract() dialect.Contracted { + ct, _ := ls.dialect.(dialect.Contracted) + return ct +} -func (l lowerer) Lower(expr string, rel *schema.Relation) ([]facts.Pred, error) { - return analyze.Lower(l.s, expr, rel) +// schema is the dialect's schema for the checker, nil when it has none. +func (ls *loadedSchema) schema() dialect.Schema { + if sd, ok := ls.dialect.(dialect.Schemaed); ok { + return sd.Schema() + } + return nil } // judge runs the schema's own obligations over one statement of the schema (a view or // function body) and files the failures as problems, the caveats for -strict. func (ls *loadedSchema) judge(what string, f *facts.Facts) { - for _, d := range obligation.Check(ls.s, ls.decls, f, lowerer{ls.s}) { + ct := ls.contract() + if ct == nil || f == nil { + return + } + for _, d := range obligation.Check(ct.Contract(), ls.decls, f, ct) { switch { + case d.Obligation.Body.ViaView: + // a view's body is the sanctioned reader of a `require via view` table case d.Failed(): ls.problems = append(ls.problems, what+": "+d.Message) case d.Message != "": @@ -121,80 +134,61 @@ func loadSchema(path string) (*loadedSchema, error) { if s, ok := schemaCache[path]; ok { return s, nil } - src, err := schema.ReadSource(path) + src, err := dialect.ReadSchema(path) if err != nil { return nil, err } - s, err := analyze.Load(src) + decl, err := dialect.Declared(src) if err != nil { return nil, err } - ls := &loadedSchema{s: s, fnRefs: map[*schema.Function][]analyze.RelationRef{}, fnAdvice: map[*schema.Function][]analyze.Note{}} - for _, p := range s.Problems { - ls.problems = append(ls.problems, p.String()) + name := decl.Name + if name == "" { + name = dialect.Postgres } - var oblProblems []obligation.Problem - ls.decls, oblProblems = obligation.Declarations(s) - for _, p := range oblProblems { - ls.problems = append(ls.problems, fmt.Sprintf("%s: directive %q: %s", p.Subject, p.Source, p.Message)) + load, ok := dialect.Lookup(name) + if !ok { + return nil, fmt.Errorf("%s: dialect %q is not available in this build", path, name) } - // function bodies (LANGUAGE sql and plpgsql) are checked like PG does at CREATE time - for _, fn := range s.Functions { - fr, err := analyze.AnalyzeFunction(s, fn) - if err != nil { - ls.problems = append(ls.problems, fmt.Sprintf("function %s: %v", fn.Name, err)) - continue - } - ls.fnRefs[fn] = fr.Relations - for _, st := range fr.Statements { - what := "function " + fn.Name - if st.Line > 0 { - what = fmt.Sprintf("%s: line %d", what, st.Line) - } - ls.judge(what, st.Facts) - } - for _, n := range fr.Notes { - if n.Advisory() { - ls.fnAdvice[fn] = append(ls.fnAdvice[fn], n) - } else { - ls.problems = append(ls.problems, fmt.Sprintf("function %s: %s", fn.Name, n.Message)) + an, err := load(src) + if err != nil { + return nil, err + } + ls := &loadedSchema{dialect: an, dialectName: name, problems: an.Problems()} + if ct := ls.contract(); ct != nil { + var oblProblems []obligation.Problem + ls.decls, oblProblems = obligation.Declarations(ct.Contract()) + for _, p := range oblProblems { + ls.problems = append(ls.problems, fmt.Sprintf("%s: directive %q: %s", p.Subject, p.Source, p.Message)) + } + } + // the schema's own statements (function bodies, policies, view bodies) are checked like + // the database does at CREATE time, and judged by the schema's own obligations + if sd := ls.schema(); sd != nil { + // the same code named twice under different Names is the schema's own + // inconsistency, whether or not any program ever declares a Go binding for it + seen := map[string]dialect.ErrorName{} + for _, e := range sd.Errors() { + if prev, dup := seen[e.Code]; dup && prev.Name != e.Name { + ls.problems = append(ls.problems, fmt.Sprintf("%s: `-- sqlshape: error %s = %s` disagrees with %s's `= %s` for the same code", e.Subject, e.Code, e.Name, prev.Subject, prev.Name)) + } else if !dup { + seen[e.Code] = e } } - } - // row-level security policies: predicates type-checked like CREATE POLICY does, and - // policies on a table whose row security is off do not apply at all - for _, rel := range s.Relations { - for _, pol := range rel.Policies { - notes, err := analyze.AnalyzePolicy(s, rel, pol) - if err != nil { - ls.problems = append(ls.problems, fmt.Sprintf("policy %s on %s: %v", pol.Name, rel.FullName(), err)) + for _, d := range sd.Definitions() { + prefix := "" + if d.What != "" { + prefix = d.What + ": " } - for _, n := range notes { - if !n.Advisory() { - ls.problems = append(ls.problems, fmt.Sprintf("policy %s on %s: %s", pol.Name, rel.FullName(), n.Message)) - } + if d.Err != "" { + ls.problems = append(ls.problems, prefix+d.Err) + continue } - } - if len(rel.Policies) > 0 && !rel.RowSecurity { - ls.problems = append(ls.problems, fmt.Sprintf("%s has policies but row level security is not enabled, so they do not apply: ALTER TABLE %s ENABLE ROW LEVEL SECURITY", rel.FullName(), rel.FullName())) - } - } - // view bodies: sqlshape's own findings (policies, domains) are the view's - for _, rel := range s.Relations { - if rel.Kind != schema.View && rel.Kind != schema.MatView { - continue - } - r, err := analyze.AnalyzeView(s, rel) - if err != nil { - ls.problems = append(ls.problems, fmt.Sprintf("view %s: %v", rel.Name, err)) - continue - } - for _, n := range r.Notes { - if !n.Advisory() { - ls.problems = append(ls.problems, fmt.Sprintf("view %s: %s", rel.Name, n.Message)) + for _, n := range d.Notes { + ls.problems = append(ls.problems, prefix+n.Message) } + ls.judge(d.What, d.Facts) } - ls.judge("view "+rel.Name, r.Facts) } schemaCache[path] = ls return ls, nil @@ -226,8 +220,9 @@ func findSchema(pass *analysis.Pass) (string, error) { type checker struct { pass *analysis.Pass - s *schema.Schema ls *loadedSchema + sch dialect.Schema // the dialect's schema, nil when it gives none + ct dialect.Contracted // the dialect's contract for the obligations, nil when it gives none strict bool bindings map[*types.TypeName]*binding // constDecls: this package's constant declarations, for mapping concatenated templates back to source @@ -242,6 +237,17 @@ type checker struct { owners map[*ast.CallExpr]string // decls are the obligations in force: the schema's declarations plus the flags' decls []obligation.Obligation + // errorDecls: this package's own `var X = sqlshape.Error(code)` declarations, by the + // var object (errornames.go). + errorDecls map[*types.Var]errorDecl + // expectPos is the first position an expect line in this package names a code or a + // Name at, for errornames.go's "no Go declaration" diagnostic to land on. + expectPos map[string]token.Pos + // possibleErrors are the named violations (Name != "") possible in some expansion of + // some call in this package, by code: what errornames.go requires a Go declaration for + // (a code the schema declares that no statement in this package can ever raise is not + // this package's business, the way an unused enum label is nobody's). + possibleErrors map[string]dialect.Violation } func run(pass *analysis.Pass) (any, error) { @@ -276,6 +282,7 @@ func run(pass *analysis.Pass) (any, error) { c := &checker{pass: pass, bindings: map[*types.TypeName]*binding{}} c.exportConstSets() c.collectDeclaredTypes() + c.collectErrorDecls() return index, nil } path, err := findSchema(pass) @@ -288,17 +295,20 @@ func run(pass *analysis.Pass) (any, error) { pass.Reportf(calls[0].Pos(), "sqlshape: load %s: %v", path, err) return index, nil } - s := ls.s - c := &checker{pass: pass, s: s, ls: ls, strict: strictFlag, bindings: map[*types.TypeName]*binding{}, index: index, owners: owners} - c.collectDeclaredTypes() + c := &checker{pass: pass, ls: ls, sch: ls.schema(), ct: ls.contract(), strict: strictFlag, bindings: map[*types.TypeName]*binding{}, index: index, owners: owners} for _, p := range ls.problems { pass.Reportf(calls[0].Pos(), "sqlshape: schema %s: %s", path, p) } + c.collectErrorDecls() + if c.sch == nil { + c.runDialect(calls, matviews) + return index, nil + } + c.collectDeclaredTypes() if strictFlag { for _, p := range ls.caveats { pass.Reportf(calls[0].Pos(), "sqlshape: schema %s: %s", path, p) } - c.adviseSchema(calls[0].Pos()) } // the flags are shorthand for declarations: a context's waive lifts them the same way ctxName, ctxProblem := packageContext(pass) @@ -307,7 +317,9 @@ func run(pass *analysis.Pass) (any, error) { } else if ctxName != "" && !slices.Contains(obligation.Contexts(ls.decls), ctxName) { pass.Reportf(calls[0].Pos(), "sqlshape: context %q is not declared in %s (declared: %s)", ctxName, path, strings.Join(obligation.Contexts(ls.decls), ", ")) } - c.decls = obligation.InContext(append(ls.decls, obligation.FromFlags(s, requireCols, noTables, noTableReads)...), ctxName) + if c.ct != nil { + c.decls = obligation.InContext(append(ls.decls, obligation.FromFlags(c.ct.Contract(), requireCols, noTables, noTableReads)...), ctxName) + } for _, call := range calls[:len(calls)-len(matviews)] { c.checkCall(call) } @@ -322,10 +334,37 @@ func run(pass *analysis.Pass) (any, error) { c.checkMatView(call) } } + if strictFlag { + // the index is only complete once every call (and copy / matview) has been + // checked, so an Advice about a relation the package never actually touches is + // reported here, not up where Advice() was first available + c.reportAdvice(calls[0].Pos()) + } c.finishBindings() + c.checkErrorDeclsCovered(calls[0].Pos()) return index, nil } +// reportAdvice reports the schema's -strict advisories, narrowed to this package: an +// advisory about a relation (or one of its columns) is reported only when this package's +// statements actually reference that relation (that column, when the advisory names one); +// a schema-wide advisory (Table == "") is always reported, as every advisory always was +// before Advice carried a subject. +func (c *checker) reportAdvice(at token.Pos) { + for _, a := range c.sch.Advice() { + if a.Table != "" { + if a.Column != "" { + if len(c.index.Column(a.Table, a.Column)) == 0 { + continue + } + } else if len(c.index.Relation(a.Table)) == 0 { + continue + } + } + c.pass.Reportf(at, "sqlshape: schema: %s", a.Message) + } +} + // owner names the declaration a call sits in: "pkg.Func", "pkg.(*T).Method", "pkg.var"; // the package path alone at the top level of an expression outside any declaration. func owner(pass *analysis.Pass, stack []ast.Node) string { @@ -352,37 +391,140 @@ func (c *checker) site(call *ast.CallExpr, at token.Pos) consumers.Site { } // record adds one expansion's relation and column uses to the index. -func (c *checker) record(call *ast.CallExpr, e *expand.Expansion, r *analyze.Result, lit literal) { +func (c *checker) record(call *ast.CallExpr, e *expand.Expansion, r *dialect.Result, lit literal) { for _, ref := range r.Relations { - name := ref.Name - if ref.Schema != "public" { - name = ref.Schema + "." + name - } - c.index.AddRelation(name, c.site(call, lit.pos(e.TemplatePos(int(ref.Position)-1)))) + c.index.AddRelation(ref.Name, c.site(call, lit.pos(e.TemplatePos(max(ref.Position, 0))))) } for _, u := range r.Uses { - c.index.AddColumn(u.Table, u.Column, c.site(call, lit.pos(e.TemplatePos(int(u.Position)-1)))) + c.index.AddColumn(u.Table, u.Column, c.site(call, lit.pos(e.TemplatePos(max(int(u.Position), 0))))) } } -// isQueryCall recognizes sqlshape.Query[R, P](...) and sqlshape.One[R, P](...). -func isQueryCall(pass *analysis.Pass, call *ast.CallExpr) bool { - var fun ast.Expr = call.Fun +// queryFlag is -query: marker functions of the program's own, checked like Query / One. +var queryFlag string + +// marker is a function whose call declares a statement: sqlshape.Query, sqlshape.One, or one +// the program registered with -query. single is a One-like marker (at most one row). +type marker struct { + pkg, name string + single bool +} + +var ( + markersOnce sync.Once + markersParsed []marker + markersErr error +) + +// markers is the set in force: the built-in two and the -query ones. +func markers() ([]marker, error) { + markersOnce.Do(func() { + markersParsed = []marker{{sqlshapePkg, "Query", false}, {sqlshapePkg, "One", true}} + for _, item := range strings.Split(queryFlag, ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + single := false + if rest, ok := strings.CutSuffix(item, ":one"); ok { + item, single = rest, true + } + dot := strings.LastIndexByte(item, '.') + if dot <= 0 || dot == len(item)-1 { + markersErr = fmt.Errorf("sqlshape: -query %q: want import/path.Func (optionally :one)", item) + return + } + markersParsed = append(markersParsed, marker{item[:dot], item[dot+1:], single}) + } + }) + return markersParsed, markersErr +} + +// markerOf is the marker a called function is, if any. +func markerOf(obj *types.Func) (marker, bool) { + if obj == nil || obj.Pkg() == nil { + return marker{}, false + } + ms, err := markers() + if err != nil { + return marker{}, false + } + for _, m := range ms { + if obj.Pkg().Path() == m.pkg && obj.Name() == m.name { + return m, true + } + } + return marker{}, false +} + +// calledFunc is the function a (possibly instantiated) selector call names. The call may +// go through a package-level var holding the instantiated generic function directly +// (`var q = sqlshape.Query[R, P]; q(...)`); calledFunc follows the var to its initializer +// so the returned *ast.Ident is still the one carrying the generic instantiation (the +// use-site ident, e.g. `q`, has none). +func calledFunc(pass *analysis.Pass, call *ast.CallExpr) (*types.Func, *ast.Ident) { + return resolveCallee(pass, call.Fun, map[*types.Var]bool{}) +} + +// resolveCallee unwraps a (possibly instantiated) selector expression, following at most +// one level of package-level var indirection per recursion, until it reaches the selector +// naming the function. +func resolveCallee(pass *analysis.Pass, fun ast.Expr, seen map[*types.Var]bool) (*types.Func, *ast.Ident) { switch f := fun.(type) { case *ast.IndexExpr: fun = f.X case *ast.IndexListExpr: fun = f.X } - sel, ok := fun.(*ast.SelectorExpr) - if !ok { - return false + switch f := fun.(type) { + case *ast.SelectorExpr: + obj, _ := pass.TypesInfo.Uses[f.Sel].(*types.Func) + return obj, f.Sel + case *ast.Ident: + v, ok := pass.TypesInfo.Uses[f].(*types.Var) + if !ok || v == nil || seen[v] { + return nil, nil + } + seen[v] = true + if init := varInit(pass, v); init != nil { + return resolveCallee(pass, init, seen) + } } - obj, ok := pass.TypesInfo.Uses[sel.Sel].(*types.Func) - if !ok || obj.Pkg() == nil { - return false + return nil, nil +} + +// varInit is the initializer expression at a package-level var's declaration (e.g. the +// `sqlshape.Query[int64, P]` in `var f = sqlshape.Query[int64, P]`), or nil if v is not +// such a var or has no single initializer. +func varInit(pass *analysis.Pass, v *types.Var) ast.Expr { + for _, f := range pass.Files { + for _, d := range f.Decls { + gd, ok := d.(*ast.GenDecl) + if !ok || gd.Tok != token.VAR { + continue + } + for _, sp := range gd.Specs { + vs := sp.(*ast.ValueSpec) + if len(vs.Values) != len(vs.Names) { + continue + } + for i, name := range vs.Names { + if obj, ok := pass.TypesInfo.Defs[name].(*types.Var); ok && obj == v { + return vs.Values[i] + } + } + } + } } - return obj.Pkg().Path() == sqlshapePkg && (obj.Name() == "Query" || obj.Name() == "One") + return nil +} + +// isQueryCall recognizes sqlshape.Query[R, P](...), sqlshape.One[R, P](...) and the markers +// registered with -query. +func isQueryCall(pass *analysis.Pass, call *ast.CallExpr) bool { + obj, _ := calledFunc(pass, call) + _, ok := markerOf(obj) + return ok } // literal is where diagnostics about the template text land. A template may be one @@ -537,45 +679,62 @@ func runeOfHex(s string) rune { return rune(r) } -func (c *checker) checkCall(call *ast.CallExpr) { +// site is a Query / One call taken apart: its type arguments, its template and the +// template's expansions. +type callSite struct { + rType, pType types.Type + single bool // One + lit literal + res *expand.Result +} + +// template reads a Query / One call: R and P, the constant template and its expansions. +// It reports what stops the call from being checked and returns false. +func (c *checker) template(call *ast.CallExpr) (callSite, bool) { pass := c.pass + var cs callSite // type arguments R, P - var fun ast.Expr = call.Fun - switch f := fun.(type) { - case *ast.IndexExpr: - fun = f.X - case *ast.IndexListExpr: - fun = f.X - } - ident := fun.(*ast.SelectorExpr).Sel - single := ident.Name == "One" + obj, ident := calledFunc(pass, call) + m, _ := markerOf(obj) + cs.single = m.single inst, ok := pass.TypesInfo.Instances[ident] if !ok || inst.TypeArgs.Len() != 2 { pass.Reportf(call.Pos(), "sqlshape: %s must be instantiated as %s[R, P]", ident.Name, ident.Name) - return + return cs, false } - rType, pType := inst.TypeArgs.At(0), inst.TypeArgs.At(1) + cs.rType, cs.pType = inst.TypeArgs.At(0), inst.TypeArgs.At(1) if len(call.Args) != 1 { - return + return cs, false } tv, ok := pass.TypesInfo.Types[call.Args[0]] if !ok || tv.Value == nil || tv.Value.Kind() != constant.String { c.unchecked++ pass.Reportf(call.Args[0].Pos(), "sqlshape: query template must be a string constant") - return + return cs, false } - lit := literal{text: constant.StringVal(tv.Value), fallback: call.Args[0].Pos(), segs: c.segments(call.Args[0], 0)} + cs.lit = literal{text: constant.StringVal(tv.Value), fallback: call.Args[0].Pos(), segs: c.segments(call.Args[0], 0)} - res, err := expand.Expand(lit.text) + res, err := expand.Expand(cs.lit.text) if err != nil { if te, ok := err.(*expand.Error); ok { - pass.Reportf(lit.pos(te.Pos), "sqlshape: template: %s", te.Msg) + pass.Reportf(cs.lit.pos(te.Pos), "sqlshape: template: %s", te.Msg) } else { - pass.Reportf(lit.pos(0), "sqlshape: template: %v", err) + pass.Reportf(cs.lit.pos(0), "sqlshape: template: %v", err) } + return cs, false + } + cs.res = res + return cs, true +} + +func (c *checker) checkCall(call *ast.CallExpr) { + pass := c.pass + cs, ok := c.template(call) + if !ok { return } + rType, pType, single, lit, res := cs.rType, cs.pType, cs.single, cs.lit, cs.res if c.strict { c.reportUnusedParams(pType, res, call.Pos()) } @@ -601,7 +760,7 @@ func (c *checker) checkCall(call *ast.CallExpr) { if res.Sparse && c.strict { pass.Reportf(lit.pos(0), "sqlshape: %d branch combinations exceed %d: checked sparsely (all branches off, all on, each on alone); the runtime cannot compare renderings with the checked set", res.Combinations, expand.MaxExpansions) } - possible := map[string]analyze.Violation{} + possible := map[string]dialect.Violation{} branch := map[string]string{} analyzedAll := true analyzed := 0 @@ -636,15 +795,15 @@ func (c *checker) checkCall(call *ast.CallExpr) { } // a text scan of the rendered SQL: it does not need the statement to analyze checkActionPlacement(e, lit, report, where) - r, err := analyze.Analyze(c.s, e.SQL) + r, err := c.ls.dialect.Analyze(e.SQL) if err != nil { analyzedAll = false - if ae, ok := err.(*analyze.Error); ok { + if de, ok := err.(*dialect.Error); ok { tp := 0 - if ae.Position > 0 { - tp = e.TemplatePos(int(ae.Position) - 1) + if de.Position >= 0 { + tp = e.TemplatePos(de.Position) } - report(lit.pos(tp), "%s (SQLSTATE %s)%s", ae.Message, ae.Code, where) + report(lit.pos(tp), "%v%s", de, where) } else { report(lit.pos(0), "%v%s", err, where) } @@ -653,26 +812,37 @@ func (c *checker) checkCall(call *ast.CallExpr) { c.checkReferences(e, r, lit, report, where) c.record(call, e, r, lit) for _, v := range c.possibleViolations(e, r, pType) { - if _, seen := possible[v.Key()]; !seen { - possible[v.Key()] = v - branch[v.Key()] = where + if _, seen := possible[v.Key]; !seen { + possible[v.Key] = v + branch[v.Key] = where } } for _, n := range r.Notes { - if n.Advisory() && !c.strict { + if n.Advisory && !c.strict { continue } tp := 0 - if n.Position > 0 { - tp = e.TemplatePos(int(n.Position) - 1) + if n.Position >= 0 { + tp = e.TemplatePos(n.Position) } - report(lit.pos(tp), "%s%s", n.Message, where) + msg := n.Message + if n.Param > 0 { + for _, p := range e.Params { + if p.N == n.Param { + msg = strings.ReplaceAll(msg, fmt.Sprintf("$%d", n.Param), p.Path.String()) + } + } + } + report(lit.pos(tp), "%s%s", msg, where) } - if single && !r.AtMostOne { - report(lit.pos(0), "One: cannot prove at most one row: %s%s", r.ManyRowsWhy, where) + if single { + // the One proof: on the facts, whatever the dialect + ok, why := cardinality.AtMostOne(r.Facts) + if !ok { + report(lit.pos(0), "One: cannot prove at most one row: %s%s", why, where) + } } c.checkParams(e, r, pType, lit, reportP, where) - checkBareOrderBy(c.s.Version, e, lit, report, where) d.addParams(e, r) d.addResult(r) for name, t := range c.checkResult(call.Pos(), r, rType, lit, reportR, where) { @@ -752,7 +922,7 @@ func (d *deduper) finish(msg string) string { } // checkParams matches each $n's Go origin against the inferred PG parameter type. -func (c *checker) checkParams(e *expand.Expansion, r *analyze.Result, pType types.Type, lit literal, report func(token.Pos, string, ...any), where string) { +func (c *checker) checkParams(e *expand.Expansion, r *dialect.Result, pType types.Type, lit literal, report func(token.Pos, string, ...any), where string) { for _, p := range e.Params { gt, err := c.resolvePath(pType, p.Path) if err != nil { @@ -762,101 +932,70 @@ func (c *checker) checkParams(e *expand.Expansion, r *analyze.Result, pType type if p.N-1 >= len(r.Params) { continue } - pg := r.Params[p.N-1] - c.meet(gt, pg, r.ParamSources[p.N-1], lit.pos(p.Pos), "parameter "+p.Path.String()) - f := c.paramFit(pg, gt) + prm := r.Params[p.N-1] + c.meet(gt, prm.Type, prm.Source, lit.pos(p.Pos), "parameter "+p.Path.String()) + f := c.fitPG(prm.Type, gt, true) switch { case !f.ok: - report(lit.pos(p.Pos), "parameter %s is %s but SQL expects %s%s", p.Path, gt, c.s.Types.Format(pg), where) + report(lit.pos(p.Pos), "parameter %s is %s but SQL expects %s%s", p.Path, gt, prm.Type.Name, where) case f.lossy != "": report(lit.pos(p.Pos), "parameter %s: %s%s", p.Path, f.lossy, where) case f.unknown: - report(lit.pos(p.Pos), "parameter %s: no known Go mapping for %s, not checked%s", p.Path, c.s.Types.Format(pg), where) + report(lit.pos(p.Pos), "parameter %s: no known Go mapping for %s, not checked%s", p.Path, prm.Type.Name, where) } if f.ok { // a composite (or composite[]) parameter: the struct's fields must line up with the type's columns - if col := c.paramColumn(pg); col != nil { - c.checkNested(*col, gt, lit.pos(p.Pos), "parameter "+p.Path.String(), report, where, true) - } + c.checkNested(dialect.Column{Type: prm.Type}, gt, lit.pos(p.Pos), "parameter "+p.Path.String(), report, where, true) } if c.strict && f.ok { - c.adviseParam(p, gt, pg, r.ParamSources[p.N-1], lit, report, where) + c.adviseParam(p, gt, prm.Type, prm.Source, f, lit, report, where) } } } // adviseParam reports advisory findings about a parameter (-strict). -func (c *checker) adviseParam(p expand.Param, gt types.Type, pg schema.TypeRef, src *analyze.Source, lit literal, report func(token.Pos, string, ...any), where string) { +func (c *checker) adviseParam(p expand.Param, gt types.Type, dt dialect.Type, src *dialect.Source, f fit, lit literal, report func(token.Pos, string, ...any), where string) { inner, nullable := unwrapNullable(gt) - if msg := c.fidelity(pg, gt); msg != "" { - report(lit.pos(p.Pos), "parameter %s: %s%s", p.Path, msg, where) + if f.advice != "" { + report(lit.pos(p.Pos), "parameter %s: %s%s", p.Path, f.advice, where) + } + base := dt + for base.Kind == dialect.Domain && base.Base != nil { + base = *base.Base } - if t := c.s.Types.ByOID(c.s.Types.BaseOf(pg).OID); t != nil && t.Kind == 'e' && !nullable && inner != nil { - report(lit.pos(p.Pos), "parameter %s is a non-pointer %s: its zero value \"\" is not a label of enum %s and fails at runtime (SQLSTATE 22P02) when unset%s", p.Path, gt, t.Name, where) + if base.Kind == dialect.Enum && !nullable && inner != nil { + report(lit.pos(p.Pos), "parameter %s is a non-pointer %s: its zero value \"\" is not a label of enum %s and fails at runtime (SQLSTATE 22P02) when unset%s", p.Path, gt, base.Named, where) } if src != nil && src.Assigned && !nullable { - if rel := c.relByFullName(src.Table); rel != nil { - if col := rel.Column(src.Column); col != nil { - switch { - case col.Default != nil: - report(lit.pos(p.Pos), "parameter %s always sends a value into %s.%s, so its DEFAULT never applies: decide which side owns the default (make the column conditional with {{if}} to use the database's)%s", p.Path, rel.Name, col.Name, where) - case col.Identity != 0: - report(lit.pos(p.Pos), "parameter %s sends a value into %s.%s, which the database generates%s", p.Path, rel.Name, col.Name, where) - } - } + switch { + case src.HasDefault: + report(lit.pos(p.Pos), "parameter %s always sends a value into %s.%s, so its DEFAULT never applies: decide which side owns the default (make the column conditional with {{if}} to use the database's)%s", p.Path, bareTable(src.Table), src.Column, where) + case src.Generated: + report(lit.pos(p.Pos), "parameter %s sends a value into %s.%s, which the database generates%s", p.Path, bareTable(src.Table), src.Column, where) } } } -// fidelity says where a Go type receives a PG type faithfully but with an implicit -// interpretation the application then owns (advisory). -func (c *checker) fidelity(pg schema.TypeRef, gt types.Type) string { - inner, _ := unwrapNullable(gt) - if inner == nil { - return "" - } - // an array parameter/result: apply the same advice to the element type, the way - // matchValue's array branch decides an element's fit (multi-dimensional arrays - // recurse the same way, one level of []/[N] at a time). - base := c.s.Types.BaseOf(pg) - if pt := c.s.Types.ByOID(base.OID); pt != nil && pt.Elem != 0 && strings.HasPrefix(pt.Name, "_") { - var elem types.Type - switch u := inner.Underlying().(type) { - case *types.Slice: - elem = u.Elem() - case *types.Array: - elem = u.Elem() - } - if elem == nil { - return "" - } - return c.fidelity(schema.TypeRef{OID: pt.Elem, Typmod: -1}, elem) - } - if !isNamed(inner, "time", "Time") { - return "" - } - switch base.OID { - case catalog.Timestamp: - return "timestamp without time zone into time.Time: which zone the value is in becomes the application's implicit choice (prefer timestamptz)" - case catalog.Date: - return "date into time.Time: a zone conversion can move the day (keep it at UTC midnight or use a civil date type)" - } - return "" -} - // checkResult matches result columns against R. It returns the struct fields that had // no result column in this expansion (name → type); checkCall decides whether that is // an error (missing everywhere) or an optional projection (missing in some branches, // allowed for nullable fields). -func (c *checker) checkResult(callPos token.Pos, r *analyze.Result, rType types.Type, lit literal, report func(token.Pos, string, ...any), where string) map[string]types.Type { +func (c *checker) checkResult(callPos token.Pos, r *dialect.Result, rType types.Type, lit literal, report func(token.Pos, string, ...any), where string) map[string]types.Type { at := callPos // `-- sqlshape: not null a, b` in the template overrides the analyzer's nullability + // a void column (SELECT some_procedure_like_function(...)) carries nothing: it binds to no field + var cols []dialect.Column + for _, col := range r.Columns { + if col.Type.Kind != dialect.Void { + cols = append(cols, col) + } + } overrides, _ := notNullOverrides(lit.text) for name, off := range overrides { found := false - for i := range r.Columns { - if r.Columns[i].Name == name { - r.Columns[i].Nullable = false + for i := range cols { + if cols[i].Name == name { + cols[i].Nullable = false found = true } } @@ -864,24 +1003,16 @@ func (c *checker) checkResult(callPos token.Pos, r *analyze.Result, rType types. report(lit.pos(off), "not null: the query has no result column %q%s", name, where) } } - // a void column (SELECT some_procedure_like_function(...)) carries nothing: it binds to no field - cols := r.Columns[:0:0] - for _, col := range r.Columns { - if col.Type.OID != catalog.Void { - cols = append(cols, col) - } - } - r.Columns = cols st, isStruct := rType.Underlying().(*types.Struct) if !isStruct || isNamed(rType, "time", "Time") { // scalar R: exactly one column - if len(r.Columns) != 1 { - report(at, "R is %s but the query returns %d columns%s", rType, len(r.Columns), where) + if len(cols) != 1 { + report(at, "R is %s but the query returns %d columns%s", rType, len(cols), where) return nil } - col := r.Columns[0] + col := cols[0] c.meet(rType, col.Type, col.Source, at, "R") - f := c.match(col.Type, rType) + f := c.fitPG(col.Type, rType, false) c.reportFit(report, at, "column "+col.Name, col, rType, f, where) if f.ok { c.checkNested(col, rType, at, "R", report, where, false) @@ -893,8 +1024,8 @@ func (c *checker) checkResult(callPos token.Pos, r *analyze.Result, rType types. } // struct R: every column needs a distinct name to bind to a field byName := map[string]int{} - for i, col := range r.Columns { - if col.Name == "?column?" { + for i, col := range cols { + if col.Name == "" { report(at, "result column %d has no name: give it an alias (... AS name) so it can bind to a field of %s%s", i+1, rType, where) continue } @@ -924,7 +1055,7 @@ func (c *checker) checkResult(callPos token.Pos, r *analyze.Result, rType types. } } matched := map[string]bool{} - for _, col := range r.Columns { + for _, col := range cols { fv, ok := fields[col.Name] if !ok { // case-insensitive fallback @@ -937,7 +1068,7 @@ func (c *checker) checkResult(callPos token.Pos, r *analyze.Result, rType types. } } if !ok { - if col.Name != "?column?" { + if col.Name != "" { report(at, "result column %q has no field in %s%s", col.Name, rType, where) } continue @@ -948,15 +1079,18 @@ func (c *checker) checkResult(callPos token.Pos, r *analyze.Result, rType types. } fname := fieldName[col.Name] c.meet(fv.Type(), col.Type, col.Source, at, "field "+fname) - f := c.match(col.Type, fv.Type()) if notnull[col.Name] { + // `col:",notnull"`: the author knows better than the analyzer -- about the + // value, and about an array's elements, which PostgreSQL never declares col.Nullable = false + col.Type.ElemNotNull = true } + f := c.fitPG(col.Type, fv.Type(), false) c.reportFit(report, at, "field "+fname, col, fv.Type(), f, where) if f.ok { c.checkNested(col, fv.Type(), at, "field "+fname, report, where, false) - if msg := c.fidelity(col.Type, fv.Type()); msg != "" && c.strict { - report(at, "field %s: %s%s", fname, msg, where) + if f.advice != "" && c.strict { + report(at, "field %s: %s%s", fname, f.advice, where) } } } @@ -969,8 +1103,8 @@ func (c *checker) checkResult(callPos token.Pos, r *analyze.Result, rType types. return missing } -func (c *checker) reportFit(report func(token.Pos, string, ...any), at token.Pos, what string, col analyze.Column, gt types.Type, f fit, where string) { - pgName := c.s.Types.Format(col.Type) +func (c *checker) reportFit(report func(token.Pos, string, ...any), at token.Pos, what string, col dialect.Column, gt types.Type, f fit, where string) { + pgName := col.Type.Name switch { case !f.ok: report(at, "%s is %s but column %q is %s%s", what, gt, col.Name, pgName, where) @@ -1031,7 +1165,7 @@ func (c *checker) resolvePath(t types.Type, p expand.Path) (types.Type, error) { // checkReferences enforces the boundary rules on the relations an expansion touches: // -schemas (a package-level scope, judged here), and the obligations the schema declares // or the flags imply (judged by internal/obligation over the statement's facts). -func (c *checker) checkReferences(e *expand.Expansion, r *analyze.Result, lit literal, report func(token.Pos, string, ...any), where string) { +func (c *checker) checkReferences(e *expand.Expansion, r *dialect.Result, lit literal, report func(token.Pos, string, ...any), where string) { var allowed map[string]bool if schemasFlag != "" { allowed = map[string]bool{} @@ -1042,17 +1176,16 @@ func (c *checker) checkReferences(e *expand.Expansion, r *analyze.Result, lit li } } for _, ref := range r.Relations { - at := lit.pos(e.TemplatePos(int(ref.Position) - 1)) - name := ref.Name - if ref.Schema != "public" { - name = ref.Schema + "." + name - } + at := lit.pos(e.TemplatePos(max(ref.Position, 0))) if allowed != nil && !allowed[ref.Schema] { - report(at, "%s is outside the schemas this code may reference (%s)%s", name, schemasFlag, where) + report(at, "%s is outside the schemas this code may reference (%s)%s", ref.Name, schemasFlag, where) } } + if c.ct == nil { + return // the obligations need the schema's contract, which this dialect does not give yet + } seen := map[string]bool{} - for _, d := range obligation.Check(c.s, c.decls, r.Facts, lowerer{c.s}) { + for _, d := range obligation.Check(c.ct.Contract(), c.decls, r.Facts, c.ct) { if d.Message == "" || (!d.Failed() && !c.strict) || seen[d.Message] { continue } @@ -1065,81 +1198,13 @@ func (c *checker) checkReferences(e *expand.Expansion, r *analyze.Result, lit li } } -// adviseSchema reports advisory findings about the schema itself (-strict). -func (c *checker) adviseSchema(at token.Pos) { - c.adviseRowSecurity(at) - for _, fn := range c.s.Functions { - for _, n := range c.ls.fnAdvice[fn] { - c.pass.Reportf(at, "sqlshape: schema: function %s: %s", fn.Name, n.Message) - } - } - for _, rel := range c.s.Relations { - if rel.Kind == schema.Table { - // a value set kept as an enum cannot lose or reorder a label without the type - // being rebuilt under every column (see migrate); a seeded lookup table changes - // with a MERGE, its rows can carry a label and an order, and the checker reads - // it just as well - for _, col := range rel.Columns { - if t := c.s.Types.ByOID(col.Type.OID); t != nil && t.Kind == 'e' && !rel.Temp { - c.pass.Reportf(at, "sqlshape: schema: %s.%s is enum %s: a seeded lookup table (rows in schema.sql, referenced by a foreign key) is easier to change — an enum cannot drop or reorder a label without being recreated under every column — and is checked the same way", rel.FullName(), col.Name, t.Name) - } - } - } - if rel.Kind != schema.MatView { - continue - } - unique := false - for _, con := range rel.Constraints { - if con.Kind == schema.Unique && con.Predicate == nil { - unique = true - } - } - if !unique { - c.pass.Reportf(at, "sqlshape: schema: materialized view %s has no unique index, so REFRESH MATERIALIZED VIEW CONCURRENTLY is not possible", rel.FullName()) - } - } -} - -// adviseRowSecurity: the ways a row-level security setup does less than it looks like. -func (c *checker) adviseRowSecurity(at token.Pos) { - for _, rel := range c.s.Relations { - if rel.Kind != schema.Table || !rel.RowSecurity { - continue - } - if len(rel.Policies) == 0 { - c.pass.Reportf(at, "sqlshape: schema: %s has row level security enabled and no policy: every role but the owner sees no rows", rel.FullName()) - } - for _, pol := range rel.Policies { - for _, name := range analyze.SettingReads(pol) { - c.pass.Reportf(at, "sqlshape: schema: policy %s on %s reads current_setting(%q, true): a session that never set it gets NULL, so the predicate hides every row silently; without missing_ok the session fails loudly instead", pol.Name, rel.FullName(), name) - } - } - if rel.ForceRowSecurity { - continue - } - // a SECURITY DEFINER function runs as its owner, whom the policies do not bind - // unless the table forces them - for fn, refs := range c.ls.fnRefs { - if !fn.SecurityDefiner { - continue - } - for _, ref := range refs { - if ref.Schema == rel.Schema && ref.Name == rel.Name { - c.pass.Reportf(at, "sqlshape: schema: function %s is SECURITY DEFINER and reaches %s, whose policies do not bind the owner: rows are unrestricted inside it (ALTER TABLE %s FORCE ROW LEVEL SECURITY applies them)", fn.Name, rel.FullName(), rel.FullName()) - break - } - } - } - } -} - // isMatViewConversion recognizes sqlshape.MatView("name"). func isMatViewConversion(pass *analysis.Pass, call *ast.CallExpr) bool { tv, ok := pass.TypesInfo.Types[call.Fun] if !ok || !tv.IsType() { return false } - return isNamed(tv.Type, sqlshapePkg, "MatView") && len(call.Args) == 1 + return isNamed(tv.Type, postgresPkg, "MatView") && len(call.Args) == 1 } // checkMatView verifies the named materialized view exists. @@ -1150,18 +1215,14 @@ func (c *checker) checkMatView(call *ast.CallExpr) { return } name := constant.StringVal(tv.Value) - sch, n := "", name - if i := strings.LastIndex(name, "."); i >= 0 { - sch, n = name[:i], name[i+1:] - } - rel := c.s.Relation(sch, n) + rel := c.sch.Relation(name) switch { case rel == nil: c.pass.Reportf(call.Args[0].Pos(), "sqlshape: materialized view %q does not exist", name) - case rel.Kind != schema.MatView: + case rel.Kind != facts.MatView: c.pass.Reportf(call.Args[0].Pos(), "sqlshape: %q is not a materialized view", name) default: - c.index.AddRelation(rel.FullName(), c.site(call, call.Args[0].Pos())) + c.index.AddRelation(rel.Name, c.site(call, call.Args[0].Pos())) } } diff --git a/internal/vet/vet_test.go b/cmd/sqlshape/internal/vet/vet_test.go similarity index 74% rename from internal/vet/vet_test.go rename to cmd/sqlshape/internal/vet/vet_test.go index 203e9e0..e9763fe 100644 --- a/internal/vet/vet_test.go +++ b/cmd/sqlshape/internal/vet/vet_test.go @@ -2,6 +2,7 @@ package vet import ( "path/filepath" + "sync" "testing" "golang.org/x/tools/go/analysis/analysistest" @@ -204,3 +205,55 @@ func TestContext(t *testing.T) { } analysistest.Run(t, td, Analyzer, "ctx") } + +// TestQueryMarkers checks -query: a program's own generic marker functions are read like +// sqlshape.Query and sqlshape.One. +func TestQueryMarkers(t *testing.T) { + td := analysistest.TestData() + if err := Analyzer.Flags.Set("schema", filepath.Join(td, "schema.sql")); err != nil { + t.Fatal(err) + } + if err := Analyzer.Flags.Set("query", "mydb.Query,mydb.One:one"); err != nil { + t.Fatal(err) + } + defer Analyzer.Flags.Set("query", "") + markersOnce = sync.Once{} + defer func() { markersOnce = sync.Once{} }() + analysistest.Run(t, td, Analyzer, "marker") +} + +// TestErrorNames covers `-- sqlshape: error = ` ⇔ `var X = +// sqlshape.Error(code)`: a correct declaration (by code, by Name, by both on the expect +// line), a wrong identifier, an undeclared code, a declaration missing entirely for a +// code an expect line names, and two declarations claiming the same code. +func TestErrorNames(t *testing.T) { + td := analysistest.TestData() + if err := Analyzer.Flags.Set("schema", filepath.Join(td, "errname_schema.sql")); err != nil { + t.Fatal(err) + } + defer Analyzer.Flags.Set("schema", "") + analysistest.Run(t, td, Analyzer, "errnameok", "errnamewrong", "errnameunknown", "errnamemissing", "errnamedup") +} + +// TestMySQL runs the analyzer against a MySQL schema in-process: the dialect adapter's +// view obligations and the type-binding directive (mysqlvet), plainly. +func TestMySQL(t *testing.T) { + td := analysistest.TestData() + if err := Analyzer.Flags.Set("schema", filepath.Join(td, "schema-mysql.sql")); err != nil { + t.Fatal(err) + } + analysistest.Run(t, td, Analyzer, "mysqlvet") +} + +// TestMySQLStrict is the MySQL side of TestStrict: the schema-level advisories. +func TestMySQLStrict(t *testing.T) { + td := analysistest.TestData() + if err := Analyzer.Flags.Set("schema", filepath.Join(td, "schema-mysql.sql")); err != nil { + t.Fatal(err) + } + if err := Analyzer.Flags.Set("strict", "true"); err != nil { + t.Fatal(err) + } + defer Analyzer.Flags.Set("strict", "false") + analysistest.Run(t, td, Analyzer, "mysqlstrict") +} diff --git a/cmd/sqlshape/main.go b/cmd/sqlshape/main.go index d59e8bc..d7cd805 100644 --- a/cmd/sqlshape/main.go +++ b/cmd/sqlshape/main.go @@ -9,8 +9,10 @@ import ( "golang.org/x/tools/go/analysis/singlechecker" - "github.com/kr9ly/sqlshape/internal/cli" - "github.com/kr9ly/sqlshape/internal/vet" + _ "github.com/kr9ly/sqlshape/check/mysql/v2/dialect" // a schema.sql declaring `-- sqlshape: mysql 8.4` + _ "github.com/kr9ly/sqlshape/check/postgres/v2/dialect" // `-- sqlshape: postgres 17`, and the default + "github.com/kr9ly/sqlshape/cmd/sqlshape/v2/internal/cli" + "github.com/kr9ly/sqlshape/cmd/sqlshape/v2/internal/vet" ) func main() { diff --git a/cmd/sqlshape/mysql_vet_test.go b/cmd/sqlshape/mysql_vet_test.go new file mode 100644 index 0000000..3de784c --- /dev/null +++ b/cmd/sqlshape/mysql_vet_test.go @@ -0,0 +1,21 @@ +package main + +import ( + "path/filepath" + "testing" + + "golang.org/x/tools/go/analysis/analysistest" + + "github.com/kr9ly/sqlshape/cmd/sqlshape/v2/internal/vet" +) + +// TestMySQLVet runs the checker end to end over a MySQL schema: the dialect line selects +// the MySQL analyzer (registered by this binary's blank import), and the diagnostics are +// the checker's dialect path over its Result. +func TestMySQLVet(t *testing.T) { + td := filepath.Join(analysistest.TestData(), "mysql") + if err := vet.Analyzer.Flags.Set("schema", filepath.Join(td, "schema.sql")); err != nil { + t.Fatal(err) + } + analysistest.Run(t, td, vet.Analyzer, "app") +} diff --git a/cmd/sqlshape/testdata/mysql/schema.sql b/cmd/sqlshape/testdata/mysql/schema.sql new file mode 100644 index 0000000..5952bad --- /dev/null +++ b/cmd/sqlshape/testdata/mysql/schema.sql @@ -0,0 +1,53 @@ +-- sqlshape: mysql 8.4 +CREATE TABLE users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(255), + active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) +) ENGINE=InnoDB; + +CREATE TABLE orders ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + total DECIMAL(10,2) NOT NULL, + note TEXT, + CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users (id) +); + +-- sqlshape: require pinned(tenant_id) +CREATE TABLE tenant_notes ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + body TEXT NOT NULL, + UNIQUE KEY tenant_notes_tenant_body (tenant_id, body(64)) +); + +-- sqlshape: waive tenant_notes pinned(tenant_id) +CREATE VIEW all_notes AS SELECT id, tenant_id, body FROM tenant_notes; + +CREATE TABLE tickets ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + status ENUM('open', 'closed') NOT NULL, + CONSTRAINT fk_tickets_user FOREIGN KEY (user_id) REFERENCES users (id) +); + +CREATE TABLE widgets ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + qty INT NOT NULL +); + +-- sqlshape: error 40001 = TooManyWidgets +CREATE TRIGGER widgets_before_insert BEFORE INSERT ON widgets FOR EACH ROW +BEGIN + IF NEW.qty > 100 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'too many widgets', MYSQL_ERRNO = 40001; + END IF; +END; + +-- sqlshape: not null +CREATE FUNCTION widget_count() RETURNS BIGINT +BEGIN + RETURN (SELECT count(*) FROM widgets); +END; diff --git a/cmd/sqlshape/testdata/mysql/src/app/app.go b/cmd/sqlshape/testdata/mysql/src/app/app.go new file mode 100644 index 0000000..fb444c1 --- /dev/null +++ b/cmd/sqlshape/testdata/mysql/src/app/app.go @@ -0,0 +1,189 @@ +package app + +import ( + "time" + + "github.com/kr9ly/sqlshape/v2" +) + +type User struct { + ID uint64 + Name string + Email *string + Active bool + CreatedAt time.Time `col:"created_at"` +} + +type ByID struct{ ID uint64 } + +var getUser = sqlshape.Query[User, ByID](`SELECT id, name, email, active, created_at FROM users WHERE id = {{.ID}}`) + +type OrderRow struct { + ID uint64 + Total string + Note *string + Name string +} + +type ListOrders struct { + UserID uint64 + Limit int64 +} + +var listOrders = sqlshape.Query[OrderRow, ListOrders](` +SELECT o.id, o.total, o.note, u.name + FROM orders o JOIN users u ON u.id = o.user_id + WHERE o.user_id = {{.UserID}} + LIMIT {{.Limit}} +`) + +type Wrong struct { + ID int32 + Email string + Total string +} + +var wrong = sqlshape.Query[Wrong, struct{ ID string }](`SELECT id, email, total FROM users WHERE id = {{.ID}}`) // want `Unknown column 'total' in 'field list' \(MySQL error 1054\)` + +var wrong2 = sqlshape.Query[Wrong, struct{ ID string }](`SELECT id, email FROM users WHERE id = {{.ID}}`) // want `field ID is int32 but column "id" is bigint unsigned` `field Email is string but column "email" may be NULL` `field Wrong.Total has no result column` `parameter .ID is string but SQL expects bigint unsigned` + +var noTable = sqlshape.Query[int64, struct{}](`SELECT id FROM customers`) // want `Table 'customers' doesn't exist \(MySQL error 1146\)` + +var syntax = sqlshape.Query[int64, struct{}](`SELECT id FROM users WHERE`) // want `MySQL error 1064` + +type Insert struct { + Name string + Email *string +} + +var insert = sqlshape.Query[struct{}, Insert](`INSERT INTO users (name, email) VALUES ({{.Name}}, {{.Email}})`) + +var insertWrong = sqlshape.Query[struct{}, struct{ Name int64 }](`INSERT INTO users (name) VALUES ({{.Name}})`) // want `parameter .Name is int64 but SQL expects varchar\(100\)` + +var update = sqlshape.Query[int64, struct{ Name string }](`UPDATE users SET name = {{.Name}} WHERE id = 1`) // want `R is int64 but the query returns 0 columns` + +type Count struct{ N int64 } + +var count = sqlshape.Query[Count, struct{}](`SELECT count(*) AS n FROM users`) + +var expr = sqlshape.Query[string, struct{}](`SELECT COALESCE(email, name) FROM users`) + +var exprNull = sqlshape.Query[string, struct{}](`SELECT COALESCE(email, NULL) FROM users`) // want `column COALESCE\(email, NULL\) is string but column "COALESCE\(email, NULL\)" may be NULL` + +type Stats struct { + N int64 + Total string + Active bool + Oldest *time.Time +} + +var stats = sqlshape.Query[Stats, struct{}](`SELECT count(*) AS n, SUM(o.total) AS total, u.active = 1 AS active, MIN(u.created_at) AS oldest FROM users u JOIN orders o ON o.user_id = u.id WHERE u.id = 1`) // want `field Total is string but column "total" may be NULL` + +// ONLY_FULL_GROUP_BY: a column outside the aggregates is not determined by the group +var mixed = sqlshape.Query[Stats, struct{}](`SELECT count(*) AS n, SUM(o.total) AS total, u.active = 1 AS active, MIN(u.created_at) AS oldest FROM users u JOIN orders o ON o.user_id = u.id`) // want `In aggregated query without GROUP BY, expression #3 of SELECT list contains nonaggregated column 'u.active'; this is incompatible with sql_mode=only_full_group_by \(MySQL error 1140\)` + +var notDependent = sqlshape.Query[Stats, struct{}](`SELECT count(*) AS n, SUM(o.total) AS total, u.active = 1 AS active, MIN(u.created_at) AS oldest FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.name`) // want `Expression #3 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'u.active' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by \(MySQL error 1055\)` + +var unknownFn = sqlshape.Query[int64, struct{}](`SELECT NOPE(id) FROM users`) // want `FUNCTION NOPE does not exist \(MySQL error 1305\)` + +var union = sqlshape.Query[int64, struct{}](`SELECT 1 UNION SELECT 2`) + +var show = sqlshape.Query[int64, struct{}](`SHOW TABLES`) // want `is not supported yet` + +// One: proved from the schema's keys through the facts the MySQL analyzer records +var userByID = sqlshape.One[User, struct{ ID uint64 }](`SELECT id, name, email, active, created_at FROM users WHERE id = {{.ID}}`) + +var userByName = sqlshape.One[User, struct{ Name string }](`SELECT id, name, email, active, created_at FROM users WHERE name = {{.Name}}`) // want `One: cannot prove at most one row: users: no unique key is fixed by equality \(keys: \(id\)\)` + +var orderOfUser = sqlshape.One[int64, struct{ ID uint64 }](`SELECT o.id FROM orders o JOIN users u ON u.id = o.user_id WHERE u.id = {{.ID}}`) // want `One: cannot prove at most one row: orders o: no unique key is fixed by equality` + +var countUsers = sqlshape.One[int64, struct{}](`SELECT count(*) FROM users`) + +var markActive = sqlshape.One[struct{}, struct{ ID uint64 }](`UPDATE users SET active = 1 WHERE id = {{.ID}}`) + +// Failure modes: the expect line names the constraints a write may violate, as the server +// names them (the key's name, the CONSTRAINT's, table.column for NOT NULL) +type NewOrder struct { + ID uint64 + UserID uint64 + Total string +} + +var insertOrder = sqlshape.Query[struct{}, NewOrder]("-- sqlshape: expect PRIMARY, fk_orders_user\nINSERT INTO orders (id, user_id, total) VALUES ({{.ID}}, {{.UserID}}, {{.Total}})") + +var insertOrderBare = sqlshape.Query[struct{}, NewOrder](`INSERT INTO orders (id, user_id, total) VALUES ({{.ID}}, {{.UserID}}, {{.Total}})`) // want `may violate PRIMARY \(PRIMARY KEY \(id\) on orders, MySQL error 1062\)` `may violate fk_orders_user \(FOREIGN KEY fk_orders_user \(user_id\) on orders REFERENCES users, MySQL error 1452\)` + +var insertOrderNullable = sqlshape.Query[struct{}, struct { + ID uint64 + UserID *uint64 + Total string +}]("-- sqlshape: expect PRIMARY, fk_orders_user\nINSERT INTO orders (id, user_id, total) VALUES ({{.ID}}, {{.UserID}}, {{.Total}})") // want `may violate orders.user_id \(NOT NULL on orders.user_id, MySQL error 1048\)` + +var insertOrderIgnore = sqlshape.Query[struct{}, NewOrder](`INSERT IGNORE INTO orders (id, user_id, total) VALUES ({{.ID}}, {{.UserID}}, {{.Total}})`) + +var staleExpect = sqlshape.Query[struct{}, struct { + ID uint64 + Name string +}]("-- sqlshape: expect fk_orders_user\nUPDATE users SET name = {{.Name}} WHERE id = {{.ID}}") // want `expects fk_orders_user but no expansion can violate it` + +var deleteUser = sqlshape.Query[struct{}, struct{ ID uint64 }](`DELETE FROM users WHERE id = {{.ID}}`) // want `may violate fk_orders_user \(FOREIGN KEY fk_orders_user \(user_id\) on orders REFERENCES users: a row of orders still refers to the one changed, MySQL error 1451\)` `may violate fk_tickets_user \(FOREIGN KEY fk_tickets_user \(user_id\) on tickets REFERENCES users: a row of tickets still refers to the one changed, MySQL error 1451\)` + +// Obligations: `require pinned(tenant_id)` on tenant_notes, judged through the contract +var noteByID = sqlshape.Query[string, struct{ ID uint64 }](`SELECT body FROM tenant_notes WHERE id = {{.ID}}`) // want `tenant_notes.tenant_id is not pinned: every statement on tenant_notes must fix tenant_id by equality \(or assign it\)` + +var noteByTenant = sqlshape.Query[string, struct { + ID uint64 + TenantID uint64 +}](`SELECT body FROM tenant_notes WHERE id = {{.ID}} AND tenant_id = {{.TenantID}}`) + +var noteViaView = sqlshape.Query[string, struct{ ID uint64 }](`SELECT body FROM all_notes WHERE id = {{.ID}}`) + +var noteWaived = sqlshape.Query[string, struct{ ID uint64 }]("-- sqlshape: waive tenant_notes pinned(tenant_id)\nSELECT body FROM tenant_notes WHERE id = {{.ID}}") + +type NewNote struct { + ID uint64 + TenantID uint64 + Body string +} + +var insertNote = sqlshape.Query[struct{}, NewNote]("-- sqlshape: expect PRIMARY, tenant_notes_tenant_body\nINSERT INTO tenant_notes (id, tenant_id, body) VALUES ({{.ID}}, {{.TenantID}}, {{.Body}})") + +var moveNote = sqlshape.Query[struct{}, struct { + ID uint64 + TenantID uint64 +}]("-- sqlshape: expect PRIMARY\nUPDATE tenant_notes SET id = {{.ID}} WHERE tenant_id = {{.TenantID}}") + +// Key identities through the parameter's source column: UserID is bound to users.id by +// the first query, then meets tickets.id +type UserID uint64 // want UserID:`bound k users.id` + +var ticketsByUser = sqlshape.Query[uint64, struct{ U UserID }](`SELECT id FROM tickets WHERE user_id = {{.U}}`) + +var wrongKey = sqlshape.Query[uint64, struct{ U UserID }](`SELECT user_id FROM tickets WHERE id = {{.U}}`) // want `parameter .U is app.UserID, which stands for key users.id elsewhere, but here meets key tickets.id` + +// an ENUM column is a value set: constants are diffed both ways +type TicketStatus string // want TicketStatus:`consts closed,open,reopened` TicketStatus:`bound v tickets.status` + +const ( + TicketOpen TicketStatus = "open" + TicketClosed TicketStatus = "closed" + TicketReopened TicketStatus = "reopened" +) + +var ticketsByStatus = sqlshape.Query[uint64, struct{ S TicketStatus }](`SELECT id FROM tickets WHERE status = {{.S}}`) // want `TicketStatus has constant "reopened" which is not a label of value set of tickets.status \(CHECK\)` + +// Error names: the trigger's own SIGNAL, named TooManyWidgets by the `-- sqlshape: +// error` line above it, is a Go name through sqlshape.Error, checked both ways. +var TooManyWidgets = sqlshape.Error("40001") // want TooManyWidgets:`sqlshape.Error\(40001\)` + +var insertWidget = sqlshape.Query[struct{}, struct{ Qty int32 }]("-- sqlshape: expect TooManyWidgets\nINSERT INTO widgets (qty) VALUES ({{.Qty}})") + +// widget_count is declared `-- sqlshape: not null` in schema.sql, above its CREATE +// FUNCTION: its call site types as a plain int64, no pointer needed. +var widgetCount = sqlshape.Query[int64, struct{}](`SELECT widget_count()`) + +// the template's own `-- sqlshape: not null col` line overrides a nullable result column +// (the SQL-side twin of the `col:",notnull"` tag), the same as on PostgreSQL. +var notedOrder = sqlshape.Query[string, struct{ ID uint64 }]("-- sqlshape: not null note\nSELECT note FROM orders WHERE id = {{.ID}} AND note IS NOT NULL") + +var notedOrderTypo = sqlshape.Query[string, struct{ ID uint64 }]("-- sqlshape: not null nope\nSELECT note FROM orders WHERE id = {{.ID}}") // want `not null: the query has no result column "nope"` `column note is string but column "note" may be NULL \(use a pointer, or tag it .col:",notnull". if you know better\)` diff --git a/internal/vet/testdata/src/github.com/kr9ly/sqlshape/sqlshape.go b/cmd/sqlshape/testdata/mysql/src/github.com/kr9ly/sqlshape/v2/sqlshape.go similarity index 85% rename from internal/vet/testdata/src/github.com/kr9ly/sqlshape/sqlshape.go rename to cmd/sqlshape/testdata/mysql/src/github.com/kr9ly/sqlshape/v2/sqlshape.go index 70a42ee..ac22353 100644 --- a/internal/vet/testdata/src/github.com/kr9ly/sqlshape/sqlshape.go +++ b/cmd/sqlshape/testdata/mysql/src/github.com/kr9ly/sqlshape/v2/sqlshape.go @@ -36,6 +36,13 @@ func One[R, P any](template string) Single[R, P] { return Single[R, P]{stmt: Stmt[R, P]{Template: template}} } +// Failure is a statement's failure mode as the program names it (see Error). +type Failure string + +// Error declares a program's Go name for a schema's `-- sqlshape: error = ` +// annotation. +func Error(code string) Failure { return Failure(code) } + // MatView is a handle on a materialized view. type MatView string diff --git a/docs/checks.ja.md b/docs/checks.ja.md index 5010b04..21d2edc 100644 --- a/docs/checks.ja.md +++ b/docs/checks.ja.md @@ -2,7 +2,7 @@ [English](checks.md) -検査器はパッケージ内の`sqlshape.Query[R, P](template)`、`sqlshape.One[R, P](template)`、`sqlshape.Copy[R](...)`、`sqlshape.MatView(...)`をすべて見つけ、テンプレートを分岐の全組み合わせに展開し([templates.ja.md](templates.ja.md))、展開した各SQLを`schema.sql`に対して解析して、その結果をGoの型と突き合わせる。このページは、書く場面ごとに、何がNGで何がOKかを、実際に出る診断と一緒に並べたもの。診断は英語で出るので、そのまま載せている。 +検査器はパッケージ内の`sqlshape.Query[R, P](template)`、`sqlshape.One[R, P](template)`、`postgres.Copy[R](...)`、`postgres.MatView(...)`(PostgreSQL)をすべて見つけ、テンプレートを分岐の全組み合わせに展開し([templates.ja.md](templates.ja.md))、展開した各SQLを`schema.sql`に対して解析して、その結果をGoの型と突き合わせる。このページは、書く場面ごとに、何がNGで何がOKかを、実際に出る診断と一緒に並べたもの。診断は英語で出るので、そのまま載せている。規則はPostgreSQLとMySQLで同じである。例はPostgreSQLのもので、規則がデータベースの名前・番号・型を使う箇所(制約名、Go型の表、診断のエラーコード)は[postgres.ja.md](postgres.ja.md)と[mysql.ja.md](mysql.ja.md)にそれぞれのものがある。 検査器の入口は2つある。`go vet -vettool=sqlshape`(または`sqlshape ./...`)はGoのパッケージを対象に実行され、`Query` / `One`のテンプレートにあるSQLをGoの型と突き合わせる。`sqlshape check file.sql`は、Goの中にないSQLに対して同じ解析器と同じスキーマの規約を実行する([第2部](#goの外のsqlにも同じ規約を適用するsqlshape-check))。 @@ -15,7 +15,7 @@ - [型に意味を持たせる](#型に意味を持たせる) - [書き込みの失敗に備える](#書き込みの失敗に備える) - [1行だけ返す(`One`)](#1行だけ返すone) - - [COPYで一括ロードする](#copyで一括ロードする) + - [COPYで一括ロードする(PostgreSQL)](#copyで一括ロードするpostgresql) - [第2部 — スキーマが宣言する規約](#第2部--スキーマが宣言する規約) - [宣言の仕組み](#宣言の仕組み) - [必ず付ける読み取り条件(`visible where`)](#必ず付ける読み取り条件visible-where) @@ -29,9 +29,11 @@ - [呼び出し元ごとに規約を変える(`context`)](#呼び出し元ごとに規約を変えるcontext) - [Goの外のSQLにも同じ規約を適用する(`sqlshape check`)](#goの外のsqlにも同じ規約を適用するsqlshape-check) - [第3部 — 文の外側](#第3部--文の外側) - - [スキーマはPostgreSQLの版を名乗る(`postgres`)](#スキーマはpostgresqlの版を名乗るpostgres) + - [スキーマはPostgreSQLのバージョンを名乗る(`postgres`)](#スキーマはpostgresqlのバージョンを名乗るpostgres) + - [スキーマはMySQLのバージョンを名乗る(`mysql`)](#スキーマはmysqlのバージョンを名乗るmysql) + - [スキーマはサーバの設定を名乗る(`server`)](#スキーマはサーバの設定を名乗るserver) - [sqlshapeを通さないSQLを書かない(`-raw-sql`)](#sqlshapeを通さないsqlを書かない-raw-sql) - - [パッケージは自分のスキーマだけを参照する(`-schemas`)](#パッケージは自分のスキーマだけを参照する-schemas) + - [パッケージは自分のスキーマだけを参照する(`-schemas`、PostgreSQL)](#パッケージは自分のスキーマだけを参照する-schemaspostgresql) - [スキーマ自体の問題](#スキーマ自体の問題) ## 第1部 — すべての文にかかる検査 @@ -76,7 +78,7 @@ type Order struct { NG ```sql -SELECT id, count(*) FROM orders GROUP BY id +SELECT id, id + 1 FROM orders -- ^ result column 2 has no name: give it an alias (... AS name) so it can bind to a field of Order SELECT o.id, c.id FROM orders o JOIN customers c ON c.id = o.customer_id @@ -86,13 +88,13 @@ SELECT o.id, c.id FROM orders o JOIN customers c ON c.id = o.customer_id OK ```sql -SELECT id, count(*) AS n FROM orders GROUP BY id +SELECT id, id + 1 AS n FROM orders SELECT o.id, c.id AS customer_id FROM orders o JOIN customers c ON c.id = o.customer_id ``` #### NULLになりうる列はNULLを受けられる型で受ける -NULLを受けられる型は、ポインタ、スライス、マップ、`sql.Null*`、`pgtype.*`、`sql.Scanner`を実装した型。 +NULLを受けられる型は、ポインタ、スライス、マップ、`sql.Null*`、`pgtype.*`のようなドライバのNULL可の値型、`sql.Scanner`を実装した型。 NG @@ -116,7 +118,7 @@ type User struct { } ``` -補足。列がNULLになりうるかは、NOT NULL制約と主キー、WHERE句(`deleted_at IS NOT NULL`や`deleted_at = ...`があればNULLではない)、外部結合(内側の列はNULLになりうる)、関数(引数がNULLでない`strict`関数の結果はNULLでない、`coalesce(x, 0)`はNULLでない。ただし一部のstrictな組み込み関数・演算子は報告するものが無いとNULLを返す。`meta ->> 'key'`もその一つ)、ビュー自身のWHERE句から判定する。ビューは下敷きの列のNOT NULLをPostgreSQL自身と同じように動的に追いかける。後から`ALTER TABLE ... DROP NOT NULL`がベーステーブルに入れば、ビューの連鎖を通じても、ビュー自身のWHERE句をくぐり抜けても反映される。判定より自分の方が正しいと分かっているなら、Go側は`col:",notnull"`タグ、SQL側はテンプレートの`-- sqlshape: not null deleted_at`行で上書きできる。関数の戻り値は`schema.sql`の`CREATE FUNCTION`の直上に`-- sqlshape: not null`と書く。 `RETURNING`の`old.col`はINSERTの後、`new.col`はDELETEの後(PostgreSQL 18)、列の宣言に関わらずNULLになりうる。書き込みのその側には行が無い。 +補足。列がNULLになりうるかは、NOT NULL制約と主キー、WHERE句(`deleted_at IS NOT NULL`や`deleted_at = ...`があればNULLではない)、外部結合(内側の列はNULLになりうる)、関数(引数がNULLでない`strict`関数の結果はNULLでない、`coalesce(x, 0)`はNULLでない。ただし一部のstrictな組み込み関数・演算子は報告するものが無いとNULLを返す。`meta ->> 'key'`もその一つ)、ビュー自身のWHERE句から判定する。ビューは下敷きの列のNOT NULLをPostgreSQL自身と同じように動的に追いかける。後から`ALTER TABLE ... DROP NOT NULL`がベーステーブルに入れば、ビューの連鎖を通じても、ビュー自身のWHERE句をくぐり抜けても反映される。判定より自分の方が正しいと分かっているなら、Go側は`col:",notnull"`タグ(配列のフィールドに付けると要素についても主張する)、SQL側はテンプレートの`-- sqlshape: not null deleted_at`行で上書きできる。関数の戻り値は`schema.sql`の`CREATE FUNCTION`の直上に`-- sqlshape: not null`と書く。 `RETURNING`の`old.col`はINSERTの後、`new.col`はDELETEの後(PostgreSQL 18)、列の宣言に関わらずNULLになりうる。書き込みのその側には行が無い。 #### 一部の分岐だけが選ぶ列はNULLを受けられる型で受ける @@ -146,12 +148,14 @@ type Order struct { #### 列の型とフィールドの型は下の表に従う -NG +情報を失いうる対応づけ(`numeric`を`float64`で受ける、`bigint`を`int32`で受ける、など)は拒否ではなく注記付きで通る。本当に不適合な組(`text`列を`int64`フィールドで受ける、など)だけが拒否される。 + +OK(注記あり) ```go type Order struct { ID int64 - Total float64 // field Total is float64 but column "total" is numeric(12,2) + Total float64 // field Total: numeric into float64 loses precision } ``` @@ -159,7 +163,7 @@ type Order struct { SELECT id, total FROM orders ``` -OK +OK(注記無し) ```go type Order struct { @@ -196,6 +200,8 @@ SELECT o.id, array_agg((i.sku, i.qty)::order_item) AS items GROUP BY o.id ``` +(隣接する2つのフィールドを入れ替えると、片方だけでなく両方の位置がずれる。`Sku`も`order_item`型の列2「qty」に来てしまうので、その位置のずれについても2つ目の指摘が出る。) + OK ```go @@ -205,6 +211,8 @@ type Item struct { } ``` +(PostgreSQLは、配列を保持する列自体がNOT NULLでも、配列の要素自体が非NULLであることは保証せず、検査器も一般には常設の注記を出す([PostgreSQLの型](postgres.ja.md)の`T[]`の行を参照)。ここでは検査器がそれを証明できる: `order_item`の行コンストラクタは、自分自身のフィールドの非NULL性とは関係なく決してNULLにならないので、`array_agg((i.sku, i.qty)::order_item)`は注記を持たず、このOK例は注記なしで通る。) + #### 1列だけ返すSQLはスカラーで受けられる OK @@ -253,47 +261,11 @@ type Order struct { #### Go型の表 -pgxが実際にscan / encodeできる組み合わせを、稼働中のPostgreSQLで確認したもの。列を受けるときも、パラメータを渡すときも同じ表に従う。 - -| PostgreSQL | Go | -|---|---| -| `bool` | `bool` | -| `smallint` / `integer` / `bigint` | `int16` / `int32` / `int64` / `int`(狭いGo型で受けると`bigint into int32`のような注記が付く) | -| `real` / `double precision` | `float32` / `float64`(`double precision into float32`は注記付き) | -| `numeric` | `string`(全桁が保たれる)、`pgtype.Numeric`、`big.Rat`、`shopspring/decimal.Decimal`、`apd.Decimal`。floatや整数でも受けられるが精度の注記が付く | -| `text` / `varchar` / `char` / `name` / `citext`などテキスト系の拡張型 | `string`(`string`はパラメータとしてはどの型にも渡せる) | -| `bytea` | `[]byte` | -| `uuid` | `uuid.UUID`(パッケージは問わない)、`[16]byte`、`string` | -| `timestamptz` / `timestamp` / `date` | `time.Time`(`timestamp`と`date`はタイムゾーンや時刻が失われるので`-strict`で注記) | -| `time` | `time.Time`、`string` | -| `interval` | `time.Duration`、`pgtype.Interval` | -| `json` / `jsonb` | `[]byte`、`json.RawMessage`、`string`、またはpgxがunmarshalできる構造体・スライス・マップ | -| `inet` | `netip.Addr` / `netip.Prefix` | -| `cidr` | `netip.Prefix` | -| `macaddr` | `net.HardwareAddr` / `string` | -| `hstore` | `map[string]*string` | -| `T[]` | `[]Go(T)`(各要素は単体の`T`のパラメータ/列と同じ判定を受ける。注記も含めて) | -| 範囲型 | `pgtype.Range[T]`。`T`はサブタイプと照合される(ユーザー定義の範囲型も同様) | -| 多重範囲型 | `pgtype.Multirange[pgtype.Range[T]]` | -| `bit` / `point` / `tsvector` | `pgtype`の対応する型 | -| `xml` / `money` / `tsquery` / `jsonpath` / `timetz` | `string` | -| `oid` | `uint32` | -| enum、seed済みlookupテーブルのキー、CHECKによる値集合 | Goのnamed string type([下記](#型に意味を持たせる)) | -| ドメイン | 基底型に対応するGo型、またはドメインに結びつけたnamed type | -| 複合型、レコード | 構造体 | - -表に無い型、あるいは自前の型で受けたい型は、Go側の型にdocコメントで対応するPostgreSQL型を宣言する: - -```go -// sqlshape: type money_amount -type Money struct{ ... } // sql.Scanner / driver.Valuer を実装する -``` - -検査器は、SQL側が`money_amount`(その配列と、それを基底型とするドメインを含む)である位置でだけ`Money`を受け入れ、それ以外の位置では報告する。値の変換は型自身の`sql.Scanner` / `driver.Valuer`に任せる(Scannerにはテキスト形式が渡る)。 +列をどのGo型で受けられるか、パラメータをどのGo型で渡せるかは、データベースとそのドライバが決める。[PostgreSQLの表](postgres.ja.md#go型の表)、[MySQLの表](mysql.ja.md#go型の表)。どちらも列を受けるときとパラメータを渡すときに同じに従う。PostgreSQLでは、表に無い型や自前の型で受けたい型を、Goの型に`// sqlshape: type `と書いて結びつける。変換はその型自身の`sql.Scanner` / `driver.Valuer`が行う(MySQLには結びつける先の名前付きの型が無い)。 ### パラメータを渡す -`{{.X}}`はSQLの中では`$n`パラメータになる。検査器は`$n`が使われている場所からPostgreSQL側で必要な型を推論し(`WHERE id = $1`なら`bigint`、`= ANY($1)`なら配列)、`P`の対応するフィールドの型がそれに合うことを確かめる。型の対応は上のGo型の表に従う。 +`{{.X}}`はSQLの中ではパラメータになる(PostgreSQLでは`$n`、MySQLでは`?`。検査器はどちらでも`$n`と番号を振る)。検査器はパラメータが使われている場所からSQL側で必要な型を推論し(`WHERE id = $1`なら`bigint`、`= ANY($1)`なら配列)、`P`の対応するフィールドの型がそれに合うことを確かめる。型の対応はデータベースのGo型の表に従う。 #### パラメータの型は使われる場所の型に合わせる @@ -339,6 +311,8 @@ type Params struct { } ``` +(`-strict`では、`orders.status`に触れるパッケージにはその列自体のenum助言、`orders.status is enum order_status: a seeded lookup table ...`も別途出る。上のパラメータについての指摘とは別の、列についての指摘。) + #### ネストしたパスと`range` `{{.Filter.Name}}`は`P`のフィールド`Filter`のフィールド`Name`を指す。`{{range .Items}} … {{.Sku}} … {{end}}`の中の`{{.Sku}}`はスライス`Items`の要素型のフィールドを指す。 @@ -355,9 +329,11 @@ type Params struct { ```sql SELECT id FROM products WHERE true {{if .Filter.Name}} AND name = {{.Filter.Name}} {{end}} - AND sku IN ({{range $i, $it := .Items}}{{if $i}}, {{end}}{{$it.Sku}}{{end}}) + AND (false {{range $i, $it := .Items}} OR sku = {{$it.Sku}} {{end}}) ``` +素朴な`sku IN ({{range}}...{{end}})`は空長で安全ではない。`.Items`が空だと`sku IN ()`となり構文エラーになる。検査器は`.Items`が取りうる長さ(0、1、2、……)を全て試し、それが構文エラーを引き起こすことをそのまま報告する。`range`をこの用途で使うときは、どの長さでも文法的に妥当な形にする必要がある。上の`false {{range}} OR ... {{end}}`の形(あるいは`testdata/src/a/a.go`の`indexParam`がそうしているような`WHERE true {{range}} OR (...) {{end}}`の形)がそれにあたる。 + #### 複合型のパラメータは構造体で渡す SQL側が複合型を期待する位置には構造体を、その配列を期待する位置には構造体のスライスを渡す。フィールドと複合型の列の対応づけはネストした行と同じ規則。 @@ -411,6 +387,8 @@ type NewOrder struct { INSERT INTO orders (customer_id, status) VALUES ({{.CustomerID}}, {{.Status}}) ``` +(ここの`Status`も非ポインタの`OrderStatus`なので、前節の「NULLを渡しうるパラメータはポインタにする」の指摘も出るし、`orders.status`自体のenum助言も出る。このNG例だけで1つでなく3つの指摘になる。) + OK。データベースの既定値を使うなら列ごと分岐にする。アプリケーションが常に決めるなら、列の`DEFAULT`を外す。 ```sql @@ -418,6 +396,8 @@ INSERT INTO orders (customer_id {{if .Status}}, status{{end}}) VALUES ({{.CustomerID}} {{if .Status}}, {{.Status}}{{end}}) ``` +(このOK例でも`orders.status`のenum助言だけは出る。前節のOK例と同じ。) + ### 型に意味を持たせる `bigint`や`text`のままでは区別できない値、たとえばenumのラベル、テーブルのID、金額の単位は、Goではnamed typeで表す。検査器はnamed typeをSQL側の意味(enum、lookupテーブルのキー、CHECKの値集合、主キー、ドメイン)に結びつけ、食い違う使い方を報告する。 @@ -469,9 +449,9 @@ type OrderStatus string const ( Pending OrderStatus = "pending" Paid OrderStatus = "paid" - Canceled OrderStatus = "canceled" // sqlshape: OrderStatus has constant "canceled" which is not a label of value set of order_statuses (lookup table) + Canceled OrderStatus = "canceled" // sqlshape: OrderStatus has constant "canceled" which is not a label of value set of order_statuses.code (lookup table) ) -// sqlshape: value set of order_statuses (lookup table) has label "shipped" but OrderStatus has no constant for it +// sqlshape: value set of order_statuses.code (lookup table) has label "shipped" but OrderStatus has no constant for it ``` OK @@ -484,7 +464,7 @@ const ( ) ``` -補足。`OrderStatus("typo")`のような変換は`sqlshape: OrderStatus("typo") is not a label of ...`、すべてのラベルを扱っていない`switch`は`sqlshape: switch on OrderStatus does not handle ... labels: shipped`として報告される。型に`Known() bool`を実装しておくと、実行時にこのビルドが知らないラベルを受け取ったとき、行マッパーが`*UnknownLabelError`を返す。値集合の置き場としてはenumよりseed済みlookupテーブルを推奨する([migrations.ja.md](migrations.ja.md#seed済みテーブル))。 +補足。`OrderStatus("typo")`のような変換は`sqlshape: OrderStatus("typo") is not a label of ...`、すべてのラベルを扱っていない`switch`は`sqlshape: switch on OrderStatus does not handle ... labels: shipped`として報告される。型に`Known() bool`を実装しておくと、実行時にこのビルドが知らないラベルを受け取ったとき、行マッパーが`*UnknownLabelError`を返す。値集合の置き場としてはenumよりseed済みlookupテーブルを推奨する([migrations.ja.md](migrations.ja.md#seed済みテーブルpostgresql))。 #### 別のテーブルのIDを渡さない @@ -518,7 +498,7 @@ type Params struct { } ``` -#### 単位の違うドメインを混ぜない +#### 単位の違うドメインを混ぜない(PostgreSQL) ドメインの列に使われたnamed typeはそのドメインに結びつけられる。SQLの中でも、ドメインは基底型とは別の単位として扱う。PostgreSQL自身は基底型に戻して演算を許すが、検査器は報告する。 @@ -533,7 +513,7 @@ NG ```sql SELECT id FROM products WHERE price + weight > 1000 --- ^ domain mismatch: yen + gram: mixes yen with gram (cast to the base type to drop the domain) +-- ^ domain mismatch: yen + gram: operands must share the domain (cast to the base type to drop it) ``` ```go @@ -590,8 +570,8 @@ INSERT INTO customers (email, name) VALUES ({{.Email}}, {{.Name}}) RETURNING id ``` ```go -_, err := CreateCustomer.First(ctx, db, p) -if sqlshape.Violates(err, "customers_email_key") { ... } +_, err := postgres.First(ctx, db, CreateCustomer, p) +if postgres.Violates(err, "customers_email_key") { ... } ``` 補足。列挙されるのは、一意制約と主キー、外部キーの両方向(挿入する行が存在しない親を参照する、削除する行がまだ子から参照されている)、EXCLUDE制約、CHECK、ドメインのCHECK、書き込む値がNULLになりうる場合のNOT NULL。パラメータ由来のNULLは、そのフィールドがnilを表せない型(`string`など)なら候補から外れる。 `NOT ENFORCED`と宣言したCHECKと外部キー(PostgreSQL 18)は決して失敗しないので、列挙されない。 @@ -614,25 +594,11 @@ expect行は「この文が失敗しうる理由の正確な一覧」として #### 制約の名前 -名前を付けた制約はその名前で呼ぶ。名前を付けなかった制約にはPostgreSQLが付ける名前がそのまま使われるので、診断・expect行・実行時エラーで同じ文字列になる。 - -| 制約 | キー | 例 | -|---|---|---| -| `PRIMARY KEY` | `
_pkey` | `orders_pkey` | -| `UNIQUE (a, b)` | `
___key` | `customers_email_key` | -| 列`(a)`の`REFERENCES` | `
__fkey` | `orders_customer_id_fkey` | -| 列をちょうど1つだけ参照するテーブルの`CHECK` `(a)` | `
__check`(複数列を参照するか、列を参照しないCHECKは`
_check`) | `orders_total_check` | -| ドメインの`CHECK` | `_check` | `yen_check` | -| `EXCLUDE (a, b)` | `
___excl` | `reservations_room_during_excl` | -| `NOT NULL` | `
.` | `orders.total` | -| トリガーが送出するエラー | SQLSTATE、または`-- sqlshape: error`で付けた名前 | `P0401`、`OrderTooLarge` | -| ビューの`WITH CHECK OPTION` | SQLSTATE(PostgreSQL自身の44000エラーは制約名を持たない) | `44000` | - -同じ名前になる制約が2つあると、PostgreSQLと同様に番号が付く(`orders_total_check1`)。生成した名前がPostgreSQLの63バイトという識別子の上限を超える場合は、マルチバイト文字を途中で切らないよう、PostgreSQLと同じやり方で切り詰める。 +失敗モードのキーは、データベースが制約に付ける名前か、NOT NULLなら`table.column`である。診断・expect行・実行時エラーで同じ文字列になる。名前を付けなかった制約に付く名前と、制約名を持たない失敗(トリガのエラー、ビューの`WITH CHECK OPTION`)のキーはデータベースごとに違う。[PostgreSQL](postgres.ja.md#制約の名前)、[MySQL](mysql.ja.md#制約名と失敗モード)。 #### トリガーが送出するエラーには名前を付ける -検査器はPL/pgSQLのトリガー本体を読むので、中の`RAISE EXCEPTION ... USING ERRCODE = 'P0401'`だけで`P0401`は失敗モードに加わる(`ERRCODE`の無い`RAISE`は`P0001`)。注釈はそのコードに名前を付けるためのもので、expect行と`Violates`でその名前を使えるようになる: +検査器はPL/pgSQLのトリガー本体を読むので、中の`RAISE EXCEPTION ... USING ERRCODE = 'P0401'`だけで`P0401`は失敗モードに加わる(`ERRCODE`の無い`RAISE`は`P0001`)。トリガーの関数の上に書く`-- sqlshape: error = `行は、そのコードに名前を付ける: ```sql -- schema.sql @@ -645,6 +611,16 @@ END $$; CREATE TRIGGER order_size BEFORE INSERT OR UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION check_order_size(); ``` +`Name`はGoの識別子でなければならない。プログラム側は`sqlshape.Error`でそれを写し取り、生のコードの代わりにそれで失敗を読み戻す: + +```go +var OrderTooLarge = sqlshape.Error("P0401") + +if postgres.Violates(err, OrderTooLarge) { ... } +``` + +expect行はその失敗をコードでもNameでも綴れる——読みやすい方を書けばよく、両方どちらも取り残されない: + ```sql -- sqlshape: expect OrderTooLarge, orders_customer_id_fkey INSERT INTO orders (customer_id, total) VALUES ({{.CustomerID}}, {{.Total}}) @@ -652,7 +628,55 @@ INSERT INTO orders (customer_id, total) VALUES ({{.CustomerID}}, {{.Total}}) トリガーが付いているテーブルへの、トリガーが発火するイベント(この例ではINSERTとUPDATE)の失敗モードに、そのSQLSTATEが加わる。 -#### RAISE無しで失敗するPL/pgSQL文 +`go vet`は`sqlshape.Error`宣言を両方向で検査する。コードがスキーマの宣言どおりの、まさにそのNameのものであること、そしてパッケージの文が起こしうる名前付き失敗モードすべてに、プログラムのどこかに(自分自身か、参照が届く他パッケージの宣言か)そういう宣言があること——宣言済みの型の束縛が届く範囲と同じ範囲で判定する。 + +NG + +```go +var Wrong = sqlshape.Error("P0401") +// sqlshape: schema names function check_order_size's error P0401 "OrderTooLarge", not "Wrong" +``` + +```go +var A = sqlshape.Error("P0401") +var B = sqlshape.Error("P0401") +// sqlshape: A and B both declare sqlshape.Error("P0401") +``` + +```go +var Ghost = sqlshape.Error("P9999") +// sqlshape: the schema declares no error "P9999" +``` + +```go +// このパッケージから検査器が届く範囲にP0401を宣言するvarが無い。文のexpect行はそれを名指している +// sqlshape: P0401 (raised by trigger order_size on orders as OrderTooLarge, SQLSTATE P0401) +// has no `var OrderTooLarge = sqlshape.Error("P0401")` declared in this program +``` + +名前を付けるかどうかは任意である。`-- sqlshape: error`注釈の無いスキーマは、expect行も`Violates`もすべてコードだけで書けて読める。 + +MySQLもトリガー本体を同じように読み、注釈の代わりになるのは自前の`SIGNAL`である: + +```sql +-- schema.sql +-- sqlshape: error 30001 = OrderTooLarge +CREATE TRIGGER order_size BEFORE INSERT ON orders FOR EACH ROW +BEGIN + IF NEW.total > 1000000 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'order too large', MYSQL_ERRNO = 30001; + END IF; +END; +``` + +```sql +-- sqlshape: expect OrderTooLarge, fk_orders_customer +INSERT INTO orders (customer_id, total) VALUES ({{.CustomerID}}, {{.Total}}) +``` + +expect行と`mysql.Violates`が判定に使うキーは、SIGNALにMySQL自身が付けるもの——`MYSQL_ERRNO`を設定していればその10進表記(`30001`)、無ければSQLSTATE(`45000`)——であることに変わりはない。項目をそのコードで綴っても注釈のNameで綴っても同じで、`Violates`もランタイムもスキーマを読まないので、`sqlshape.Error("30001")`から作った`sqlshape.Failure`もコードそのものを運んでおり、そのコードで判定される。`MYSQL_ERRNO`を設定しないときのキー、`SELECT ... INTO`とトリガーが自分の表に書く場合の番号は[mysql.ja.md](mysql.ja.md#トリガとストアドルーチン)にある。 + +#### RAISE無しで失敗するPL/pgSQL文(PostgreSQL) PL/pgSQLの一部の文は`RAISE`が無くても失敗しうる。検査器はそのSQLSTATEも、明示的な`RAISE`と同様に本体の失敗モードへ加える。 @@ -675,6 +699,30 @@ SELECT place_order({{.CustomerID}}, {{.Note}}) 補足。本体でパラメータに由来するとわかったNOT NULLは、呼び出し側の引数まで辿られる。`STRICT`な関数はNULLでは呼ばれないので、その引数からの違反は候補から外れる。 +MySQLもストアドFUNCTIONの呼び出しと`CALL`されるPROCEDUREを同じに扱う。本体のSIGNAL、本体自身の書き込みが違反しうる制約、その書き込みが発火させるトリガーが、呼び出した文まで届く。 + +```sql +-- schema.sql +-- sqlshape: error 30001 = OrderTooLarge +-- sqlshape: not null +CREATE FUNCTION place_order(cust_id BIGINT UNSIGNED, amount DECIMAL(10,2)) RETURNS BIGINT +BEGIN + IF amount > 1000000 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'order too large', MYSQL_ERRNO = 30001; + END IF; + INSERT INTO orders (customer_id, total) VALUES (cust_id, amount); + RETURN LAST_INSERT_ID(); +END; +``` + +```sql +SELECT place_order({{.CustomerID}}, {{.Total}}) +-- ^ may violate 30001 (raised by function place_order() as OrderTooLarge, MySQL error 30001); +-- add `-- sqlshape: expect OrderTooLarge` to the template or make it impossible +``` + +MySQL固有のもの——引数の数が違う場合(1318)、呼び出し側自身が読むか書く表に関数自身が書く場合(1442、実行のたびに)、`CALL`のOUT引数と結果列の規則——は[mysql.ja.md](mysql.ja.md#トリガとストアドルーチン)にある。 + ### 1行だけ返す(`One`) `sqlshape.One[R, P]`は1行以下しか返さないと宣言するもので、検査器はそれを展開ごとにスキーマから証明する。証明できなければエラー。実行時には`Get`は無ければ`ErrNoRows`、`Find`は有無を返し、データベースが証明に反して2行返したらどちらも`ErrManyRows`を返す。 @@ -696,7 +744,19 @@ var ByEmail = sqlshape.One[User, struct{ Email string }](` SELECT id, email FROM users WHERE email = {{.Email}}`) ``` -補足。1行以下と言えるのは、FROMに現れるすべてのテーブルについて、その一意キー(主キー、`UNIQUE`、一意インデックス、またはWHERE句が同じ条件を含む部分一意インデックス)がリテラル・パラメータ・外側の参照・相関の無いスカラーサブクエリのいずれかと等値で固定されているとき。等値はJOIN(外部結合のON句はNULLになりうる側だけを固定する)、ビュー、サブクエリ、CTEを通して追跡する。`GROUP BY`の無い集約、定数の`LIMIT 0` / `LIMIT 1`、FROMの無いSELECT、1行の`VALUES`、1行の`INSERT ... RETURNING`も1行以下と見なす。`FULL JOIN`は決して1行以下にならない。`DEFERRABLE`と宣言したキーも同様——一意性がコミットまで検査されないため、そのトランザクションが生きている間は同じ値を持つ2行が存在しうる。時制キー(`PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)`、PostgreSQL 18)は、スカラー列が等値で固定され、範囲列が既知の値と等しいか、要素型の既知の点を含む(`valid_at @> {{.Day}}::date`)ときに固定される。同じ`id`の行どうしは範囲が重ならないので、1つの点を含む行は多くても1つである。既知側が範囲だと足りない(空の範囲はどの範囲にも含まれる)。重なり(`&&`)でも足りない。 +補足。1行以下と言えるのは、FROMに現れるすべてのテーブルについて、その一意キー(主キー、`UNIQUE`、一意インデックス、またはWHERE句が同じ条件を含む部分一意インデックス)がリテラル・パラメータ・外側の参照・相関の無いスカラーサブクエリのいずれかと等値で固定されているとき。何が固定になるかは次のとおり。 + +- `col = NULL`は何も固定しない(決して真にならない) +- `col IS NOT DISTINCT FROM v`は、列が`NOT NULL`で`v`が既知かつ`NULL`でなければ固定する +- 要素1個の`col = ANY(ARRAY[v])`は`col = v`と同じ +- 列へのキャスト(`GROUP BY status::text`)は透かして見る +- 等値はJOIN(外部結合のON句はNULLになりうる側だけを固定する)、ビュー、サブクエリ、CTEを通して追跡する + +キーに関わらずそれ自体で1行以下と見なすもの: `GROUP BY`の無い集約、定数の`LIMIT 0` / `LIMIT 1`、FROMの無いSELECT、1行の`VALUES`、1行の`INSERT ... RETURNING`。 + +決して1行以下にならないもの: `FULL JOIN`と、`DEFERRABLE`と宣言したキー。一意性がコミットまで検査されないため、そのトランザクションが生きている間は同じ値を持つ2行が存在しうる。 + +時制キー(`PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)`、PostgreSQL 18)は、スカラー列が等値で固定され、範囲列が既知の値と等しいか、要素型の既知の点を含む(`valid_at @> {{.Day}}::date`)ときに固定される。同じ`id`の行どうしは範囲が重ならないので、1つの点を含む行は多くても1つである。既知側が範囲だと足りない(空の範囲はどの範囲にも含まれる)。重なり(`&&`)でも足りない。 #### すべての分岐で証明できなければならない @@ -705,14 +765,14 @@ NG ```go var Find = sqlshape.One[User, struct{ ID *int64 }](` SELECT id, email FROM users WHERE true {{if .ID}} AND id = {{.ID}} {{end}}`) -// One: cannot prove at most one row: users: no unique key is fixed by equality (keys: (id), (email)) [if@39:else] +// One: cannot prove at most one row: users: no unique key is fixed by equality (keys: (id), (email)) [if@45:else] ``` `.ID`がnilの分岐では条件が無くなり、全行が返る。それを見つけるのがこの検査の意図なので、この文は`Query`にするか、`.ID`を非ポインタにして分岐を外す。 -### COPYで一括ロードする +### COPYで一括ロードする(PostgreSQL) -`sqlshape.Copy[R]("order_items", "order_id", "line_no", ...)`はINSERTと同じように検査される。テーブルと列が存在すること、各列の型がその列に値を入れるフィールドと合うこと、指定しなかった列にはすべて既定値があるか生成列であること。 +`postgres.Copy[R]("order_items", "order_id", "line_no", ...)`はINSERTと同じように検査される。テーブルと列が存在すること、各列の型がその列に値を入れるフィールドと合うこと、指定しなかった列にはすべて既定値があるか生成列であること。 NG @@ -721,7 +781,7 @@ type Item struct { OrderID int64 Sku string } -var Load = sqlshape.Copy[Item]("order_items", "order_id", "sku") +var Load = postgres.Copy[Item]("order_items", "order_id", "sku") // Copy into order_items: column "line_no" is NOT NULL without a default and is not copied ``` @@ -733,7 +793,7 @@ type Item struct { LineNo int16 Sku string } -var Load = sqlshape.Copy[Item]("order_items", "order_id", "line_no", "sku") +var Load = postgres.Copy[Item]("order_items", "order_id", "line_no", "sku") ``` ## 第2部 — スキーマが宣言する規約 @@ -758,19 +818,20 @@ var Load = sqlshape.Copy[Item]("order_items", "order_id", "line_no", "sku") | `paired(outbox)` | 同じ文で名指しの表にも書くこと(書き込みCTE) | `insert` | | `single` | 高々1行しか触らないと証明できること(`One`の証明) | `delete` | -``は`select` / `insert` / `update` / `delete`のカンマ区切りか、まとめ書きの`read`(SELECTと、WHEREで行を読むUPDATE / DELETE / MERGEの対象)、`write`、`all`。文は表に対して実際にすることで判定される。MERGEの各枝はその枝の文種の書き込み(INSERT枝しかないMERGEは`on update`の義務を負わない)、`INSERT ... ON CONFLICT DO UPDATE`はinsertとupdateの両方、`TRUNCATE`は全行のdelete、自動更新可能ビューを通した書き込みは基底表への書き込み。宣言の中の`$n`は「行を見る前に決まっている何かの値」— パラメータ、リテラル、外側の参照 — を指し、その番号のパラメータという意味ではない。Goテンプレートの`{{.X}}`はそういう値の一つ。 +``は`select` / `insert` / `update` / `delete`のカンマ区切りか、まとめ書きの`read`(SELECTと、WHEREで行を読むUPDATE / DELETE / MERGEの対象)、`write`、`all`。文は表に対して実際にすることで判定される。MERGEの各枝はその枝の文種の書き込み(INSERT枝しかないMERGEは`on update`の義務を負わない)、`INSERT ... ON CONFLICT DO UPDATE`はinsertとupdateの両方、`TRUNCATE`は全行のdelete、自動更新可能ビューを通した書き込みは基底表への書き込み。裏返すと、ビューに宣言した義務が縛るのはビューの読み手だけである — ビュー経由の書き込みは基底表の義務で判定され、ビュー自身の義務には決してかからないので、書き込みにも効かせたい規則は基底表に宣言する。宣言の中の`$n`は「行を見る前に決まっている何かの値」— パラメータ、リテラル、外側の参照 — を指し、その番号のパラメータという意味ではない。Goテンプレートの`{{.X}}`はそういう値の一つ。 `require`行ではないが義務に展開される宣言が3つある。`visible where `(`require on read`のもとの綴り)、`aggregate`、`transitions`。`sensitive`は列にラベルを付ける。vetのフラグ`-require-columns=tenant_id`・`-no-table-reads`・`-no-tables`は、その列を持つ全表への`require pinned(tenant_id)`、全表への`require via view`、`require via view on all`の略記で、文脈の`waive`は宣言と同じように解除できる。 ディレクティブは直下の`CREATE TABLE`・`CREATE VIEW`・`CREATE FUNCTION`・seedの`INSERT`に属する。それ以外の文(`ALTER TABLE`、`COMMENT ON`)の直上に書いたものは宣言にならず、スキーマの問題として報告される。 -義務の履行経路は5つで、見ておくべきものは`-strict`が報告する。 +義務の履行経路は6つで、見ておくべきものは`-strict`が報告する。 1. 文自身のWHERE / ON / SET 2. ビュー経由。ビューの定義はスキーマ読み込み時にそれ自身が判定され、ビューの読み手はその中の表について再度判定されない(関数の本体も同じ扱いで、トリガー関数も含む。vetはスキーマの問題として、`sqlshape check`は発見として報告する) 3. 行レベルセキュリティのポリシー。USINGがそれを成り立たせていれば、行セキュリティの対象ロールについて履行される(表が`FORCE ROW LEVEL SECURITY`でなければ`-strict`が所有者への注記を出す) -4. 複合外部キーをまたいで。`FOREIGN KEY (order_id, tenant_id) REFERENCES orders (id, tenant_id)`があれば、`order_id = orders.id`で結合し`orders.tenant_id`が固定されていれば`order_items.tenant_id`も固定されている -5. 文側のopt-out。`-- sqlshape: unfiltered orders`(述語型の義務)か`-- sqlshape: waive orders pinned(tenant_id)`(宣言どおりの綴りで1つ。`waive orders`だけならその表の義務を全部)。opt-outは`-strict`で報告される +4. 複合外部キーをまたいで。`FOREIGN KEY (order_id, tenant_id) REFERENCES orders (id, tenant_id)`があれば、`order_id = orders.id`で結合し`orders.tenant_id`が固定されていれば`order_items.tenant_id`も固定されている。ただし`order_items.tenant_id`が`NOT NULL`(か、文がそう証明している)ときに限る。外部キーのどれか1列がNULLの行はどちらのデータベースでも制約の検査対象外で、親と結合できても値を揃えていない +5. 2の書き込み側の対称形。自動更新可能なビューに`WITH CHECK OPTION`が付いていれば、そのビュー経由の書き込みはビュー自身のWHERE(CASCADEDなら結合の有無を問わず下位の全ビューのWHEREも、LOCALなら自前のCHECK OPTIONを宣言した下位ビューのWHEREだけ)が固定する値で`pinned`を履行する。サーバ自身がそれを満たさない行を拒む(PostgreSQLのSQLSTATE 44000、MySQLの1369`ER_VIEW_CHECK_FAILED`)からで、CHECK OPTIONを何も宣言していないビューはこの経路を持たない +6. 文側のopt-out。`-- sqlshape: unfiltered orders`(述語型の義務)か`-- sqlshape: waive orders pinned(tenant_id)`(宣言どおりの綴りで1つ。`waive orders`だけならその表の義務を全部)。opt-outは`-strict`で報告される 表の出現ごとに判定する。自己結合やサブクエリでもう一度その表を読めば、そこでも義務を負う。`RETURNING`は判定しない。ビューに付けた義務はビューの読み手への義務で、ビューの定義文は中の表の義務を自分で履行する側。 @@ -839,7 +900,7 @@ UPDATE orders SET status = {{.Status}} WHERE id = {{.ID}} AND version = {{.Versi -- orders.version is not pinned: every statement on orders must fix version by equality (or assign it) ``` -補足。版を上げるのはトリガ、見た版を名指しするのは文、何も当たらなかった`One`のUPDATEは`ErrNoRows`を返す(先を越された)。 +補足。バージョンを上げるのはトリガ、見たバージョンを名指しするのは文、何も当たらなかった`One`のUPDATEは`ErrNoRows`を返す(先を越された)。 ### テーブルを直接読まない(`via view`、`-no-table-reads` / `-no-tables`) @@ -911,13 +972,13 @@ SELECT o.status, v.id FROM orders o JOIN invoices v ON v.order_id = o.id WHERE o -- sqlshape: aggregate orders (order_items, order_item_tags) lock version ``` -ルートは`pinned(version) on update, delete`(見た版を名指しする)を負い、各子表はUPDATE / DELETEで、外部キーをたどった先(孫なら親を経由して)のルート行がその版であることの`EXISTS`を負う。 +ルートは`pinned(version) on update, delete`(見たバージョンを名指しする)を負い、各子表はUPDATE / DELETEで、外部キーをたどった先(孫なら親を経由して)のルート行がそのバージョンであることの`EXISTS`を負う。 ```sql UPDATE order_items i SET qty = {{.Q}} FROM orders o WHERE o.id = i.order_id AND o.version = {{.V}} AND i.id = {{.ID}} ``` -補足。版を上げるのはDBの仕事のまま(ルートのトリガ。子から上げるなら子のトリガがルートを更新する)。何も当たらなかった`One`の書き込みは`ErrNoRows`を返す。 +補足。バージョンを上げるのはDBの仕事のまま(ルートのトリガ。子から上げるなら子のトリガがルートを更新する)。何も当たらなかった`One`の書き込みは`ErrNoRows`を返す。 ### ステータス列は宣言した遷移でしか動かさない(`transitions`) @@ -966,6 +1027,8 @@ INSERT INTO outbox (id, payload) SELECT id, 'created' FROM o -- a write to orders must also write outbox in the same statement (a data-modifying WITH): require paired(outbox) on insert ``` +規則は「同じ文で両方の表に書く」であって、`outbox`に書く値と`orders`の行の対応は見ない(無関係な値を書いたoutbox行も通る)。対になっていることが約束で、中身は文の責任である。 + `single`は、DELETEが高々1行しか触らないことを`One`と同じ証明で求める。 ```sql @@ -983,7 +1046,7 @@ CREATE TABLE orders (...); CREATE VIEW order_contacts AS SELECT id, email, left(phone, 3) || '***' AS phone_masked FROM orders; ``` -ラベルの付いた列は、そのラベルを`may read`する文脈([文脈](#呼び出し元ごとに規約を変えるcontext))でしか参照できない。SELECT句でもWHEREでも同じ。値を書き込むのは読むことではない。ラベルは列をそのまま通すビューと、その表の行を返す関数(`RETURNS SETOF orders`)を通って伝わり、式(マスクした列)で止まる。 +ラベルの付いた列は、そのラベルを`may read`する文脈([文脈](#呼び出し元ごとに規約を変えるcontext))でしか参照できない。SELECT句でもWHEREでも同じ。値を書き込むのは読むことではないが、`RETURNING`で返すのは読むこと(誰が渡した値でも、データベースの外に出る)。ラベルは列をそのまま通すビューと、その表の行を返す関数(`RETURNS SETOF orders`)を通って伝わり、式(マスクした列)で止まる。 ```sql SELECT email FROM orders WHERE id = {{.ID}} -- billing以外ではNG @@ -1029,9 +1092,9 @@ sqlshape: 2 finding(s) ## 第3部 — 文の外側 -### スキーマはPostgreSQLの版を名乗る(`postgres`) +### スキーマはPostgreSQLのバージョンを名乗る(`postgres`) -`schema.sql`は、どの版のPostgreSQL向けに書かれているかを1回宣言する。この宣言が判定の土台になる。スキーマと全部の文を読む文法、型・関数・演算子を解決するカタログ、`pgtest`とマイグレーション系コマンドが起動するPostgreSQLの版が、ここで決まる。宣言の無いスキーマは読まない。 +`schema.sql`は、どのバージョンのPostgreSQL向けに書かれているかを1回宣言する。この宣言が判定の土台になる。スキーマと全部の文を読む文法、型・関数・演算子を解決するカタログ、マイグレーション系コマンドが起動するPostgreSQLのバージョンが、ここで決まる。宣言の無いスキーマは読まない。規則の中でPostgreSQLに属するもの(Go型の表、制約名、`One`の証明の材料、ランタイム、マイグレーションコマンド)は[postgres.ja.md](postgres.ja.md)にまとめてある。 ```sql -- sqlshape: postgres 18 @@ -1041,14 +1104,63 @@ CREATE TABLE ... NG - `schema.sql`(ディレクトリなら`*.sql`のどれか)に宣言が無い -- sqlshapeが持っていない版(メッセージが対応版を列挙する。17と18) +- sqlshapeが持っていないバージョン(メッセージが対応バージョンを列挙する。17と18) - 値の違う宣言が2つある OK - ファイルのどこに書いてもよい。同じ値なら繰り返しもよい -新しい版だけが受け付ける構文(`RETURNING old.*`、`WITHOUT OVERLAPS`、`NOT ENFORCED`、`VIRTUAL`な生成列は18のもの)は、古い版を宣言していれば構文エラーになる。その版のサーバに流したときと同じ結果である。PostgreSQLの版を上げる作業は、この数字を変えて検査器の報告を読むことになる。 +新しいバージョンだけが受け付ける構文(`RETURNING old.*`、`WITHOUT OVERLAPS`、`NOT ENFORCED`、`VIRTUAL`な生成列は18のもの)は、古いバージョンを宣言していれば構文エラーになる。そのバージョンのサーバに流したときと同じ結果である。PostgreSQLのバージョンを上げる作業は、この数字を変えて検査器の報告を読むことになる。 + +### スキーマはMySQLのバージョンを名乗る(`mysql`) + +同じ宣言でMySQLを名乗れる。そのときはMySQL自身の文法がスキーマと全部の文を読み、MySQLの規則が式に型を付け、文は`github.com/kr9ly/sqlshape/mysql/v2`が`database/sql`の上で実行する。 + +```sql +-- sqlshape: mysql 8.4 +CREATE TABLE ... +``` + +NG + +- sqlshapeが持っていないバージョン(持っているのは8.4) +- 値の違う宣言が2つある、または`postgres`も同時に名乗る + +第1部と第2部の規則はMySQLのスキーマにも同じにかかる。判定するのがPostgreSQLではなくMySQLのアナライザーになるだけである。規則が名前・番号・型を使う箇所(制約名とエラー番号、Go型の表、`One`の証明の材料、`ONLY_FULL_GROUP_BY`の検査)でMySQLのものが何かは、MySQLに無くて検査しないものと合わせて[mysql.ja.md](mysql.ja.md)にある。 + +### スキーマはサーバの設定を名乗る(`server`) + +文の判定を変えるサーバ変数は、バージョンと並べて1行に1つ宣言する。検査器と本番の接続を同じ設定に揃えるための行である。宣言が無ければ検査器はサーバの既定値を仮定する。MySQL 8.4なら既定の`sql_mode`(`ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION`)と`lower_case_table_names = 0`、つまりLinuxで初期化したままのサーバである。 + +```sql +-- sqlshape: mysql 8.4 +-- sqlshape: server sql_mode = 'ANSI,STRICT_ALL_TABLES' +-- sqlshape: server lower_case_table_names = 1 +CREATE TABLE ... +``` + +NG + +- その方言が読まない変数。MySQLでは`sql_mode`・`lower_case_table_names`・`max_sp_recursion_depth`以外、PostgreSQLでは今のところ全部(判定はサーバの既定値に従い、`search_path`はスキーマ自身の`SET`で扱う) +- 8.4に無い`sql_mode`の名前、0・1・2以外の`lower_case_table_names` +- 空白を含む値を文字列リテラルにしていない、同じ変数を2回宣言している + +OK + +- `sql_mode`はカンマ区切りの名前(大文字小文字は問わない)、空文字列、組み合わせモードの`ANSI`と`TRADITIONAL`。組み合わせはサーバと同じに展開する +- `lower_case_table_names`は0、1、2 + +検査器はこれらに次のように従う。 + +- パーサは字句解析のビットをそのまま読む。`ANSI_QUOTES`なら二重引用符は識別子、`PIPES_AS_CONCAT`なら`||`は`CONCAT`(無ければ`OR`)。`IGNORE_SPACE`、`NO_BACKSLASH_ESCAPES`、`HIGH_NOT_PRECEDENCE`もサーバどおりに効く。`REAL_AS_FLOAT`なら`REAL`列は`DOUBLE`ではなく`FLOAT`(`float32`)になる +- `ONLY_FULL_GROUP_BY`は上のグループ検査(1055、1140、`DISTINCT`の3065)の有無を決める。`HAVING`の名前解決(1054)と`ORDER BY`の集約の規則(3029、3028)はサーバと同じくどのモードでもかかる +- 厳密モード(`STRICT_TRANS_TABLES`か`STRICT_ALL_TABLES`)は2つを決める。文字列関数(`CONCAT`、`SUBSTRING`、`LOWER`など)がNULL可になるのは厳密モードのときだけで、無ければ`NOT NULL`列の`CONCAT(name, 'x')`は`*string`ではなく`string`で受ける。`NOT NULL`列への`NULL`が失敗モード(1048)になるのも厳密モードのときで、無ければ1行の`INSERT`と`REPLACE`(その`ON DUPLICATE KEY UPDATE`を含む)だけが`NULL`を拒み、複数行、`INSERT ... SELECT`、`UPDATE`は型の暗黙の既定値を警告付きで格納するので、検査器はそれらに1048を挙げない +- `NO_UNSIGNED_SUBTRACTION`なら符号なし同士の減算は符号付き(`uint64`ではなく`int64`)になる +- 残りの名前(`NO_ZERO_DATE`、`ERROR_FOR_DIVISION_BY_ZERO`、`NO_ENGINE_SUBSTITUTION`、`PAD_CHAR_TO_FULL_LENGTH`など)は実行時にしか効かない。検査器はそのまま受け付ける +- `lower_case_table_names = 1`は表名とビュー名を小文字にして持つ。サーバの報告と同じである(`SELECT * FROM Users`は表`users`を読み、factsも境界の検査もその名前で見る)。2は宣言どおりの綴りで持ち、大文字小文字を無視して照合する。0は`Users`と`users`を区別する(1146)。ディレクティブ(`unfiltered`、`waive`、義務)が名乗る表名も同じ規則で解決する。1と2ならどの綴りでも届き、0なら`CREATE`の綴りで書く + +宣言は「文が走るサーバはこう設定されている」という約束であり、`mysql.Verify(ctx, db, schemaSQL)`がそれを確かめる。接続のセッションの`@@sql_mode`(DSNやプールの初期化が上書きしうる)とサーバの`lower_case_table_names`を読み、宣言との差を返す([mysql.ja.md](mysql.ja.md#ランタイム-databasesql))。 ### sqlshapeを通さないSQLを書かない(`-raw-sql`) @@ -1067,9 +1179,9 @@ OK rows, err := pool.Query(ctx, "SELECT id FROM orders WHERE status = $1", status) ``` -補足。`-raw-sql=forbid`は定数であってもsqlshapeを通らない文をすべて拒否する(`pgxpool.Query executes SQL outside sqlshape; with -raw-sql=forbid every statement goes through sqlshape.Query / One / Copy (or list the package in -raw-sql-allow)`)。移行中のパッケージは`-raw-sql-allow=pkg/...`で除外する。 +補足。`-raw-sql=forbid`は定数であってもsqlshapeを通らない文をすべて拒否する(`pgx.Query executes SQL outside sqlshape; with -raw-sql=forbid every statement goes through sqlshape.Query / One / postgres.Copy (or list the package in -raw-sql-allow)`)。移行中のパッケージは`-raw-sql-allow=pkg/...`で除外する。 -### パッケージは自分のスキーマだけを参照する(`-schemas`) +### パッケージは自分のスキーマだけを参照する(`-schemas`、PostgreSQL) `-schemas=a_api,b_private`は、そのパッケージが参照してよいPostgreSQLのスキーマを限定する。1つのデータベースを複数サービスで使うときの境界。 @@ -1096,9 +1208,11 @@ END $$; -- schema.sql CREATE VIEW order_summary AS SELECT o.id, c.nmae AS customer_name FROM orders o JOIN customers c ON c.id = o.customer_id; --- sqlshape: schema schema.sql: view order_summary: column "nmae" does not exist (SQLSTATE 42703) +-- sqlshape: schema schema.sql: view order_summary: 42703: column c.nmae does not exist (at ) ``` +MySQLのトリガやストアドPROCEDURE / FUNCTIONの本体も、スキーマごとに1回、同じように検査される。`NEW` / `OLD`、`DECLARE`した変数や引数、制御構造、`SELECT ... INTO`、カーソル、`CALL`、`SIGNAL` / `RESIGNAL`がスコープに入る。本体の作成時にサーバ自身が拒むものもスキーマの問題として報告される。本体についてMySQL固有のもの——制約名とエラー番号、SIGNALのキーの規則——は[mysql.ja.md](mysql.ja.md#トリガとストアドルーチン)にある。 + 補足。行レベルセキュリティのポリシーもここで検査される。`CREATE POLICY`の条件式はboolean型で、集約やウィンドウ関数を含まず、ドメインの単位を守っていなければならない。行セキュリティを有効にしていないテーブルにポリシーがあれば報告する。ポリシーの条件を文の側で繰り返すことは要求しない。絞り込むのはデータベースの仕事である。 `-strict`ではスキーマと文についての助言が加わる。一覧は[flags.ja.md](flags.ja.md#-strict)。 diff --git a/docs/checks.md b/docs/checks.md index 2b1976c..4af5236 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -3,11 +3,14 @@ [日本語](checks.ja.md) The checker finds every `sqlshape.Query[R, P](template)`, `sqlshape.One[R, P](template)`, -`sqlshape.Copy[R](...)` and `sqlshape.MatView(...)` in a package, expands each template into every -combination of its branches ([templates.md](templates.md)), analyzes each expansion against -`schema.sql`, and compares the result with the Go types. This page goes through what you write, -situation by situation, and shows what is rejected and what passes, together with the diagnostic -you will see. +`postgres.Copy[R](...)` and `postgres.MatView(...)` (PostgreSQL) in a package, expands each template +into every combination of its branches ([templates.md](templates.md)), analyzes each expansion +against `schema.sql`, and compares the result with the Go types. This page goes through what you +write, situation by situation, and shows what is rejected and what passes, together with the +diagnostic you will see. The rules are the same for PostgreSQL and MySQL; the examples are +PostgreSQL's, and where a rule uses the database's names, numbers or types (the constraint names, +the Go type table, the error codes in the diagnostics), [postgres.md](postgres.md) and +[mysql.md](mysql.md) have each database's. The checker has two entry points. `go vet -vettool=sqlshape` (or `sqlshape ./...`) runs it over Go packages, where the SQL lives in `Query` / `One` templates and is compared with the Go types. @@ -28,7 +31,7 @@ statement: the Go code around it, and the schema itself. - [Giving types a meaning](#giving-types-a-meaning) - [Preparing for a write to fail](#preparing-for-a-write-to-fail) - [Returning one row (`One`)](#returning-one-row-one) - - [Bulk loading with COPY](#bulk-loading-with-copy) + - [Bulk loading with COPY (PostgreSQL)](#bulk-loading-with-copy-postgresql) - [Part 2 — Rules the schema declares](#part-2--rules-the-schema-declares) - [How a declaration works](#how-a-declaration-works) - [Every read carries the visibility predicate (`visible where`)](#every-read-carries-the-visibility-predicate-visible-where) @@ -43,8 +46,10 @@ statement: the Go code around it, and the schema itself. - [The same rules for SQL outside Go (`sqlshape check`)](#the-same-rules-for-sql-outside-go-sqlshape-check) - [Part 3 — Outside the statement](#part-3--outside-the-statement) - [The schema names its PostgreSQL version (`postgres`)](#the-schema-names-its-postgresql-version-postgres) + - [The schema names its MySQL version (`mysql`)](#the-schema-names-its-mysql-version-mysql) + - [The schema names the server's settings (`server`)](#the-schema-names-the-servers-settings-server) - [Do not run SQL that bypasses sqlshape (`-raw-sql`)](#do-not-run-sql-that-bypasses-sqlshape--raw-sql) - - [A package references only its schemas (`-schemas`)](#a-package-references-only-its-schemas--schemas) + - [A package references only its schemas (`-schemas`, PostgreSQL)](#a-package-references-only-its-schemas--schemas-postgresql) - [Problems in the schema itself](#problems-in-the-schema-itself) ## Part 1 — Checks on every statement @@ -92,7 +97,7 @@ type Order struct { Rejected ```sql -SELECT id, count(*) FROM orders GROUP BY id +SELECT id, id + 1 FROM orders -- ^ result column 2 has no name: give it an alias (... AS name) so it can bind to a field of Order SELECT o.id, c.id FROM orders o JOIN customers c ON c.id = o.customer_id @@ -102,13 +107,13 @@ SELECT o.id, c.id FROM orders o JOIN customers c ON c.id = o.customer_id Passes ```sql -SELECT id, count(*) AS n FROM orders GROUP BY id +SELECT id, id + 1 AS n FROM orders SELECT o.id, c.id AS customer_id FROM orders o JOIN customers c ON c.id = o.customer_id ``` #### A column that may be NULL needs a field that can hold NULL -Fields that can hold NULL: pointers, slices, maps, `sql.Null*`, `pgtype.*`, and any type that +Fields that can hold NULL: pointers, slices, maps, `sql.Null*`, the driver's nullable value types such as `pgtype.*`, and any type that implements `sql.Scanner`. Rejected @@ -141,8 +146,8 @@ still return NULL when there is nothing to report), and from a view's own WHERE tracks its underlying column's NOT NULL live, the way PostgreSQL itself does: a later `ALTER TABLE ... DROP NOT NULL` on the base table reaches it too, even through a chain of views and past whatever the view's own WHERE clause does. If you know better than the checker, override -it on the Go side with the `col:",notnull"` tag or on the SQL side with a -`-- sqlshape: not null deleted_at` line in the template. For a function's result, put +it on the Go side with the `col:",notnull"` tag (on an array field it asserts the elements too) +or on the SQL side with a `-- sqlshape: not null deleted_at` line in the template. For a function's result, put `-- sqlshape: not null` above its `CREATE FUNCTION` in `schema.sql`. In a `RETURNING` list, `old.col` after an INSERT and `new.col` after a DELETE (PostgreSQL 18) may be NULL whatever the column declares: the row does not exist on that side of the write. #### A column only some branches select needs a field that can hold NULL @@ -174,12 +179,16 @@ The branches that do not select the column leave the field nil. A field no branc #### Column and field types follow the table below -Rejected +A binding that can lose information (a `numeric` into a `float64`, a `bigint` into an `int32`, ...) +is accepted with a note, not rejected: only a genuinely incompatible pair (a `text` column into an +`int64` field, say) is. + +Passes, with a note ```go type Order struct { ID int64 - Total float64 // field Total is float64 but column "total" is numeric(12,2) + Total float64 // field Total: numeric into float64 loses precision } ``` @@ -187,7 +196,7 @@ type Order struct { SELECT id, total FROM orders ``` -Passes +Passes, silently ```go type Order struct { @@ -226,6 +235,9 @@ SELECT o.id, array_agg((i.sku, i.qty)::order_item) AS items GROUP BY o.id ``` +(Swapping two adjacent fields misplaces both, not just the first: `Sku` also lands on the +`order_item` type's column 2, "qty", so a second diagnostic names that position mismatch too.) + Passes ```go @@ -235,6 +247,12 @@ type Item struct { } ``` +(PostgreSQL never guarantees an array's own elements are non-NULL, even when the column holding +the array is NOT NULL, and the checker still carries a standing note for that in general (see +`T[]` in [PostgreSQL types](postgres.md)). Here the checker proves it anyway: `order_item`'s row +constructor is never NULL itself, whatever its own fields' nullability, so `array_agg((i.sku, +i.qty)::order_item)` carries no such note and this Passes example is accepted with none.) + #### A single-column statement can be received by a scalar Passes @@ -284,53 +302,19 @@ treated as a nested row. #### The Go type table -What pgx actually scans and encodes, verified against a running PostgreSQL. The same table applies -to receiving columns and to passing parameters. - -| PostgreSQL | Go | -|---|---| -| `bool` | `bool` | -| `smallint` / `integer` / `bigint` | `int16` / `int32` / `int64` / `int` (a narrower Go type is accepted with a note such as `bigint into int32`) | -| `real` / `double precision` | `float32` / `float64` (`double precision into float32` is noted) | -| `numeric` | `string` (keeps every digit), `pgtype.Numeric`, `big.Rat`, `shopspring/decimal.Decimal`, `apd.Decimal`; a float or an integer is accepted with a precision note | -| `text` / `varchar` / `char` / `name` / `citext` and other text-like extension types | `string` (a `string` also encodes as a parameter of any type) | -| `bytea` | `[]byte` | -| `uuid` | `uuid.UUID` (any package), `[16]byte`, `string` | -| `timestamptz` / `timestamp` / `date` | `time.Time` (`-strict` notes that `timestamp` and `date` lose the zone or the time) | -| `time` | `time.Time`, `string` | -| `interval` | `time.Duration`, `pgtype.Interval` | -| `json` / `jsonb` | `[]byte`, `json.RawMessage`, `string`, or any struct, slice or map pgx unmarshals into | -| `inet` | `netip.Addr` / `netip.Prefix` | -| `cidr` | `netip.Prefix` | -| `macaddr` | `net.HardwareAddr` / `string` | -| `hstore` | `map[string]*string` | -| `T[]` | `[]Go(T)` (each element is checked the same way a plain `T` parameter or column is, notes included) | -| ranges | `pgtype.Range[T]`, with `T` checked against the subtype (user-defined ranges too) | -| multiranges | `pgtype.Multirange[pgtype.Range[T]]` | -| `bit` / `point` / `tsvector` | the `pgtype` value | -| `xml` / `money` / `tsquery` / `jsonpath` / `timetz` | `string` | -| `oid` | `uint32` | -| enums, seeded lookup keys, CHECK value sets | a Go named string type ([below](#giving-types-a-meaning)) | -| domains | the base type's Go type, or a named type bound to the domain | -| composites, records | a struct | - -A type not in the table, or one you want to receive with your own type, is declared on the Go type -in its doc comment: - -```go -// sqlshape: type money_amount -type Money struct{ ... } // implements sql.Scanner / driver.Valuer -``` - -The checker then accepts `Money` exactly where the SQL has `money_amount` (its arrays and domains -over it included) and reports it anywhere else. Conversion is left to the type's own -`sql.Scanner` / `driver.Valuer`; the Scanner receives the text form. +The Go types a column may be received with, and a parameter passed as, are the database's, as its +driver scans and encodes them: [PostgreSQL's table](postgres.md#the-go-type-table), +[MySQL's table](mysql.md#the-go-type-table). Both apply to receiving columns and to passing +parameters alike. On PostgreSQL a type not in the table, or one you want to receive with your own +type, is bound with `// sqlshape: type ` on the Go type; conversion is then the type's own +`sql.Scanner` / `driver.Valuer` (MySQL has no named types to bind to). ### Passing parameters -`{{.X}}` becomes a `$n` parameter in the SQL. The checker infers the PostgreSQL type each `$n` needs -from where it is used (`WHERE id = $1` needs `bigint`, `= ANY($1)` an array) and verifies that the -corresponding field of `P` fits, by the type table above. +`{{.X}}` becomes a parameter in the SQL (`$n` on PostgreSQL, `?` on MySQL; the checker numbers them +`$n` either way). The checker infers the SQL type each parameter needs from where it is used +(`WHERE id = $1` needs `bigint`, `= ANY($1)` an array) and verifies that the corresponding field of +`P` fits, by the database's type table. #### A parameter's type follows where it is used @@ -381,6 +365,10 @@ type Params struct { } ``` +(Under `-strict`, a package that touches `orders.status` also gets that column's own enum +advisory, `orders.status is enum order_status: a seeded lookup table ...` -- a separate finding +from the one above, about the column rather than the parameter.) + #### Nested paths and `range` `{{.Filter.Name}}` is the field `Name` of the field `Filter` of `P`. Inside @@ -399,9 +387,15 @@ type Params struct { ```sql SELECT id FROM products WHERE true {{if .Filter.Name}} AND name = {{.Filter.Name}} {{end}} - AND sku IN ({{range $i, $it := .Items}}{{if $i}}, {{end}}{{$it.Sku}}{{end}}) + AND (false {{range $i, $it := .Items}} OR sku = {{$it.Sku}} {{end}}) ``` +A plain `sku IN ({{range}}...{{end}})` is not empty-safe: with zero `.Items`, it expands to +`sku IN ()`, a syntax error. The checker's branch-state exploration tries every length `.Items` +could have (0, 1, 2, ...) and reports exactly that error when it finds it. A `range` used this way +needs a form that stays valid SQL at every length, such as the `false {{range}} OR ... {{end}}` +above (or `WHERE true {{range}} OR (...) {{end}}`, as `testdata/src/a/a.go`'s `indexParam` does). + #### Composite parameters are structs Where the SQL expects a composite type, pass a struct; where it expects an array of one, a slice of @@ -456,6 +450,10 @@ type NewOrder struct { INSERT INTO orders (customer_id, status) VALUES ({{.CustomerID}}, {{.Status}}) ``` +(`Status` here is also a non-pointer `OrderStatus`, so the "A parameter that may be NULL is a +pointer" advisory above fires too, and so does `orders.status`'s own enum advisory: three findings +for this one Rejected example, not one.) + Passes: to use the database's default, make the column conditional. If the application always decides, drop the column's `DEFAULT`. @@ -464,6 +462,9 @@ INSERT INTO orders (customer_id {{if .Status}}, status{{end}}) VALUES ({{.CustomerID}} {{if .Status}}, {{.Status}}{{end}}) ``` +(The Passes example still gets `orders.status`'s enum advisory alone, the same as the previous +section's Passes example.) + ### Giving types a meaning Values a `bigint` or `text` cannot tell apart, such as an enum label, a table's ID or an amount in @@ -531,9 +532,9 @@ type OrderStatus string const ( Pending OrderStatus = "pending" Paid OrderStatus = "paid" - Canceled OrderStatus = "canceled" // sqlshape: OrderStatus has constant "canceled" which is not a label of value set of order_statuses (lookup table) + Canceled OrderStatus = "canceled" // sqlshape: OrderStatus has constant "canceled" which is not a label of value set of order_statuses.code (lookup table) ) -// sqlshape: value set of order_statuses (lookup table) has label "shipped" but OrderStatus has no constant for it +// sqlshape: value set of order_statuses.code (lookup table) has label "shipped" but OrderStatus has no constant for it ``` Passes @@ -551,7 +552,7 @@ A conversion such as `OrderStatus("typo")` is reported as label as `sqlshape: switch on OrderStatus does not handle ... labels: shipped`. If the type implements `Known() bool`, the row mapper returns `*UnknownLabelError` at run time for a label this build does not know. A seeded lookup table is the recommended home for a value set, over an enum -([migrations.md](migrations.md#seeded-tables)). +([migrations.md](migrations.md#seeded-tables-postgresql)). #### Do not pass another table's ID @@ -587,7 +588,7 @@ type Params struct { } ``` -#### Do not mix domains of different units +#### Do not mix domains of different units (PostgreSQL) A named type used with a domain column is bound to that domain. Inside SQL too, a domain is a unit distinct from its base type: PostgreSQL itself falls back to the base type and allows the @@ -604,7 +605,7 @@ Rejected ```sql SELECT id FROM products WHERE price + weight > 1000 --- ^ domain mismatch: yen + gram: mixes yen with gram (cast to the base type to drop the domain) +-- ^ domain mismatch: yen + gram: operands must share the domain (cast to the base type to drop it) ``` ```go @@ -667,8 +668,8 @@ INSERT INTO customers (email, name) VALUES ({{.Email}}, {{.Name}}) RETURNING id ``` ```go -_, err := CreateCustomer.First(ctx, db, p) -if sqlshape.Violates(err, "customers_email_key") { ... } +_, err := postgres.First(ctx, db, CreateCustomer, p) +if postgres.Violates(err, "customers_email_key") { ... } ``` Listed are unique constraints and primary keys, foreign keys in both directions (the inserted row @@ -705,30 +706,17 @@ that are no longer needed. #### Constraint names -A named constraint goes by its name. An unnamed one goes by the name PostgreSQL gives it, so the -diagnostic, the expect line and the run-time error all carry the same string. - -| constraint | key | example | -|---|---|---| -| `PRIMARY KEY` | `
_pkey` | `orders_pkey` | -| `UNIQUE (a, b)` | `
___key` | `customers_email_key` | -| `REFERENCES` on `(a)` | `
__fkey` | `orders_customer_id_fkey` | -| table `CHECK` referencing exactly one column `(a)` | `
__check` (a CHECK on several columns, or on none, is `
_check`) | `orders_total_check` | -| domain `CHECK` | `_check` | `yen_check` | -| `EXCLUDE (a, b)` | `
___excl` | `reservations_room_during_excl` | -| `NOT NULL` | `
.` | `orders.total` | -| an error raised by a trigger | the SQLSTATE, or the name given with `-- sqlshape: error` | `P0401`, `OrderTooLarge` | -| `WITH CHECK OPTION` on a view | the SQLSTATE (PostgreSQL's own 44000 error names no constraint) | `44000` | - -A second constraint that would get the same name is numbered, as PostgreSQL does -(`orders_total_check1`). A generated name over PostgreSQL's 63-byte identifier limit is cut down the -same way PostgreSQL cuts it, without splitting a multibyte character. +A failure mode is keyed by the constraint's name as the database names it, or `table.column` for +`NOT NULL`, so the diagnostic, the expect line and the run-time error all carry the same string. +The names an unnamed constraint gets, and the keys of the failures no constraint names (a +trigger's error, a view's `WITH CHECK OPTION`), are each database's: +[PostgreSQL's](postgres.md#constraint-names), [MySQL's](mysql.md#constraint-names-and-failure-modes). #### Name the errors a trigger raises The checker reads a PL/pgSQL trigger body, so a `RAISE EXCEPTION ... USING ERRCODE = 'P0401'` -inside it already adds `P0401` to the failure modes (a `RAISE` without `ERRCODE` is `P0001`). -The annotation gives the code a name to use on expect lines and in `Violates`: +inside it already adds `P0401` to the failure modes (a `RAISE` without `ERRCODE` is `P0001`). A +`-- sqlshape: error = ` line above the trigger's function gives that code a name: ```sql -- schema.sql @@ -741,6 +729,18 @@ END $$; CREATE TRIGGER order_size BEFORE INSERT OR UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION check_order_size(); ``` +`Name` must be a Go identifier: the program mirrors it with `sqlshape.Error`, and reads the +failure back with it instead of the raw code: + +```go +var OrderTooLarge = sqlshape.Error("P0401") + +if postgres.Violates(err, OrderTooLarge) { ... } +``` + +An expect line may spell the failure by the code or by the Name, whichever reads better -- +the two are interchangeable, and writing both leaves neither unmatched: + ```sql -- sqlshape: expect OrderTooLarge, orders_customer_id_fkey INSERT INTO orders (customer_id, total) VALUES ({{.CustomerID}}, {{.Total}}) @@ -749,7 +749,65 @@ INSERT INTO orders (customer_id, total) VALUES ({{.CustomerID}}, {{.Total}}) The SQLSTATE joins the failure modes of the statements on the trigger's table for the events it fires on (here INSERT and UPDATE). -#### PL/pgSQL statements that fail on their own +`go vet` checks the `sqlshape.Error` declaration both ways: that its code is one the schema +actually declares under that very Name, and that every named failure mode a package's +statements can raise has such a declaration somewhere in the program (its own, or another +package's that a reference reaches, the same reach a declared type's binding has). + +Rejected + +```go +var Wrong = sqlshape.Error("P0401") +// sqlshape: schema names function check_order_size's error P0401 "OrderTooLarge", not "Wrong" +``` + +```go +var A = sqlshape.Error("P0401") +var B = sqlshape.Error("P0401") +// sqlshape: A and B both declare sqlshape.Error("P0401") +``` + +```go +var Ghost = sqlshape.Error("P9999") +// sqlshape: the schema declares no error "P9999" +``` + +```go +// no var declares P0401 anywhere the checker can reach from this package, though a +// statement's expect line names it +// sqlshape: P0401 (raised by trigger order_size on orders as OrderTooLarge, SQLSTATE P0401) +// has no `var OrderTooLarge = sqlshape.Error("P0401")` declared in this program +``` + +Naming a code is optional: a schema with no `-- sqlshape: error` annotation is written and read +entirely by its codes, on both the expect line and `Violates`. + +MySQL reads a trigger body the same way, and its own `SIGNAL` takes the annotation's place: + +```sql +-- schema.sql +-- sqlshape: error 30001 = OrderTooLarge +CREATE TRIGGER order_size BEFORE INSERT ON orders FOR EACH ROW +BEGIN + IF NEW.total > 1000000 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'order too large', MYSQL_ERRNO = 30001; + END IF; +END; +``` + +```sql +-- sqlshape: expect OrderTooLarge, fk_orders_customer +INSERT INTO orders (customer_id, total) VALUES ({{.CustomerID}}, {{.Total}}) +``` + +The key the expect line and `mysql.Violates` judge by is the one MySQL gives the SIGNAL itself -- +the decimal `MYSQL_ERRNO` when it sets one (`30001`), else the SQLSTATE (`45000`) -- whether an +item spells it as that code or as the annotation's Name: neither `Violates` nor the runtime reads +the schema, so a `sqlshape.Failure` from `sqlshape.Error("30001")` still carries the code, and +judges by it. What the key is when the SIGNAL sets no `MYSQL_ERRNO`, and the numbers a `SELECT +... INTO` and a trigger writing its own table carry, are [mysql.md's](mysql.md#triggers-and-stored-routines). + +#### PL/pgSQL statements that fail on their own (PostgreSQL) A few PL/pgSQL statements can fail without a `RAISE`, and the checker adds their SQLSTATE to the body's failure modes the same way it does for an explicit one: @@ -784,6 +842,34 @@ SELECT place_order({{.CustomerID}}, {{.Note}}) A NOT NULL the body blames on a parameter is traced to the call's argument. A `STRICT` function is not called with a NULL, so that argument's violation is dropped. +MySQL calls a stored FUNCTION or a `CALL`ed PROCEDURE the same way: the body's SIGNALs, the +constraints its own writes can violate and the triggers those writes fire reach the calling +statement. + +```sql +-- schema.sql +-- sqlshape: error 30001 = OrderTooLarge +-- sqlshape: not null +CREATE FUNCTION place_order(cust_id BIGINT UNSIGNED, amount DECIMAL(10,2)) RETURNS BIGINT +BEGIN + IF amount > 1000000 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'order too large', MYSQL_ERRNO = 30001; + END IF; + INSERT INTO orders (customer_id, total) VALUES (cust_id, amount); + RETURN LAST_INSERT_ID(); +END; +``` + +```sql +SELECT place_order({{.CustomerID}}, {{.Total}}) +-- ^ may violate 30001 (raised by function place_order() as OrderTooLarge, MySQL error 30001); +-- add `-- sqlshape: expect OrderTooLarge` to the template or make it impossible +``` + +What is MySQL's about it -- a wrong argument count (1318), a function writing a table its own +caller reads or writes (1442, on every execution), `CALL`'s own `OUT` argument and result-column +rules -- is [mysql.md's](mysql.md#triggers-and-stored-routines). + ### Returning one row (`One`) `sqlshape.One[R, P]` declares that the statement returns at most one row, and the checker proves it @@ -810,17 +896,26 @@ SELECT id, email FROM users WHERE email = {{.Email}}`) A statement is single when, for every table in FROM, a unique key (primary key, `UNIQUE`, unique index, or a partial unique index whose predicate the WHERE clause repeats) is fixed by equality to a -literal, a parameter, an outer reference or an uncorrelated scalar subquery. Equalities are followed -through joins (an outer join's ON fixes only the nullable side), views, subqueries and CTEs. An -aggregate without `GROUP BY`, a constant `LIMIT 0` / `LIMIT 1`, a SELECT without FROM, a one-row -`VALUES` and a one-row `INSERT ... RETURNING` are single too. A `FULL JOIN` never is, and neither is -a key declared `DEFERRABLE`: its uniqueness is not enforced until commit, so a transaction can hold -two rows sharing it for its own lifetime. A temporal key (`PRIMARY KEY (id, valid_at WITHOUT -OVERLAPS)`, PostgreSQL 18) is fixed when its scalar columns are fixed by equality and its range -column either equals a known value or contains a known point of the element type (`valid_at @> -{{.Day}}::date`): no two rows with the same `id` have overlapping ranges, so one point lies in at -most one of them. A range on the known side does not do (it could be empty, which every range -contains), nor does an overlap. +literal, a parameter, an outer reference or an uncorrelated scalar subquery. What counts as fixing: + +- `col = NULL` fixes nothing (it is never true); +- `col IS NOT DISTINCT FROM v` fixes a `NOT NULL` column when `v` is known and not `NULL`; +- `col = ANY(ARRAY[v])` with one element is `col = v`; +- a cast on the column (`GROUP BY status::text`) is seen through; +- equalities are followed through joins (an outer join's ON fixes only the nullable side), views, + subqueries and CTEs. + +Single on their own, with no key involved: an aggregate without `GROUP BY`, a constant `LIMIT 0` / +`LIMIT 1`, a SELECT without FROM, a one-row `VALUES` and a one-row `INSERT ... RETURNING`. + +Never single: a `FULL JOIN`, and a key declared `DEFERRABLE`: its uniqueness is not enforced until +commit, so a transaction can hold two rows sharing it for its own lifetime. + +A temporal key (`PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)`, PostgreSQL 18) is fixed when its +scalar columns are fixed by equality and its range column either equals a known value or contains a +known point of the element type (`valid_at @> {{.Day}}::date`): no two rows with the same `id` +have overlapping ranges, so one point lies in at most one of them. A range on the known side does +not do (it could be empty, which every range contains), nor does an overlap. #### Every branch must be provable @@ -829,15 +924,15 @@ Rejected ```go var Find = sqlshape.One[User, struct{ ID *int64 }](` SELECT id, email FROM users WHERE true {{if .ID}} AND id = {{.ID}} {{end}}`) -// One: cannot prove at most one row: users: no unique key is fixed by equality (keys: (id), (email)) [if@39:else] +// One: cannot prove at most one row: users: no unique key is fixed by equality (keys: (id), (email)) [if@45:else] ``` In the branch where `.ID` is nil the condition disappears and every row comes back. Finding that is the point of the check: make this a `Query`, or make `.ID` a non-pointer and drop the branch. -### Bulk loading with COPY +### Bulk loading with COPY (PostgreSQL) -`sqlshape.Copy[R]("order_items", "order_id", "line_no", ...)` is checked like an INSERT: the table +`postgres.Copy[R]("order_items", "order_id", "line_no", ...)` is checked like an INSERT: the table and columns exist, each column's type fits the field that feeds it, and every column left out has a default or is generated. @@ -848,7 +943,7 @@ type Item struct { OrderID int64 Sku string } -var Load = sqlshape.Copy[Item]("order_items", "order_id", "sku") +var Load = postgres.Copy[Item]("order_items", "order_id", "sku") // Copy into order_items: column "line_no" is NOT NULL without a default and is not copied ``` @@ -860,7 +955,7 @@ type Item struct { LineNo int16 Sku string } -var Load = sqlshape.Copy[Item]("order_items", "order_id", "line_no", "sku") +var Load = postgres.Copy[Item]("order_items", "order_id", "line_no", "sku") ``` ## Part 2 — Rules the schema declares @@ -895,7 +990,9 @@ obligation with what it provably does. The general form is a directive above `CR statement is judged by what it does to the table: each branch of a MERGE is the write of its own kind (an INSERT-only MERGE owes nothing `on update`), an `INSERT ... ON CONFLICT DO UPDATE` is an insert and an update, `TRUNCATE` is a delete of every row, and a write through an automatically updatable view is -a write to the base table. A +a write to the base table -- which also means an obligation declared on a view binds the view's +readers only: a write through the view is judged against the base table's obligations, never the +view's own, so a rule that must hold for the writes too belongs on the base table. A `$n` in a declaration stands for any value known before the row is examined -- a parameter, a literal, an outer reference -- not for that parameter number; a Go template's `{{.X}}` is such a value. @@ -910,7 +1007,7 @@ A directive belongs to the `CREATE TABLE`, `CREATE VIEW`, `CREATE FUNCTION` or s below it. One written above another statement (an `ALTER TABLE`, a `COMMENT ON`) is a schema problem, not a declaration. -An obligation is discharged one of five ways, and `-strict` reports the ones that deserve a look: +An obligation is discharged one of six ways, and `-strict` reports the ones that deserve a look: 1. by the statement's own WHERE / ON / SET; 2. by a view: a view's definition is judged on its own when the schema loads, and readers of the @@ -920,8 +1017,18 @@ An obligation is discharged one of five ways, and `-strict` reports the ones tha 3. by a row-level security policy whose USING establishes it, for roles subject to row security (`-strict` notes the owner caveat unless the table has `FORCE ROW LEVEL SECURITY`); 4. across a composite foreign key: with `FOREIGN KEY (order_id, tenant_id) REFERENCES orders (id, tenant_id)`, - a join on `order_id = orders.id` where `orders.tenant_id` is pinned pins `order_items.tenant_id` too; -5. by an opt-out in the statement: `-- sqlshape: unfiltered orders` (the predicate obligations) or + a join on `order_id = orders.id` where `orders.tenant_id` is pinned pins `order_items.tenant_id` too, + provided `order_items.tenant_id` is `NOT NULL` (or the statement proves it so): a NULL in any + column of a foreign key exempts the row from the constraint on both databases, so such a row + joins the parent while agreeing on nothing; +5. the write-side counterpart of 2: a write through an auto-updatable view declared + `WITH CHECK OPTION` is pinned by whatever the view's own WHERE fixes (CASCADED also by every + underlying view's, joined or not; LOCAL only by an underlying view that declares a check + option of its own) -- the server refuses any row that would not satisfy it, so `pinned` is + discharged even where the statement's own WHERE never mentions the column (PostgreSQL's + SQLSTATE 44000, MySQL's 1369 `ER_VIEW_CHECK_FAILED`; a view with no CHECK OPTION at all never + discharges this way); +6. by an opt-out in the statement: `-- sqlshape: unfiltered orders` (the predicate obligations) or `-- sqlshape: waive orders pinned(tenant_id)` (one obligation, spelled as declared; `waive orders` alone waives every obligation on the table). Opt-outs are reported with `-strict`. @@ -1164,6 +1271,10 @@ INSERT INTO outbox (id, payload) SELECT id, 'created' FROM o -- a write to orders must also write outbox in the same statement (a data-modifying WITH): require paired(outbox) on insert ``` +The rule is that the statement writes both tables; what it writes into `outbox` is not compared +with the `orders` row (an outbox row carrying unrelated values passes). The pairing is the promise, +the payload is the statement's. + `single` says a DELETE must provably touch at most one row, by the same proof as `One`: ```sql @@ -1183,7 +1294,8 @@ CREATE VIEW order_contacts AS SELECT id, email, left(phone, 3) || '***' AS phone A statement may reference a labelled column -- in its SELECT list or in a WHERE alike -- only in a context that `may read` the label ([contexts](#different-callers-different-rules-context)); storing a -value into it is not reading it. The label follows a column through a view that passes it through +value into it is not reading it, but `RETURNING` it is (the value leaves the database, whoever +supplied it). The label follows a column through a view that passes it through and through a function returning the table's rows (`RETURNS SETOF orders`), and stops at an expression (a masked column). @@ -1250,8 +1362,10 @@ accepted as in vet. `schema.sql` declares, once, the PostgreSQL major version it is written for. The declaration decides how everything else is judged: the grammar that parses the schema and every statement, the -catalog of types, functions and operators they resolve against, and the PostgreSQL `pgtest` and the -migration commands boot. A schema without it is not read. +catalog of types, functions and operators they resolve against, and the PostgreSQL the migration +commands boot. A schema without it is not read. What is PostgreSQL's in the rules (the +Go type table, the constraint names, the `One` proof's materials, the runtime, the migration +commands) is gathered in [postgres.md](postgres.md). ```sql -- sqlshape: postgres 18 @@ -1273,6 +1387,88 @@ ENFORCED`, `VIRTUAL` generated columns are 18's) is a syntax error under an olde it is on that server. Moving to a new PostgreSQL is changing the number and reading what the checker reports. +### The schema names its MySQL version (`mysql`) + +The same declaration can name MySQL instead. Then MySQL's own grammar parses the schema and every +statement, MySQL's rules type the expressions, and the statements run through +`github.com/kr9ly/sqlshape/mysql/v2` on `database/sql`. + +```sql +-- sqlshape: mysql 8.4 +CREATE TABLE ... +``` + +Rejected + +- a version sqlshape does not embed (8.4 is the one embedded) +- two declarations that disagree, or one that also names `postgres` + +Every rule in Part 1 and Part 2 applies to a MySQL schema the same way, judged by the MySQL +analyzer instead of the PostgreSQL one. Where the rules use a name, a number or a type +(constraint names and error numbers, the Go type table, the materials of the `One` proof, the +`ONLY_FULL_GROUP_BY` check), MySQL's are in [mysql.md](mysql.md), together with what does not +exist on MySQL and is not checked there. + +### The schema names the server's settings (`server`) + +A server variable that changes how a statement is judged is declared next to the version, one per +line, so the checker and the production connection agree on it. Without the line +the checker assumes the server's defaults; on MySQL 8.4 that is the default `sql_mode` +(`ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION`) +and `lower_case_table_names = 0`, a freshly initialized Linux server. + +```sql +-- sqlshape: mysql 8.4 +-- sqlshape: server sql_mode = 'ANSI,STRICT_ALL_TABLES' +-- sqlshape: server lower_case_table_names = 1 +CREATE TABLE ... +``` + +Rejected + +- a variable the dialect does not read: for MySQL anything but `sql_mode`, + `lower_case_table_names` and `max_sp_recursion_depth`; for PostgreSQL every variable, for now (its judgments follow the + server's defaults, and `search_path` is the schema's own `SET`) +- a `sql_mode` name 8.4 does not have, a `lower_case_table_names` other than 0, 1 or 2 +- a value with spaces not written as a string literal, a variable declared twice + +Accepted + +- `sql_mode`: the names in any case, separated by commas, the empty string, and the combination + modes `ANSI` and `TRADITIONAL`, expanded as the server expands them +- `lower_case_table_names`: 0, 1 or 2 + +What the checker does with them: + +- The parser takes the lexer's bits: under `ANSI_QUOTES` a double-quoted name is an identifier, + under `PIPES_AS_CONCAT` the `||` is `CONCAT` (an `OR` otherwise), and `IGNORE_SPACE`, + `NO_BACKSLASH_ESCAPES` and `HIGH_NOT_PRECEDENCE` read as they do on the server. Under + `REAL_AS_FLOAT` a `REAL` column is a `FLOAT` (`float32`), not a `DOUBLE`. +- `ONLY_FULL_GROUP_BY` turns the group check above on and off (1055, 1140 and the `DISTINCT` + rule 3065); the `HAVING` resolution rule (1054) and the aggregate-in-`ORDER BY` rules (3029, + 3028) hold in every mode, as they do on the server. +- Strict mode (`STRICT_TRANS_TABLES` or `STRICT_ALL_TABLES`) decides two things. A string + function (`CONCAT`, `SUBSTRING`, `LOWER`, ...) is nullable only in strict mode, so without it + `CONCAT(name, 'x')` over a `NOT NULL` column is `string`, not `*string`. And a `NULL` stored into + a `NOT NULL` column is a failure mode (1048) in strict mode; without it only a single-row + `INSERT` or `REPLACE` (its `ON DUPLICATE KEY UPDATE` included) still rejects the `NULL`, while + more rows, `INSERT ... SELECT` and `UPDATE` store the type's implicit default with a warning, + so the checker lists no 1048 for them. +- `NO_UNSIGNED_SUBTRACTION` makes the difference of unsigned operands signed (`int64`, not + `uint64`). +- The remaining names (`NO_ZERO_DATE`, `ERROR_FOR_DIVISION_BY_ZERO`, `NO_ENGINE_SUBSTITUTION`, + `PAD_CHAR_TO_FULL_LENGTH`, ...) act at run time only; the checker accepts them as written. +- `lower_case_table_names = 1` stores table and view names lower-cased, as the server reports + them (`SELECT * FROM Users` reads the table `users`, and so do the facts and the boundary + checks); `2` keeps the declared spelling and compares without case; `0` distinguishes + `Users` from `users` (1146). A directive (`unfiltered`, `waive`, the obligations) names a + table by the same rule: under 1 or 2 any spelling reaches it, under 0 the `CREATE`'s. + +The declaration is a promise about the server the statements run on, and `mysql.Verify(ctx, db, +schemaSQL)` checks it: it reads the connection's session `@@sql_mode` (a DSN or a pool's setup +may override it) and the server's `lower_case_table_names`, and reports a difference from the +declaration ([mysql.md](mysql.md#the-runtime-databasesql)). + ### Do not run SQL that bypasses sqlshape (`-raw-sql`) template guarantee does not cover. @@ -1291,10 +1487,10 @@ rows, err := pool.Query(ctx, "SELECT id FROM orders WHERE status = $1", status) ``` `-raw-sql=forbid` rejects every statement that does not go through sqlshape, constant or not -(`pgxpool.Query executes SQL outside sqlshape; with -raw-sql=forbid every statement goes through sqlshape.Query / One / Copy (or list the package in -raw-sql-allow)`). +(`pgx.Query executes SQL outside sqlshape; with -raw-sql=forbid every statement goes through sqlshape.Query / One / postgres.Copy (or list the package in -raw-sql-allow)`). Packages still migrating are exempted with `-raw-sql-allow=pkg/...`. -### A package references only its schemas (`-schemas`) +### A package references only its schemas (`-schemas`, PostgreSQL) `-schemas=a_api,b_private` restricts the PostgreSQL schemas a package may reference: a service boundary over one database. @@ -1333,9 +1529,15 @@ END $$; -- schema.sql CREATE VIEW order_summary AS SELECT o.id, c.nmae AS customer_name FROM orders o JOIN customers c ON c.id = o.customer_id; --- sqlshape: schema schema.sql: view order_summary: column "nmae" does not exist (SQLSTATE 42703) +-- sqlshape: schema schema.sql: view order_summary: 42703: column c.nmae does not exist (at ) ``` +A MySQL trigger or stored PROCEDURE/FUNCTION body is checked the same way, once per schema: +`NEW`/`OLD`, `DECLARE`d variables and parameters, control flow, `SELECT ... INTO`, cursors, +`CALL` and `SIGNAL`/`RESIGNAL` are all in scope; what the server itself refuses when the body is +created is reported as a schema problem too. What is MySQL's about the body -- the constraint +names and error numbers, the SIGNAL key rules -- is [mysql.md's](mysql.md#triggers-and-stored-routines). + Row-level security policies are checked here too: a `CREATE POLICY` predicate must be boolean, contain no aggregates or window functions, and respect domain units. A policy on a table whose row security is off is reported. The checker does not ask statements to repeat a policy's predicate; the diff --git a/docs/design.md b/docs/design.md index f29455a..ac016ca 100644 --- a/docs/design.md +++ b/docs/design.md @@ -16,7 +16,7 @@ RDBMSを使うアプリケーションに要るのは4つ。SQLの構文と型 棄てた案。① embedded PGにschema.sqlを流してPREPARE / Describeで聞く(当初案。起動コスト、nullabilityと参照の全数が取れない)。② libpg_queryの手法を`analyze.c`まで広げて偽カタログで動かす(研究課題に近い)。③ PGをwasmで動かす(実用段階か未確認)。④ カタログをPGソースの`.dat`から生成する(COPY dumpならデフォルト値解決済み・OID確定・拡張も同じ経路)。 -忠実度の測り方。PGの回帰テスト`src/test/regress`の全文を本物のPGと並走させ、パラメータ型・結果列・エラーの一致を`go test`でゲートする(`internal/analyze/regress_test.go`)。残る不一致は環境依存のcollation、RLSの再帰、権限、サーバー内部エラー、意図的な相違3件で、いずれも静的解析の外。 +忠実度の測り方。PGの回帰テスト`src/test/regress`の全文を本物のPGと並走させ、パラメータ型・結果列・エラーの一致を`go test`でゲートする(`check/postgres/analyze/regress_test.go`)。残る不一致は環境依存のcollation、RLSの再帰、権限、サーバー内部エラー、意図的な相違3件で、いずれも静的解析の外。 意図的な相違。ドメインの演算結果はドメインを保つ(PGは基底型に落とす)。配列リテラルの要素に対するCHECKの定数評価。PG 17のリライタが絡む1件。 @@ -64,6 +64,12 @@ RDBMSを使うアプリケーションに要るのは4つ。SQLの構文と型 理由。診断・宣言・実行時エラーが同じ文字列で結ばれ、どの違反を処理すべきかがコードに書かれた状態が保たれる。命名をPGに揃えるのは、実行時エラーが運ぶ制約名と一致させるため。 +#### トリガ・ルーチンの独自コードにGoの名前を与える(裁定2026-09-13) + +決めたこと。正はスキーマの`-- sqlshape: error = `注釈であり、Goはそれを`sqlshape.Error(code)`で写すだけ(`var OrderTooLarge = sqlshape.Error("30001")`)。`vet`が両方向を見る——宣言のコードがスキーマの宣言どおりの、まさにそのNameのものであること、パッケージの文が起こしうる名前付き失敗モードのすべてに宣言が(自分のパッケージか、参照が届く他パッケージに)あること。ランタイムの`Violates`は`~string`を取るだけで、渡された値がFailureか生の文字列かを区別せず、スキーマも読まない——`sqlshape.Error(code)`が返す`Failure`はコードそのものを運ぶ値でしかなく、Nameを解決したり登録したりする経路は無い。expect行はコードでもNameでも書け、両方書いても片方が余らない(`Violation.Name`が付いているときだけ、そのNameも満たされたキーに数える)。 + +理由。「正は1つ、複製は写しでしかない」という他の裁定(型のPG名↔Goの対応、enumラベルのFactなど)と同じ形に揃えた。ランタイムにNameからコードへ解決させる方式(登録テーブルをどこかに持つ)は、ランタイムがスキーマに依存する新しい経路を作ってしまい、「ランタイムはschema.sqlを読まない」という既存の不変を壊す。Go側にNameとコードを両方持つ独自の宣言方式(`sqlshape.NamedError{Name, Code}`のような値をvetでなく実行時にも参照する形)も検討したが、`Failure`はただの`~string`のままにして`Violates[K ~string]`の型引数で受けるほうが、コードの読み書き・比較・switchが素朴な文字列のまま扱えて、ランタイム側に一切の分岐が要らない。この裁定はMySQLの`SIGNAL`キーにも及ぶ——先の裁定(下記MySQLトリガの項)でNameをキーの別名にしないと決めたのは「複数のSIGNALが同じ`MYSQL_ERRNO`を別の意味で使いうる」ことを懸念してのものだったが、Nameをキーの別名にする経路自体を作らず、Goの`sqlshape.Error(code)`がコードだけを運ぶ値である以上、その懸念は生じない(`Violates(err, OrderTooLarge)`は結局`Violates(err, "30001")`と同じ呼び出しでしかない)。 + ### `One`はスキーマから証明する 決めたこと。`One`と宣言した文は、すべてのFROM項目の一意キーが等値で固定されているか(JOIN・ビュー・サブクエリ・CTEを通して追跡)、または集約・`LIMIT 1`・単一行INSERTかを、展開ごとに証明する。証明できなければエラー。実行時に2行目が来たら`ErrManyRows`。 @@ -76,10 +82,24 @@ RDBMSを使うアプリケーションに要るのは4つ。SQLの構文と型 理由。schema層がDDL全般のオブジェクトモデルを持ち、embedded PGで生成DDLを機械検証できるので、外部ツールの出力を検査する層より自前で出す方が短い。両側正準形にするのは、直接ロードとdumpで式の綴りが違うため(`'x'`と`'x'::text`)。diffとapplyを分けるのは、間に人の手編集(順序・分割・USING・backfill差し込み)が入る前提だから。終点比較なら手編集の中身を問わない。 -受け入れた冗長と限界。列順の差は許容して注記する(PGは列を途中に挿せないので、弾くとapplyが二度と通らなくなる)。renameした列を含む制約・FKはDROP + ADDで出る(正しいが再検証が走る。痛くなったらprops比較にrename写像を当てる)。domainの基底型・rangeのサブタイプ・INHERITS・PARTITION・OF typeの変更は注記のみ。pg_dumpは利用者環境のもの(embedded PGにクライアントツールは同梱されない。pg_catalog直読みは必要になったら)。 +受け入れた冗長と限界。列順の差は許容して注記する(PGは列を途中に挿せないので、弾くとapplyが二度と通らなくなる)。renameした列を含む制約・FKはDROP + ADDで出る(正しいが再検証が走る。痛くなったらprops比較にrename写像を当てる)。domainの基底型・rangeのサブタイプ・INHERITS・OF typeの変更は注記のみ。行を持つ表のパーティションキーの変更と、列に使われている複合型の属性の型変更は、無損失のDDLが無いので問題として止める(注記だとapplyは通り、verify-schemaが永久に差分を報告する)。pg_dumpは利用者環境のもの(embedded PGにクライアントツールは同梱されない。pg_catalog直読みは必要になったら)。 棄てた案。sqldefのラップ。pg_catalog直読みによる逆ロード(pg_dumpの方が正準形が得やすい)。 +スコープは運用まで(裁定2026-09-16)。パーティションの一覧はスキーマの一部なので、列と同じ資格で比較・計画・検証する。Skeemaの`partitioning=keep`のように「時系列RANGEのローテーションは運用だから差分を見ない」と切ると、verify-schemaの立場からは検出できないドリフトを設計で作ることになる。こちらの答えは、行を落とすDROP PARTITIONは列のDROPと同じく宣言を要求し、行を動かす変更(MySQLのREORGANIZE、PGのDETACH + ATTACH)はDDLとして導き、無損失のDDLが無い差分は問題で止める。無視はしない。この先にあるのは、後から表をパーティション化する手順(PGはDEFAULTパーティションにATTACHしてから範囲ごとに行を移す)と、ローテーションの規則そのものを宣言してパーティション集合を導くこと。 + +#### 忠実度の測り方(裁定2026-09-15) + +検査器と同じく本物のサーバをオラクルにするが、問う内容が違う。検査器は「この文をサーバはどう判定するか」を聞き、計画器は「この DDL をサーバは受け入れ、着いた先が目的のスキーマか」を聞く。`check/{postgres,mysql}/migrate`の`TestMigrateProbe`が、計画器の語彙からスキーマAを乱数で生成し、変異を1〜5個重ねてBを作り(変異は自分の`-- @migrate`宣言も書く)、Plan(A→B)のDDLを3行ずつ入ったAを持つ実サーバに流す。合格は三つ: 拒まれない、読み返した正準形がBと一致する(PGは列順を除く)、そこから再Planが空。落ちた組は変異を削って最小化し、直した穴は`probe_findings_test.go`に最小の組で固定する。 + +網羅の単位。検査器のコーパスはサーバ自身の回帰テストで、網羅は所与だった。乱数生成には網羅の単位が無いので、計画器の入力に置く。計画器の入力はdiffの出力だから、diffが出し得る変更の全種(`(Op, Kind, Field)`の三つ組。`+ table`、`~ column type`、`~ constraint deferrable`…)をアルファベットと呼び、`diff.Alphabet()`がCompareと同じProps関数から機械的に列挙する(キーを写して二重に持たない。無条件に増えたキーは自動で拾い、新しい条件の下のキーは代表スキーマの拡張が要る)。ゲートはseed 1の200組で、踏んだ集合と理由付きの既知未到達の和がアルファベット全体に一致すること。既知未到達に置けるのは「計画器がDDLを書かず問題として止める差分」と「構造的に現れない三つ組」だけで、生成器の怠けは置けない。diffにプロパティが増えると生成器が追従するまでゲートが落ちる。組み合わせと順序は乱数に任せる、という分担。乱数だけだと「seed 1の200組がたまたま全変異を引く」ことに網羅が依存し、変異を1つ足すとPRNGの列がずれて既存の網羅が落ちる(3巡目で実際に起きた)ので、各変異を単独で適用した組を毎回1つずつ作るdirected passを足し、乱数の組との和で集計する。PostgreSQLはprobeを17と18の両方で回す(`TestMigrateProbe18`。18専用の語彙——VIRTUAL生成列、NOT ENFORCED、WITHOUT OVERLAPS、PERIOD——は18のゲートで踏む)。 + +数字(2026-09-16、5巡目)。PostgreSQLはアルファベット103のうち17のゲートで94、18のゲートで98を踏む。未到達は、無損失のDDLが無く問題で止める差分4(INHERITS、OF type、domainの基底型、rangeのサブタイプ。以前は注記だったが、注記だとapplyが通りverify-schemaが永久に差分を出すので問題に変えた)、構造的に現れないもの1(pg_dumpは名前付きUNIQUEを常に制約で出すので`~ index unique`は無い)、17のゲートではさらにPG 17が構文を拒む18の語彙4。MySQLは48を全部踏む(パーティションはRANGE / LIST / COLUMNS / HASH / KEY / LINEAR / サブパーティションまで構造にし、Textのフォールバックは無くした)。行を持つ表のパーティションキーの変更は、旧表をRENAMEして目標の形を新しく作り、宣言されたパーティションを付け、`INSERT ... SELECT`でPGのrouterに全行を配らせ、旧表を落とす手順で書く(空→X、X→空、X→Yとも。コメント・トリガ・ポリシー・RULE・被参照FK・sequenceを付け直す。bigserialのsequenceはOWNED BY NONEで退避して新表に再所有、identityは旧sequenceを退避改名し`OVERRIDING SYSTEM VALUE`で入れて`setval`で続きから)。固定した計画器の穴は`probe_findings_test.go`のPG 35本、MySQL 21本。ほぼ全部がDDLの順序(FKが乗るキー、生成列の依存、PK不在の窓、スキーマの作成と削除、ポリシーとRULEが読む列の型変更)で、依存グラフへの作り替えはせず種類別の固定順の穴埋めで収束している。 + +届かないもの。生成器のモデル外にある実世界のスキーマの形(レガシーな綴り、拡張型、巨大な表)と、データ依存の失敗(backfillの値、ロック時間)。前者は利用者の`schema.sql`が持ち込むもので、applyの終点比較が最後の網。パーティションは語彙に無く、次の巡で足す。 + +棄てた案。実世界コーパス(regressの`CREATE TABLE`群をAにする)はdumpの往復に乗らないオブジェクトが多く、変異の網羅にもならない。計画器の行カバレッジをゲートにするのは、未到達行が語彙の穴か死んだ分岐か区別できず宣言の単位として粗い(補助の数字としてなら可)。 + ### 行レベルセキュリティはスキーマの一部として検査するが、絞るのはDB 決めたこと。`CREATE POLICY`の条件式を型検査し、ポリシーとENABLE / FORCEの設定をdiff / applyで運ぶ。`-require-columns`はポリシーが列を固定していれば満たしたとみなす。ポリシーのUSING式から`visible where`相当の条件を導出して文に要求することはしない。 @@ -90,7 +110,7 @@ RDBMSを使うアプリケーションに要るのは4つ。SQLの構文と型 ### PL/pgSQLの本体も解析する -決めたこと。`LANGUAGE plpgsql`の関数本体をlibpg_queryのPL/pgSQLパーサで構造に分け、埋め込まれた各SQL断片を既存のアナライザーで検査する(`internal/analyze/plpgsql.go`)。PL変数は関数パラメータと同じ経路(`funcParam`)でスコープに入れ、`plVar`印で`$n`から外し、変数と列の衝突はPostgreSQLの既定(`variable_conflict = error`)どおりエラーにする。record変数の形はそれを埋めたクエリから取り、トリガー関数は`CREATE TRIGGER`の結びつきごとに`NEW` / `OLD`をそのテーブルの行型として解析する(結びつきの無いトリガー関数は解析しない)。`RAISE`のSQLSTATEは本体から拾い、`-- sqlshape: error`注釈は名前を付ける役に退く。`EXECUTE`は定数文字列だけ検査し、それ以外は`-strict`の助言。 +決めたこと。`LANGUAGE plpgsql`の関数本体をlibpg_queryのPL/pgSQLパーサで構造に分け、埋め込まれた各SQL断片を既存のアナライザーで検査する(`check/postgres/analyze/plpgsql.go`)。PL変数は関数パラメータと同じ経路(`funcParam`)でスコープに入れ、`plVar`印で`$n`から外し、変数と列の衝突はPostgreSQLの既定(`variable_conflict = error`)どおりエラーにする。record変数の形はそれを埋めたクエリから取り、トリガー関数は`CREATE TRIGGER`の結びつきごとに`NEW` / `OLD`をそのテーブルの行型として解析する(結びつきの無いトリガー関数は解析しない)。`RAISE`のSQLSTATEは本体から拾い、`-- sqlshape: error`注釈は名前を付ける役に退く。`EXECUTE`は定数文字列だけ検査し、それ以外は`-strict`の助言。 理由。DB側にロジックを置く方針なのに、トリガー関数と書き込み関数の大半を占めるPL/pgSQLが読めないと、関数経由の失敗モード・消費者索引・SECURITY DEFINERの到達がすべて本体の手前で止まり、READMEの主張が成り立たない。パーサが本体を構造化してくれるので、足したのはPL側の層だけで済んだ。 @@ -98,15 +118,17 @@ RDBMSを使うアプリケーションに要るのは4つ。SQLの構文と型 ### 境界の規則は義務として宣言し、文の事実で判定する -決めたこと。`visible where`・`-require-columns`・`-no-table-reads` / `-no-tables`は、表(ビュー)が参照側の文に課す**義務**の実例として一つの仕組みに載せる。宣言は`schema.sql`の`-- sqlshape: require [on ]`(bodyはSQLのboolean式、`pinned(列)`、`immutable(列)`、`via view`)。アナライザーは判定せず、文が証明できること(葉・正規化述語・等値の閉包・代入集合)を`internal/facts`のデータとして出し、`internal/obligation`が「事実 ⇒ 義務」を判定して履行経路(文自身 / ビュー / ポリシー / 複合FK / waive)ごと返す。`aggregate`は義務の束を生む宣言、`context`は文脈ごとの差分、`sqlshape check`はGoの外の文への同じ判定の入口。従来の構文とフラグは略記として残す。経緯・設計・ロードマップは[obligations.md](obligations.md)。 +決めたこと。`visible where`・`-require-columns`・`-no-table-reads` / `-no-tables`は、表(ビュー)が参照側の文に課す**義務**の実例として一つの仕組みに載せる。宣言は`schema.sql`の`-- sqlshape: require [on ]`(bodyはSQLのboolean式、`pinned(列)`、`immutable(列)`、`via view`)。アナライザーは判定せず、文が証明できること(葉・正規化述語・等値の閉包・代入集合)を`x/facts`のデータとして出し、`internal/obligation`が「事実 ⇒ 義務」を判定して履行経路(文自身 / ビュー / ポリシー / 複合FK / waive)ごと返す。`aggregate`は義務の束を生む宣言、`context`は文脈ごとの差分、`sqlshape check`はGoの外の文への同じ判定の入口。従来の構文とフラグは略記として残す。経緯・設計・ロードマップは[obligations.md](obligations.md)。 理由。規則を一つずつ実装すると「ポリシーが満たす」「ビューが運ぶ」のような特例が規則の数だけ増える。判定を一本にすると新しい規則は宣言で済み、方言を足すときも「Factsを出すアナライザー」と「述語を落とすLowerer」だけで判定を共有できる。核(アナライザー・スキーマ・vet)はobligationを知らない。 棄てた案。文をまたぐ規則(トランザクション内の対)を扱う層(分析の単位は文、という核の裁定と衝突する)。方言をまたぐ義務DSL(SQL式と少数の構造述語で足りる)。opt-outの命名規則によるパッケージ→文脈の写像(暗黙は読めない。パッケージコメントの`// sqlshape: context`とフラグで明示する)。 +事実の正しさは実サーバで測る(2026-09-16)。`x/stmtprobe`がスキーマと行と文を生成し、両方言のアナライザーが出したFactsの主張——述語が返る全行で成り立つ、列が既知の値に固定されている、高々1行、書き込みが格納する値——をサーバの返した行で反証する(検査器の型をembedded PGで、マイグレーションの計画を正準形一致で測るのと同じ位置づけ。マイグレーションのprobeと同じく生成器・判定器・報告を方言中立に一つ書き、ドライバだけ方言ごと)。初回で両方言に1件ずつ見つかった(MySQLの1093 / 1443、PGのJOIN ON内サブクエリの外部参照)。同じ生成器で失敗モードも測る: 全種類の制約を持つスキーマへの書き込みを走らせ、サーバが出した制約エラーは予測した違反のどれかにランタイムの`Violates`で一致しなければならない(PGが「毎回失敗する」noteで言う確定失敗も予測に数える)。MySQLの本体はサーバのCREATE時判定と(`check/mysql/internal/analyze`のbody probe)、文の型とエラーはmysql-testコーパス1,281ファイルの並走と(同corpus probe、PGのregress probeの対)で測る。コーパスは初回44,342文一致・4,605不一致をベースラインとして固定し、以後の巡で削る(PGは5,282から47まで削った)。 + ### PostgreSQLの版は`schema.sql`が宣言し、版ごとに本物の文法とカタログで判定する -決めたこと。`schema.sql`は`-- sqlshape: postgres 18`と1行で、どの版のPostgreSQL向けかを宣言する。宣言は必須で、無いファイルは読まない。この1行が、スキーマと全部の文を読む文法、型・関数・演算子を解決するカタログ、`pgtest`とマイグレーション系コマンドが起動するPostgreSQLの版を決める。パーサはlibpg_queryを版ごとにWebAssemblyへビルドして埋め込み、wazeroで実行する(`internal/pgparse`)。カタログは版ごとにその版の埋め込みPostgreSQLからdumpしたTSV(`internal/catalog/data//`)、オラクルとregressコーパスも版ごと。ノードのGo型は最新版の`pg_query.proto`から生成した1組だけを持ち、どの版の木もパーサのJSON出力をprotojsonで読む。古い版で名前が違うフィールドは読む前にJSON上で最新版の形に書き換える(`pgparse/upgrade.go`。17→18は`returningList`→`returningClause.exprs`、`is_enforced=true`、`generated_kind="s"`と、生の木には現れない3フィールドの削除)。版で判定が変わる規則はアナライザー内の版分岐(RETURNING old/new、セッションTZの略称、数値フィールドの厳格化、aclitemの引用符、JSON_VALUEの照合衝突、jsonpath引数の型、CTE越しの外側集約)。`diff` / `apply` / `verify-schema`は接続先の`server_version`が宣言と違うメジャー版なら警告して続行する。 +決めたこと。`schema.sql`は`-- sqlshape: postgres 18`と1行で、どの版のPostgreSQL向けかを宣言する。宣言は必須で、無いファイルは読まない。この1行が、スキーマと全部の文を読む文法、型・関数・演算子を解決するカタログ、`pgtest`とマイグレーション系コマンドが起動するPostgreSQLの版を決める。パーサはlibpg_queryを版ごとにWebAssemblyへビルドして埋め込み、wazeroで実行する(`check/postgres/pgparse`)。カタログは版ごとにその版の埋め込みPostgreSQLからdumpしたTSV(`check/postgres/catalog/data//`)、オラクルとregressコーパスも版ごと。ノードのGo型は最新版の`pg_query.proto`から生成した1組だけを持ち、どの版の木もパーサのJSON出力をprotojsonで読む。古い版で名前が違うフィールドは読む前にJSON上で最新版の形に書き換える(`pgparse/upgrade.go`。17→18は`returningList`→`returningClause.exprs`、`is_enforced=true`、`generated_kind="s"`と、生の木には現れない3フィールドの削除)。版で判定が変わる規則はアナライザー内の版分岐(RETURNING old/new、セッションTZの略称、数値フィールドの厳格化、aclitemの引用符、JSON_VALUEの照合衝突、jsonpath引数の型、CTE越しの外側集約)。`diff` / `apply` / `verify-schema`は接続先の`server_version`が宣言と違うメジャー版なら警告して続行する。 理由。宣言版より新しい構文をその版の本物の文法が拒否するので、gram.yの差分表を持たずに済む。cgoが消えて`go install`にCツールチェーンが要らず、1つのランナーで全プラットフォームをクロスコンパイルできる。wasmモジュールは隔離されるので版ごとのパーサを同じバイナリに複数積める。JSON経路を選んだのは、protobufのフィールド番号をlibpg_queryが版ごとに振り直すためで(17→18で267フィールド、enum値434が変わった)、JSON経路のほうがprotobuf経路より速くもあった(1文215µs対273µs)。書き換え漏れはprotojsonが未知フィールドとして拒否するので黙って消えない。 @@ -114,39 +136,77 @@ RDBMSを使うアプリケーションに要るのは4つ。SQLの構文と型 忠実度。regressコーパスをその版の本物と並走させた結果、17は22,103文で既知の不一致35、18は23,384文で47(`testdata/regress_baseline_.txt`)。18の47は17の既知集合と、RETURNING old/newのnullability 10件(INSERTのold・DELETEのnewはアナライザーがnullable、オラクルは列のNOT NULLしか見えない)、DO INSTEADルール付きDELETE RETURNINGの1件(アナライザーはPGと同じ0A000、オラクルのDescribeが空を返す)。pgparseの`TestCorpus`は17の43,226文が書き換え経由で読め、18のJSON経路がprotobuf経路と全一致することを検問する。 -追従。新しいメジャー版は「版を1つ足す」作業で、手順は[`internal/pgparse/README.md`](../internal/pgparse/README.md)にまとめてある。libpg_queryのリリースを待つ形は変わらない(18は8か月遅れた)。サポート窓はPGの5年に合わせるが、まず17と18の2版でこの構造を作った。メジャー間で判定が変わるのは、構文の追加、カタログの追加・オーバーロードによる曖昧化・稀な削除、予約語の追加、ごく稀な型規則の変更で、regressコーパスの差分がそれを列挙する。 +追従。新しいメジャー版は「版を1つ足す」作業で、手順は[`check/postgres/pgparse/README.md`](../check/postgres/pgparse/README.md)にまとめてある。libpg_queryのリリースを待つ形は変わらない(18は8か月遅れた)。サポート窓はPGの5年に合わせるが、まず17と18の2版でこの構造を作った。メジャー間で判定が変わるのは、構文の追加、カタログの追加・オーバーロードによる曖昧化・稀な削除、予約語の追加、ごく稀な型規則の変更で、regressコーパスの差分がそれを列挙する。 + +#### 検査器が本体で、ランタイムは DB ごとの別ライブラリ(2.0) + +裁定 2026-09-10。sqlshape の約束は「`schema.sql`に対してテンプレートの有限展開を静的に検査する」で、それはマーカー`sqlshape.Query[R, P](tmpl)` / `One`ひとつで閉じる。SafeQL が使うクライアントライブラリを問わないのと同じ位置に立つ: 検査対象はマーカーで、ランタイムは「あると便利な 1 実装」。 -### スコープ外 +- ルートモジュール`github.com/kr9ly/sqlshape/v2`は依存ゼロの核だけを持つ: `Query` / `One` / `Stmt` / `Render`(テンプレートの意味論 — `{{.X}}`のプレースホルダ化と分岐の有限集合)と、依存の無い検査器の共有部(`x/expand` / `dialect` / `facts` / `obligation`)。利用者の go.mod に pgx も wazero も載らない +- ランタイムは DB ごとの別モジュールで、互いに似ていなくてよい。`sqlshape/postgres`は pgx の自然な形(`iter.Seq2`、Batch、Copy、pgtype のスキャナ、`MatView`、`ConstraintError`)、`sqlshape/mysql`は database/sql の形(`?`描画、`*sql.Rows`)。共通シグネチャは作らない。受け型の表はドライバごと +- マーカーは設定できる。既定は`sqlshape.Query` / `One`、`-query=pkg.Func`で利用者自身の generic 関数`F[R, P any](string) T`を登録できる。ランタイムを自作してよい。定数の生 SQL(`db.Query(ctx, "SELECT …")`)は R / P 無しの薄い検査(構文・名前・パラメータ個数) +- 失うものは明示する: `-raw-sql=forbid`の「検査済み SQL しか実行されない」、`expect`行と`ConstraintError`の対応、`One`の実行時`ErrManyRows`はランタイム側の契約で、sqlshape のランタイムを使ったときだけ成立する。docs は「静的に約束すること」と「ランタイムを使えば加わること」を分けて書く +- 検査器も DB ごとのモジュール: `sqlshape/check/postgres`(Apache。analyze / schema / catalog / pgparse / oracle / verify / dump / diff / migrate。パッケージは公開だがツール向けで互換性の約束はしない)と`sqlshape/check/mysql`(GPLv2、旧`mysql/`)。`sqlshape/pgtest`は check/postgres の上。`sqlshape/cmd/sqlshape`(GPLv2)が vet と cli を内包して全部を積む。7 モジュールを 1 つの版番号でロックステップ、2.0.0 から +- DB × 言語の行列: Go の各 DB が`sqlshape/`、TypeScript は別リポジトリの`@sqlshape/`、`check/`は言語非依存で共有、言語フロントエンド(vet)は`cmd/`側 +- 退けた案: ルートに PG ランタイムを残して MySQL だけ別モジュール(依存の混入が残る)、ランタイム間で`Run(ctx, db, stmt, p)`の関数形を揃える(揃える理由が無い) + +### ユーザーのテストはsqlshapeの関心外で、pgtest / mysqltestは開発者の道具 + +裁定 2026-09-11。`pgtest.Verify`が確かめるのは「検査器の判定がサーバと一致するか」で、これはsqlshapeの正しさの検証であってアプリケーションのテストではない。ユーザーのスキーマ固有の文で不一致が出てもそれはsqlshapeのバグで、報告してもらうもの。ユーザーのテストに自己検証を持ち込ませるのは「静的解析は信用できないかもしれない」と言うのと同じで、製品として弱い。サーバを起こす便宜(`Start`)も、本物のDBでテストしたいユーザーは自分の環境(testcontainers等)を既に持っていて、sqlshapeが口を出す理由が無い。そこで`pgtest`と`mysqltest`はユーザー向けAPIから外し、`check/*`と同じ「ツール向け、互換性の約束なし」の位置に置く。モジュールは残す(oracle、examples、敵対的テスト、regressの照合が使う)。ユーザー向けdocs(README、runtime、postgres.md / mysql.md)からテスト用サーバの節を消し、互換性の約束の文からも外した。sqlshape自身の正しさはregress 22,103文、oracleの5,033文、敵対的テストの照合で保証し、ユーザーにはその数字を見せる。退けた案: `Verify`を任意の接続に対して使える形にしてユーザーに勧める(検証の主語が逆)、mysqltestに`Verify`を足す(oracleがGPL側にあるのでApacheのmysqltestからは呼べず、構図も成立しない) + +## スコープ外 FETCH(カーソルの列は静的に決まらない)。EXPLAINの実行(embedded PGの統計は本番と違う。性能の助言は構造的に判定できるもの、インデックスの先頭列とビューへの述語押し込みに限る)。初期リリースではPostgreSQL以外のRDBMS(下記「検討中」のMySQL参照)。 ## コード配置 +層の規則(`x/boundary_test.go` が import グラフで検査する、負債リストは空): `x/*` は stdlib と `x/*` しか import しない(契約と共有部)。言語フロントエンド `cmd/sqlshape/internal/vet` は `x/*` と自分の internal だけ — 方言は `x/dialect.Register` で名乗り、`cmd/sqlshape/main.go` の blank import が積む。方言アダプタ `check//dialect` は自分の `check//*` と `x/*` だけで、`dialect.Analyzer`(文の解析)に加えて `Schemaed`(関係・定義・助言・型・出所)と `Contracted`(義務の contract と lowering)を実装する。 + | package | 役割 | |---|---| -| `sqlshape` | 公開API: `Query[R, P]` / `One[R, P]` / `Batch` / `Copy[R]` / `MatView`、pgx上のランタイム(行マッパー、型登録、`ConstraintError`、描画のバイト比較) | +| `sqlshape` | 宣言の核(依存ゼロ): `Query[R, P]` / `One[R, P]` / `Stmt` / `Render`、行の束縛規則(`Fields`)、`Labelled` | +| `postgres` | pgx 上のランタイム(別モジュール): `Run` / `Collect` / `First` / `Exec` / `Get` / `Find`、`Batch` / `Copy[R]` / `MatView`、行マッパー、型登録、`ConstraintError`、描画のバイト比較) | +| `mysql` | database/sql 上の MySQL ランタイム(別モジュール、go-sql-driver/mysql の受け型): `Run` / `Collect` / `First` / `Exec` / `Get` / `Find` / `ExecOne`、`$n` → `?` の並べ替え(`x/placeholder`)、`ConstraintError`(1062 / 1452 / 1048 / 3819 をスキーマの名前に) | +| `mysqltest` | PATH の mysqld を schema.sql を載せて起こす(アプリケーションのテスト用、pgtest の MySQL 版)。`check/mysql` の oracle もこの上 | +| `x/placeholder` | `$n` → `?` の書き換えと位置の逆写像。MySQL のランタイムとアナライザーが共有 | | `pgtest` | schema.sqlを適用した本物のPGをアプリケーションのテストに提供する。`Verify`がアナライザーとPGを突き合わせる | -| `internal/expand` | テンプレートの全展開。`{{.X}}` → `$n`と`P`上のパス | -| `internal/pgparse` | パーサ。libpg_queryを版ごとにWebAssemblyへビルドして埋め込み、wazeroで実行する。`Version.Parse` / `Deparse` / `SplitWithScanner` / `ParsePlPgSqlToJSON`と、最新版のprotoから生成したノード型 | -| `internal/schema` | libpg_queryでschema.sqlをカタログの上に載せる。テーブル・ビュー・enum・ドメイン・複合型・関数・制約・ポリシー・seed | -| `internal/catalog` | 埋め込みのpg_catalog(版ごとの型・関数・演算子・キャスト・集約、`data//`)と拡張のdump | -| `internal/analyze` | アナライザー本体。10章の型変換、スコープ、DML、`$n`推論、nullability、カーディナリティ、違反の列挙、PG互換のエラー。文の事実(`facts.Facts`)の生産と、義務の述語を事実の言語に落とす`Lower` | -| `internal/facts` | アナライザーと義務検査の間のデータ契約。文種・スコープの木・葉・正規化述語・等値の辺・固定列・代入集合。パーサのノードを含まず、方言を知らない | -| `internal/obligation` | 境界の規則。schema.sqlの`require` / `visible where`とvetのフラグを義務に読み、事実に対して履行を判定する([obligations.md](obligations.md))。`analyze`にも`vet`にも依存しない | -| `internal/vet` | `go/analysis`アナライザー。結果列 ↔ `R`、`$n` ↔ `P`、束縛、expect行、SuggestedFix。境界の規則は`obligation`に委ね、フラグを義務に展開して渡す | -| `internal/oracle` | 差分テストのオラクルとしての本物のPG(embedded-postgres)。検査時には使わない | -| `internal/dump` / `diff` / `migrate` / `consumers` / `cli` | マイグレーション側。pg_dumpによる正準形、オブジェクト単位のdiff、DDL生成と終点検証、消費者索引、サブコマンド | +| `x/expand` | テンプレートの全展開。`{{.X}}` → `$n`と`P`上のパス | +| `check/postgres/pgparse` | パーサ。libpg_queryを版ごとにWebAssemblyへビルドして埋め込み、wazeroで実行する。`Version.Parse` / `Deparse` / `SplitWithScanner` / `ParsePlPgSqlToJSON`と、最新版のprotoから生成したノード型 | +| `check/postgres/schema` | libpg_queryでschema.sqlをカタログの上に載せる。テーブル・ビュー・enum・ドメイン・複合型・関数・制約・ポリシー・seed | +| `check/postgres/catalog` | 埋め込みのpg_catalog(版ごとの型・関数・演算子・キャスト・集約、`data//`)と拡張のdump | +| `check/postgres/analyze` | アナライザー本体。10章の型変換、スコープ、DML、`$n`推論、nullability、カーディナリティ、違反の列挙、PG互換のエラー。文の事実(`facts.Facts`)の生産と、義務の述語を事実の言語に落とす`Lower` | +| `x/facts` | アナライザーと義務検査の間のデータ契約。文種・スコープの木・葉・正規化述語・等値の辺・固定列・代入集合。パーサのノードを含まず、方言を知らない | +| `x/cardinality` | One の証明を facts の上で(方言非依存、PostgreSQL も MySQL もこれだけ): 全 leaf のユニークキー(部分インデックスは述語を文が繰り返すとき、WITHOUT OVERLAPS は範囲が既知の点を含むとき)が等式で固定される、join の辺で知識を運ぶ、派生表・ビュー・CTE は外側で固定された出力列を種にして本体の scope で、GROUP BY は全項が固定されるとき、LIMIT 1 / 1 行 VALUES / 集約は Single、集合演算 / 複数行 VALUES / FULL JOIN は Many、producer の AtMostOne はそのまま | +| `x/obligation` | 境界の規則(方言非依存)。`obligation.Schema` / `Relation` interface 越しにスキーマを見る(PG は `schema.Schema.Contract()` が適合)。schema.sqlの`require` / `visible where`とvetのフラグを義務に読み、事実に対して履行を判定する([obligations.md](obligations.md))。`analyze`にも`vet`にも依存しない | +| `cmd/sqlshape/internal/vet` | `go/analysis`アナライザー。`x/dialect` の契約だけを読む(PG 固有型なし)。結果列 ↔ `R`、`$n` ↔ `P`、束縛、expect行、SuggestedFix。境界の規則は`obligation`に委ね、フラグを義務に展開して渡す | +| `check/postgres/oracle` | 差分テストのオラクルとしての本物のPG(embedded-postgres)。検査時には使わない | +| `check/postgres/dump` / `diff` / `migrate` / `consumers` / `cli` | マイグレーション側。pg_dumpによる正準形、オブジェクト単位のdiff、DDL生成と終点検証、消費者索引、サブコマンド | ## 検討中 - MySQLを、PostgreSQLとは別の実装としてサポートする。初期リリースの範囲外。方言をまたぐ共通DSLは作らない(作った瞬間にSQLをDSLに翻訳させる形に戻る)。代わりにプラットフォームごとに、そのDBの構文と型規則を実装したアナライザーと、そのDBのdumpを使うマイグレーション支援を持つ。共有するのはテンプレートの展開、`go/analysis`のフロントエンド、Go側の型照合の枠組み - - ライセンスと構成(裁定 2026-09-09、改訂): MySQLサーバはGPLv2で、Apache 2.0のsqlshapeと同じ成果物には混ぜられない(libpg_queryの形が成立したのはPostgreSQL Licenseが寛容だったから)。守るのは「利用者のランタイムにGPLが混入しない」の1点で、優先するのは利用者の使いやすさ。MySQLとPostgreSQLで別のバイナリを使い分けさせることはしない。そこでモノレポの中でモジュールを2種に分ける。① Apache 2.0のまま残るもの: 利用者のコードにリンクされる`sqlshape`(ランタイム)と`pgtest`、`pgtest`が使うPostgreSQL側の解析(`internal/analyze` / `schema` / `catalog` / `pgparse` / `oracle` / `verify`)、テンプレート展開、義務、vetのフロントエンド。import pathは`github.com/kr9ly/sqlshape`のまま動かさない。② GPLv2に置くもの: MySQLの文法・字句解析器・関数表を切り出したwasmとそのアナライザー(`github.com/kr9ly/sqlshape/mysql`、ネストしたモジュール)、そして両方を積む1つのバイナリ`cmd/sqlshape`(ネストしたモジュール、GPLv2。Apache→GPLの一方向importは互換)。バイナリは`schema.sql`の宣言(`-- sqlshape: postgres 18` / `-- sqlshape: mysql 8.4`)で方言を選び、vetとしてもマイグレーションとしても同じ1つで済む。利用者のアプリにリンクされるのは①だけなので、GPLは開発ツールに閉じる(gccと同じ)。`internal/`の参照はパスの接頭辞で判定されるのでネストしたモジュールからも通る見込みだが、これとネストしたモジュールのタグ付け(`cmd/sqlshape/vX.Y.Z`)と`go install`の解決はスパイクで確認する。退けた案: MySQL対応を別リポジトリ・別バイナリ`sqlshape-mysql`にする(当初の裁定。利用者にDBごとのバイナリを使い分けさせることになる)、パーサを別プロセスにしてパイプで木を渡す(FSFは密なデータ交換を結合とみなし得るとしていて灰色、構造も歪む)、Apache 2.0の互換パーサ(TiDB parser / Vitess)を使う(原則を捨てる)、抽出を事実に限定して文法を手書きする(一番品質が欲しい文法が手書きになる) + - ライセンスと構成(裁定 2026-09-09、改訂): MySQLサーバはGPLv2で、Apache 2.0のsqlshapeと同じ成果物には混ぜられない(libpg_queryの形が成立したのはPostgreSQL Licenseが寛容だったから)。守るのは「利用者のランタイムにGPLが混入しない」の1点で、優先するのは利用者の使いやすさ。MySQLとPostgreSQLで別のバイナリを使い分けさせることはしない。そこでモノレポの中でモジュールを2種に分ける。① Apache 2.0のまま残るもの: 利用者のコードにリンクされる`sqlshape`(ランタイム)と`pgtest`、`pgtest`が使うPostgreSQL側の解析(`check/postgres/analyze` / `schema` / `catalog` / `pgparse` / `oracle` / `verify`)、テンプレート展開、義務、vetのフロントエンド。import pathは`github.com/kr9ly/sqlshape/v2`のまま動かさない。② GPLv2に置くもの: MySQLの文法・字句解析器・関数表を切り出したwasmとそのアナライザー(`github.com/kr9ly/sqlshape/check/mysql/v2`、ネストしたモジュール)、そして両方を積む1つのバイナリ`cmd/sqlshape`(ネストしたモジュール、GPLv2。Apache→GPLの一方向importは互換)。バイナリは`schema.sql`の宣言(`-- sqlshape: postgres 18` / `-- sqlshape: mysql 8.4`)で方言を選び、vetとしてもマイグレーションとしても同じ1つで済む。利用者のアプリにリンクされるのは①だけなので、GPLは開発ツールに閉じる(gccと同じ)。`internal/`の参照はパスの接頭辞で判定されるのでネストしたモジュールからも通る見込みだが、これとネストしたモジュールのタグ付け(`cmd/sqlshape/vX.Y.Z`)と`go install`の解決はスパイクで確認する。退けた案: MySQL対応を別リポジトリ・別バイナリ`sqlshape-mysql`にする(当初の裁定。利用者にDBごとのバイナリを使い分けさせることになる)、パーサを別プロセスにしてパイプで木を渡す(FSFは密なデータ交換を結合とみなし得るとしていて灰色、構造も歪む)、Apache 2.0の互換パーサ(TiDB parser / Vitess)を使う(原則を捨てる)、抽出を事実に限定して文法を手書きする(一番品質が欲しい文法が手書きになる) + - バージョン(裁定 2026-09-10): 3 モジュールは 1 つの版番号でロックステップ、全部 1.x(`cmd/sqlshape`の`/v2`は外した。MySQL 追加は additive なので minor が semver 上も正しく、ランタイムの import path も動かさない)。同一コミットに`vX.Y.Z` / `mysql/vX.Y.Z` / `cmd/sqlshape/vX.Y.Z`を打ち、`scripts/release.sh`が go.mod の相互 require を揃えてコミット・タグ付けする。release.yml はルートの`v*`で発火。退けた案: モジュールごとに独立した番号(root 1.x / mysql 0.x / cmd 2.x、多段の手順が要る)、root も`/v2`に上げて 2.x で揃える(ランタイム利用者の import path が変わる) - パーサと字句解析器(裁定 2026-09-09): MySQLサーバのソースから機械的に切り出し、libpg_queryと同じ形でwasmにする。原則は「本物から機械的に抽出したものを土台にし、手で書く部分を最小にする」。文法は`sql/sql_yacc.yy`(bison、18k行、`%expect 59`)からC++のアクション(`NEW_PTN`が923箇所)を剥がして規則・優先順位・トークンだけを残し、規則名と子要素だけの素の構文木(CST)を組む汎用アクションを機械生成してbisonに通す。字句解析器は`sql/sql_lex.cc`の`lex_one_token`(726行)と2トークン先読みの`MYSQLlex`(`WITH ROLLUP`等の合成)、`Lex_input_stream`(440行)、`find_keyword`(`sql/lex.h`の784語の表から`gen_lex_hash`が生成する完全ハッシュ)、`consume_comment`・`int_token`をそのまま使う。測ったところ、字句解析器がサーバ状態を読むのは5点だけ — `sql_mode`(`ANSI_QUOTES`の1箇所)、`default_collation_for_utf8mb4`、接続文字集合、コメント収集の`m_parser_state`、非推奨文字集合の`push_warning`(2箇所)— なのでTHDはこの5点を持つ数十行のshimで置き換える。文字の分類(`state_map` / `ident_map`)は`strings/sql_chars.cc`が文字集合のctype表から作るもので、`strings/`ライブラリごと取り込む(PGの`src/port`をlibpg_queryが抱えるのと同じ)。バージョンコメント`/*!80000 ... */`は`MYSQL_VERSION_ID`との比較なので、版ごとにビルドすれば自然に正しい。sqlshape用のASTへの変換だけが手書きで、CSTの規則名を読む。生成手段はこの裁定でbison + emccに決まる(goyaccでpure Goに持つ案は字句解析器の書き直しを伴うので原則に反する)。残る確認はスパイク: shimの表面積が本当に5点で閉じるか、抽出した文法とlexerがemccで通るか、`mysql-test/t`(1810ファイル)の受理率 - - `sql_mode`は判定を変えるので、版と同じくschema.sqlが宣言する(`-- sqlshape: mysql 8.4 sql_mode=...`の形は未決)。字句では`ANSI_QUOTES`、文法のアクションを剥がすので`PIPES_AS_CONCAT`(`||`)の意味はCST→ASTの変換で`sql_mode`を見て決める + - スパイクの結果(2026-09-09、`mysql/spike/`): 三点とも成立。shimの表面積は5点で閉じた(警告・digest・utf8本文・オプティマイザヒントは空実装)。アクションを剥がした文法はbison 3.8で`%expect 59`のまま通り、トークン番号は本物と一致する。`mysql-test/t`の1,455ファイルを文に割った137,062文のうち、エラーを期待しない125,187文の99.89%を受理し、残りはテストハーネス側の事情(delimiter、クライアント文字集合の切り替え、パーサ深さの試験)で文法の穴は見つかっていない。MySQLがアクション内で出す構文エラー(予約関数名のテーブル名、`<=>ALL`)はCSTパーサでは通るので、意味層で拾う + - CST→AST(裁定 2026-09-09): 剥がしたアクションの中身が CST→AST の対応表なので、parsegenがそれを読み戻して`shapes.go`(規則×選択肢 → server が何を組んだか)を生成し、`mysqlast`が汎用に再生する。ノードは server の PT クラス(`PT_query_specification` / `PTI_where` / `Item_func_eq`)で、引数名はヘッダのコンストラクタから、by-value の struct のフィールド名は`parser_yystype.h`から取る。読めないアクション(到達範囲で約 45)は hook を手書きし、hook は規則名と右辺の記号列で結び、版が上がってずれれば init で落ちる。読めもせず hook も無い構文は`Unsupported`として明示する。mysql-test の DML/DDL の 97.7% が組める + - アナライザーが読む木(裁定 2026-09-09): mysqlast を直接読む(PG 側が pg_query のノードを直接読むのと同じ位置づけ)。parsegen が持つコンストラクタ引数名から PT クラスごとの型付きビュー(`AsPTQuerySpecification(v)` と引数名のアクセサ)を生成して、アナライザーはそれを通す。MySQL 専用の正規化した文モデルは作らない。版ごとの揺れは hook と引数名の照合で露見させ、吸収層が要るかは SELECT の解析を書いてから判断する +- 方言の口(裁定 2026-09-09): vet が`analyze.Result` / `schema.Schema` / `pgparse`に直接依存しているので、ルートに`x/dialect`を切る。`Load(schemaSQL) (Analyzer, error)`、`Analyzer.Analyze(sql) (*Result, error)`、Go 型 ↔ SQL 型の対応表を方言が提供し、`schema.sql`の先頭宣言(`-- sqlshape: postgres 18` / `-- sqlshape: mysql 8.4`)で選ぶ。`Result`は方言非依存の形(型は名前と NULL 可否、出所はテーブル・列名、`facts.Facts`)に寄せる。PG 実装は`check/postgres/analyze`の薄い adapter、MySQL 実装は`mysql/`側。vet が pgparse に触る 2 箇所(ORDER BY の hazard、束縛の定数)は方言側へ。SQLite 等の第三の方言もこの口に足すだけで済む形にする(パーサは同じ手法 — 本物の文法からの機械抽出 — で、Lemon の文法なら parsegen に相当する生成器をもう 1 つ書く) + - 方言間の線引き(裁定 2026-09-10): 機能は方言の事実として現れるだけで、共通化しない。契約(`One`の証明・義務・失敗モード)は事実(`facts.Facts`: どの列を読む・固定する・書く、どのキーで一意になる、どの制約に当たり得るか)の上に書く。契約が必要とする事実をその方言が生産できないなら、その契約はその方言ではエラーにする(黙って通さない)。これで「PG にある機能を MySQL でどう真似るか」は問いとして消え、「この契約が要る事実は何か、その方言はそれを出せるか」に置き換わる。3 層で見る: ① SQL の意味論(文法・型・エラー・名前解決)は各 server がそのまま正で、oracle で検算する ② sqlshape の契約は事実の上 ③ Go の受け型と実行時はドライバごとの表。含意: `dialect.Result`に Facts を載せて obligation を方言非依存にするのが次の設計課題。方言にだけある機能と契約の交差(`INSERT IGNORE`は失敗モードを飲み込む、`ON DUPLICATE KEY UPDATE`は`ON CONFLICT`相当、`RETURNING`が無いので「書いた値を読む」規則が成立しない)は個別に裁定する。**ドキュメント**: 何を約束し何を約束しないかが方言ごとに変わるので、利用者向け docs は方言別の約束の表として整理し直す。実装より難しいと見ていて、契約の結線を MySQL に通すタイミングで再度整理する + - 実施(2026-09-09、縦串): `x/dialect`は宣言の読み取り(`Declared`、`-- sqlshape: `で name が postgres か登録済みの方言のもの)・`Register` / `Lookup`のレジストリ・方言非依存の`Analyzer { Analyze(sql) (*Result, error); Problems() }`と`Result { Params []Type; Columns []Column }`(`Type`は方言の綴りと Go 型名の列、`Column`は名前・型・NULL 可否)だけ。vet は宣言が postgres 以外のとき`dialect.go`の細い経路(展開 → Analyze → 解析エラー / 結果列 ↔ R / パラメータ ↔ P)を通り、PG 経路は無変更で残す。PG を`Result`に寄せる作業は、`Result`が義務・違反・基数・束縛を運べるようになってから。プレースホルダは展開器の`$n`を方言側が翻訳する(MySQL は`?`。`$`は MySQL の識別子に使えるので、引用符・識別子の中は触らない)。MySQL 実装は`mysql/check/postgres/analyze`(`Analyze(schema, sql)`、独自の`Result`)と公開パッケージ`mysql/dialect`(登録と Go 型表)、`cmd/sqlshape`が blank import する + - 文字集合レジストリ(裁定 2026-09-09): `strings/`の照合ライブラリを丸ごとリンクする(ctype-*.cc、collations*.cc、`uca9dump`が生成するzh/jaの重み表)。wasmは4.6MBで、名前だけの表なら0.9MBだが、手で持つ表をゼロにすることと、型層でPAD SPACE・mbmaxlen・既定照合を参照するときにレジストリがそのまま答えになることを優先した。サイズが問題になったら重み表(`uca900_data.h`とhan表、約3MB)だけを空にする + - サーバ設定の宣言(裁定 2026-09-11、実施同日): 判定を変えるサーバ変数は、版と同じくschema.sqlが宣言する。構文は方言非依存で`-- sqlshape: server = `(1行1変数、値はSQLの文字列リテラルか裸のトークン、`x/dialect.Settings`が読む)、受け付ける変数の列挙は方言ごと(知らない変数・不正な値はスキーマのProblem)。MySQLが読むのは`sql_mode`と`lower_case_table_names`の2つで、宣言が無ければ「8.4を初期化したままのサーバ」(sql_modeの既定、lower_case_table_names=0)を規定とする。`sql_mode`の語彙・ビット・組み合わせモード(`ANSI` / `TRADITIONAL`)の展開は`x/sqlmode`(sys_vars.ccの`sql_mode_names` / `expand_sql_mode`の写し。ルートに置くのは、GPLの検査器とApacheのランタイム / mysqltestの両方が同じ表を読むため — `x/placeholder`と同じ位置づけ)。検査器が従うのは、字句・文法のビット(`ANSI_QUOTES` / `PIPES_AS_CONCAT` / `IGNORE_SPACE` / `NO_BACKSLASH_ESCAPES` / `HIGH_NOT_PRECEDENCE`、shimのTHDにそのまま渡す — `||`は字句解析器が`OR_OR_SYM` / `OR2_SYM`を選ぶので変換層の分岐は不要だった)、`ONLY_FULL_GROUP_BY`(グループ検査の有無)、`STRICT_TRANS_TABLES` / `STRICT_ALL_TABLES`(`Item_str_func`のNULL可否、1048の失敗モード)、`REAL_AS_FLOAT`(DDLの`REAL`)、`NO_UNSIGNED_SUBTRACTION`(減算のunsigned)。残りの語(日付・0除算・エンジン置換・パディング)は実行時にしか効かないので受理して素通し。`lower_case_table_names`は1なら表名・ビュー名を小文字に正規化して保持(サーバの報告と同じ)、2なら宣言どおりの綴りで大文字小文字を無視して照合、0は区別する。サーバとの突き合わせ: `mysqltest.Start`が宣言をそのままmysqldの起動オプションに渡す(`lower_case_table_names`は初期化後に変えられないのでテンプレートを値ごとに分ける。知らない変数はmysqldが起動を拒む)ので、オラクルもアプリのテストも宣言と同じ世界で走る。本番の接続に対しては`mysql.Verify(ctx, db, schemaSQL)`が`@@SESSION.sql_mode`(DSN / プールで上書きされうるのでSESSION)と`@@lower_case_table_names`を読み、宣言とビットで比べて不一致を返す。退けた案: `SET`文をschema.sqlに書かせる(DDLに混ざり、適用対象になる)、`-- sqlshape: mysql 8.4 sql_mode=...`と版の行に載せる(変数が増えると読めない)、mysqltestに宣言の正規表現を複製する(表が二重になる) + - マイグレーション(裁定 2026-09-11、実施中): PG と同じ 3 部構成を `check/mysql/dump` / `diff` / `migrate` に置き、`sqlshape diff` / `apply` / `verify-schema` は schema.sql の宣言で方言を選ぶ(cli を方言ごとの driver に切る)。正準形は「その文を適用したサーバに `SHOW CREATE TABLE` / `SHOW CREATE VIEW` で読み返した DDL」を自前のローダー(`check/mysql/internal/schema`)で読んだモデル。両側(目標の schema.sql と稼働 DB)を同じ経路で読むので、比較されるのは書き方でなく MySQL が保持している定義になる(PG が両側を pg_dump で読むのと同じ位置づけ。mysqldump は要らない。`SHOW CREATE TABLE` の出力をローダーがそのまま読めることは実測済み: 列 9 / キー 3 / CHECK 2、`/*!80016 NOT ENFORCED */` も読む)。読み返しで落とすもの: `AUTO_INCREMENT=N` のカウンタ、ビューの `DEFINER`。正準化に使うサーバは、`-db` があるときはその稼働サーバ上の一時データベース(`sqlshape_scratch_<乱数>` を作って適用し、読み返して落とす。同じサーバ版・同じ設定で正規化されるので、別のローカル mysqld より正しい。CREATE / DROP DATABASE 権限が要る)、`-from`(テキスト同士)とテストでは PATH の mysqld(mysqltest、宣言した設定で起動)。宣言した `sql_mode` は一時 DB のセッションに `SET SESSION` で入れ、`lower_case_table_names` はサーバの値が宣言と違えば止める。差分はモデルの上で、表(エンジン・文字集合・照合・コメント)、列(型の正準綴り・NULL 可否・既定値・AUTO_INCREMENT・ON UPDATE・生成式と STORED・コメント・INVISIBLE・照合・位置)、キー(種別・列と接頭長・コメント)、外部キー(列・参照・ON DELETE / UPDATE)、CHECK(式・ENFORCED)、ビュー(サーバが正規化した本文・ALGORITHM・CHECK OPTION)。列の順序は PG では直せないので注記だったが、MySQL は `MODIFY ... AFTER` で直せるので差分として出す。トリガ・プロシージャ・イベントはローダーが読まないので比較しない(docs に明記)。seed 表は MySQL のローダーに無いので今回は範囲外。DDL の生成は MySQL 構文: drop(参照している外部キー → ビュー → 表 / 列 / キー / CHECK)、宣言された rename(`RENAME TABLE` / `RENAME COLUMN`)、alter(`MODIFY COLUMN` の全定義 + `FIRST` / `AFTER`、表オプション、キーと FK と CHECK は DROP + ADD)、add(正準テキストの `CREATE TABLE` を FK 依存順に、ビューはその後)、backfill。`-- @migrate` の文法は PG と同じ(`enum` は MySQL では列を名指す: `enum orders.status: drop 'x' using 'y'` → UPDATE してから MODIFY)。verify は「稼働 DB の正準テキスト + DDL」を一時 DB に適用して読み返し、目標と比べる。apply: MySQL の DDL はトランザクションにならない(暗黙コミット)ので 1 文ずつ実行し、失敗した文で止めてどこまで進んだかを報告する。`-no-transaction` は MySQL では意味を持たない。退けた案: mysqldump を両側に使う(バイナリ依存が増え、`SHOW CREATE` と同じ情報しか出ない)、information_schema を直接読んでモデルを組む(ローダーの二重実装になる。正準 DDL を読ませれば一つで済む)、ローカル mysqld を必ず要求する(配布バイナリが無いので PG の embedded とは事情が違う) + - トリガとストアドルーチン(裁定 2026-09-11 セッション末、実施): PostgreSQLのPL/pgSQL本体解析と同じ位置づけで**必須**とし、後回しにしない(docs/checks.mdは「失敗モードはトリガと呼び出す関数の中まで計算する」と約束しており、MySQLだけトリガ本体を読めないままだと`SIGNAL`の失敗モードが漏れ、本体のDMLが義務の判定を逃れる)。本体はスキーマごとに1回解析してキャッシュし(`AnalyzeTrigger` / `AnalyzeRoutine`、`BodyResult`)、結果をルーチンの呼び出し側・CALL・トリガを起こす文のそれぞれが読む。SIGNALのキーは`SET MYSQL_ERRNO`があればその10進表記、無ければSQLSTATE(PostgreSQLの`RAISE ... USING ERRCODE`と同じ位置づけ。runtime側の`mysql/errors.go`も同じ規則でサーバのエラーを読み戻す)。`-- sqlshape: error = `はPGの`error`注釈と同じ構文で足した。当初はNameをキーの別名にしない設計だった(`Violation.Key()`はConstraintしか見ず、Nameは`describeViolation`の説明文の装飾に留める)——理由は、`MYSQL_ERRNO`は同じ値でも複数のSIGNALが別の意味で使う可能性があり、キーをNameで直接上書きすると`mysql.Violates`のランタイム側の照合(サーバのSQLSTATE / 番号から一意に引ける)と食い違う経路が生まれることを懸念したため。この懸念は裁定2026-09-13(上記「トリガ・ルーチンの独自コードにGoの名前を与える」)で解消した: NameはGoの`sqlshape.Error(code)`宣言の識別子名としてvetが照合するだけで、キーそのものの別名としてランタイムに登録されることは無い(`Violates`はスキーマもNameも知らず、渡された値のコードだけを見る)ので、複数のSIGNALがコードを共有してもNameが誤って解決される経路自体が存在しない。expect行・`mysql.Violates`はNameでも書けるようになったが、それは`Violation.Name`が一致するキーの別表記として認められるだけで、キー自体(`Violation.Key()`)は変わらずSIGNALのコードのままである。`DECLARE ... HANDLER FOR`はブロック単位で吸収し(SQLEXCEPTION / SQLWARNING / NOT FOUND / SQLSTATE / 番号 / 条件名)、値なしRESIGNALで戻す設計はPL/pgSQLの`EXCEPTION`ブロックと同型。`CALL`の結果列は「本体のINTO無しSELECTが0個なら列無し、1個ならその列、複数あって形が全部一致すれば1つの列リスト、割れていれば検査器自身のエラー」とし、mysqld自身の拒否ではなく検査器の判断にした——mysqldは実行時にどちらの結果集合を返すか経路依存で決めるだけで、CREATE時にもCALL時にも形の一致を検査しないため、静的に扱うには検査器側で裁定するしかなかった。マイグレーションはトリガ・ルーチンをDROP + CREATEで置き換える(MySQLに`CREATE OR REPLACE TRIGGER`が無いのはテーブル同様の制約で、表・ビューと同じ依存順の裁定に従う: トリガのDROPは表のDROPより前、ルーチンのCREATEはビューより前、トリガのCREATEはbackfillの後)。`mysqlparse.Split`の複合文分割(裁定2026-09-09)は変えず、DELIMITER非依存の`;`拡張方式のままトリガ・ルーチン本体にも通用した——本体を字句的に読むだけの分割なので、意味解析側の変更だけで済んだ - オプティマイザヒント(`/*+ ... */`)は別の文法`sql/sql_hints.yy`(725行)と`Hint_scanner`を持つ。同じ手順で後から足せる。sqlshapeの判定には効かないので初期は読み飛ばす - カタログ(裁定 2026-09-09、測定に基づく): MySQLには`pg_proc`に当たる表がないが、組み込み関数の定義はソースにまとまっていて機械抽出できる。①関数表`sql/item_create.cc`の`func_array`(347エントリ): 名前 → Itemクラス × 引数個数(固定 / 最小・最大 / 偶奇)。②キーワード構文の関数(`CAST`・`TRIM`・`DATE_ADD`等、81クラス)は`sql_yacc.yy`のアクションの`NEW_PTN Item_func_*`に書かれているので、アクションを剥がす前に「規則 → Itemクラス」として拾う。③結果型の種別はItemクラスの継承から取れる: 登録277クラスの直接の基底は`Item_int_func`(77、LONGLONG)・`Item_str_func`(53、文字列)・`Item_dec_func` / `Item_real_func`(21、DOUBLE)・`Item_json_func`(10)・`Item_bool_func`(8)・日時系(9)・空間系(33)で大半が決まる。④引数型・NULL可・正確な結果型は各クラスの`resolve_type`(257個)にあり、その多くが宣言的な呼び出し — `param_type_is_default(from, to, TYPE)`(97箇所、引数の既定型)、`set_data_type_string / longlong / double / decimal / datetime / date / time / json`、`set_nullable`(82)、`aggregate_type`(引数型の合成)— で書かれているので、本体を字句的に読んで表にできる。⑤宣言的でない残り(`unsigned_flag`の伝播、長さ計算、引数で結果型が変わるもの)と④の検算は、本物のmysqld(配布tarballを`--initialize`で起こす。embedded-postgresの役)に①②から生成した`SELECT f(?, ...)`をプリペアドステートメントで問い、結果メタデータで埋める。「何が存在し何個の引数を取るか」を手で列挙しないのが要点で、抽出した表が計測の入力になる。型規則(暗黙変換・数値の昇格・文字列と数値の比較)は`Item_func::aggregate_type`と`item_cmpfunc.cc`の比較種別決定(`get_cmp_type`相当)を読んで移し、mysqldとの差分テストで検算する + - 型規則の実施(2026-09-10): `mysql/check/postgres/analyze/types.go`が server の規則をそのまま持つ — `Item_num_op::set_numeric_type`(REAL > DECIMAL > INT の昇格、`numeric_context_result_type`で日時は整数・文字列は実数扱い、`result_precision`の unsigned 伝播)、`Item_func_num1`(1 引数の数値関数、`Item_func_int_val`は小数を scale 0 に)、`Item::aggregate_type`(NULL を飛ばして`field_type_merge`で畳み、符号が混じる整数は一段広げる)。`field_types_merge_rules`(30×30)と`field_types_result_type`は手で写さず parsegen が`sql/field.cc`から抽出して catalog に生成する(`catalog.Merge` / `catalog.ResultKind`)。関数は catalog の Item クラスで判定: family が結果種別を決め、facts(`set_data_type_*`、`set_nullable`、`unsigned_flag=true`、`param_type_is_default(from,to,TYPE)`、`param_type_uses_non_param`)が型・NULL 可否・プレースホルダの型を補う。ヘッダにインラインで書かれた`resolve_type`(109 個)も parsegen が読むようにした(facts を持つクラス 144 → 323)。NULL 可否の既定は「引数のどれかが NULL 可なら NULL 可」(`Item_func::fix_fields`)、集約は「行が無ければ NULL」で COUNT / BIT_* / 窓関数だけ例外。読めないもの(`Item_temporal_hybrid_func`の ADDTIME / DATE_ADD、サブクエリ、ユーザー変数)は未型付けで通す + - mysqld オラクル(2026-09-10、実施): `mysql/check/postgres/oracle`。PATH の`mysqld`(`nix-shell -p mysql84`。配布 tarball の bin/ でも同じ)を版ごとに一度`--initialize-insecure`して`~/.cache/sqlshape/mysqld-/template`に置き、Start ごとに一時ディレクトリへ複製して unix socket・networking off で起こす(約 2 秒)。`Describe(sql)`は SELECT を全パラメータ NULL で実行して結果セットのメタデータ(型・unsigned・NULL 可否・長さ・precision)を読む。書き込み文は prepare だけ。プレースホルダの型は COM_STMT_PREPARE が全部 VARCHAR で返すので検算できない。`analyze`の`TestOracle`が`analyzeCases` / `errorCases`の全文を server に問い、列名・型・NULL 可否・エラー番号の一致をゲートにする(mysqld が無ければ skip)。初回の突き合わせで直ったもの: 文字列関数は strict mode で常に NULL 可(`Item_str_func::fix_fields`)、JSON 関数と型キャストも NULL 可、FLOOR / CEILING は桁が収まれば bigint、文字列リテラルの列名は値そのもの、`IN (subquery)`は NULL 可、`x.*`の不明テーブルは 1051。版の差(文法は 8.4.6、oracle は 8.4.11)は同じ LTS 系列なので許容し、`Oracle.Version`に記録する + - 関数表の差分テスト(2026-09-10、実施): `analyze`の`TestProbe`が registry の 347 関数 × 代表引数型(int / bigint unsigned / decimal / double / varchar / varbinary / datetime / date / time / json / NULL 可 int、文字列先頭+整数、整数先頭+文字列、末尾 NULL)× 引数個数で 5,267 文を生成し、server と analyzer の答えを`testdata/probe.golden`に 1 文 1 行で残す(`=` 一致 / `?` 未型付け / `!` 不一致 / `x` server が拒否 / `X` analyzer が拒否)。比較は型名と符号と NULL 可否で、文字列は「文字列 / バイナリ列」の 2 クラス(長さを計算しないので VARCHAR と TEXT の境目は判定できず、Go 側の受け型は同じ)。到達点: 一致 4,994、未型付け 29、不一致 10(REGEXP_REPLACE の NULL 可否 9、QUOTE の照合 1)、server 拒否 234(引数型の検査はしない)。この過程で直したもの: 文字列関数の結果照合(Item の既定はバイナリ、`agg_arg_charsets_*`があれば引数から、明示の charset があればそれ)、`Item_geometry_func`の NULL 可、コンストラクタと`fix_fields`に書かれた`set_nullable` / `unsigned_flag`(parsegen がクラス本体も読む)、`fix_fields`の連鎖(`Item_str_func::fix_fields`に届くかで strict の NULL 可を決める)、`return Base::resolve_type()`形の継承、`set_data_type_*`は最後の文が勝つ、NULLIF / UNIX_TIMESTAMP / FROM_UNIXTIME / ST_X 系の instantiator、schema ローダーの BINARY(n) / VARBINARY(n)(parsegen が同じ引数個数の別コンストラクタで名前付けしていた)。golden の変更はレビュー対象 + - カタログの検算(裁定 2026-09-09): mysqld オラクル(配布 tarball を`--initialize`で起こす)はカタログ生成のときに手元でだけ使い、生成物をコミットする。CI は検算しない(PG の`catalog/gen`と同じ扱い。tarball は 500MB 級で CI に載せる価値がない)。tarball は`~/.cache/sqlshape/mysql-`に置く + - マイグレーションの差分テスト(2026-09-15、実施): PG と同じ probe を`check/mysql/migrate`に置く。測り方と数字は「マイグレーションは自前のdiff / apply」の「忠実度の測り方」に一本化した。MySQL 固有の判定は正準形が「その文を適用した mysqld に`SHOW CREATE`で読み返した DDL」であること、列の移動があること、DDL が文ごとに commit されるので apply が文単位で進むこと - 回帰コーパスはMySQL自身の`mysql-test/t/*.test`。パーサの受理率と、オラクルとの差分probeの両方に使う - 順序: パーサのスパイク(受理率で手段を裁定)→ 字句解析器 → CST→AST → スキーマローダー(CREATE TABLEの部分集合、`mysqldump`の正準形)→ アナライザーの型規則(mysqldをオラクルに差分テスト)→ テンプレート・vet・義務の結線(ここは共有部分なので薄い)。1.2の後 -- TypeScriptフロントエンド。2つ目の言語フロントエンドで「核はSQLしか見ない」構想を検証する。言語非依存で共有するのは`internal/analyze`、`internal/schema`、マイグレーション一式、失敗モード・`One`の証明・境界の規則。言語依存はその言語のデファクトに合わせる: 呼び出し箇所の発見(TypeScript Compiler APIかtsc plugin、ESLintルール)、`R` / `P`相当の型解決(`query(sql)`)、PG型とTS型の対応表(pg / postgres.jsが実際に返す型)、ランタイム。最初の設計論点はテンプレート構文で、Goの`text/template`を持ち込まず、`sql\`...\``のタグ付きテンプレートと`${}`の断片で「有限の展開集合」を保つ方法(断片をリテラルの選択肢に限る、など)を決める。MySQLと同じく、`internal/vet`のPG固有型の切り出しが前提。横展開の見立て: 検査の大半(列と型の存在、失敗モード、`One`の証明、境界の規約、インジェクション保証、マイグレーション)はSQL側だけで閉じていてホスト言語の型を見ない。言語ごとに必須なのは「lintの口」「テンプレート定数の取り出し」「ランタイム」の3つで、`R` / `P`の型照合は静的型がある言語で足す層(動的型の言語では実行時の行マッピング検査か、型ヒントがあれば静的に) +- TypeScriptフロントエンド。2つ目の言語フロントエンドで「核はSQLしか見ない」構想を検証する。言語非依存で共有するのは`check/postgres/analyze`、`check/postgres/schema`、マイグレーション一式、失敗モード・`One`の証明・境界の規則。言語依存はその言語のデファクトに合わせる: 呼び出し箇所の発見(TypeScript Compiler APIかtsc plugin、ESLintルール)、`R` / `P`相当の型解決(`query(sql)`)、PG型とTS型の対応表(pg / postgres.jsが実際に返す型)、ランタイム。最初の設計論点はテンプレート構文で、Goの`text/template`を持ち込まず、`sql\`...\``のタグ付きテンプレートと`${}`の断片で「有限の展開集合」を保つ方法(断片をリテラルの選択肢に限る、など)を決める。MySQLと同じく、`cmd/sqlshape/internal/vet`のPG固有型の切り出しが前提。横展開の見立て: 検査の大半(列と型の存在、失敗モード、`One`の証明、境界の規約、インジェクション保証、マイグレーション)はSQL側だけで閉じていてホスト言語の型を見ない。言語ごとに必須なのは「lintの口」「テンプレート定数の取り出し」「ランタイム」の3つで、`R` / `P`の型照合は静的型がある言語で足す層(動的型の言語では実行時の行マッピング検査か、型ヒントがあれば静的に) - 2-way SQL構文(`/*{{.X}}*/'lit'`)。psqlでそのまま流せるテンプレート。expandとRenderの前段で同じ変換を入れる - ORMからの移行支援。ORMが発行したSQLを観測して`Query[R, P]`に起こす diff --git a/docs/flags.ja.md b/docs/flags.ja.md index ffd12c7..448c174 100644 --- a/docs/flags.ja.md +++ b/docs/flags.ja.md @@ -2,7 +2,13 @@ [English](flags.md) -`cmd/sqlshape`は`go vet -vettool`互換の検査器である。`sqlshape ./...`、`sqlshape vet ./...`、`go vet -vettool=$(which sqlshape) ./...`のどれでも実行でき、以下のフラグはどの形でも同じように渡せる。マイグレーションのサブコマンド(`diff`、`apply`、`verify-schema`)のフラグは別で、[migrations.ja.md](migrations.ja.md)にある。`sqlshape check`のフラグは[下記](#sqlshape-checkのフラグ)。 +`cmd/sqlshape`は`go vet -vettool`互換の検査器である。次のどの形でも実行でき、以下のフラグはどの形でも同じように渡せる。 + +- `sqlshape ./...` +- `sqlshape vet ./...` +- `go vet -vettool=$(which sqlshape) ./...` + +マイグレーションのサブコマンド(`diff`、`apply`、`verify-schema`)のフラグは別で、[migrations.ja.md](migrations.ja.md)にある。`sqlshape check`のフラグは[下記](#sqlshape-checkのフラグ)。 ## 検査器のフラグ @@ -16,13 +22,15 @@ | `-context=ops` | なし | パッケージを判定する義務の文脈。パッケージコメントに`// sqlshape: context `があればそちらが優先 | | `-require-columns=tenant_id` | なし | すべての文が、その列を持つ各テーブルでその列を等値で固定しなければならない。INSERTは値を入れなければならない。その列を固定する行レベルセキュリティのポリシーがあれば満たしたことになる | -`-no-table-reads`・`-no-tables`・`-require-columns`は、`schema.sql`で表ごとに宣言する義務(`require via view`、`require via view on all`、`require pinned(列)`)の略記。宣言形なら`on`による文種の指定、`immutable(列)`、任意の述語も書ける。文脈の`waive`はフラグ由来の義務も宣言と同じように解除する([checks.ja.md](checks.ja.md#宣言の仕組み))。 +| `-query=pkg.Func,pkg.Other:one` | なし | 自前のマーカー関数を`sqlshape.Query`と同じように読む(`:one`は`One`と同じ扱い)。テンプレートを引数に取る generic な`F[R, P any](string) T`であればよい。検査器が読むのは宣言であって、実行するランタイムではない | | `-raw-sql=constant` | `constant` | sqlshapeを通さないドライバ呼び出し(pgx / `database/sql`の`Query`、`Exec`など)の扱い。`constant`はSQL引数が定数であることを要求し、`forbid`は拒否し、`allow`は無視する | | `-raw-sql-allow=pkg/...` | なし | `-raw-sql=forbid`を適用しないパッケージ(`/...`で終わる接頭辞も可) | | `-coverage` | off | パッケージごとに、検査した`Query` / `One`の数と、検査できなかった数(テンプレートが定数でないもの)を報告する | | `-sync-comments` | off | スキーマの`COMMENT ON`から、結果の構造体のフィールドと型にdocコメントを提案する(`-fix`で適用) | | `-fix` | off | 提案された修正(構造体の書き換え、docコメント)をソースに適用する | +`-no-table-reads`・`-no-tables`・`-require-columns`は、`schema.sql`で表ごとに宣言する義務(`require via view`、`require via view on all`、`require pinned(列)`)の略記。宣言形なら`on`による文種の指定、`immutable(列)`、任意の述語も書ける。文脈の`waive`はフラグ由来の義務も宣言と同じように解除する([checks.ja.md](checks.ja.md#宣言の仕組み))。 + ## `sqlshape check`のフラグ @@ -67,7 +75,7 @@ 検査器は`go vet`のツールなので、`go vet`が走る場所ならどこでも走る。一度ビルドして`-vettool`に指定する: ``` -$ go install github.com/kr9ly/sqlshape/cmd/sqlshape@latest +$ go install github.com/kr9ly/sqlshape/cmd/sqlshape/v2@latest $ go vet -vettool="$(which sqlshape)" -strict ./... ``` diff --git a/docs/flags.md b/docs/flags.md index ec5d917..435fd00 100644 --- a/docs/flags.md +++ b/docs/flags.md @@ -2,9 +2,13 @@ [日本語](flags.ja.md) -`cmd/sqlshape` is a `go vet -vettool`-compatible checker. Run it as `sqlshape ./...`, -`sqlshape vet ./...` or `go vet -vettool=$(which sqlshape) ./...`; the flags below are passed the -same way in each case. +`cmd/sqlshape` is a `go vet -vettool`-compatible checker. Any of these runs it, and the flags +below are passed the same way in each case: + +- `sqlshape ./...` +- `sqlshape vet ./...` +- `go vet -vettool=$(which sqlshape) ./...` + The migration subcommands (`diff`, `apply`, `verify-schema`) have their own flags, listed in [migrations.md](migrations.md); `sqlshape check`'s are [below](#sqlshape-check-flags). @@ -19,17 +23,18 @@ The migration subcommands (`diff`, `apply`, `verify-schema`) have their own flag | `-schemas=a_api,b_private` | all | the PostgreSQL schemas this code may reference (a service boundary over one database) | | `-context=ops` | none | the obligation context packages are judged under, unless a package names its own with `// sqlshape: context ` in its package comment | | `-require-columns=tenant_id` | none | every statement must pin these columns by equality on each table that has them; INSERTs must assign them (a row-level security policy fixing the column also satisfies it) | +| `-query=pkg.Func,pkg.Other:one` | none | marker functions of your own, read like `sqlshape.Query` (`:one` like `One`): a generic `F[R, P any](string) T` whose argument is the template. The checker reads declarations, not the runtime that executes them | +| `-raw-sql=constant` | `constant` | driver calls outside sqlshape (pgx / `database/sql` `Query`, `Exec`, ...): `constant` requires their SQL to be a constant string, `forbid` rejects them, `allow` ignores them | +| `-raw-sql-allow=pkg/...` | none | packages (or prefixes ending in `/...`) where `-raw-sql=forbid` does not apply | +| `-coverage` | off | report per package how many `Query` / `One` declarations were checked and how many could not be (non-constant templates) | +| `-sync-comments` | off | propose doc comments for result struct fields and types from the schema's `COMMENT ON` (applied with `-fix`) | +| `-fix` | off | apply the suggested fixes (struct rewrites, doc comments) to the source | `-no-table-reads`, `-no-tables` and `-require-columns` are shorthands for obligations declared per table in `schema.sql` (`require via view`, `require via view on all`, `require pinned(col)`); the declaration form also gives `on` kinds, `immutable(col)` and arbitrary predicates; a context's `waive` lifts the flag's obligation as it lifts a declared one ([checks.md](checks.md#how-a-declaration-works)). -| `-raw-sql=constant` | `constant` | driver calls outside sqlshape (pgx / `database/sql` `Query`, `Exec`, ...): `constant` requires their SQL to be a constant string, `forbid` rejects them, `allow` ignores them | -| `-raw-sql-allow=pkg/...` | none | packages (or prefixes ending in `/...`) where `-raw-sql=forbid` does not apply | -| `-coverage` | off | report per package how many `Query` / `One` declarations were checked and how many could not be (non-constant templates) | -| `-sync-comments` | off | propose doc comments for result struct fields and types from the schema's `COMMENT ON` (applied with `-fix`) | -| `-fix` | off | apply the suggested fixes (struct rewrites, doc comments) to the source | ## `sqlshape check` flags @@ -83,7 +88,7 @@ The checker is a `go vet` tool, so it runs wherever `go vet` runs. Build it once the `-vettool`: ``` -$ go install github.com/kr9ly/sqlshape/cmd/sqlshape@latest +$ go install github.com/kr9ly/sqlshape/cmd/sqlshape/v2@latest $ go vet -vettool="$(which sqlshape)" -strict ./... ``` diff --git a/docs/migrations.ja.md b/docs/migrations.ja.md index 05a3528..0847f37 100644 --- a/docs/migrations.ja.md +++ b/docs/migrations.ja.md @@ -2,7 +2,7 @@ [English](migrations.md) -`schema.sql`がデータベースの唯一の定義であり、マイグレーションファイルは書かない。`sqlshape`が稼働中のデータベースと`schema.sql`を比較してDDLを生成し、そのDDLを当てれば本当に`schema.sql`の状態になることを確認してから実行する。 +`schema.sql`がデータベースの唯一の定義であり、マイグレーションファイルは書かない。`sqlshape`が稼働中のデータベースと`schema.sql`を比較してDDLを生成し、そのDDLを当てれば本当に`schema.sql`の状態になることを確認してから実行する。コマンドはPostgreSQLとMySQLの両方で使える。どちらに話すかはスキーマの宣言が決め、MySQLで違うところは下の[MySQL](#mysql)の節にまとめてある。 ``` $ sqlshape diff -db "$DSN" > up.sql # データベースの状態から schema.sql に至る DDL @@ -13,7 +13,7 @@ $ sqlshape verify-schema -db "$DSN" # ドリフト検出: データベ ## 何を比較するか -両側とも`pg_dump`の出力として読むので、比較されるのは`schema.sql`の書き方ではなく、PostgreSQLが実際に保持している定義である。`'x'`と`'x'::text`、`IN (...)`と`= ANY (ARRAY[...])`のような書き方の違いは差分にならない。`-- sqlshape:`のディレクティブはデータベースの状態ではないので比較対象に入らない。 +PostgreSQLでは両側とも`pg_dump`の出力として読むので、比較されるのは`schema.sql`の書き方ではなく、PostgreSQLが実際に保持している定義である。`'x'`と`'x'::text`、`IN (...)`と`= ANY (ARRAY[...])`のような書き方の違いは差分にならない。`-- sqlshape:`のディレクティブはデータベースの状態ではないので比較対象に入らない。 比較はオブジェクト単位で行う。テーブル、列、制約、インデックス、ビュー、関数、型、ドメイン、enum、トリガー、ルール、ポリシー、行セキュリティの設定、シーケンス、拡張、コメントが対象で、seed済みテーブルは行単位でも比較する。 @@ -38,7 +38,9 @@ $ sqlshape verify-schema -db "$DSN" # ドリフト検出: データベ renameは`RENAME TO` / `RENAME COLUMN`になる。renameした列を含む制約や外部キーは`DROP`して`ADD`する形で出る。正しい手順だが、制約の再検証が走る。enumのラベル削除は型の作り直しになる(PostgreSQLには`DROP VALUE`が無い)。旧型をrenameし、新型を作り、その型を使うすべての列に`ALTER COLUMN ... TYPE ... USING CASE ...`を当て、それらのテーブルに依存するビューを作り直し、旧型を落とす。enumの配列を持つ列があれば問題として報告する。`backfill`は移行後のスキーマで型検査され、列が作られた後に`UPDATE`として出力される。 -ドメインの基底型、範囲型のサブタイプ、`INHERITS`、パーティショニング、`OF type`の変更はDDLとしては出さず、`-- `で始まる注記として出力する。人が手順を決める必要がある変更である。 +パーティションもほかと同じスキーマである。新しいパーティション表は`PARTITION BY`付きで作り、子ができてからATTACHする。既存の親への子の追加は`CREATE TABLE ... PARTITION OF ... FOR VALUES ...`、消える子は行を持っていくので`-- @migrate drop`の宣言を要求した上で`DROP TABLE`、表が子になる・子でなくなるのは`ATTACH PARTITION` / `DETACH PARTITION`、境界の変更はDETACHしてATTACHする(計画中の全DETACHを全ATTACHより前に出すので、動く境界が隣と重なることはない)。子の列は親に従うので、計画が子の列を直接ALTERすることはない。行を持つ表を後からパーティション化する、パーティション化をやめる、別のキーや戦略に移す、はどれもDDLになる。計画は表をRENAMEし、宣言された形をパーティションごと新しく作り、全行を親経由でINSERTしてPostgreSQLのrouterに配らせ、コメント・トリガ・ポリシー・RULE・被参照の外部キー・sequence(bigserialは同じsequenceを使い続け、identity列は最大値の続きから)を新しい表に付け直し、旧表を落とす。どのパーティションにも入らない行はサーバのエラーになるので、データを覆っていない`schema.sql`は何も失わずに`apply`を止める。 + +ドメインの基底型、範囲型のサブタイプ、`INHERITS`、`OF type`の変更はDDLとしては出さず、`-- `で始まる注記として出力する。人が手順を決める必要がある変更である。 列の`ALTER COLUMN ... TYPE`も、新しい型が`numeric`の精度・スケール、`varchar(n)` / `char(n)`の長さ、`time` / `timestamp`系の秒以下の精度、`bit(n)`の長さのいずれかを狭める場合は同様に注記が付く。同じ基底型どうしのALTERはPostgreSQLが`USING`無しでそのまま実行してしまい、既存の値は無警告で丸められる、あるいは切り詰められる。注記はあくまで警告であってブロックはしない——diffの他の部分と同じ「提案であり、人が手を入れる」という契約に沿っているので、無編集のまま適用すればやはり値は丸められる、または切り詰められる。適用前に`USING`を足すか、先にデータを直しておくこと。 @@ -54,7 +56,7 @@ renameは`RENAME TO` / `RENAME COLUMN`になる。renameした列を含む制約 `sqlshape verify-schema -db DSN`は、データベースが`schema.sql`と違う箇所を列挙する。ドリフト、手で当てたマイグレーション、追従が遅れた環境を見つけるために使う。違いがあれば終了コード1、一致していれば0を返す。 -## seed済みテーブル +## seed済みテーブル(PostgreSQL) 行を`schema.sql`に普通の`INSERT ... VALUES`で書いておくテーブルをseed済みテーブルと呼ぶ。その行はスキーマの一部として扱われる。 @@ -79,10 +81,76 @@ INSERTそのものを消すと、そのテーブルは普通のテーブルに 検査器も同じ行を値集合として使う。キー列、またはそれを参照する列に使われたGoのnamed typeは、enumのラベルと同じ規則でこの行と比較される([checks.ja.md](checks.ja.md#型に意味を持たせる))。値集合の置き場としてlookupテーブルを推奨する理由はここにある。値の追加・ラベル変更・並び替え・廃止はどれも1行の変更と`MERGE` 1文で済むが、enumでは型を作り直してすべての列に当て直すことになる。 +## MySQL + +MySQLでは両側をサーバ自身の描き方で読む。全部の表とビューに`SHOW CREATE TABLE` / `SHOW CREATE VIEW`をかけ、`schema.sql`を読むのと同じローダーで読む。比較されるのは`schema.sql`の書き方ではなくMySQLが保持している定義なので、`INT`と`int(11)`、`0`と書いた既定値と`'0'`で保持されたもの、サーバが名前を付けたキー(`orders_ibfk_1`、`orders_chk_1`)は差分にならない。目標側の正準形は、`-db`のサーバ上に一時データベース(`sqlshape_scratch_<乱数>`)を作って`schema.sql`を適用し、読み返してから落として得る。マイグレーションの対象そのもののサーバが、そのバージョンと設定(`-- sqlshape: server`)で正規化するということである。`-from`(テキスト同士、サーバ無し)では`PATH`の`mysqld`を使う。 + +比較はオブジェクトごとに行う。 + +- 表: エンジン、文字集合、照合、行フォーマット、コメント、パーティショニング(`RANGE`、`LIST`、`RANGE COLUMNS`、`LIST COLUMNS`、`HASH`、`KEY`、`LINEAR`、`ALGORITHM`、`HASH` / `KEY`によるサブパーティション、各パーティションの境界・コメント・明示の`SUBPARTITION`名。ローダーが構造にしない形——パーティションやサブパーティションの`TABLESPACE`や`MAX_ROWS`オプション——は見えない差分ではなく問題として報告する) +- 列: 型、サーバが綴った定義全体、位置(MySQLは列を並べ替えられるので、順序の違いは差分であり、計画は`MODIFY COLUMN ... AFTER`で直す) +- キー、外部キー、CHECK制約 +- ビュー +- トリガとストアドプロシージャ・関数: `SHOW CREATE TRIGGER` / `SHOW CREATE PROCEDURE` / `SHOW CREATE FUNCTION`で読み戻し、DEFINERを落とした定義テキストで比較する +- イベント: スケジュール、`STARTS` / `ENDS`、`ON COMPLETION`、状態、コメント、本体を`SHOW CREATE EVENT`で読み戻して比較する。`schema.sql`がサーバに任せた時刻——省いた`STARTS`、式で書いた`STARTS`(`CURRENT_TIMESTAMP + INTERVAL 1 DAY`)、式の`AT`——はイベント作成時に埋まる(`SHOW CREATE EVENT`は作成時刻のリテラルとして読み戻す、測定済み)ので比較しない。リテラルで書いた時刻は書いたとおりに比較する + +比較しないのは、MySQLのローダーがまだ知らないseed行。`ON COMPLETION PRESERVE`の無い一回限りのイベント(`AT ...`)は実行後にサーバが消すので、`verify-schema`はそれ以降「無い」と報告する。これはイベント自身の定義であって、ドリフトではない。既存の表の`AUTO_INCREMENT=`カウンタはデータであってスキーマではないので、これも比較しない。新規の表が自分で宣言した`AUTO_INCREMENT=`はスキーマの決定であり、作成時にそのまま届く。 + +計画はMySQL自身の定義を使う。 + +| 変更 | DDL | +|---|---| +| 新しい表 | 正準の`CREATE TABLE` | +| 変わった列 | 目標の定義による`ALTER TABLE ... MODIFY COLUMN` | +| 変わったキー・外部キー・CHECK | `DROP`と`ADD` | +| 変わったビュー | `CREATE OR REPLACE VIEW` | +| パーティション化される表、種類やキーが変わる表 | `ALTER TABLE ... PARTITION BY ...`(行の配り直しはサーバがやる。どのパーティションにも入らない行があればサーバのエラー1526) | +| パーティション化をやめる表 | `ALTER TABLE ... REMOVE PARTITIONING` | +| `RANGE`の末尾に足すパーティション | `ADD PARTITION` | +| 消える`RANGE`パーティション | `DROP PARTITION`。行を持っていくので`-- @migrate drop partition orders.p0`の宣言が要る | +| `RANGE`の境界の移動、`MAXVALUE`の前への挿入 | `REORGANIZE PARTITION ... INTO (...)`(行の移動はサーバがやる) | +| `LIST`パーティションの追加・消滅・値リストの変更 | `ADD PARTITION`、同じ宣言の下での`DROP PARTITION`、変わった値リストは全部まとめて1つの`REORGANIZE PARTITION ... INTO`(値が残る2つのパーティションの間を動くとき、文の間で行が浮かない) | +| `HASH` / `KEY`のパーティション数 | `ADD PARTITION PARTITIONS n` / `COALESCE PARTITION n` | +| `LINEAR`、`KEY`の列や`ALGORITHM`、サブパーティションの変更 | `ALTER TABLE ... PARTITION BY ...`の全文(行の配り直しはサーバがやる。行は失わない) | +| パーティションの`COMMENT` | 新しいコメントでの`REORGANIZE PARTITION ... INTO`(行は動かない、測定済み) | +| 変わった、または消えるトリガ・プロシージャ・関数 | `DROP`してから`CREATE`(MySQLには`CREATE OR REPLACE TRIGGER`が無い) | +| 変わった、または消えるイベント | `DROP EVENT`してから`CREATE EVENT`。目標のテキストそのまま(`STARTS`を省いていれば、新しいイベントはマイグレーションを実行した時刻から始まる) | + +順序は、マイグレーション自身の手順が互いにつまずかないように決める。 + +1. トリガの`DROP`は表のDROPより前(表ごと消えるトリガは`DROP TABLE`が黙って持っていくので出さない) +2. 消える表は、それを参照する外部キーを先に落とす +3. ルーチンの`CREATE`はビューより前、かつ表より前(ビューが関数を呼ぶことがある) +4. トリガの`CREATE`はbackfillの後。新しく足したトリガがマイグレーション自身の書き込みで発火しないためである + +`-- @migrate`の宣言は同じだが2点違う。MySQLではENUMは列の型なので、`enum`は列を名指す(`-- @migrate enum orders.status: drop 'canceled' using 'cancelled'`)。計画は型を狭める前に行を更新する。パーティションは表ではないので、落とす宣言は`-- @migrate drop partition orders.p0`と書く。 + +表の`DEFAULT CHARSET` / `COLLATE`の変更は、自分の照合を持たない文字列列の全部に`MODIFY COLUMN`も出す。表オプションだけではそれらの列は旧エンコーディングのまま残り(測定済み)、`apply`の後の計画が空にならない。 + +`apply`はDDLを1文ずつ実行する。MySQLのDDLは暗黙にコミットされるのでスクリプトはトランザクションにならず、`-no-transaction`は効かない。ある文が失敗したら、`apply`はどの文かと、その前の何文が適用済みかを言う。その状態から`sqlshape diff`をかければ残りが出る。 + +## 計画がどう検証されているか + +`diff`が書くDDLは、`apply`が実行するのと同じやり方で本物のサーバに判定させている。手で選んだ例ではなく生成器で(`check/postgres/migrate`と`check/mysql/migrate`の`TestMigrateProbe`)。計画器の語彙からスキーマを乱数で作り、変異を1〜5個重ねて目的のスキーマにし(変異は自分の`-- @migrate`宣言も書く)、全部の表に3行ずつ入れて、元のスキーマを持つサーバで計画を実行する。合格の条件は三つ。サーバが何も拒まない、実行後に読み返した正準形が目的のスキーマと一致する(PostgreSQLは列順を除く)、そこから再び計画すると空になる。落ちた組は最小化し、直したものはサーバのエラーとともに回帰テストとして固定する(`probe_findings_test.go`)。乱数の組に加えて、各変異を単独で適用した組を毎回1つずつ作るので、どの変更の種類も引きに依存しない。PostgreSQLのprobeは17と18(`TestMigrateProbe18`)の両方に対して、それぞれのバージョンの語彙で回す。 + +生成器が届くべき範囲は、勘ではなく定義している。計画の入力はdiffの出力そのものなので、diffが報告し得る変更の全種(表の追加、列の型の変更、制約がDEFERRABLEになる、…)をdiff自身の比較関数から列挙し、ゲート(固定seedの200組)は、その全種が「どれかの組で現れる」か「理由付きで到達不能に挙げてある」かのどちらかでなければ落ちる。理由として認めるのは二つだけ。計画器がその変更にDDLを書かず注記か問題として返すもの(PostgreSQLの`INHERITS`、`OF type`、domainの基底型、rangeのサブタイプ、行を持つ表のパーティションキー)と、構造的に現れないもの(PostgreSQL 17のサーバに対するPostgreSQL 18の構文、pg_dumpがその形で描かない変更)。組み合わせと順序は乱数の組に任せる。PostgreSQLは103種のうち17で94種、18で98種、MySQLは48種全部に届いていて、未到達はどれも計画が問題として止めるものか、現れ得ないものである。見つかった計画器の穴の大半は本物のサーバが拒む順序の問題で、全部が回帰テストになっている。 + +届かないもの。生成器のモデルの外にあるスキーマの形(レガシーな綴り、拡張の型、巨大な表)と、データに依存する失敗(backfillの値、ロック時間)。これらは利用者の`schema.sql`とともにやって来るもので、`apply`が実行前に行う終点の確認が受け止める。 + ## 必要な環境 +PostgreSQL: + - `pg_dump`。`PATH`にあるか、`$SQLSHAPE_PG_DUMP`で指定する。メジャーバージョンは対象データベース以上であること。 -- 比較のために、`sqlshape`はスキーマが宣言した版(`-- sqlshape: postgres 17`)の専用PostgreSQLを初回にダウンロードして`~/.cache/sqlshape`(`$SQLSHAPE_PG_CACHE`)にキャッシュし、そこで`schema.sql`を実行する。接続先のデータベースが宣言と違うメジャー版で動いているときは、その旨をstderrに出して続行する。DDLは宣言した版の規則で判定される。初回はダウンロードに数秒かかり、以後は0.25秒程度で起動する。利用者のデータベースはこの用途には使わない。 +- 比較のために、`sqlshape`はスキーマが宣言したバージョン(`-- sqlshape: postgres 17`)の専用PostgreSQLを初回にダウンロードして`~/.cache/sqlshape`(`$SQLSHAPE_PG_CACHE`)にキャッシュし、そこで`schema.sql`を実行する。接続先のデータベースが宣言と違うメジャーバージョンで動いているときは、その旨をstderrに出して続行する。DDLは宣言したバージョンの規則で判定される。初回はダウンロードに数秒かかり、以後は0.25秒程度で起動する。利用者のデータベースはこの用途には使わない。 + +MySQL: + +- `-db`では、接続ユーザーに`CREATE DATABASE`と`DROP DATABASE`の権限が要る(一時データベースのため)。サーバの`lower_case_table_names`はスキーマが宣言した値(宣言が無ければ0)でなければならず、違えばコマンドは止まる。サーバがスキーマの宣言と違うバージョンのMySQLで動いているときは、その旨を出して続行する。 +- `-from`(テキスト同士)では、`PATH`の`mysqld`(`nix-shell -p mysql84`、ディストリビューションのパッケージ、サーバtarballの`bin/`)をスキーマの宣言した設定で起こす。 + +共通: + - `-schema PATH`で`schema.sql`、または`*.sql`を名前順に適用する`schema/`ディレクトリを指定できる。既定は作業ディレクトリから上に辿って最初に見つかるもの。 終了コードは、0が差分なし、1が何かを見つけた(差分、ドリフト、拒否されたapply)、2が使い方か環境のエラー。 diff --git a/docs/migrations.md b/docs/migrations.md index 6f6896b..079fe50 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -4,7 +4,9 @@ `schema.sql` is the only definition of the database. There are no migration files to write: the `sqlshape` binary compares the live database with `schema.sql` and derives the DDL, checks -that the DDL really leads to `schema.sql`, and runs it. +that the DDL really leads to `schema.sql`, and runs it. The commands work on PostgreSQL and MySQL; +the schema's declaration selects the database, and the [MySQL](#mysql) section below has what is +MySQL's about them. ``` $ sqlshape diff -db "$DSN" > up.sql # DDL from the database's state to schema.sql @@ -15,7 +17,7 @@ $ sqlshape verify-schema -db "$DSN" # drift: where a database differs fr ## What is compared -Both sides are read through `pg_dump`, so what is compared is what PostgreSQL itself stores, +On PostgreSQL both sides are read through `pg_dump`, so what is compared is what PostgreSQL itself stores, not the spelling in `schema.sql`: `'x'` and `'x'::text`, or `IN (...)` and `= ANY (ARRAY[...])`, are not differences. `-- sqlshape:` directives are not database state and are not compared. @@ -63,8 +65,23 @@ views over those tables, drop the old type; an array column of the enum is repor problem. A `backfill` is type-checked against the target schema and emitted as an `UPDATE` after the column exists. -Changes to a domain's base type, a range's subtype, `INHERITS`, partitioning and `OF type` are -printed as `-- ` notes for the operator rather than as DDL. +Partitions are schema like everything else. A new partitioned table is created with its +`PARTITION BY` and its partitions attached once they exist; a partition added to a table is a +`CREATE TABLE ... PARTITION OF ... FOR VALUES ...`; a partition that goes is a `DROP TABLE` and, +since it takes its rows, needs a `-- @migrate drop` of that table; a table becoming or ceasing +to be a partition is `ATTACH PARTITION` / `DETACH PARTITION`, a changed bound a detach and an +attach (every detach in the plan runs before any attach, so a moving bound never overlaps a +neighbour's), and the plan never alters a partition's own columns, which follow the parent's. +A table that holds rows can be partitioned after the fact, unpartitioned, or moved to another +key or strategy: the plan renames the table, creates the declared shape fresh with its +partitions, inserts every row through the parent so PostgreSQL's router places it, carries the +comments, triggers, policies, rules, inbound foreign keys and sequences over (a bigserial keeps +its sequence, an identity column continues from the highest value), and drops the old table. +Rows no partition takes are the server's error, so a `schema.sql` that does not cover the data +stops `apply` rather than losing anything. + +Changes to a domain's base type, a range's subtype, `INHERITS` and `OF type` are printed as +`-- ` notes for the operator rather than as DDL. A column's `ALTER COLUMN ... TYPE` gets one of these notes too when the new type narrows a `numeric`'s precision or scale, a `varchar(n)` / `char(n)` length, a `time` / `timestamp` family's @@ -93,7 +110,7 @@ else refuses. Then the DDL runs in one transaction. migration applied by hand, an environment that fell behind. Exit code 1 when there is a difference, 0 when the database matches. -## Seeded tables +## Seeded tables (PostgreSQL) A table whose rows are written in `schema.sql` with an ordinary `INSERT ... VALUES` is a seeded table; its rows are part of the schema. @@ -135,8 +152,123 @@ column referencing it, is diffed against them like enum labels recommended home for a value set: adding, relabelling, reordering and retiring a value are each a one-row change and a `MERGE`, where an enum needs the type recreated under every column. +## MySQL + +On MySQL both sides are read as the server's own rendering: `SHOW CREATE TABLE` and `SHOW CREATE +VIEW` for every table and view, parsed by the loader that reads `schema.sql`. What is compared is +therefore what MySQL stores, not the spelling of `schema.sql`: `INT` and `int(11)`, a default written +`0` and stored `'0'`, a key named by the server (`orders_ibfk_1`, `orders_chk_1`) are not +differences. The target's canonical form comes from applying `schema.sql` to a scratch database +(`sqlshape_scratch_`) on the `-db` server, dropped when done, so it is normalized by the +very server the migration targets, version and settings (`-- sqlshape: server`) included; with +`-from` (two texts, no server) it comes from a `mysqld` on `PATH`. + +Compared, object by object: + +- tables: engine, charset, collation, row format, comment, and partitioning (`RANGE`, `LIST`, + `RANGE COLUMNS`, `LIST COLUMNS`, `HASH`, `KEY`, `LINEAR`, `ALGORITHM`, subpartitioning by + `HASH` / `KEY`, each partition's bound, comment and explicit `SUBPARTITION` names; a form the + loader does not model, such as a `TABLESPACE` or `MAX_ROWS` option on a partition or + subpartition, is a problem rather than a difference it cannot see); +- columns: type, the whole definition as the server spells it, and their position (MySQL can + reorder columns, so an order difference is a change the plan settles with + `MODIFY COLUMN ... AFTER`); +- keys, foreign keys, check constraints; +- views; +- triggers, stored procedures and functions: by their definition text, read back from + `SHOW CREATE TRIGGER` / `SHOW CREATE PROCEDURE` / `SHOW CREATE FUNCTION` with the `DEFINER` + dropped; +- events: schedule, `STARTS` / `ENDS`, `ON COMPLETION`, status, comment and body, read back + from `SHOW CREATE EVENT`. A time `schema.sql` leaves to the server -- a `STARTS` it omits or + writes as an expression (`CURRENT_TIMESTAMP + INTERVAL 1 DAY`), an `AT` expression -- is + filled in when the event is created (`SHOW CREATE EVENT` reads it back as the literal time + of creation, measured), so it is not compared; a literal time is compared as written. + +Not compared: seeded rows, which the MySQL loader does not know yet. A one-time event +(`AT ...`) without `ON COMPLETION PRESERVE` is dropped by the server once it has run, so +`verify-schema` reports it missing from then on: that is the event's own definition, not +drift. An existing table's `AUTO_INCREMENT=` counter is data, not schema, and is never +compared either; a brand new table's own declared `AUTO_INCREMENT=` is a schema decision +and does reach the table when it is created. + +The plan uses MySQL's own definitions: + +| change | DDL | +|---|---| +| a new table | the canonical `CREATE TABLE` | +| a changed column | `ALTER TABLE ... MODIFY COLUMN` with the target's definition | +| a changed key, foreign key or check | a `DROP` and an `ADD` | +| a changed view | `CREATE OR REPLACE VIEW` | +| a table gaining partitioning, or a different kind or key | `ALTER TABLE ... PARTITION BY ...` (the server redistributes the rows; a row no partition takes is its error 1526) | +| a table losing partitioning | `ALTER TABLE ... REMOVE PARTITIONING` | +| a `RANGE` partition added at the end | `ADD PARTITION` | +| a `RANGE` partition gone | `DROP PARTITION`, which takes its rows and so needs `-- @migrate drop partition orders.p0` | +| a `RANGE` bound moved, or a partition inserted before `MAXVALUE` | `REORGANIZE PARTITION ... INTO (...)` (the server moves the rows) | +| a `LIST` partition added, gone, or its value list changed | `ADD PARTITION`, `DROP PARTITION` under the same declaration, and every changed value list in one `REORGANIZE PARTITION ... INTO` (a value moving between two kept partitions never floats between statements) | +| a `HASH` or `KEY` table's partition count | `ADD PARTITION PARTITIONS n` / `COALESCE PARTITION n` | +| `LINEAR`, `KEY`'s columns or `ALGORITHM`, or the subpartitioning changed | `ALTER TABLE ... PARTITION BY ...`, the whole clause (the server redistributes the rows; none are lost) | +| a partition's `COMMENT` | `REORGANIZE PARTITION ... INTO` with the new comment (no rows move, measured) | +| a changed or removed trigger, procedure or function | a `DROP` and a `CREATE` (MySQL has no `CREATE OR REPLACE TRIGGER`) | +| a changed or removed event | a `DROP EVENT` and a `CREATE EVENT`, the target's own text (a `STARTS` it omits starts the new event when the migration runs) | + +The order keeps the migration's own steps from tripping over each other: + +1. a trigger's `DROP` comes before the table drops (a trigger going with a table that is itself + dropped is not listed: `DROP TABLE` takes it silently); +2. a table that goes has the foreign keys referencing it dropped first; +3. a routine's `CREATE` comes before the views (a view may call a function) and before the tables; +4. a trigger's `CREATE` comes after the backfills, so a newly added trigger does not fire on the + migration's own writes. + +The `-- @migrate` declarations are the same, with two differences: an ENUM is a column type on +MySQL, so `enum` names the column (`-- @migrate enum orders.status: drop 'canceled' using +'cancelled'`), and the plan updates the rows before it narrows the type; and a partition is not +a table, so dropping one is declared as `-- @migrate drop partition orders.p0`. + +A table's `DEFAULT CHARSET` / `COLLATE` change also re-issues `MODIFY COLUMN` for every string +column without a collation of its own: the table option alone leaves such columns in the old +encoding (measured), and the plan after `apply` would never be empty. + +`apply` runs the DDL statement by statement: MySQL's DDL commits implicitly, so a script is not a +transaction and `-no-transaction` has no effect. When a statement fails, `apply` says which one +and how many before it are applied; `sqlshape diff` from that state gives what remains. + +## How the plan is tested + +The DDL `diff` writes is judged against a real server, the way `apply` would run it, by a +generator rather than by hand-picked cases (`check/postgres/migrate` and `check/mysql/migrate`, +`TestMigrateProbe`). From the planner's vocabulary it draws a schema, mutates it one to five +times into a target (each mutation writes its own `-- @migrate` declaration), fills every table +with three rows, and runs the plan on a server holding the source. A pair passes when the server +refuses nothing, what it reads back afterwards is the target's canonical form (column order +aside on PostgreSQL), and a second plan from there is empty. A failing pair is minimized and the +fix is pinned as a regression test with the server's error (`probe_findings_test.go`). Besides +the random pairs, every mutation is applied alone once per run, so no kind of change depends on +the draw. PostgreSQL's probe runs against 17 and against 18 (`TestMigrateProbe18`), each with +that version's own vocabulary. + +What the generator has to reach is defined, not guessed: a plan's whole input is the diff, so +every kind of change the diff can report (a table added, a column's type changed, a constraint +turning deferrable, ...) is enumerated from the diff's own comparison functions, and the gate +(200 pairs of one fixed seed) fails unless each of them is produced by some pair or listed as +unreachable with a reason. Only two reasons are accepted: the planner writes no DDL for that +change and reports it instead (PostgreSQL's `INHERITS`, `OF type`, a domain's base type, a +range's subtype, and the partition key of a table holding rows), or the change cannot appear +(PostgreSQL 18 syntax against a PostgreSQL 17 server, or a change pg_dump never renders as +such). Combinations and orderings are left to the random pairs. PostgreSQL reaches 94 of 103 +kinds on 17 and 98 on 18, MySQL all 48; every unreached kind is a problem the plan stops on or +a change that cannot appear. The classes of planner bugs the probe found, most of them orderings +a real server refuses, are the regression tests. + +What this does not reach: schema shapes outside the generator's model (legacy spellings, +extension types, very large tables) and failures that depend on the data (a backfill's values, +lock time). Those arrive with your `schema.sql`, and `apply`'s end check before it runs anything +is what catches them. + ## Requirements +PostgreSQL: + - `pg_dump` on `PATH` or named by `$SQLSHAPE_PG_DUMP`; its major version must be at least the database's. - The comparisons run `schema.sql` on a private PostgreSQL of the major version the schema @@ -145,6 +277,18 @@ a one-row change and a `MERGE`, where an enum needs the type recreated under eve afterwards it starts in a quarter of a second. Your database is never used for this. When the database runs another major version than the schema declares, the commands say so on stderr and go on: the DDL is judged by the declared version's rules. + +MySQL: + +- With `-db`, the connection's user can `CREATE DATABASE` and `DROP DATABASE` (for the scratch + database). The server's `lower_case_table_names` must be the one the schema declares (0 when + it declares none); the commands stop otherwise. When the server runs another MySQL version than + the schema declares, the commands say so and go on. +- With `-from` (two schema texts), a `mysqld` on `PATH` (`nix-shell -p mysql84`, a distribution + package, a server tarball's `bin/`), started with the schema's declared settings. + +Both: + - `-schema PATH` names `schema.sql`, or a `schema/` directory whose `*.sql` files apply in name order; the default is the nearest one from the working directory up. diff --git a/docs/mysql.ja.md b/docs/mysql.ja.md new file mode 100644 index 0000000..4e614e8 --- /dev/null +++ b/docs/mysql.ja.md @@ -0,0 +1,470 @@ +# MySQL + +[English](mysql.md) + +sqlshapeのうちMySQLに属するものを1枚にまとめる。スキーマが名乗るバージョンとサーバ設定、検査器が何を埋め込みどう検証しているか、規則が使う型と制約名、`database/sql`の上のランタイム、マイグレーションコマンド。規則そのものは[checks.ja.md](checks.ja.md)にあり、どのデータベースでも同じである。規則に出てくる名前・番号・型が、`mysql`を名乗るスキーマではどこから来るのか——それがこのページの中身である。 + +## スキーマがバージョンとサーバ設定を名乗る + +```sql +-- sqlshape: mysql 8.4 +-- sqlshape: server sql_mode = 'ANSI,STRICT_ALL_TABLES' +-- sqlshape: server lower_case_table_names = 1 +CREATE TABLE ... +``` + +`schema.sql`は、どのバージョンのMySQL向けのスキーマかを宣言する。対応しているのは8.4。宣言すると、MySQL自身の文法がスキーマとすべての文を解析し、MySQLの規則で式に型が付く。値の違う宣言が2つある場合や、`postgres`を同時に名乗る場合はスキーマのエラーになる。 + +文の判定を変えるサーバ変数は、バージョンと並べて1行に1つ宣言する。検査器の前提と本番の接続を同じ設定に揃えるための行である([checks.ja.md](checks.ja.md#スキーマはサーバの設定を名乗るserver))。MySQLで読むのは3つ: + +- `sql_mode`。カンマ区切りの名前(大文字小文字は問わない)、空文字列、組み合わせモードの`ANSI`と`TRADITIONAL`を受け付け、組み合わせはサーバと同じに展開する。判定を変えるモードは次のとおり。 + - 字句解析のビット(`ANSI_QUOTES`、`PIPES_AS_CONCAT`、`IGNORE_SPACE`、`NO_BACKSLASH_ESCAPES`、`HIGH_NOT_PRECEDENCE`、`REAL_AS_FLOAT`): パーサがテキストをどう読むか + - `ONLY_FULL_GROUP_BY`: グループ検査を行うかどうか + - 厳密モード(`STRICT_TRANS_TABLES`か`STRICT_ALL_TABLES`): 文字列関数がNULL可になるか、`NOT NULL`列への`NULL`が失敗モードになるか + - `NO_UNSIGNED_SUBTRACTION`: 符号なし同士の減算が符号付きになる + + 残りのモードは実行時にしか効かないので、宣言はそのまま受け付ける。 +- `lower_case_table_names`。0は表名とビュー名を大文字小文字で区別する(Linuxの既定)、1は小文字に変換して保持する、2は綴りを保ったまま区別せずに照合する。2は大文字小文字を区別しないファイルシステム(macOSやWindows)専用で、区別するファイルシステムでは`mysqld`が警告を出して0で起動するため、2を宣言したスキーマとはずれが生じる(そのずれは`mysql.Verify`が検出して返す)。 +- `max_sp_recursion_depth`。0〜255。0(既定)なら、自分自身を`CALL`するPROCEDUREは呼び出しのたびに1456になる。0より大きければ、呼び出しがどの深さまで達するかは静的に決まらないので、検査器は何も予測しない。 + +宣言が無ければ、Linuxで初期化したままの8.4を仮定する。すなわち既定の`sql_mode`(`ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION`)、`lower_case_table_names = 0`、`max_sp_recursion_depth = 0`である。 + +## 検査器が埋め込んでいるもの + +パーサと字句解析器はMySQL 8.4自身のもので、サーバのソースから切り出して(文法はアクションを剥がし、字句解析器はそのまま)WebAssemblyにしてある。関数の表も同じソースから読む。アナライザーはその構文木の上に建てたpure Goの実装で、検査時にMySQLへ接続することはない。MySQLのパーサを抱えているため、検査器のMySQL側(`check/mysql`)はGNU General Public License v2である。リンクされるのは`sqlshape`バイナリだけで、利用者のプログラムには入らない。 + +判定は動いている`mysqld`と照合してある。型を付けた5,033文(組み込み関数の全部について引数の組み合わせごとの結果型)、エラーになる65文、`ONLY_FULL_GROUP_BY`検査の376文、既定でない`sql_mode`の下の22文と6つの書き込みが8.4と一致する。 + +MySQL自身のテストコーパスも並走させてある。`mysql-test/t`の1,281ファイル(約137,000文)を`mysqld`とアナライザーの両方に流し、SELECTの列は名前・型ファミリー・nullabilityで、エラーは番号で突き合わせる。両者が一致しないと分かっている文は2,525件。1件ずつベースライン(`check/mysql/internal/analyze/testdata/corpus_baseline.txt`)に固定してあり、新しい不一致が出るとビルドが落ちる。各行には不一致の種別が付いている: アナライザーが予測しないエラー、別の番号で予測するエラー、型の違う列、ローダーが扱わないスキーマ構文。このファイルが、MySQLアナライザーがまだサーバと同じには判定できないものの正直な一覧である。 + +## 規則が使うもの + +[checks.ja.md](checks.ja.md)の規則は、MySQLのスキーマにもそのまま適用される。変わるのは、判定を下すアナライザーがPostgreSQL用からMySQL用になることだけである。結果列・パラメータとGo型の対応、NULLの扱い、型の意味、失敗モード、`One`の証明、第2部の宣言のすべて(`visible where`、`pinned`、`via view`、`EXISTS`、`aggregate`、`transitions`、`never`、`paired`、`single`、`sensitive`、`context`)が同じに働く。診断にはMySQLのエラー番号とメッセージ文がそのまま載る(`Unknown column 'nope' in 'field list' (MySQL error 1054)`)。`{{.X}}`はワイヤ上では`?`になる。 + +### Go型の表 + +go-sql-driver/mysqlが`parseTime=true`で実際に返す型を、動いているサーバと照合して表にしてある。整数は`int64`(`BIGINT UNSIGNED`は`uint64`)、`DECIMAL`はその文字列、時刻型は`time.Time`か文字列、バイナリ文字列とJSONは`[]byte`で届く。比較と論理演算子の結果は`bigint(1)`で、`bool`で受けられる。`TINYINT(1)`も同じ。 + +| MySQL | Go | +|---|---| +| `TINYINT` / `SMALLINT` / `MEDIUMINT` / `INT` / `YEAR` | `int64` / `int32` / `int`(`UNSIGNED`なら`uint64` / `uint32` / `uint`も)。`TINYINT(1)`は`bool`も | +| `BIGINT` | `int64` / `int`(`UNSIGNED`なら`uint64` / `int64`)。`bigint(1)`は`bool`も | +| `DECIMAL` | `string` | +| `FLOAT` / `DOUBLE` | `float32` / `float64` / `float64` | +| `BIT` | `[]byte` | +| `CHAR` / `VARCHAR` / `TEXT` / `ENUM` / `SET` | `string` / `[]byte` | +| `BINARY` / `VARBINARY` / `BLOB` | `[]byte` | +| `JSON` | `[]byte` / `string` | +| `DATE` / `DATETIME` / `TIMESTAMP` | `time.Time` / `string` | +| `TIME` | `string` | +| `ENUM`列、キーの同一性 | Goのnamed type([型に意味を持たせる](checks.ja.md#型に意味を持たせる))。`ENUM`は`CHECK (col IN (...))`と同じ値集合 | + +MySQLには`// sqlshape: type`の束縛は無い。束縛先となる名前付きの型がMySQLに無いためである。 + +宣言された列の型と、結果集合で実際に届く型が違うケースがいくつかある(いずれも実測)。 + +- ウィンドウ関数の整数(`MIN(id) OVER ()`、`FIRST_VALUE`、`NTH_VALUE`、`LAG`、`LEAD`など)は、ウィンドウの一時表を経由してクライアントに届くため型が広がる。`INT`と`BIGINT`は`BIGINT`に、`TINYINT` / `SMALLINT` / `MEDIUMINT`は`INT`になり、符号は保たれ、`YEAR`は`INT UNSIGNED`になる。ふつうの集約は列の型を保つ(`SMALLINT`の`MIN(small)`は`SMALLINT`)。`ROLLUP`のグループ列も型を保つが、実行計画がたまたまグループ化を一時表に落とした場合だけは広がる——この場合は検査器は予測しない +- `ROLLUP`の下では、グループ列を読む結果列がnullableになる(超集約行でNULLになるため)。選択リストの定数はならない +- `DATE'...'` / `TIME'...'` / `TIMESTAMP'...'`リテラルはその型を持ち(小数桁は書かれた桁数)、NULLにならない。サーバがちょうどその型として読めないもの——時刻部のある`DATE`、時刻部の無い`TIMESTAMP`、`NO_ZERO_IN_DATE`下のゼロの月、`-14:00`〜`+14:00`の外の時差——は文のエラー1525になる。`<=>`も被演算子にかかわらずNULLにならない +- `USER()`、`CURRENT_USER()`、`DATABASE()`、`SCHEMA()`、`VERSION()`、`CURRENT_ROLE()`はバイナリ文字列ではなく文字列(utf8mb3)である + +### 制約名と失敗モード + +失敗モードの名前はMySQLが制約に付ける名前そのもので、番号はMySQLのエラー番号である。 + +| 制約 | 名前 | エラー | +|---|---|---| +| 主キー | `PRIMARY` | 1062 | +| `UNIQUE`キー | キーの名前 | 1062(サーバが自分で番号を振るキーと、NULLが避けるキーは違反しない。プレフィックスキー`UNIQUE (c(10))`と式キー`UNIQUE ((n * 2))`は読む列を通して違反する) | +| 外部キー | `CONSTRAINT`名。無ければ`
_ibfk_` | 子側は1452、親側は1451(`ON DELETE` / `ON UPDATE CASCADE`を追う) | +| `CHECK` | `CONSTRAINT`名。無ければ`
_chk_` | 3819 | +| `NOT NULL` | `
.` | 1048 | +| ビューの`WITH CHECK OPTION` | ビュー自身の名前(MySQLのエラーメッセージはビューを名指しする。PostgreSQLの44000は何も名指ししない) | 1369 | +| ビュー経由のINSERTが代入しない、既定値の無い基底列 | ビュー自身の名前(サーバのメッセージは列でなくビューを名指しする。ビューがその列を見せているかどうかは関係ない) | 1423 | + +どの違反がありうるかは、文の形によっても変わる: + +- `INSERT IGNORE`は何にも違反しない +- `ON DUPLICATE KEY UPDATE`はINSERTのキー違反を吸収する +- `REPLACE`はキーには違反せず、参照している外部キーには違反しうる(1451) +- 厳密モードでなければ、`NOT NULL`列への`NULL`を拒むのは1行の`INSERT`と`REPLACE`(その`ON DUPLICATE KEY UPDATE`を含む)だけ。複数行、`INSERT ... SELECT`、`UPDATE`は型の暗黙の既定値を警告付きで格納するので、これらには1048を挙げない +- `UPDATE IGNORE`は、キーや`NOT NULL`の違反と同じように`WITH CHECK OPTION`ビューの1369も吸収する(測定済み)。トリガ自身の`SIGNAL`はどの`IGNORE`も吸収しないのと対照的である + +実行時のエラーは`mysql.Violates(err, key)`が同じ名前で判定する。`Run` / `Exec`の外で走らせた文のエラーは、`mysql.WrapError(err)`で先に同じ包み方にしておく。 + +列が決して格納できないリテラルは、失敗モードではなく文自身のエラーとして報告する。厳密モード(既定)では毎回必ず失敗するからである。検査器は、`INSERT ... VALUES`、`UPDATE ... SET`、`ON DUPLICATE KEY UPDATE`、`REPLACE`で列に直接書かれたリテラルに、サーバの`Field::store`の規則をそのまま適用する(いずれも実測)。 + +| 格納先 | 拒まれるもの | エラー | +|---|---|---| +| 整数列 | 範囲外の値(実数・小数は丸めた後で判定) | 1264 | +| | 数を含まない文字列(`'abc'`、`''`) | 1366 | +| | 数の後ろに他の文字が続く文字列(`'12a'`) | 1265 | +| `YEAR` | 0、1〜99、1901〜2155以外 | 1264 | +| `DECIMAL(M,D)` | D桁に丸めた後で整数部がM-D桁を超えるもの。`UNSIGNED`への負数 | 1264 | +| `FLOAT` | 単精度の範囲を超える値 | 1264 | +| `DATE` / `DATETIME` / `TIMESTAMP` | `str_to_datetime`が読めない、範囲外、`sql_mode`が禁じる月・日・日付のゼロ(`NO_ZERO_IN_DATE`、`NO_ZERO_DATE`)、その月に無い日(`ALLOW_INVALID_DATES`でなければ)の文字列。`number_to_datetime`が拒む数。1970-01-01 00:00:01〜2038-01-19 03:14:07 UTCの外の`TIMESTAMP`(セッションのタイムゾーンで結論が変わらない範囲だけ判定) | 1292 | +| `TIME` | `str_to_time`が読めない文字列、60以上の分・秒、838時間超 | 1292 | +| `CHAR(n)` / `VARCHAR(n)` / `BINARY(n)` / `VARBINARY(n)` | 末尾の空白を除いてn文字(バイナリならnバイト)を超える文字列 | 1406 | +| `ENUM` | どのメンバーの名前でもなく(照合順序どおりに比較)メンバーの番号でもない文字列。1〜メンバー数の外の数 | 1265 | +| `SET` | 集合に無いメンバーを含むリスト | 1265 | +| 空間型 | 数と文字列。どちらも正しい形のジオメトリを持てない | 1416 | + +読まないもの: + +- `INSERT IGNORE` / `UPDATE IGNORE`と、`sql_mode`が厳密でないスキーマ(値は警告付きで調整して格納される。`STRICT_TRANS_TABLES`だけの下では非トランザクショナルな表は最初の行だけ厳密) +- ルーチンやトリガの本体の中の値 +- 16進・ビットのリテラル、`JSON`と`BIT`の列 +- サーバが畳み込む式(`100 + 28`) + +### 空間型 + +ジオメトリの値は7つの型(`POINT`〜`GEOMETRYCOLLECTION`)のどれかを取り、列はそのどれか、または`GEOMETRY`(何でも受ける)で宣言される。検査器は、コンストラクタ(`POINT(1, 1)`)、型付きのリーダ(`ST_PointFromText`)、定数のテキストやWKBから値の型が決まるときにそれを把握し、判定に使う(いずれも実測)。 + +| 文 | エラー | +|---|---| +| サーバのWKTリーダが拒む定数を渡した`ST_GeomFromText`(型付きの変種も): 壊れたテキスト、1点の`LINESTRING`、4点未満か閉じていないポリゴンのリング、`(x y)`と`x y`を混ぜたか空の`MULTIPOINT`。拒まれる定数WKB(壊れている、末尾に余りのバイト)を渡した`ST_GeomFromWKB`。`ST_GeomFromWKB`にジオメトリ値を渡す | 3037 | +| 別の型の定数を渡した型付きリーダ(`ST_PointFromText('LINESTRING(...)')`。`GEOMCOLL`系は`MULTI*`も受ける) | 3516 | +| 0〜4294967295の外の定数SRID | 1690 | +| 引数1つの`LINESTRING(...)`。`POINT`定数で作ったリングが4点未満か閉じていない`POLYGON(...)` | 3037 | +| 別のジオメトリ型と分かる引数を渡した`LINESTRING` / `POLYGON` / `MULTI*`。ジオメトリを渡した算術・ビット演算子、数値関数、`BETWEEN`(比較は許される) | 1210 | +| 空間列に格納する値が列の型の内部形式でない: 数値や文字列、4バイトのSRID+リトルエンディアンの正しいWKBでない16進 / `UNHEX`定数、別のジオメトリ型の定数 | 1416。`sql_mode`にかかわらず、`IGNORE`でも | + +型付きの空間列に、別のジオメトリ型を持つnullableな式を格納する形(`LINESTRING`列の値を`POINT`列へ)は、NULLでない値のたびに失敗する。これは失敗モード`1416`で、`mysql.Violates(err, "1416")`が一致する。 + +読まないもの: 列が宣言するSRIDとの不一致(3643)、定数SRIDが実在の空間参照系を指すかどうか(3548)、関数自身の実行時の検査(縮退したリングへの`ST_Centroid`)、GeoJSON。 + +### 定数の算術 + +サーバが計算できない定数式は文自身の1690で、行を読む前に判定される(すべて実測。item_func.ccの計算どおり): + +| 式 | エラー | +|---|---| +| 厳密な結果が`BIGINT`(オペランドのどちらかが符号なしなら`BIGINT UNSIGNED`)に収まらない整数の`+` `-` `*` `DIV`(`9223372036854775807 * 2`、`CAST(1 AS UNSIGNED) - 2`、`-9223372036854775808 DIV -1`。文字列オペランドは演算子を`DOUBLE`に、小数オペランドは`DECIMAL`にする。どちらも判定しない)。`ABS(-9223372036854775808)`。整数の範囲を超える`ROUND(n, -k)` | 1690 `BIGINT [UNSIGNED] value is out of range` | +| 無限大になる`DOUBLE`の結果: `1e308 + 1e308`、`1e300 / 1e-300`、`EXP(710)`、`POW(2, 1024)`、`COT(0)`、`DEGREES(1e307)`。`FLT_MAX`を超える`CAST(x AS FLOAT)` | 1690 `DOUBLE value is out of range` | +| 関数や演算子が計算した`DOUBLE`を`BIGINT`の範囲外で`CAST(f AS SIGNED / UNSIGNED)`する(`CAST(POW(2, 63) AS SIGNED)`。リテラルは丸め込まれるだけ) | 1690。内側の式の名で | +| 定数nが1〜1024の外の`RANDOM_BYTES(n)` | 1690 `length value is out of range` | + +定数がどこに置かれているかで、文が失敗するのか行が失敗するのかが決まる。 + +- 文のエラーになる位置——オプティマイザが行を読む前に畳み込む場所: `WHERE`・`HAVING`・`ON`の条件の項(キーのある列と比較する`id = 9223372036854775807 + 1`、`IN (...)`、`LIKE`)、`FROM`の無い問い合わせの選択項目、派生表・CTEの選択項目、`INSERT ... VALUES`の値 +- 行ごとに実行される位置: `FROM`のある問い合わせの選択項目、`UPDATE`の`SET`、`ON DUPLICATE KEY UPDATE`の代入、索引の無い列との比較、`HAVING`での集約との比較。この場合、文は失敗モード`1690`を持ち(`mysql.Violates(err, "1690")`が一致する)、行の無い問い合わせは失敗しない +- サーバが評価しない場所は検査器も放っておく: `AND` / `OR`を決めた定数の後ろ(`1 = 0 AND x`)、定数条件の`IF` / `CASE` / `COALESCE` / `IFNULL`が取らない枝、定数の`IN`が一致した後ろのリスト、NULLになりえないxの`x IS NULL`、`GROUP BY` / `ORDER BY`の項目、`EXISTS`のサブクエリの選択リスト、`LIMIT 0` +- 読まないもの: トリガやルーチンの本体、16進 / ビットリテラルのオペランド、ユーザー変数、ウィンドウの`ORDER BY`、`DECIMAL`の桁溢れ(65桁) + +次の2つの形は、義務判定(x/obligation)から見ると書き込みを2つ持つ文である: + +| 文 | 記録する書き込み | 理由 | +|---|---|---| +| `INSERT ... ON DUPLICATE KEY UPDATE` | INSERTと、枝が代入する列へのUPDATE | 枝は既存行を動かす | +| `REPLACE` | INSERTとDELETE | キーが衝突すると既存行を先に削除する(その表の`AFTER DELETE`トリガが発火する、測定済み) | + +`WITH CHECK OPTION`を宣言したビュー経由の書き込みは、基底表の`require pinned(<列>)`を履行できる(`Discharge.Path`は`ByView`): + +- ビュー自身のWHEREがその列を等値で固定しているとき。`WITH CHECK OPTION`とだけ書けばCASCADEDなので、下位の全ビューのWHEREも数える(結合の向こうにあっても。測定済み)。`WITH LOCAL CHECK OPTION`はそのビュー自身で止まるが、自前のCHECK OPTIONを宣言した下位ビューはサーバが検査し続けるので数える(測定済み) +- 裏付けは上の1369そのもの。CHECK OPTIONを宣言していないビューはこの経路を持たない(そこを経由した書き込みは行をビューから静かに外すだけで、サーバは拒まない) +- `UPDATE`にも`INSERT` / `REPLACE`にも効く(`DELETE`はCHECK OPTIONを検査しない。実測)。下位のビューが自分のCHECK OPTIONを宣言していれば、書き込む先のビューに宣言が無くても検査され、1369は書き込む先のビューを名指しする(実測) + +失敗モードにしないもの: 長さ・範囲・`ENUM`値の切り詰め(1265 / 1406 / 1366 / 1264)。これらは型の側の性質(パラメータのGo型、リテラル自身の値集合)なので、型検査の側で捕まえる。 + +### 定数の変換 + +厳密モードの`INSERT` / `REPLACE` / `UPDATE` / `DELETE`(`IGNORE`の外)は、定数の変換が出す警告をエラー1292 `ER_TRUNCATED_WRONG_VALUE`に格上げする。`SELECT`・`SET`・`DO`・非厳密の書き込みでは、同じ式が警告つきで実行される(行があっても。すべてmysqldで実測): + +| 式 | 失敗する条件 | +|---|---| +| 定数の`CAST` / `CONVERT`を`DATE` / `DATETIME`へ | 値がセッションのゼロ日付フラグの下でdatetimeでない(`CAST('2004-10-0' AS DATE)`、`CAST(65 AS DATETIME)`) | +| ... `TIME`へ | `str_to_time`が拒む、または838時間を超える | +| ... `YEAR`へ | 文字列の先頭の整数の後ろに何かが続く(`'2020extra'`、`'2020.5'`)か、値が0〜99・1901〜2155の外。数字が全く無い文字列は警告なしの0 | +| ... `CHAR(n)`へ | 値の文字列形がnより長い(`CAST(1000 AS CHAR(3))`) | +| ... `SIGNED` / `UNSIGNED`へ | 文字列が整数そのものでない(`'abc'`、`'1.5'`、`'1e2'`)か、絶対値が`BIGINT UNSIGNED`を超える。範囲外の小数 | +| ... `DOUBLE` / `FLOAT` / `DECIMAL`へ | 文字列が数を持たない、後ろに余りがある、doubleの読みが溢れる(`'1e999'`) | +| 定数の`TIMESTAMP(x)` | xがdatetimeでない(時刻だけの文字列、モードが禁じる`TIMESTAMP('0000-00-00 10:00:00')`) | +| `+` `-` `*` `/`の文字列オペランド | 数そのものでない(`10E+0 + 'a'`。`''`は静かに0) | +| ビット演算子の文字列オペランド | 整数そのものでない(`1 >> ''`) | +| 数値列と定数文字列の比較 | 文字列が数を持たない(`WHERE i = '1invalid'`。`''`・`' 1'`・`'1e1'`・`'1.5'`は通る)。`BETWEEN`は行ごと | + +失敗の落ちる場所は上の1690と同じ規則に従う: + +- `INSERT ... VALUES`の値や条件の項そのものは文のエラー +- `UPDATE`の`SET`や`FROM`つきの選択項目は失敗モード`1292`(`mysql.Violates(err, "1292")`が一致する) +- ただし比較のオペランドにある定数だけは常に行ごとに実行されるので、`WHERE d = CAST('2004-10-0' AS DATE)`はエラーではなく違反になる + +文の種類にもモードにもよらず失敗する変換が2つある: + +- `DATE` / `DATETIME` / `TIMESTAMP`の値と、datetimeとして読めない定数文字列との比較。比較がどこにあっても——選択項目、`JOIN`の`ON`、`HAVING`、`ORDER BY` / `GROUP BY`の項目、死んだ枝でも——文の1525 `Incorrect DATETIME value`になる。厳密モードの書き込みの中では格納形の1292になる(`BETWEEN`と`IN`は先に変換しない。`TIME`と`YEAR`の列はこの規則の対象外) +- ゼロの月か日の上に時差を書いたdatetime文字列・リテラル。時差の変換で失敗する: 1292 `Truncated incorrect temporal value` + +`TIMESTAMP`列に格納する`DATE'...'` / `TIMESTAMP'...'`リテラルは、1970-01-01 00:00:01〜2038-01-19 03:14:07.999999 UTCに収まらなければならない。 + +- 時差が書いてあれば厳密に判定する(範囲はUTCで定義されているので、セッションのタイムゾーンが結論を動かすことはない)。時差が無ければ、セッションのタイムゾーンで結論が変わらない場合だけ判定する +- 小数秒は先に列の精度へ丸められ、繰り上がりも数える(`TIMESTAMP'1970-01-01 00:00:00.999999+00:00'`は`TIMESTAMP(0)`では00:00:01になり、格納される) +- `TIMESTAMP`列は`ALLOW_INVALID_DATES`を無視し、実在する月日の上の年0も範囲外として検査する。`DATETIME`はどちらも受け付ける + +読まないもの: + +- 関数の結果の格納(`STR_TO_DATE(...)`) +- 時間型キャストの値を列の格納検査へ運ぶこと +- `UPDATE`の`ORDER BY`の定数 +- 時間型の列とスカラサブクエリ定数の比較 +- 今のモードでは格納できない列の`DEFAULT` +- `TIME`列自身の行ごとの文字列変換 + +### 定数引数の関数 + +引数の値によって実行時に失敗するかどうかが決まる関数は、引数が定数のときに判定する。失敗の置き場所は上の定数算術と同じ規則に従う: 畳み込まれる位置なら文のエラー、行ごとに走る位置なら自分のエラー番号をキーにした違反、0行の問い合わせは失敗しない。 + +| 関数 | 失敗する条件 | エラー | +|---|---|---| +| `INET_ATON` | 値が「最大255の数字グループをドット3個以下で区切った形」でない(`'122.256'`、末尾ドット。`'1.2.3'`は通る) | 1411、`IGNORE`以外の厳密モードの書き込みのみ | +| `INET6_ATON` | 完全なドット4区切りIPv4でも正しいIPv6テキスト(`::`ギャップは1つ、グループは16進4桁まで)でもない | 1411、同じ条件 | +| `UNHEX` | 16進以外の文字を含む | 1411、同じ条件 | +| `STR_TO_DATE` | フォーマットで値が読めない(`extract_date_time`の指定子、en_USの月名・曜日名、`%V`/`%v`週と対応する`%X`/`%x`年)、または日付がセッションのゼロ日付フラグに反する(`NO_ZERO_DATE`下では日付結果の年・月・日いずれかの0も) | 1411。読めた値の後ろに空白以外が残ると1292(メッセージの型語はフォーマットが決める結果型) | +| `UUID_TO_BIN` | 値が「16進32桁」「8-4-4-4-12のダッシュ形」「ダッシュ形を波括弧で囲んだ形」のどれでもない | 1411、文の種類・モードを問わない | +| `BIN_TO_UUID` | 値がちょうど16バイトでない | 1411、文の種類・モードを問わない | +| `PERIOD_ADD` / `PERIOD_DIFF` | period引数が「月1〜12の正の`[YY]YYMM`」でない | 1210、文の種類・モードを問わない | + +行ごとの違反は`mysql.Violates(err, "1411")` / `"1210"`が照合する。 + +これとは別に、解決の時点で拒まれる系統がある。式がどこにあっても、行が有っても無くても拒まれる: + +- `NAME_CONST`: 両引数がリテラルであること(単項マイナス1段と`COLLATE`は可。畳み込みが要る`1+1`や`TRUE`は不可)。名前がNULLなら1382 +- `LIKE`の`ESCAPE`: 1文字以下の定数であること +- `NTILE`: 正の個数。`NTH_VALUE`: 正の整数位置 +- `MATCH ... AGAINST`: `MATCH`の列は1つのリレーションに収まること(selectの別名や2表跨ぎは1210 `to MATCH`)、`AGAINST`は定数であること(列を含むと1210 `to AGAINST`) + +### 関数呼び出しの引数とシステム変数の読み取り + +関数呼び出しの引数に別名が付いている形(`f(x AS a)`、ローダブル関数の構文)は、引数を解決するより前に、サーバと同じ順序で拒む: + +- ネイティブ関数は、まず引数の個数(1582、名前は書かれたまま)、次に別名が1583(名前は小文字化される。`SELECT ABS(3 AS three)`は`... native function 'abs'`) +- それ以外の名前——ストアド関数も、存在しない名前も同じ——は、関数を探すより前に1584 +- データディクショナリの内部関数(`INTERNAL_TABLE_ROWS`などサーバが内部と印を付けるもの)は、どちらの検査よりも前に、文が名前を書いた時点で3566 + +スコープを明示したシステム変数の読み取りは、変数自身のスコープと一致しなければならない: + +- GLOBAL専用変数の`@@session.x`(`@@local.x`も同じ)と、SESSION専用変数の`@@global.x`は文の1238。読み取りがどこにあっても——死んだ分岐でも、サブクエリでも、0行の`SELECT`でも——変わらない +- スコープ表はサーバソース(`sql/sys_vars.cc`)から生成していて、標準の変数を全部持つ +- プラグインやコンポーネントの変数(`@@x.y`を含む)と修飾なしの`@@x`は判定せず、未知の名前はサーバ自身の1193に任せる + +### トリガとストアドルーチン + +ローダーは`CREATE TRIGGER` / `CREATE PROCEDURE` / `CREATE FUNCTION`(`DEFINER`、`IF NOT EXISTS`、特性句込み)と`DROP` / `ALTER`(特性のみ)を、表と同じように読む。`DELIMITER`文は不要である。本体の中の`;`は1つの複合文の内側として読まれ、mysqlクライアント互換の`DELIMITER x`行が混ざっていてもそのまま読める。CREATE時にサーバ自身が拒むものは、未知の表と同じくスキーマのProblemになる: + +- 対象の表が無い(1146) +- 同名のトリガ・ルーチンが既にある(1359 / 1304)。`DROP`しようとしたトリガ・ルーチンが無い(1360 / 1305) +- `FOLLOWS` / `PRECEDES`が指す先のトリガが無い(3011) + +`DROP TABLE`はその表のトリガを道連れにし、`RENAME TABLE`はトリガを付け替える。 + +`CREATE EVENT`も同じように読む(2つのスケジュール形、`STARTS` / `ENDS`、`ON COMPLETION`、`ENABLE` / `DISABLE`、`COMMENT`)。`ALTER EVENT`は書いた句だけを置き換え(スケジュールを書けばスケジュール全体、`RENAME TO`なら名前)、`DROP EVENT`も適用する。プログラムから実行する文がイベントに触れることはないので、本体を読むのはスキーマ自身の検査のためである。サーバは`CREATE`時に本体を何も検査しない——存在しない表への`DELETE`も受け付け、実行のたびに失敗する(測定済み)。検査器はそれをスキーマのProblemとして報告し、本体の各文はルーチンと同じように義務の判定にかける。イベント本体の`RETURN`は1313。マイグレーションのコマンドはイベントも管理する([migrations.ja.md](migrations.ja.md#mysql))。 + +本体はスキーマごとに1回読む。PostgreSQLのPL/pgSQL関数本体の読み方と同じ位置づけである([checks.ja.md](checks.ja.md#トリガーが送出するエラーには名前を付ける))。`IF` / `CASE` / `LOOP` / `WHILE` / `REPEAT`、ラベル付きブロックと`LEAVE` / `ITERATE`、`RETURN`、`SET`、`SELECT ... INTO`、カーソル(`DECLARE` / `OPEN` / `FETCH` / `CLOSE`)、`CALL`、`SIGNAL` / `RESIGNAL`、`DECLARE ... HANDLER FOR`を歩く。名前はサーバと同じ規則で解決する: + +- `NEW.col` / `OLD.col`はトリガ表の列として型付けする。無い列は1054 +- INSERTトリガでの`OLD`、DELETEトリガでの`NEW`は1363 +- `OLD`への代入(イベントの種類より先に検査される。INSERTトリガでも同じ)と、BEFORE以外での`NEW`への代入は1362。トリガの外の`SET NEW.col` / `SET OLD.col`は1193(そこでの`NEW.col`の読み取りは、サーバが実行時にしか解決しない列参照として残る) +- NOT NULL列の`NEW.col`も、BEFOREトリガの中ではNULLになりうる。AFTERトリガでは宣言どおりで、サーバ自身のNOT NULL検査は両者の間で走る(測定済み) +- `DECLARE`した変数やルーチンの引数は、同名の列より優先して解決する + +本体の作成時にサーバ自身が拒むものは、ここでもエラーになる。 + +| 構文 | エラー | +|---|---| +| 対応ラベルの無い`LEAVE` / `ITERATE` | 1308 | +| FUNCTION以外での`RETURN` | 1313 | +| `RETURN`の無いFUNCTION | 1320 | +| 未宣言のカーソルや変数、`FETCH`の列数不一致 | 1324 / 1327 / 1328 | +| `SELECT ... INTO`の列数不一致 | 1222 | +| 結果集合を返すトリガ・関数(自身のINTO無しトップレベル`SELECT`、または`CALL`した先のPROCEDURE自身のもの) | 1415 | +| 本体内の`COMMIT` / `START TRANSACTION` / DDL文 | 1422 | +| トリガが自分の表に書く | 1442。タイミング×イベント×書き込みの18通り全部で(測定済み)。常に失敗するので、発火する文にではなくトリガの定義に報告する | +| 同じブロック内で同名の変数を2回`DECLARE`する | 1331 | +| 同じブロック内で同名の`DECLARE ... CONDITION`か`DECLARE ... CURSOR`を2回(変数とカーソルの同名は名前空間が別で許される) | 1332 / 1333 | +| 同じブロック内の2つの`HANDLER`が同じ条件値を名指す(`SQLEXCEPTION`と`SQLSTATE '45000'`のように重なるだけなら許される) | 1413 | +| トリガまたはFUNCTION内の動的SQL(`PREPARE` / `EXECUTE` / `DEALLOCATE PREPARE`)と`FLUSH`(PROCEDUREは対象外) | 1336 | +| 本体内の`LOCK TABLES` / `UNLOCK TABLES`・`LOAD DATA`・`ALTER VIEW`(PROCEDUREも対象。`SELECT ... INTO OUTFILE`は結果集合を返さないのでトリガや関数の中でも許される) | 1314 | +| 5文字でないSQLSTATEリテラル(`SIGNAL SQLSTATE '4500'`、それを名指す`CONDITION`や`HANDLER`) | CREATE時に1407。5文字ならどんな文字でも受理する(測定済み) | +| `MYSQL_ERRNO = 0`を設定する`SIGNAL` / `RESIGNAL` | 1231。常に失敗する | +| どの`HANDLER`の外でも、届く値の無い`RESIGNAL` | 1645。常に失敗する | +| 自分自身を`CALL`するPROCEDURE | `max_sp_recursion_depth`が0(既定)のあいだ、再帰呼び出しのたびに1456(0より大きく宣言すれば何も予測しない)。直接の自己再帰だけで、別ルーチン経由で自身に戻るものは対象外 | +| 自分自身を呼ぶFUNCTION | 呼び出しのたびに1424(`CREATE`は通る。`max_sp_recursion_depth`は関数には効かない) | +| 上流で使用中の表に書き戻すトリガの連鎖(`INSERT INTO x`がxのトリガを発火してyに書き、yのトリガがxに書く)、またはロック付きで読み戻す連鎖(`SELECT ... FOR UPDATE` / `FOR SHARE` / `LOCK IN SHARE MODE`。ロック無しの読みは衝突しない、測定済み) | 1442。どちらのトリガも自分の表には書いていなくても。`CALL`したルーチンが続ける連鎖も同じに数え、連鎖のどこかで見つかった確実な失敗(1442、1456)は発火した文に報告する | + +本体の失敗モードのうち、`SIGNAL`と同じ「失敗しうる」形のもの: + +| 構文 | 失敗モード | +|---|---| +| `ELSE`の無い`CASE`(simple・searchedいずれも) | 1339「Case not found for CASE statement」。どの`WHEN`にも一致しないとき | +| 自身の`SET MYSQL_ERRNO`を持つ、値の無い`RESIGNAL` | 捕まえた`SIGNAL`の番号ではなく、自身が設定した番号で再送する | + +本体の書き込みと`SIGNAL`は、呼び出し側から見える失敗モードになる: + +- トリガ・ルーチンの本体自身の書き込みは、その書き込み自身の失敗モード——本文の制約、その書き込みが発火させるトリガの失敗モード(再帰は打ち切り)——を本体の失敗モードに持ち込む +- `SIGNAL`のキーは、番号を設定していれば`MYSQL_ERRNO`の10進表記、無ければSQLSTATE([ランタイム](#ランタイム-databasesql)がエラーを読み戻すのと同じ規則)。組み込みの番号を名乗っても同じで、SQLSTATE `'23000'`の下で`SET MYSQL_ERRNO = 1062`とすればキーは`1062`になる。メッセージに書かれていないキー名を探しにいくことはない(測定済み) +- SQLSTATEクラス`01`は警告なので失敗モードにならない。未処理のクラス`02`は1643、それ以外の未処理は1644になる +- 名前付き`CONDITION`はその値に解決する。値の無い`RESIGNAL`は、最も内側の`HANDLER`が処理中のものをそのまま再送する +- `DECLARE ... HANDLER FOR`は、そのブロック内の一致する失敗モードを吸収する(`SQLEXCEPTION`はクラス`01`と`02`以外の全部、`SQLWARNING` / `NOT FOUND`はそのクラス、SQLSTATEや番号そのものは一致するもの) +- `INSERT` / `UPDATE IGNORE`はトリガのSIGNALを何も吸収しない(測定済み: 文はそれでも失敗する) +- `SELECT ... INTO`は、`One`が使うのと同じ証明で「多くとも1行」と示せない限り1172を持つ(1行も無い場合はNOT FOUNDの警告で、失敗にはならない) + +`CREATE TRIGGER` / `FUNCTION` / `PROCEDURE`の上に書く`-- sqlshape: error = `は、PostgreSQLの同じ注釈([checks.ja.md](checks.ja.md#トリガーが送出するエラーには名前を付ける))と同じもので、``にNameを与える。プログラムはそれを`sqlshape.Error()`で写し取り、vetがスキーマと両方向に照合する。expect行と`mysql.Violates`が判定に使うのは相変わらずキーそのもの(``ではなく`30001`)だが、``で綴っても同じことになる——`sqlshape.Error("30001")`から作った`sqlshape.Failure`はそのコードだけを運んでいるからである。 + +表への文は、その表のトリガのうち該当する事象のものの失敗モードを引き継ぐ(INSERT / UPDATE / DELETE)。`REPLACE`はINSERTとDELETEのトリガを、`ON DUPLICATE KEY UPDATE`はINSERTとUPDATEのトリガを発火させる(測定済み)。 + +カタログに無い名前のFUNCTION呼び出しは、スキーマのルーチンに解決する: + +- 組み込み関数と同名の未修飾の呼び出しは組み込みが勝つ(サーバと同じ)。`db.f`と修飾すればルーチンを名指す +- 引数の数が違えば1318、そのルーチンが無ければ1305 +- 結果は`RETURNS`の型で、常にNULL可である。宣言した型が何であれ`RETURN`はNULLを返しうるので、そうでないことの静的な証明は無い。`CREATE FUNCTION`の上に`-- sqlshape: not null`を書けば、その関数が決してNULLを返さないと宣言でき、呼び出しはNOT NULLとして型付けされる(PostgreSQLの同じ指示子は[checks.ja.md](checks.ja.md#NULLになりうる列はNULLを受けられる型で受ける))。PROCEDUREとTRIGGERは値を返さないので、この指示子を拒む +- 本体自身の失敗モード(SIGNAL、書き込みの違反、その書き込みが発火させるもの)は、呼び出した文に持ち込まれる +- 本体内の`CALL`はトップレベルと同じに解決し(1305 / 1318 / 1414)、呼び先の失敗モードと書き込みは本体のものになる + +呼び出した文が読むか書く表に、関数自身が書く場合は、実行のたびに1442になる: + +- 文の側は表を読んでいるだけでも衝突する。列を読まず`FROM`に名前を挙げるだけでも同じ(測定済み) +- 書き込みは入れ子の呼び出しを通して数える。tに書くプロシージャを`CALL`する関数も、そうする別の関数を`RETURN`で呼ぶ関数も、同じように衝突する +- 本体の中では文ごとに衝突を見る。tに書くfを`UPDATE t SET v = f(v)`で呼ぶルーチンは`CALL`すると1442になるが、`SELECT COUNT(*) INTO n FROM t; SET @x = f(1)`と2文に分かれていれば衝突しない +- トリガ自身の表は本体の全文と衝突する(`other`のトリガが`SET @y = g(1)`でotherに書くgを呼べば1442)。発火させた文が読んでいるだけの表はトリガの表ではないので衝突しない(測定済み) + +`CALL p(...)`はIN / INOUT引数をその引数宣言の型で型付けし、OUT / INOUT引数は変数でなければならない(1414)。文自身のfactsは`Kind Call`。変数として数えるもの: + +| 引数 | 変数か | +|---|---| +| 宣言した変数、引数、`?` | 数える | +| `BEFORE`トリガ自身の本体内の`NEW.col` | 数える(サーバ自身の1414メッセージがこの例外を名指ししている) | +| `OLD.col`、`AFTER`トリガの`NEW.col` | 数えない | + +結果列は本体自身のINTO無し`SELECT`から決まる: 1つも無ければ列無し、1つならその列、複数あって形が揃っていれば1つの列リストとして扱う。形が割れていれば検査器自身のエラーになる——これはmysqld自身が拒むものではなく、実行時にどの経路を通ったかで違う結果集合が返るだけだからである。失敗モードは本体自身のもの。 + +### `One`の証明 + +`PRIMARY KEY`と列全体にかかる`UNIQUE`キー、`LIMIT 1`、`GROUP BY`の無い集約から証明する。集約はどこに置かれていてもよい(`COALESCE(SUM(total), 0)`も`SUM(total)`と同じく1行。サブクエリが自分の列だけで集約するものはそのサブクエリの証明になる)。MySQLには部分インデックスが無い。 + +### グループ化 + +MySQLは`sql_mode`の`ONLY_FULL_GROUP_BY`で検査を行い、検査器も同じ検査を同じ番号で行う。グループ化または集約する問い合わせでは、SELECTリスト、`HAVING`、`ORDER BY`、ウィンドウの`PARTITION BY` / `ORDER BY`の各式が、`GROUP BY`の式か、集約か、グループ列に関数従属する列だけからできていなければならない(1055。`GROUP BY`が無ければ1140)。サーバが認める従属は検査器が認める従属と同じである: + +- `PRIMARY`か`UNIQUE`キーが既知になった表の全列(NULL可のキー列は、そのNULLを退ける条件があるときだけ) +- `WHERE`と内部結合の`col = col`と`col = リテラル` +- 外部結合の`ON`がNULL可側に与えるもの +- 派生表やビューの本体が出力列を通して与えるもの +- `ROLLUP`の下ではグループ式そのものだけ + +隣り合う検査も一緒に行う。 + +| 規則 | エラー | +|---|---| +| `HAVING`で集約の外に書く列は、SELECTリストの列か別名か`GROUP BY`の列でなければならない | 1054 | +| `DISTINCT`があるとき、SELECTリストに無い`ORDER BY`の式が読めるのはSELECTリストの列だけ | 3065 | +| 他で集約していない問い合わせの`ORDER BY`の集約 | 3029 | +| 集合演算の`ORDER BY`の集約 | 3028 | + +```sql +SELECT email, count(*) FROM users GROUP BY name +-- Expression #1 of SELECT list is not in GROUP BY clause and contains nonaggregated column +-- 'users.email' which is not functionally dependent on columns in GROUP BY clause; this is +-- incompatible with sql_mode=only_full_group_by (MySQL error 1055) + +SELECT name, count(*) FROM users GROUP BY id -- OK: id は主キー +``` + +### 名前解決 + +`ORDER BY`、`GROUP BY`、`HAVING`は、サーバと同じにSELECTリストの別名を参照できる。裸の名前だけでなく式の中でも(`ORDER BY c + 1`、`GROUP BY CONCAT(f1)`)。 + +- `GROUP BY`では同名の表の列が別名に勝つ。別名付きで書いた項目は、別名の無い同名の列に勝つ。同名の別々の項目が2つあれば1052 +- 外側のブロックの選択リスト・`GROUP BY`・`HAVING`・`ORDER BY`に置かれた入れ子の問い合わせは、そのブロックの別名も参照できる。`WHERE`や`ON`に置かれたものは参照できない(`SELECT name c, (SELECT 1 FROM orders WHERE note = c) FROM users`は通り、同じサブクエリを`WHERE`に置くと1054) +- サブクエリより後で宣言された別名の参照は1247(`forward reference in item list`)。集約を含む別名の参照は、入れ子の問い合わせ自身の`HAVING`から以外は1247(`reference to group function`)で、`GROUP BY`からは常に1247。ウィンドウ関数を含む別名は3594 +- `HAVING`はブロック自身の表に対しては名前を解決しない。選択リストにも`GROUP BY`にも無い列は、最上位では1054、サブクエリの中では外側のブロックの列になる(`WHERE EXISTS (SELECT 1 FROM orders HAVING id)`は外側の`id`を読む) + +そのほかの解決規則: + +- `_rowid`は、基底表の最初のキー(サーバの並び順: `PRIMARY`、`NOT NULL`列だけの一意キー、残り)が`NOT NULL`の整数型(`INT`系、`YEAR`、`BIT`)1列の一意キーであるとき、その列を指す。修飾した参照か、表が1つだけのレベルで使える。ビューと派生表には無い +- `INSERT ... SELECT ... ON DUPLICATE KEY UPDATE`の代入は、対象表と`SELECT`の表の両方に対して解決する(両方にある修飾の無い名前は1052)。ただし`SELECT`がグループ化・集約されていれば対象表だけを見る。`VALUES(c)`は常に対象表の列で、選択リストの別名はそこからは見えない +- 行エイリアス(`INSERT ... VALUES (...) AS new [(名前リスト)]`、8.0.19)は、挿入される列——表の全列ではなく、そのINSERT自身のフィールド——を、名前リストがあれば位置対応で改名して、`ON DUPLICATE KEY UPDATE`に対象表と並べて見せる。両方が持つ修飾の無い名前は1052、名前リストの個数違いは1353、対象表と衝突するエイリアスは1066で、`VALUES(c)`はエイリアスと併用できる(いずれも実測) +- 派生表には別名が要る(1248)。`QUALIFY`は、8.4がハイパーグラフオプティマイザ無しで拒むとおりに拒む(6037) +- `USING`と`NATURAL`の結合は共通列を1つにまとめる(修飾の無い名前は左側に解決し、`SELECT *`には1回だけ並ぶ) +- 表名とビュー名は`lower_case_table_names`の言うとおりに照合する。列名とキー名は常に大文字小文字を区別しない +- 書く表をサブクエリが読む`UPDATE` / `DELETE`は、サーバが拒むとおりに拒む。読んでいるのが表そのものなら1093(`WHERE`・`EXISTS`・`IN`のどれでも)、その表の上のビューなら1443。派生表で包めば実体化されて通り、同じ表からの`INSERT ... SELECT`も通る(実測) + +### ビュー + +ビューは、`ALGORITHM=TEMPTABLE`と書かれているか、問い合わせがマージできない形(`GROUP BY`・`HAVING`・`DISTINCT`・`LIMIT`・集合演算・ウィンドウ関数・選択リスト内のサブクエリ)でない限り、読む側の問い合わせにマージされる(サーバ自身の`is_mergeable`)。マージされるビュー経由の書き込みは基底表に届き、それ以外のビューへの書き込みは1288になる(`INSERT`では同じ拒否が1471と綴られる)。 + +マージされるビューは、サーバ自身の2つの書き込みフラグを持つ。自分の問い合わせのFROMの葉について: + +- どれかの葉が更新可能(基底表か、更新可能なビュー)なら、更新可能 +- 全部の葉が挿入可能なら、挿入可能 +- どれかの葉が外部結合のNULL側に居れば、どちらでもない + +したがって、派生表や`TEMPTABLE`ビューとの結合は「更新可能だが挿入不能」、`TEMPTABLE`ビューの上のビューはどちらでもない。書き込みごとの規則(すべてサーバで実測): + +- `INSERT`は挿入可能フラグが要る(無ければ1471)。 + - フィールド——列リスト、無ければビューの全列——はビューに対して解決する(1054、1136) + - フィールドの中の派生列はその列の1348。フィールドの外の派生列と、同じ基底列が2つのビュー列の裏に立つ形は1471。`COLLATE`の包みは透過する(サーバの`field_for_view_update`) + - 結合ビューではさらに列リストが必須で(1394)、名指す列は`ON DUPLICATE KEY UPDATE`の代入も含めて基底表1つに収まらなければならない(1393)。`REPLACE`は結合ビューに届かない(1395。削除の半分を含むため) + - 失敗モードは基底表自身のもの(1062ほか)。代入されないまま残る、既定値の無い基底列は、表の1364ではなくビューの1423になる +- `UPDATE`は更新可能フラグが要り(無ければ1288)、素の列になら代入できる。ビュー自身の選択リストの式への代入はその列の1348、ビューの中の実体化された葉の列は(更新可能なビュー越しでも)ビューの1288、結合ビュー越しの代入は基底表1つに収めること(1393) +- `DELETE`は、更新可能で葉が1つのビューならよい(派生列だけのビューでも構わない)。葉が2つ以上なら1395、それ以外(共通テーブル式を対象にした形も)は1288 +- 書く対象のビュー自身をサブクエリが読めば、対象そのものの扱いで1093になる(同じ基底表の上の別のビューなら1443) + +サーバがマージしないビューに付けた`WITH CHECK OPTION`は、サーバが`CREATE`を拒む(1368)のと同じく、スキーマの読み込み時に拒む。`CREATE OR REPLACE VIEW`は前の定義を置き換える。`ALTER VIEW`も同じく置き換える(ビューが存在しなければ1146、その名前が表なら1347)。 + +### 結果集合を返さない文 + +`SELECT` / `INSERT` / `UPDATE` / `DELETE` / `CALL`のほかに、検査器は次の文を読む(`Exec`で実行するもの)。 + +- `LOAD DATA [LOCAL] INFILE ... INTO TABLE t`は、ファイルの行を挿入するINSERTとして読む。 + - 対象は基底表でなければならない(ビューは1288)。列リストはその表で解決する(`@var`はフィールドを受け取るだけで、列には代入しない)。`SET`の代入は式を列に対して型付けし、その中のプレースホルダは列の型を取る + - 失敗モードはINSERTのもの——埋める列にかかるキー・外部キー・CHECK(1062 / 1452 / 3819)、`IGNORE`が警告に変えること、`REPLACE`が衝突する行を先に消すこと + - ただしサーバが変える点が2つある(実測): `NOT NULL`列のフィールドがNULLになりうることは1048ではなく1263(`SET col = NULL`は1048のまま)。列リストから外した`NOT NULL`列は1364にならず、型の暗黙の既定値を取る +- `LOCK TABLES`が名指す表は存在しなければならず(1146。ビューもロックできる)、別名は重複してはならない(1066)。`UNLOCK TABLES`は何も解決しない。どちらにもパラメータは無い +- `SELECT ... INTO OUTFILE` / `INTO DUMPFILE`は、`INTO`の位置がどちらでも、包んでいる`SELECT`と同じに型付けするが、列は返さない(行はサーバ上のファイルに書かれる)。`SELECT ... INTO @var` / `INTO var`も同じで、行は変数に入る(数は一致しなければならない。1222)。末尾の`FOR UPDATE` / `FOR SHARE` / `LOCK IN SHARE MODE`は`SELECT`の列をそのまま残す +- `INSERT INTO t VALUES ()`(全行が空。列リストの有無は問わない)は既定値の行を挿入する。どの列にも代入しないので1136にはならず、既定値の無い`NOT NULL`列があれば通常どおり1364である + +### MySQLに無いもの + +MySQLに無いものはMySQLでは検査しない: `Copy`と`MatView`、PL/pgSQL、ドメイン、複合型と配列、`-schemas`(MySQLのスキーマは1つのデータベース)、`// sqlshape: type`、マイグレーションのseed表。 + +## ランタイム: database/sql + +``` +$ go get github.com/kr9ly/sqlshape/v2 # Query / One: 検査器が読む宣言 +$ go get github.com/kr9ly/sqlshape/mysql/v2 # database/sql と go-sql-driver/mysql の上で実行する +``` + +`github.com/kr9ly/sqlshape/mysql/v2`は、宣言した文を`database/sql`とgo-sql-driver/mysqlを通して実行する。`mysql.DB`は`*sql.DB`、`*sql.Tx`、`*sql.Conn`のどれでもよい。テンプレートの`{{.X}}`はワイヤ上ではMySQLの位置指定`?`になり、引数はプレースホルダの出現順に並べ直される(2回使ったパラメータは2回送る)。 + +```go +for o, err := range mysql.Run(ctx, db, ListOrders, p) { ... } // iter.Seq2[Order, error]、逐次読み出し +orders, err := mysql.Collect(ctx, db, ListOrders, p) // []Order +first, err := mysql.First(ctx, db, ListOrders, p) // 無ければ ErrNoRows(sql.ErrNoRows) +res, err := mysql.Exec(ctx, db, MarkPaid, p) // sql.Result + +u, err := mysql.Get(ctx, db, UserByEmail, p) // One: 無ければ ErrNoRows +u, ok, err := mysql.Find(ctx, db, UserByEmail, p) // One: ok が有無 +res, err := mysql.ExecOne(ctx, db, MarkPaid, p) // One: 1行も触らなければ ErrNoRows +``` + +どのランタイムでも同じこと(行のマッピング、`One`、検査済みのSQLだけが走る保証、expect行の名前で返るエラー)は[runtime.ja.md](runtime.ja.md)にある。MySQL固有のもの: + +- 受け型はドライバのもので、上の表のとおり。`DECIMAL`はその文字列で届くので、自前のmoney型で包める。 +- 制約違反は`*mysql.ConstraintError`として返り、その`Key`は[上](#制約名と失敗モード)のスキーマ上の名前である。`mysql.Violates(err, key)`で判定する。トリガやルーチン自身の`SIGNAL`も同じように返り、キーは[上](#トリガとストアドルーチン)のとおりである。 +- `ExecOne`は`RowsAffected`で判定する。MySQLは変更のあった行を数えるので、既に同じ値を持つ行へのUPDATEは、DSNに`clientFoundRows=true`が無いと`ErrNoRows`になる。`INSERT ... ON DUPLICATE KEY UPDATE`と`REPLACE`がその1行について報告する0・1・2行は、どれも1行とみなす。 +- `Batch`、`Copy`、`MatView`は無い。 +- `mysql.Verify(ctx, db, schemaSQL)`は、接続のセッションの`@@sql_mode`とサーバの`lower_case_table_names`を読み、スキーマの宣言(無ければサーバの既定値)と違えばエラーを返す。DSNの`sql_mode=...`、プールのセッション初期化、別の設定で立てたサーバは、検査器が判定に使わなかった規則で文を走らせることになるからである。プールを開いた直後に1回呼ぶ。 + + ```go + if err := mysql.Verify(ctx, db, schemaSQL); err != nil { ... } + ``` + +## マイグレーション + +`sqlshape diff`、`apply`、`verify-schema`は、MySQLのスキーマにもPostgreSQLと同じに働く。データベースと`schema.sql`の差分がDDLになり、DDLは到達する状態で検査され、それから実行される。両側はサーバ自身の`SHOW CREATE TABLE` / `SHOW CREATE VIEW`で読み、目標側は`-db`のサーバ上の一時データベースで正準化する。MySQLのDDLは暗黙にコミットされるので、DDLは1文ずつ実行する。何を比較するか、`enum`宣言の形、必要な環境は[migrations.ja.md](migrations.ja.md#mysql)にある。 + +## License + +The MySQL side of the checker (`check/mysql`) carries MySQL's own parser and is under the GNU +General Public License v2, see [cmd/sqlshape/LICENSE](../cmd/sqlshape/LICENSE); it is linked into +the `sqlshape` binary, a development tool, and nothing under it is linked into your program. The +runtime (`mysql`) is Apache License 2.0, like the declarations. diff --git a/docs/mysql.md b/docs/mysql.md new file mode 100644 index 0000000..62c0f3b --- /dev/null +++ b/docs/mysql.md @@ -0,0 +1,674 @@ +# MySQL + +[日本語](mysql.ja.md) + +Everything about sqlshape that is MySQL's: the version and server settings the schema declares, +what the checker embeds and how its verdicts are verified, the types and constraint names the +rules use, the runtime on `database/sql`, and the migration commands. The rules themselves are in +[checks.md](checks.md) and are the same for every database; this page is where the names, +numbers and types in those rules come from when the schema declares `mysql`. + +## The schema declares the version and the server's settings + +```sql +-- sqlshape: mysql 8.4 +-- sqlshape: server sql_mode = 'ANSI,STRICT_ALL_TABLES' +-- sqlshape: server lower_case_table_names = 1 +CREATE TABLE ... +``` + +`schema.sql` names the MySQL version it is written for; 8.4 is the one embedded. MySQL's own +grammar then parses the schema and every statement, and MySQL's rules type the expressions. Two +declarations that disagree, or one that also names `postgres`, are rejected. + +A server variable that changes how a statement is judged is declared next to the version, one +per line, so the checker and the production connection agree on it +([checks.md](checks.md#the-schema-names-the-servers-settings-server)). MySQL reads three: + +- `sql_mode`: the names in any case, comma-separated, the empty string, and the combination modes + `ANSI` and `TRADITIONAL`, expanded as the server expands them. The modes that change a judgment: + - the lexer bits (`ANSI_QUOTES`, `PIPES_AS_CONCAT`, `IGNORE_SPACE`, `NO_BACKSLASH_ESCAPES`, + `HIGH_NOT_PRECEDENCE`, `REAL_AS_FLOAT`): how the parser reads the text; + - `ONLY_FULL_GROUP_BY`: the group check on or off; + - strict mode (`STRICT_TRANS_TABLES` or `STRICT_ALL_TABLES`): whether a string function is + nullable, and whether a `NULL` into a `NOT NULL` column is a failure mode; + - `NO_UNSIGNED_SUBTRACTION`: the difference of unsigned operands is signed. + + The rest act at run time only and are accepted as written. +- `lower_case_table_names`: 0 compares table and view names case-sensitively (the Linux + default), 1 stores them lower-cased, 2 keeps the spelling and compares without case. + 2 is only for a case-insensitive filesystem (macOS / Windows); on a case-sensitive one + `mysqld` warns and starts at 0 instead, out of step with a schema that declares 2 + (`mysql.Verify` returns that drift). +- `max_sp_recursion_depth`: 0 to 255. At 0 (the default) a PROCEDURE that `CALL`s itself is + 1456 on every invocation; above 0 the depth a call reaches is not static, so the checker + predicts nothing for it. + +Without a declaration the checker assumes a freshly initialized 8.4 server: the default +`sql_mode` (`ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION`), +`lower_case_table_names = 0` and `max_sp_recursion_depth = 0`. + +## What the checker embeds + +The parser and lexer are MySQL 8.4's own, extracted from the server source (the grammar with its +actions stripped, the lexer as it is) and built to WebAssembly; the function catalog is read from +the same source. The analyzer is a pure-Go implementation over that tree; checking never connects +to a MySQL. Because it carries MySQL's parser, the MySQL side of the checker (`check/mysql`) is +under the GNU General Public License v2; it is linked into the `sqlshape` binary only, never into +your program. + +The verdicts are checked against a running `mysqld`: 5,033 typed statements (the result types of +every built-in function in every argument combination), 65 error statements, the 376 statements +of the `ONLY_FULL_GROUP_BY` check, and 22 statements and 6 writes under non-default `sql_mode` +values agree with 8.4. + +MySQL's own test corpus is replayed as well: the 1,281 files of `mysql-test/t` (some 137,000 +statements) run against a `mysqld` and the analyzer side by side, a SELECT's columns compared by +name, type family and nullability, an error by its number. The statements on which the two +knowingly disagree -- 2,525 -- are pinned one by one +(`check/mysql/internal/analyze/testdata/corpus_baseline.txt`, each with the class of the +disagreement: an error the analyzer does not predict or predicts under another number, a column +typed differently, a schema construct the loader does not model), and a new disagreement fails +the build. That file is the honest list of what the MySQL analyzer does not yet decide the way +the server does. + +## What the rules use + +Every rule in [checks.md](checks.md) applies to a MySQL schema the same way, judged by the MySQL +analyzer: result columns and parameters against the Go types, the NULL handling, the meaning of +types, the failure modes, the `One` proof, and every declaration of Part 2 (`visible where`, +`pinned`, `via view`, `EXISTS`, `aggregate`, `transitions`, `never`, `paired`, `single`, +`sensitive`, `context`). Diagnostics carry MySQL's error numbers and message texts +(`Unknown column 'nope' in 'field list' (MySQL error 1054)`). `{{.X}}` becomes a `?` on the wire. + +### The Go type table + +What go-sql-driver/mysql delivers with `parseTime=true`, verified against a running server. An +integer arrives as `int64` (`uint64` for `BIGINT UNSIGNED`), a `DECIMAL` as its text, temporal +types as `time.Time` or a string, binary strings and JSON as `[]byte`. A comparison or a logical +operator is a `bigint(1)`, which `bool` may receive; so may a `TINYINT(1)`. + +| MySQL | Go | +|---|---| +| `TINYINT` / `SMALLINT` / `MEDIUMINT` / `INT` / `YEAR` | `int64` / `int32` / `int` (`uint64` / `uint32` / `uint` too when `UNSIGNED`); `TINYINT(1)` also `bool` | +| `BIGINT` | `int64` / `int` (`uint64` / `int64` when `UNSIGNED`); a `bigint(1)` also `bool` | +| `DECIMAL` | `string` | +| `FLOAT` / `DOUBLE` | `float32` / `float64` / `float64` | +| `BIT` | `[]byte` | +| `CHAR` / `VARCHAR` / `TEXT` / `ENUM` / `SET` | `string` / `[]byte` | +| `BINARY` / `VARBINARY` / `BLOB` | `[]byte` | +| `JSON` | `[]byte` / `string` | +| `DATE` / `DATETIME` / `TIMESTAMP` | `time.Time` / `string` | +| `TIME` | `string` | +| `ENUM` columns, key identities | a Go named type ([Giving types a meaning](checks.md#giving-types-a-meaning)); an `ENUM` is a value set like a `CHECK (col IN (...))` | + +There is no `// sqlshape: type` binding on MySQL: it has no named types to bind a Go type to. + +A few results are not the type they are written over (all measured): + +- a window function's integer (`MIN(id) OVER ()`, `FIRST_VALUE`, `NTH_VALUE`, `LAG`, `LEAD` + ...) reaches the client through the window's temporary table, which widens it: `INT` and + `BIGINT` become `BIGINT`, `TINYINT` / `SMALLINT` / `MEDIUMINT` become `INT`, the signedness + kept, and `YEAR` becomes `INT UNSIGNED`. A plain aggregate keeps the column's type + (`MIN(small)` over a `SMALLINT` is a `SMALLINT`), and so does a `ROLLUP` group column + unless the plan happens to materialize the grouping, which the checker does not predict; +- under `ROLLUP`, the columns that read the grouped columns are nullable (the super-aggregate + rows hold NULL there); a constant in the select list is not; +- `DATE'...'` / `TIME'...'` / `TIMESTAMP'...'` literals carry their type (the fractional digits + written) and are never NULL; one the server cannot read as exactly that type (a `DATE` with + a time part, a `TIMESTAMP` without one, a zero month under `NO_ZERO_IN_DATE`, a + displacement outside `-14:00` to `+14:00`) is the statement's error 1525; `<=>` is never + NULL, whatever its operands; +- `USER()`, `CURRENT_USER()`, `DATABASE()`, `SCHEMA()`, `VERSION()` and `CURRENT_ROLE()` are + character strings (utf8mb3), not binary strings. + +### Constraint names and failure modes + +A failure mode is named as MySQL names the constraint, and numbered as MySQL numbers the error: + +| constraint | name | error | +|---|---|---| +| primary key | `PRIMARY` | 1062 | +| `UNIQUE` key | the key's name | 1062 (a key the server numbers itself, or one a NULL leaves alone, cannot be violated; a prefix key `UNIQUE (c(10))` or an expression key `UNIQUE ((n * 2))` is violated through the columns it reads) | +| foreign key | the `CONSTRAINT` name, or `
_ibfk_` when it has none | 1452 on the child side, 1451 on the parent side (following `ON DELETE` / `ON UPDATE CASCADE`) | +| `CHECK` | the `CONSTRAINT` name, or `
_chk_` | 3819 | +| `NOT NULL` | `
.` | 1048 | +| a view's `WITH CHECK OPTION` | the view's own name (MySQL's own message names it, unlike PostgreSQL's unnamed 44000) | 1369 | +| a base column with no default an INSERT through a view leaves unassigned | the view's own name (the server's message names the view, not the column -- whether or not the view exposes it) | 1423 | + +What a statement's own form does to the list: + +- `INSERT IGNORE` violates nothing; +- `ON DUPLICATE KEY UPDATE` absorbs the insert's key violations; +- `REPLACE` violates no key and may violate a referencing foreign key (1451); +- `UPDATE IGNORE` absorbs a `WITH CHECK OPTION` view's 1369 the same way it absorbs a key + or `NOT NULL` violation (measured), unlike a trigger's own `SIGNAL`, which no `IGNORE` + absorbs; +- without strict mode only a single-row `INSERT` or `REPLACE` (its `ON DUPLICATE KEY UPDATE` + included) rejects a `NULL` for a `NOT NULL` column; more rows, `INSERT ... SELECT` and `UPDATE` + store the type's implicit default with a warning, so no 1048 is listed for them. + +`mysql.Violates(err, key)` tests the run-time error by the same names; `mysql.WrapError(err)` +gives an error from a statement run outside `Run` / `Exec` the same wrapping first. + +A literal the column can never store is not a failure mode but the statement's own error, +since every execution fails the same way in strict mode (the default). The checker applies the +server's own `Field::store` rules to a literal written straight into a column by `INSERT ... +VALUES`, `UPDATE ... SET`, `ON DUPLICATE KEY UPDATE` or `REPLACE` (all measured): + +| into | rejected | error | +|---|---|---| +| an integer column | a value outside its range, a real or decimal included once rounded | 1264 | +| | a string holding no number (`'abc'`, `''`) | 1366 | +| | a number followed by other text (`'12a'`) | 1265 | +| `YEAR` | anything but 0, 1-99 and 1901-2155 | 1264 | +| `DECIMAL(M,D)` | more than M-D integer digits once rounded to D places; a negative value into `UNSIGNED` | 1264 | +| `FLOAT` | a value beyond the single-precision range | 1264 | +| `DATE` / `DATETIME` / `TIMESTAMP` | a string `str_to_datetime` cannot read or finds out of range, a zero month, day or date the `sql_mode` forbids (`NO_ZERO_IN_DATE`, `NO_ZERO_DATE`), a day the month lacks unless `ALLOW_INVALID_DATES`; a number `number_to_datetime` rejects; a `TIMESTAMP` outside 1970-01-01 00:00:01 to 2038-01-19 03:14:07 UTC (judged only where the session time zone cannot change the verdict) | 1292 | +| `TIME` | a string `str_to_time` cannot read, minutes or seconds of 60 or more, more than 838 hours | 1292 | +| `CHAR(n)` / `VARCHAR(n)` / `BINARY(n)` / `VARBINARY(n)` | a string longer than n characters (bytes when binary) once trailing spaces are dropped | 1406 | +| `ENUM` | a string that names no member (compared as the collation does) and is not a member's index; a number outside 1 to the member count | 1265 | +| `SET` | a list naming a member the set lacks | 1265 | +| any spatial type | a number or a string: neither can hold a well-formed geometry | 1416 | + +Not read: `INSERT IGNORE` / `UPDATE IGNORE` and a schema whose `sql_mode` is not strict (the +value is stored adjusted, with a warning; under `STRICT_TRANS_TABLES` alone a nontransactional +table is strict for the first row only), a value inside a routine or trigger body, hex and bit +literals, `JSON` and `BIT` columns, and an expression the server would fold (`100 + 28`). + +### Spatial types + +A geometry value has one of seven types (`POINT` ... `GEOMETRYCOLLECTION`); a column is declared +with one of them or with `GEOMETRY` (any). The checker knows a value's type when a constructor +(`POINT(1, 1)`), a typed reader (`ST_PointFromText`) or a constant text or WKB fixes it, and +judges with it (all measured): + +| statement | error | +|---|---| +| `ST_GeomFromText` (and the typed variants) over a constant the server's WKT reader refuses: malformed text, a `LINESTRING` of one point, a polygon ring of fewer than four points or not closed, a `MULTIPOINT` mixing `(x y)` and `x y` or empty; `ST_GeomFromWKB` over a constant WKB it refuses (malformed, trailing bytes); a geometry value given to `ST_GeomFromWKB` | 3037 | +| a typed reader over a constant of another type (`ST_PointFromText('LINESTRING(...)')`; the `GEOMCOLL` variants take the `MULTI*` types) | 3516 | +| a constant SRID outside 0 to 4294967295 | 1690 | +| `LINESTRING(...)` of one argument; a `POLYGON(...)` ring made of `POINT` constants with fewer than four points or not closed | 3037 | +| `LINESTRING` / `POLYGON` / `MULTI*` given an argument known to be another geometry type; an arithmetic or bit operator, a numeric function or `BETWEEN` given a geometry (comparisons are allowed) | 1210 | +| a value stored into a spatial column that is not the internal format of the column's type: a number or a character string, a hex / `UNHEX` constant that is not a 4-byte SRID followed by a well-formed little-endian WKB, a constant of another geometry type | 1416, whatever the `sql_mode`, `IGNORE` included | + +A nullable expression of another geometry type stored into a typed spatial column (a `LINESTRING` +column into a `POINT` column) fails on every non-NULL value: it is the failure mode `1416`, which +`mysql.Violates(err, "1416")` matches. Not read: the SRID a column declares (3643), whether a +constant SRID names a spatial reference system (3548), the functions' own run-time checks +(`ST_Centroid` over a degenerate ring), GeoJSON. + +### Constant arithmetic + +A constant expression the server cannot compute is the statement's own 1690, judged before any +row is read (all measured, as item_func.cc computes): + +| expression | error | +|---|---| +| an integer `+` `-` `*` `DIV` whose exact result does not fit `BIGINT`, or `BIGINT UNSIGNED` when an operand is unsigned (`9223372036854775807 * 2`, `CAST(1 AS UNSIGNED) - 2`, `-9223372036854775808 DIV -1`; a string operand makes the operator a `DOUBLE` one, a decimal operand a `DECIMAL` one, neither judged); `ABS(-9223372036854775808)`; `ROUND(n, -k)` over an integer past its range | 1690 `BIGINT [UNSIGNED] value is out of range` | +| a `DOUBLE` result that is infinite: `1e308 + 1e308`, `1e300 / 1e-300`, `EXP(710)`, `POW(2, 1024)`, `COT(0)`, `DEGREES(1e307)`; `CAST(x AS FLOAT)` beyond `FLT_MAX` | 1690 `DOUBLE value is out of range` | +| `CAST(f AS SIGNED / UNSIGNED)` of a `DOUBLE` a function or operator computed, outside the `BIGINT` range (`CAST(POW(2, 63) AS SIGNED)`; a literal is clamped instead) | 1690, naming the inner expression | +| `RANDOM_BYTES(n)` with a constant n outside 1 to 1024 | 1690 `length value is out of range` | + +Where the constant sits decides whether the statement fails or a row does. In a `WHERE`, +`HAVING` or `ON` the optimizer folds it before reading rows (`id = 9223372036854775807 + 1` +against a keyed column, `IN (...)`, `LIKE`, a term of the condition): the statement fails. So +does a select item of a query without a `FROM`, a derived table's or CTE's, an `INSERT ... +VALUES` value. A select item of a query with a `FROM`, an `UPDATE`'s `SET`, an `ON DUPLICATE +KEY UPDATE` assignment, a comparison against an unindexed column or against an aggregate in +`HAVING` run per row: the statement carries the failure mode `1690`, which +`mysql.Violates(err, "1690")` matches, and a query over no rows does not fail. What the server never evaluates is left alone: the constants after the +one that decides an `AND` / `OR` (`1 = 0 AND x`), the branch an `IF` / `CASE` / `COALESCE` / +`IFNULL` with a constant condition does not take, the list after the entry a constant `IN` +matches, `x IS NULL` over a never-NULL x, a `GROUP BY` / `ORDER BY` item, an `EXISTS` +subquery's select list, `LIMIT 0`. Not read: a trigger's or routine's body, a hex or bit +literal operand, a user variable, a window's `ORDER BY`, `DECIMAL` overflow (65 digits). + +The same two shapes are two writes for the obligation checker (x/obligation), not one: + +| statement | writes recorded | why | +|---|---|---| +| `INSERT ... ON DUPLICATE KEY UPDATE` | an INSERT, and an UPDATE of the columns the branch assigns | the branch moves an existing row | +| `REPLACE` | an INSERT and a DELETE | a colliding key deletes the old row first (its `AFTER DELETE` trigger fires, measured) | + +A write through a view declared `WITH CHECK OPTION` discharges `require pinned()` on the +base table (`Discharge.Path` `ByView`): + +- when the view's own `WHERE` fixes the column by equality; a plain `WITH CHECK OPTION` is + CASCADED, so every underlying view's `WHERE` counts too (behind a join as well, measured); + `WITH LOCAL CHECK OPTION` stops at the view itself except for an underlying view that + declares a check option of its own, which the server keeps enforcing (measured); +- the 1369 above is what makes the pin genuine: a view without the clause never discharges it + (a write through it moves the row out of the view's `WHERE` silently); +- for `UPDATE`, `INSERT` and `REPLACE` alike (a `DELETE` never checks the option, measured); + an underlying view's own check option is enforced whatever the view written through says, + and the 1369 still names the latter (measured). + +Not a failure mode here: a length, range or `ENUM` value truncation (1265 / 1406 / 1366 / +1264). It is a property of the type (a parameter's Go type, a literal's own value set), caught +on that side. + +### Constant conversions + +A strict `INSERT` / `REPLACE` / `UPDATE` / `DELETE` (outside `IGNORE`) escalates a constant +conversion's warning to the error 1292 `ER_TRUNCATED_WRONG_VALUE`; a `SELECT`, a `SET`, a `DO` +or a non-strict write runs the same expression with a warning, rows present or not (all +measured against mysqld): + +| expression | fails when | +|---|---| +| `CAST` / `CONVERT` of a constant to `DATE` / `DATETIME` | the value is no datetime under the session's zero-date flags (`CAST('2004-10-0' AS DATE)`, `CAST(65 AS DATETIME)`) | +| ... to `TIME` | `str_to_time` rejects it or it passes 838 hours | +| ... to `YEAR` | the leading integer of the string is followed by anything (`'2020extra'`, `'2020.5'`), or the value is outside 0-99 and 1901-2155; a string with no digits at all is 0 without a warning | +| ... to `CHAR(n)` | the value's string form is longer than n (`CAST(1000 AS CHAR(3))`) | +| ... to `SIGNED` / `UNSIGNED` | the string is not a whole integer (`'abc'`, `'1.5'`, `'1e2'`) or its magnitude passes `BIGINT UNSIGNED`; a decimal outside the range | +| ... to `DOUBLE` / `FLOAT` / `DECIMAL` | the string holds no number, has a tail, or overflows the double parse (`'1e999'`) | +| `TIMESTAMP(x)` of a constant | x is no datetime (a time-only string, `TIMESTAMP('0000-00-00 10:00:00')` where the mode forbids it) | +| a string operand of `+` `-` `*` `/` | it holds no full number (`10E+0 + 'a'`; `''` is 0 silently) | +| a string operand of a bit operator | it is not a whole integer (`1 >> ''`) | +| a numeric column compared with a constant string | the string holds no number (`WHERE i = '1invalid'`; `''`, `' 1'`, `'1e1'`, `'1.5'` all pass); a `BETWEEN` runs per row instead | + +The failure lands where the 1690 above lands — an `INSERT ... VALUES` value or a bare +condition term is the statement's error, an `UPDATE`'s `SET` or a select item over a `FROM` +the failure mode `1292` (`mysql.Violates(err, "1292")`) — except that a comparison's constant +operand always runs per row, so `WHERE d = CAST('2004-10-0' AS DATE)` is the violation, not +the error. + +Two conversions fail whatever the statement and the mode: + +- a `DATE` / `DATETIME` / `TIMESTAMP` value compared with a constant string that is no + datetime is the statement's 1525 `Incorrect DATETIME value` wherever the comparison sits — + a select item, a `JOIN`'s `ON`, a `HAVING`, an `ORDER BY` / `GROUP BY` item, a dead branch — + and in a strict write the store-shaped 1292 instead (`BETWEEN` and `IN` do not convert + eagerly; a `TIME` or `YEAR` column is not this rule); +- a datetime string or literal carrying a time zone displacement over a zero month or day + fails the displacement conversion: 1292 `Truncated incorrect temporal value`. + +A `DATE'...'` / `TIMESTAMP'...'` literal stored into a `TIMESTAMP` column must fit 1970-01-01 +00:00:01 to 2038-01-19 03:14:07.999999 UTC — exact when the literal writes a displacement (the +range is defined in UTC), otherwise judged only where the session time zone cannot move the +verdict; the fractional seconds round to the column's precision first, and the carry counts +(`TIMESTAMP'1970-01-01 00:00:00.999999+00:00'` into a `TIMESTAMP(0)` is 00:00:01, stored). A +`TIMESTAMP` column ignores `ALLOW_INVALID_DATES` and range-checks a year 0 over a real month +and day, where a `DATETIME` accepts both. + +Not read: a function's result stored (`STR_TO_DATE(...)`), a temporal cast's value carried +into the column store, an `UPDATE`'s `ORDER BY` constant, a scalar subquery's constant +compared with a temporal column, a column `DEFAULT` the current mode cannot store, a `TIME` +column's own per-row string conversion. + +### Constant function arguments + +A function that decides a run-time failure by an argument's value is judged when the +argument is a constant, where the constant-arithmetic rules above place the failure (a +folded term fails the statement, a per-row term is the violation keyed by its own number, +and a query over no rows does not fail): + +| function | fails when | error | +|---|---|---| +| `INET_ATON` | the value is not digit groups of at most 255 separated by up to three dots (`'122.256'`, a trailing dot; `'1.2.3'` runs) | 1411, in a strict write outside `IGNORE` | +| `INET6_ATON` | the value is neither a full dotted IPv4 (four groups, no leading `0x`) nor a valid IPv6 text (one `::` gap at most, four hex digits per group) | 1411, the same gate | +| `UNHEX` | the value holds a non-hex character | 1411, the same gate | +| `STR_TO_DATE` | the value does not parse under the format (`extract_date_time`'s specifiers, the en_US month and day names, `%V`/`%v` weeks with their `%X`/`%x` years), or the date fails the session's zero-date flags (under `NO_ZERO_DATE` any zero year, month or day of a date result) | 1411; a parsed value with a non-space tail is 1292, spelt with the format's own result type | +| `UUID_TO_BIN` | the value is not 32 hex digits, the dashed 8-4-4-4-12 form, or that form in braces | 1411, every statement and mode | +| `BIN_TO_UUID` | the value is not exactly 16 bytes | 1411, every statement and mode | +| `PERIOD_ADD` / `PERIOD_DIFF` | a period argument is not a positive `[YY]YYMM` with month 1 to 12 | 1210, every statement and mode | + +`mysql.Violates(err, "1411")` / `"1210"` match the per-row violations. A second family is +refused at resolution, wherever the expression sits and rows or none: `NAME_CONST` takes +two literals (one unary minus or a `COLLATE` wrapper is fine, a folded `1+1` or `TRUE` is +not; a NULL name is 1382), `LIKE`'s `ESCAPE` takes a constant of at most one character, +`NTILE` a positive count and `NTH_VALUE` a positive integer position, and `MATCH` columns +of one relation (a select alias or two tables is 1210 `to MATCH`) with a constant +`AGAINST` (a column in it is 1210 `to AGAINST`). + +### Function call arguments and system variable reads + +A function call argument carrying an alias (`f(x AS a)`, the loadable function syntax) is +refused as the server refuses it, before the argument is even resolved: a native function +checks its argument count first (1582, spelling the name as written), then the alias is 1583 +with the name lowercased (`SELECT ABS(3 AS three)` is `... native function 'abs'`); any other +name -- a stored function, even one that does not exist -- is 1584 before the function is +looked up. The data dictionary's own functions (`INTERNAL_TABLE_ROWS` and the rest the +server marks internal) are 3566 whenever a statement names them, before either check. + +An explicitly scoped system variable read must match the variable's own scope: `@@session.x` +(`@@local.x` means the same) of a GLOBAL-only variable and `@@global.x` of a SESSION-only one +are the statement's 1238, wherever the read sits -- a dead branch, a subquery, a `SELECT` +with no rows. The scope table is generated from the server source (`sql/sys_vars.cc`), so it +covers every stock variable; a plugin's or component's variable (`@@x.y` included) and an +unqualified `@@x` are never judged, and an unknown name is left to the server's own 1193. + +### Triggers and stored routines + +The loader reads `CREATE TRIGGER` / `CREATE PROCEDURE` / `CREATE FUNCTION` (`DEFINER`, +`IF NOT EXISTS`, the characteristics) and `DROP` / `ALTER` (characteristics only) the way it +reads a table: no `DELIMITER` is needed, a body's own `;` is read as part of the one compound +statement it belongs to, and a mysql-client `DELIMITER x` line is read too. What the server +itself refuses at CREATE time is a problem the same way an unknown table is: no such table +(1146), the trigger or routine already exists (1359 / 1304), no such trigger or routine to +`DROP` (1360 / 1305), no such trigger for `FOLLOWS` / `PRECEDES` to name (3011). `DROP TABLE` +takes a table's triggers with it; `RENAME TABLE` moves them. + +`CREATE EVENT` is read the same way (both schedule forms, `STARTS` / `ENDS`, `ON COMPLETION`, +`ENABLE` / `DISABLE`, `COMMENT`), and `ALTER EVENT` (each clause written replaces that part +of the event, a new schedule the whole schedule, `RENAME TO` the name) and `DROP EVENT` are +applied. Nothing a statement of the program runs reaches an event, +so its body is read for the schema's own sake: the server checks nothing of it at `CREATE` +time (a `DELETE` from a table that does not exist is accepted and fails at every run, +measured), the checker reports it as a schema problem, and the body's own statements are +judged for the obligations like a routine's. A `RETURN` in an event is 1313. The migration +commands manage events ([migrations.md](migrations.md#mysql)). + +The body is read once per schema, the way a PL/pgSQL function's is on PostgreSQL +([checks.md](checks.md#name-the-errors-a-trigger-raises)). `IF` / `CASE` / `LOOP` / `WHILE` / +`REPEAT`, labelled blocks with `LEAVE` / `ITERATE`, `RETURN`, `SET`, `SELECT ... INTO`, cursors +(`DECLARE` / `OPEN` / `FETCH` / `CLOSE`), `CALL`, `SIGNAL` / `RESIGNAL` and +`DECLARE ... HANDLER FOR` are walked. Names resolve as on the server: + +- `NEW.col` / `OLD.col` type as the trigger's own table's columns; an unknown column is 1054; +- `OLD` in an INSERT trigger, or `NEW` in a DELETE trigger, is 1363; +- writing `OLD` (checked before the event: in an INSERT trigger too), or writing `NEW` outside + a BEFORE trigger, is 1362; outside a trigger, `SET NEW.col` / `SET OLD.col` is 1193 (a + read of `NEW.col` there is a column the server resolves only at run time); +- a NOT NULL column's `NEW.col` can still be NULL in a BEFORE trigger and is as declared in an + AFTER one: the server's own NOT NULL check runs between the two (measured); +- a `DECLARE`d variable or a routine's own parameter shadows a column of the same name. + +What the server itself refuses when the body is created is an error here too: + +| construct | error | +|---|---| +| `LEAVE` / `ITERATE` with no matching label | 1308 | +| `RETURN` outside a FUNCTION | 1313 | +| a FUNCTION with no `RETURN` | 1320 | +| an undeclared cursor or variable, a `FETCH` column-count mismatch | 1324 / 1327 / 1328 | +| a mismatched `SELECT ... INTO` column count | 1222 | +| a trigger or function that returns a result set (its own INTO-less `SELECT`, or a `CALL`ed PROCEDURE's own) | 1415 | +| `COMMIT` / `START TRANSACTION` / a DDL statement inside a body | 1422 | +| a trigger that writes its own table | 1442, on every one of the 18 timing x event x write combinations (measured): always a failure, reported on the trigger's own definition rather than on a statement that fires it | +| two `DECLARE`s of the same variable name in one block | 1331 | +| two `DECLARE ... CONDITION`s or two `DECLARE ... CURSOR`s of one name in one block (a variable and a cursor of one name are different namespaces) | 1332 / 1333 | +| two `HANDLER`s of one block naming the same condition value (handlers that merely overlap, `SQLEXCEPTION` next to `SQLSTATE '45000'`, are accepted) | 1413 | +| dynamic SQL (`PREPARE` / `EXECUTE` / `DEALLOCATE PREPARE`) or `FLUSH` in a trigger or FUNCTION (a PROCEDURE is exempt) | 1336 | +| `LOCK TABLES` / `UNLOCK TABLES`, `LOAD DATA` or `ALTER VIEW` in any body, a PROCEDURE's included (`SELECT ... INTO OUTFILE` is allowed, even in a trigger or function: it returns no result set) | 1314 | +| a SQLSTATE literal that is not five characters (`SIGNAL SQLSTATE '4500'`, a `CONDITION` or `HANDLER` naming one) | 1407 at CREATE; five characters of any kind are accepted (measured) | +| a `SIGNAL` or `RESIGNAL` setting `MYSQL_ERRNO = 0` | 1231, certain every time | +| a bare `RESIGNAL` reached outside any `HANDLER` | 1645, certain every time | +| a PROCEDURE that `CALL`s itself | 1456 on every recursive invocation while `max_sp_recursion_depth` is 0 (the default; declare it above 0 and nothing is predicted); direct self-recursion only, not a routine reaching itself through another | +| a FUNCTION that calls itself | 1424 on every invocation (the `CREATE` goes through; the setting does not apply to functions) | +| a trigger chain writing back into a table already in use further up (`INSERT INTO x` fires `x`'s trigger writing `y`, whose trigger writes `x`), or reading it under a lock (`SELECT ... FOR UPDATE` / `FOR SHARE` / `LOCK IN SHARE MODE`; a plain read does not collide, measured) | 1442, though neither trigger writes its own table; a chain that a `CALL`ed routine continues counts the same, and a certain failure found anywhere along the chain (1442, 1456) is reported on the firing statement | + +And among the body's failure modes, the "may" shape a `SIGNAL` has: + +| construct | failure mode | +|---|---| +| a `CASE` (simple or searched) with no `ELSE` | 1339, "Case not found for CASE statement", when no `WHEN` matches | +| a bare `RESIGNAL` with its own `SET MYSQL_ERRNO` | re-raises with that number, not the caught `SIGNAL`'s | + +A trigger's or routine's own writes bring their own failure modes into the body's: the +schema's constraints, and what those writes' own triggers raise in turn (a cycle is cut). A +`SIGNAL`'s key is its `MYSQL_ERRNO` as decimal text when it sets one -- a builtin's number +too, `SET MYSQL_ERRNO = 1062` under SQLSTATE `'23000'` is keyed `1062`, not by a key name the +message never carried (measured) -- else its SQLSTATE (the same rule the +[runtime](#the-runtime-databasesql) reads an error back by); SQLSTATE class +`01` is a warning and no failure mode, an unhandled class `02` is 1643, anything else +unhandled is 1644. A named `CONDITION` resolves to its value; a bare `RESIGNAL` re-raises +whatever the innermost `HANDLER` is itself handling. `-- sqlshape: error = ` +above a `CREATE TRIGGER` / `FUNCTION` / `PROCEDURE`, the same annotation +[checks.md](checks.md#name-the-errors-a-trigger-raises) documents for PostgreSQL, gives +`` a Name a program mirrors with `sqlshape.Error()` and vet checks against the +schema both ways; an expect line and `mysql.Violates` still judge by the key itself +(`30001`, not ``) -- they may just as well spell it as ``, since a +`sqlshape.Failure` from `sqlshape.Error("30001")` carries the code and nothing else. A +`DECLARE ... HANDLER FOR` absorbs the matching failure modes of its own block (`SQLEXCEPTION` +everything but classes `01` and `02`, `SQLWARNING` / `NOT FOUND` their class, a SQLSTATE or +number itself); `INSERT` / `UPDATE IGNORE` absorbs none of a trigger's SIGNALs (measured: the +statement still fails). `SELECT ... INTO` carries 1172 unless the query is provably at most +one row, the same proof `One` uses (a query with no row is NOT FOUND, a warning, never a +failure). + +A statement on a table takes the failure modes of that table's triggers for its event: +`INSERT` / `UPDATE` / `DELETE`; `REPLACE` fires the INSERT and DELETE triggers, `ON DUPLICATE +KEY UPDATE` the INSERT and UPDATE ones (measured). + +A call to a FUNCTION the catalog does not know resolves to the schema's own (an unqualified +name that is also a native function resolves to the native one, as on the server; `db.f` +names the routine): a wrong argument count is 1318, no such routine 1305, the result types as +the `RETURNS` declaration and is always nullable (a stored function's `RETURN` can produce +NULL regardless of the declared type; there is no static proof otherwise), and the body's own +failure modes (its SIGNALs, its writes' violations, what they fire) reach the calling +statement. A function that writes a table the calling statement itself reads or writes is +1442 on every execution (reading the table is enough, measured; so is naming it in `FROM` +without reading a column). The writes count through nested calls (a function that `CALL`s +a procedure writing t, or `RETURN`s another function that does, collides the same way), and +inside a body each statement is the invoking one: `UPDATE t SET v = f(v)` with f writing t is +1442 when the routine is `CALL`ed, `SELECT COUNT(*) INTO n FROM t; SET @x = f(1)` as two +statements is not, and a trigger's own table collides with every statement of its body (a +trigger on `other` running `SET @y = g(1)` with g writing `other` is 1442; a table the +firing statement merely reads is not the trigger's, measured). A `CALL` inside a body is +resolved the way a top-level one is (1305 / 1318 / 1414), and the callee's failure modes and +writes become the body's. `-- sqlshape: not null` +above a `CREATE FUNCTION` declares the function never returns NULL, the same directive +[checks.md](checks.md#a-column-that-may-be-null-needs-a-field-that-can-hold-null) documents +for PostgreSQL; a call then types as NOT NULL instead. A PROCEDURE or a TRIGGER still refuses +the directive: neither returns a value for it to describe. + +`CALL p(...)` types an `IN` / `INOUT` argument by its parameter and requires an `OUT` / +`INOUT` argument to be a variable (1414); its own facts are `Kind Call`. What counts as a +variable there: + +| argument | variable? | +|---|---| +| a declared variable, a parameter, a `?` | yes | +| `NEW.col` inside a `BEFORE` trigger's own body | yes (the server's own 1414 message names this exception) | +| `OLD.col`, or `NEW.col` in an `AFTER` trigger | no | +Its result columns come from the body's own INTO-less `SELECT`s: none is no columns, one is +those columns, several of the same shape agree on one list, several of different shapes is +the checker's own error (not something mysqld itself refuses -- it only ever returns +whichever result set the execution path taken produced, at run time). Its failure modes are +the body's own. + +### The `One` proof + +Proved from `PRIMARY KEY` and `UNIQUE` keys over whole columns, `LIMIT 1`, and an aggregate +without `GROUP BY` (wherever it sits: `COALESCE(SUM(total), 0)` is one row as much as `SUM(total)`; +an aggregate a subquery owns is the subquery's); MySQL has no partial indexes. + +### Grouping + +MySQL runs the checks of `sql_mode` `ONLY_FULL_GROUP_BY`, and so does the checker, with the +server's numbers: in a grouped or aggregated query every select-list, `HAVING`, `ORDER BY` and +window `PARTITION BY` / `ORDER BY` expression is a `GROUP BY` expression, an aggregate, or made of +columns functionally dependent on the group columns (1055; 1140 without `GROUP BY`). The +dependencies the server recognizes are the ones the checker recognizes: + +- a table's columns once its `PRIMARY` or `UNIQUE` key is known (a nullable key column only + where a conjunct rejects its NULL); +- `col = col` and `col = literal` in `WHERE` and inner joins; +- an outer join's `ON`, into its nullable side; +- a derived table's or view's body, through its outputs; +- under `ROLLUP`, the group expressions only. + +The neighbouring checks come with it: + +| rule | error | +|---|---| +| a column named outside an aggregate in `HAVING` must be a select-list column or alias, or a `GROUP BY` column | 1054 | +| with `DISTINCT`, an `ORDER BY` expression not in the select list may read only select-list columns | 3065 | +| an aggregate in the `ORDER BY` of a query that aggregates nowhere else | 3029 | +| an aggregate in the `ORDER BY` of a set operation | 3028 | + +```sql +SELECT email, count(*) FROM users GROUP BY name +-- Expression #1 of SELECT list is not in GROUP BY clause and contains nonaggregated column +-- 'users.email' which is not functionally dependent on columns in GROUP BY clause; this is +-- incompatible with sql_mode=only_full_group_by (MySQL error 1055) + +SELECT name, count(*) FROM users GROUP BY id -- OK: id is the primary key +``` + +### Name resolution + +`ORDER BY`, `GROUP BY` and `HAVING` see the select list's aliases as the server does, in an +expression as well as bare (`ORDER BY c + 1`, `GROUP BY CONCAT(f1)`; a table column of the same +name wins in `GROUP BY`, an item written with an alias wins over an unaliased column of the +name, two different items of the name are 1052). A nested query placed in the select list, +`GROUP BY`, `HAVING` or `ORDER BY` of an enclosing block sees that block's aliases too, one in +its `WHERE` or `ON` does not (`SELECT name c, (SELECT 1 FROM orders WHERE note = c) FROM users` +runs; the same subquery in the `WHERE` is 1054); an alias declared after the subquery is 1247 +(`forward reference in item list`), an alias of an aggregate is 1247 (`reference to group +function`) except from the nested query's own `HAVING`, and always from a `GROUP BY` placement; +an alias of a window function is 3594. `HAVING` never resolves a name against the block's own +tables: a column not in the select list or `GROUP BY` is 1054 at the top level, and an +enclosing block's column inside a subquery (`WHERE EXISTS (SELECT 1 FROM orders HAVING id)` +reads the outer `id`). `_rowid` names a base table's first key when, after the server's +ordering (`PRIMARY`, the unique keys over `NOT NULL` columns, the rest), that key is unique over +one `NOT NULL` integer column (`INT` family, `YEAR`, `BIT`), for a qualified reference or a +level with a single table; a view or a derived table has none. `INSERT ... SELECT ... ON +DUPLICATE KEY UPDATE` resolves its assignments against the target and the `SELECT`'s tables +(an unqualified name both have is 1052) unless the `SELECT` is grouped or aggregated, when the +target alone is in view; `VALUES(c)` is always the target's column; a select alias is never +visible there. The row alias (`INSERT ... VALUES (...) AS new [(names)]`, 8.0.19) exposes the +inserted columns -- the insert's own fields, renamed positionally by the name list -- to +`ON DUPLICATE KEY UPDATE` beside the target: an unqualified name both carry is 1052, a name +list of the wrong count 1353, an alias colliding with the target 1066, and `VALUES(c)` stays +usable beside it (all measured). A derived table needs an alias (1248); `QUALIFY` is rejected as 8.4 rejects it +without the hypergraph optimizer (6037). A `USING` or `NATURAL` join +coalesces its common columns (an unqualified name resolves to the left side, `SELECT *` lists it +once). Table and view names compare as `lower_case_table_names` says; column and key names never +mind case. An `UPDATE` or `DELETE` whose subquery reads the table it writes is refused as the +server refuses it: 1093 for the table itself (in `WHERE`, `EXISTS` or `IN` alike), 1443 for a +view over it; a derived table over it is materialized and accepted, as is `INSERT ... SELECT` +from the same table (measured). + +### Views + +A view is merged into the query that reads it unless it says `ALGORITHM=TEMPTABLE` or its +query cannot be merged (`GROUP BY`, `HAVING`, `DISTINCT`, `LIMIT`, a set operation, a window +function or a subquery in the select list) -- the server's own `is_mergeable`. A write through a +merged view lands on its base table; through any other view it is 1288 (an `INSERT`'s spelling +of the same refusal is 1471). A merged view carries the server's own two write flags, computed +over the `FROM` leaves of its query: it is updatable when any leaf is (a base table, or an +updatable view), insertable when every leaf is, and neither when any leaf sits on the nullable +side of an outer join -- so a join with a derived table or a `TEMPTABLE` view stays updatable +but is never insertable, and a view over a `TEMPTABLE` view is neither. Each write's own +rules, measured against the server: + +- `INSERT` needs the insertable flag (else 1471). The fields -- the column list, or without + one every view column -- resolve against the view (1054, 1136); a derived column among them + is that column's 1348, one outside them, or the same base column behind two view columns, + is 1471. A `COLLATE` wrapper is transparent (the server's `field_for_view_update`): such a + column stays plain. A join view additionally needs an explicit column list (1394) naming + columns of exactly one base table, the `ON DUPLICATE KEY UPDATE` assignments included + (1393), and `REPLACE` never reaches one (1395: the delete half). The write's failure modes + are the base table's own (1062 and the rest), and a base column with no default the + statement leaves unassigned is the view's 1423 rather than the table's 1364. +- `UPDATE` needs the updatable flag (else 1288) and may then assign any plain column: an + expression of the view's own select list is that column's 1348, a column of a materialized + leaf inside the view the view's 1288, and the assignments through a join view must keep to + one base table (1393). +- `DELETE` takes any updatable single-leaf view -- derived columns included, even a view of + only expressions; a view of more than one leaf is 1395, anything else (a CTE target + included) 1288. +- a subquery reading the very view the statement writes is the target itself: 1093, not the + 1443 a different view over the same base table gets. + +`WITH CHECK OPTION` on +a view the server would not merge is refused when the schema loads, as the server refuses the +`CREATE` (1368). `CREATE OR REPLACE VIEW` replaces the earlier definition, and so does `ALTER +VIEW` (the view must exist, 1146, and be a view, 1347). + +### Statements without a result set + +Besides `SELECT` / `INSERT` / `UPDATE` / `DELETE` / `CALL`, the checker reads these, run with +`Exec`: + +- `LOAD DATA [LOCAL] INFILE ... INTO TABLE t` is an INSERT of the file's rows: the target must be a + base table (a view is 1288), the column list resolves against it (a `@var` takes its field and + assigns nothing), a `SET` assignment types its expression against the column, and a + placeholder in one takes the column's type. Its failure modes are the INSERT's -- the keys, + foreign keys and checks of the columns it fills (1062 / 1452 / 3819), `IGNORE` turning them + into warnings, `REPLACE` deleting the colliding row first -- with two differences the server + makes (measured): a field for a `NOT NULL` column may be NULL, which is 1263 rather than 1048 + (a `SET col = NULL` stays 1048), and a `NOT NULL` column the column list leaves out takes its + type's implicit default rather than 1364. +- `LOCK TABLES` names tables that must exist (1146, a view may be locked) under distinct aliases + (1066); `UNLOCK TABLES` resolves nothing. Neither has parameters. +- `SELECT ... INTO OUTFILE` / `INTO DUMPFILE`, in either position of the `INTO`, is typed like the + `SELECT` it wraps but returns no columns: the rows go to a file on the server. So is `SELECT ... + INTO @var` / `INTO var` (the row goes into the variables; the count must match, 1222). A + trailing `FOR UPDATE` / `FOR SHARE` / `LOCK IN SHARE MODE` leaves a `SELECT`'s columns as they + are. +- `INSERT INTO t VALUES ()` (every row empty, with or without a column list) inserts a row of + defaults: no column is assigned, so there is no 1136, and an omitted `NOT NULL` column + without a default is the usual 1364. + +### Not on MySQL + +What does not exist on MySQL is not checked there: `Copy` and `MatView`, PL/pgSQL, domains, +composite types and arrays, `-schemas` (a MySQL schema is one database), `// sqlshape: type`, and +seeded tables in migrations. + +## The runtime: database/sql + +``` +$ go get github.com/kr9ly/sqlshape/v2 # Query / One: the declarations the checker reads +$ go get github.com/kr9ly/sqlshape/mysql/v2 # running them on database/sql with go-sql-driver/mysql +``` + +`github.com/kr9ly/sqlshape/mysql/v2` runs a declared statement through `database/sql`, with +go-sql-driver/mysql as the driver. `mysql.DB` is `*sql.DB`, `*sql.Tx` or `*sql.Conn`. The +template's `{{.X}}` become MySQL's positional `?` on the wire, the arguments lined up in the +order the placeholders appear (a parameter used twice is sent twice). + +```go +for o, err := range mysql.Run(ctx, db, ListOrders, p) { ... } // iter.Seq2[Order, error], streamed +orders, err := mysql.Collect(ctx, db, ListOrders, p) // []Order +first, err := mysql.First(ctx, db, ListOrders, p) // ErrNoRows (sql.ErrNoRows) when none +res, err := mysql.Exec(ctx, db, MarkPaid, p) // sql.Result + +u, err := mysql.Get(ctx, db, UserByEmail, p) // One: ErrNoRows when absent +u, ok, err := mysql.Find(ctx, db, UserByEmail, p) // One: ok reports presence +res, err := mysql.ExecOne(ctx, db, MarkPaid, p) // One: ErrNoRows when no row was touched +``` + +What every runtime does the same way (row mapping, `One`, the guarantee that only checked SQL +runs, errors under the expect line's names) is in [runtime.md](runtime.md). What is MySQL's: + +- The receive types are the driver's, as in the table above; a `DECIMAL` arrives as its text, so + a money type of your own can wrap it. +- A constraint violation comes back as a `*mysql.ConstraintError` whose `Key` is the schema's + name for it, as [above](#constraint-names-and-failure-modes); `mysql.Violates(err, key)` tests + for it. A trigger's or a routine's own SIGNAL comes back the same way, keyed the way + [above](#triggers-and-stored-routines) describes. +- `ExecOne` judges `RowsAffected`, which MySQL counts as changed rows: an `UPDATE` to the values + a row already has reports `ErrNoRows` unless the DSN sets `clientFoundRows=true`. For an + `INSERT ... ON DUPLICATE KEY UPDATE` or a `REPLACE` the 0, 1 or 2 rows MySQL reports for the one + row are all one row. +- There is no `Batch`, `Copy` or `MatView`. +- `mysql.Verify(ctx, db, schemaSQL)` asks the connection for its session `@@sql_mode` and the + server's `lower_case_table_names` and returns an error when they differ from what the schema + declares (the server's defaults when it declares none): a DSN's `sql_mode=...`, a pool's session + setup or a server configured otherwise would run the statements under rules the checker did + not judge them by. Call it once at start-up, after opening the pool. + + ```go + if err := mysql.Verify(ctx, db, schemaSQL); err != nil { ... } + ``` + +## Migrations + +`sqlshape diff`, `apply` and `verify-schema` work on a MySQL schema the way they do on +PostgreSQL: the difference between the database and `schema.sql` becomes DDL, the DDL is checked +by its end state, then run. Both sides are read as the server's own `SHOW CREATE TABLE` / +`SHOW CREATE VIEW`, the target's in a scratch database on the `-db` server, and the DDL runs +statement by statement since MySQL's DDL commits implicitly. What is compared, the `enum` +declaration's form and the requirements are in [migrations.md](migrations.md#mysql). + +## License + +The MySQL side of the checker (`check/mysql`) carries MySQL's own parser and is under the GNU +General Public License v2, see [cmd/sqlshape/LICENSE](../cmd/sqlshape/LICENSE); it is linked into +the `sqlshape` binary, a development tool, and nothing under it is linked into your program. The +runtime (`mysql`) is Apache License 2.0, like the declarations. diff --git a/docs/obligations.md b/docs/obligations.md index 4deefb7..4bbea50 100644 --- a/docs/obligations.md +++ b/docs/obligations.md @@ -8,9 +8,9 @@ | 規則 | 宣言の場所 | 実装 | |---|---|---| -| `-- sqlshape: visible where ` | schema.sqlの表 | `internal/analyze` `checkVisibility`(proverの事実を使う) | -| `-require-columns=tenant_id` | vetのフラグ(全表一律) | `internal/vet` `vet.go` + `rls.go`(`pinsColumn`、独自のAND走査) | -| `-no-table-reads` / `-no-tables` / `-schemas` | vetのフラグ | `internal/vet` `vet.go`(参照の種別を見る) | +| `-- sqlshape: visible where ` | schema.sqlの表 | `check/postgres/analyze` `checkVisibility`(proverの事実を使う) | +| `-require-columns=tenant_id` | vetのフラグ(全表一律) | `cmd/sqlshape/internal/vet` `vet.go` + `rls.go`(`pinsColumn`、独自のAND走査) | +| `-no-table-reads` / `-no-tables` / `-schemas` | vetのフラグ | `cmd/sqlshape/internal/vet` `vet.go`(参照の種別を見る) | 「ポリシーが列を固定していれば`-require-columns`を満たす」「ビューが述語を運べば`visible where`を満たす」のような特例が規則ごとに個別に書かれている。次に来る要求 — 表をまたぐ制約(`order_items`は`orders`とtenantを揃えて結合する)、列の不変性(`tenant_id`をUPDATEしない)、表単位の「ビュー経由でしか読まない」— を同じ調子で足すと、規則の数だけ特例が増える。 @@ -20,7 +20,7 @@ ### facts — 文から導く事実 -文(展開ごと)について、以下を導く。`One`の証明器(`internal/analyze/card.go`の`prover`: `fix` / `equate` / `fixpoint`)がすでに等値の部分を持っていて、これが核になる。 +文(展開ごと)について、以下を導く。`One`の証明器(`check/postgres/analyze/card.go`の`prover`: `fix` / `equate` / `fixpoint`)がすでに等値の部分を持っていて、これが核になる。 - 等値: `col = $n`、`col = const`、`a.col = b.col`(WHERE・JOIN ON・USING由来)。ユニオン・ファインドで閉包を取る - NULL性: `col IS NULL` / `IS NOT NULL`が含意されるか @@ -68,7 +68,7 @@ opt-outは今と同じで文側に置く。`-- sqlshape: unfiltered memos`は`-- 1. 文自身がWHERE・ON・SETで満たす 2. ビュー経由: ビュー定義の事実を継承して満たす(`visible where`をビューが運ぶ、の一般化) 3. ポリシー経由: RLSのUSINGが満たす。所有者への注記は今と同じ -4. FK伝播: 結合先で満たされた義務が、複合FKの等値を通してこの表にも届く(`orders.tenant_id`が固定されていれば`order_items.tenant_id`も固定されている) +4. FK伝播: 結合先で満たされた義務が、複合FKの等値を通してこの表にも届く(`orders.tenant_id`が固定されていれば`order_items.tenant_id`も固定されている)。届く先の列は`NOT NULL`(宣言か、文の述語による証明)に限る。外部キーのどれか1列がNULLの行は制約の検査対象外(MATCH SIMPLE。両サーバとも)で、親と結合できても揃っていない(裁定2026-09-16) 5. opt-out 証明できなければエラー。証明できないのは、義務が強すぎるか、スキーマに一意性・FKが足りていないかの発見になる(`One`と同じ)。 @@ -145,7 +145,7 @@ DDDが集約に言わせている規則は、既存の義務に展開される 1.1.0 の敵対的テスト(7レーン、rc後)で裁定したもの。 -- 書き込みは「表に対して実際にすること」で数える。MERGEは枝ごとに枝の文種の`Write`(文全体の`Write`は出さない)、`INSERT ... ON CONFLICT DO UPDATE`はINSERTの`Write`とUPDATEの`Write`の2つ、`TRUNCATE`は全行のDELETE、自動更新可能ビューへの書き込みは基底表の`Write`(列名も基底表のもの)。`Write.InWith`がWITH項目の書き込みを区別し、`single`の証明対象から外す +- 書き込みは「表に対して実際にすること」で数える。MERGEは枝ごとに枝の文種の`Write`(文全体の`Write`は出さない)、`INSERT ... ON CONFLICT DO UPDATE`はINSERTの`Write`とUPDATEの`Write`の2つ、`TRUNCATE`は全行のDELETE、自動更新可能ビューへの書き込みは基底表の`Write`(列名も基底表のもの)。`Write.InWith`がWITH項目の書き込みを区別し、`single`の証明対象から外す。MySQLの`INSERT ... ON DUPLICATE KEY UPDATE`も同じ形で、INSERTの`Write`とON DUPLICATE枝のUPDATEの`Write`の2つ。MySQLの`REPLACE INTO`はキーが衝突すると既存行を削除してから挿入するので、INSERTの`Write`に加えてDELETEの`Write`も出す(衝突しない実行がありうる文でも、義務は文の形で判定するので両方出す)。`facts.Write.Table`が基底表であるのと同じく、`facts.Leaf.Table`(`obligation.Check`が義務の宛先を探す方)も自動更新可能ビューへの書き込みでは基底表を名乗る(2巡目でMySQL側の食い違いを修正: 以前はビュー自身の名前のままで、ビュー経由の書き込みに基底表の義務が一切効いていなかった) - `pinned`をUPDATEで満たすのはWHEREの固定だけ。SETで列に代入しても満たさない(代入で満たすのはINSERTと、INSERT枝しかないMERGE)。ON CONFLICT DO UPDATE / MERGEの枝が`pinned`列に代入するなら、文が同じ列を固定していることも求める - 文脈の`waive on `は名指しの文種だけを解除する(`Kinds`の差分)。`-require-columns`等フラグ由来の義務は宣言と同格で、文脈の`waive`で解除できる(`FromFlags`を`InContext`の前に足す) - スキーマに無い文脈名、ディレクティブとして読めない`sqlshape: context`行、ディレクティブを読まない文(ALTER TABLE / COMMENT ON)の直上のディレクティブは、黙って無視せず報告する @@ -228,6 +228,7 @@ ORMが一枚のクラス定義に混ぜて置いている制約は、この枠 - **opt-outの名前と粒度** → `waive
[]`。義務単位(bodyを宣言どおりに綴る)と表単位(省略)。`unfiltered`は述語型だけを外す別名として残す - **関数本体の中の文** → ビューと同じ扱い。本体はschema読み込み時に自身が判定され(PL/pgSQLは行番号つき)、呼び出し側はその関数の中の表について判定されない。`via view`は関数呼び出しを直接参照と数えない(`-no-tables`が関数を許してきたのと同じ線。「DBがAPIを出す」路線ではビューと関数が出口) - **ビューに付けた義務** → ビューの**読み手**への義務。ビューの定義文は、中の表の義務を自分で履行する側。`require pinned(tenant_id)`をビューに付ければ読み手が固定し、`require via view`を表に付ければビューの定義文が履行経路になる。両方が同時に成り立つ +- **`WITH CHECK OPTION`を宣言したビューへの書き込み** → 上の対称形(読み手ではなく**書き手**側)。ビューのWHEREが保証する等値を、その書き込みの`pinned`履行として使う(`Discharge.Path`は`ByView`)。CASCADED(PostgreSQLの、およびMySQLの既定)は結合の向こうも含めて下位の全ビューのWHEREまで届き、LOCALはそのビュー自身のWHEREで止まる — ただし自前のCHECK OPTIONを宣言した下位ビューはどちらのサーバも検査し続けるので数える(裁定2026-09-16、3巡目)。履行できるのはサーバ自身がCHECK OPTION違反を拒む(PostgreSQL SQLSTATE 44000、MySQL 1369 `ER_VIEW_CHECK_FAILED`)からで、CHECK OPTIONを宣言していないビューへの書き込みはこの経路を持たない(行がビューから見えなくなるだけで、サーバは拒まない)。`x/facts`の`LiftThroughView`がビュー本体のWHERE(`Origin`が`FromStatement`のもの)を書き込み文のleafへ列名を介して写像し、`x/obligation`の`pinned`が`FromView`起源の等値を`FromPolicy`と同じ形で見る - **文をまたぐ規則** → 持たない。分析の単位は文で、これは核の裁定(「核はSQLしか見ない」)と同じ根。トランザクション内の対(accountsのUPDATEとledgerのINSERT)は、書き込みCTEで1文にまとめる形に変換できるものだけ扱う(ロードマップの`paired`)。Go側で`pgx.Tx`上の呼び出し集合を集める層は作らない - **FK伝播をNULL性と`One`の証明に使うか** → 義務の外。`prover`の話で、design.mdのRLSの項「未対応」に残す。義務側は複合FKで`pinned`を運ぶところまで - **「掘り先」の優先順位** → 1.1.0には入れない。次は状態機械とoutbox(`paired`)。下記ロードマップ @@ -255,10 +256,10 @@ flowchart LR | package | 依存 | 役割 | |---|---|---| -| `internal/facts` | なし | データ契約。文種・スコープの木・葉(表・別名・役割・位置)・正規化述語・等値類・固定列・NULL拒否列・代入集合。パーサのノードを含まない | +| `x/facts` | なし | データ契約。文種・スコープの木・葉(表・別名・役割・位置)・正規化述語・等値類・固定列・NULL拒否列・代入集合。パーサのノードを含まない | | `internal/obligation` | `facts` `schema` | 宣言の文法、フラグからの展開、含意判定、FK閉包、履行経路の記録。`analyze`にも`vet`にも依存しない | -| `internal/analyze` | `facts` | 判定の代わりにFactsを出す。`recordFixed`(各クエリレベルの等値閉包)とMERGEのONが生産点。ビュー本体とポリシーのUSINGも同じ形で葉に付ける(`Origin`で区別)。`Lower`が宣言の述語を事実の言語に落とす | -| `internal/vet` / `internal/cli` | `obligation` | 入口。`-require-columns=tenant_id`を`Pinned("tenant_id")`の宣言列に、`-no-table-reads`を`ViaView`に展開して渡す。文の`waive`行を読んで葉に付ける。`Discharge`を診断に写す | +| `check/postgres/analyze` | `facts` | 判定の代わりにFactsを出す。`recordFixed`(各クエリレベルの等値閉包)とMERGEのONが生産点。ビュー本体とポリシーのUSINGも同じ形で葉に付ける(`Origin`で区別)。`Lower`が宣言の述語を事実の言語に落とす | +| `cmd/sqlshape/internal/vet` / `cmd/sqlshape/internal/cli` | `obligation` | 入口。`-require-columns=tenant_id`を`Pinned("tenant_id")`の宣言列に、`-no-table-reads`を`ViaView`に展開して渡す。文の`waive`行を読んで葉に付ける。`Discharge`を診断に写す | **正規化述語(`facts.Pred`)が共通言語。** `Eq(col, Param | Const | Column | Known)` / `IsNull` / `IsNotNull` / `Opaque(正準テキスト)`。文のconjunctも、ビューの述語も、RLSのUSINGも、宣言のSQL式も、全部これに落ちる。落とすのは方言側の仕事(`obligation.Lowerer`インターフェースをanalyzeが実装する)で、obligationは含意しか判定しない。`Opaque`同士のテキスト一致が、今の`sameExpr`にあたる構文フォールバック。 @@ -272,8 +273,8 @@ flowchart LR ## 移行 -0. 契約の型だけ切る: `internal/facts`、`internal/obligation`(`Check`は未実装)。済 -1. analyzeがFactsを出す。判定は従来のまま。Factsのスナップショットテストを足す。既存テストは無変更で緑。済(`internal/analyze/facts.go`、`TestFacts`)。MERGEのONも一つのスコープとして出る(今の`checkVisibility`はMERGEを見ていないので、段3で載せ替えるとMERGEにも`visible where`が効くようになる。意図した拡張として受け入れる) +0. 契約の型だけ切る: `x/facts`、`internal/obligation`(`Check`は未実装)。済 +1. analyzeがFactsを出す。判定は従来のまま。Factsのスナップショットテストを足す。既存テストは無変更で緑。済(`check/postgres/analyze/facts.go`、`TestFacts`)。MERGEのONも一つのスコープとして出る(今の`checkVisibility`はMERGEを見ていないので、段3で載せ替えるとMERGEにも`visible where`が効くようになる。意図した拡張として受け入れる) 2. obligationの中身: `require`文法、`Pinned` / `Immutable` / `ViaView`、含意エンジン、FK閉包、ビュー・ポリシー継承。schemaと手書きFactsだけで回る単体テスト 3. 3規則を載せ替える。`visible where`は`Predicate on read`の別名、`-require-columns`は`Pinned`、`-no-table-reads` / `-no-tables`は`ViaView`。`checkVisibility`と`vet/rls.go`を削除し、schemaは`Directives`を溜めるだけにする。診断文は据え置き、`internal/vet/testdata`と`policy_test.go`が回帰を押さえる。**ここが「アドオンをコアから切り離す」の完了点**。済。`internal/vet/testdata`は無変更で緑。`policy_test.go`は`obligation/visible_test.go`に移した。載せ替えで変わった振る舞い(意図した拡張): (a) MERGEのONにも`visible where`と`pinned`が効く、(b) 判定が表単位から葉単位になった(自己結合・サブクエリの各出現がそれぞれ義務を負う。`-require-columns`は以前、文中のどこかで固定されていれば同じ表の他の出現も通していた)、(c) `visible where`をRLSポリシーのUSINGが履行できる(経路3。所有者への注記は`-strict`で出る)、(d) 複合FKで結合先の固定が伝播する(経路4)。schemaのseed文(`schema.CheckStatement`)からは`visible where`の判定が外れた — seedはINSERT VALUESに限られ読みを持たない 4. 新機能: `on `、`Immutable`、表をまたぐ`EXISTS`、`waive`の一般形。済(`EXISTS`の含意は「サブクエリの裁定」で後追い)。`on` / `immutable` / `via view`の表単位は段2の実装で動いており、docs(checks / templates / flags)に書いた。`waive
[]`を文側とビュー定義側に足した(bodyは宣言どおりの綴り、省略で全部、`unfiltered`は述語型だけの別名)。opt-outは`Discharge`に`Waived`として残り、vetは`-strict`で報告する。表をまたぐ`EXISTS`は構文一致(Opaque)で判定できる段階で、含意(サブクエリの事実)は未対応 diff --git a/docs/postgres.ja.md b/docs/postgres.ja.md new file mode 100644 index 0000000..fe136cc --- /dev/null +++ b/docs/postgres.ja.md @@ -0,0 +1,171 @@ +# PostgreSQL + +[English](postgres.md) + +sqlshapeのうちPostgreSQLに属するものを1枚にまとめる。スキーマが名乗るバージョン、検査器が何を埋め込みどう検証しているか、pgxの上のランタイム、マイグレーションコマンド。規則そのものは[checks.ja.md](checks.ja.md)にあり、どのデータベースでも同じである。規則の中に出てくる名前・番号・型が、`postgres`を名乗るスキーマではどこから来るのか、がこのページの中身になる。 + +## スキーマがバージョンを名乗る + +```sql +-- sqlshape: postgres 17 +CREATE TABLE ... +``` + +`schema.sql`は、どのバージョンのPostgreSQL向けに書かれているかを1回宣言する。17か18。この宣言が判定の土台になる。スキーマと全部の文を読む文法、型・関数・演算子を解決するカタログ、マイグレーション系コマンドが起動するPostgreSQLのバージョンが、ここで決まる。宣言の無いスキーマは読まない。 + +新しいバージョンだけが受け付ける構文(`RETURNING old.*`、`WITHOUT OVERLAPS`、`NOT ENFORCED`、`VIRTUAL`な生成列は18のもの)は、古いバージョンを宣言していれば構文エラーになる。そのバージョンのサーバに流したときと同じ結果である。PostgreSQLのバージョンを上げる作業は、この数字を変えて検査器の報告を読むことになる。 + +## 検査器が埋め込んでいるもの + +パーサは宣言したバージョンのlibpg_queryで、WebAssemblyとして埋め込みwazeroで動かす。サーバが読める文は検査器も同じように読む。SELECTとDML、MERGE、CTE、ウィンドウ関数、GROUPING SETS、SQL/JSON、範囲型、18の`RETURNING old` / `new`と時制キー、citextやhstoreなどの拡張、ビュー・関数(SQLとPL/pgSQLの本体まで)・トリガ・ポリシーを含むDDL。アナライザーはそのバージョンの`pg_catalog`から組み上げたpure Goの実装で、検査時にPostgreSQLへ接続することはない。Cコンパイラもリンクするライブラリも要らない。初回だけモジュールのコンパイルに1秒ほどかかり、結果はユーザーのキャッシュディレクトリ(Linuxでは`~/.cache/sqlshape`)に置かれる。 + +判定の裏付けはPostgreSQL自身の回帰テストである。`src/test/regress`の文を、アナライザーと同じバージョンの本物のPostgreSQLに並走させ、パラメータの型・結果列・エラーの判定が一致することを確認している。一致しないのは17で22,103文のうち19件、18で23,384文のうち31件。全件を`check/postgres/analyze/testdata/regress_baseline_.txt`に列挙してあり、どれも静的解析では判定できないもの(行レベルセキュリティの再帰、権限、サーバ内部のエラー)か、検査器が正しくサーバのDescribeには見えないもの(INSERTの`RETURNING old`がNULLであること)である。この突き合わせは`go test ./...`の一部なので、新しい不一致が出ればテストが失敗する。 + +## 規則が使うもの + +- 型。PostgreSQLの各型をpgxがどのGo型で読み書きするかは[下のGo型の表](#go型の表)にある。表に無い型や、自分の型で受けたい型は、Goの型に`// sqlshape: type `と書いて結びつける。 +- 制約名。失敗モードの名前はPostgreSQLが制約に付ける名前そのものである([下の表](#制約の名前))。規則は[書き込みの失敗に備える](checks.ja.md#書き込みの失敗に備える)。 +- `One`の証明の材料は、主キー、`UNIQUE`制約、一意インデックス、文が述語を繰り返す部分一意インデックス、既知の点で固定した時制キー(`WITHOUT OVERLAPS`、18)。`DEFERRABLE`なキーは何も証明しない([1行だけ返す](checks.ja.md#1行だけ返すone))。 +- スキーマ自身も検査する。SQL関数とPL/pgSQL関数の本体、ビュー、トリガ、行レベルセキュリティのポリシーはロード時に解析され、PL/pgSQLの`RAISE`はその関数を呼ぶ文の失敗モードに加わる。 +- PostgreSQLのスキーマ(`CREATE SCHEMA app`)は関係の名前の一部である。`-schemas=a_api,b_private`はパッケージが参照してよいスキーマを絞る。1つのデータベースを複数サービスで使うときの境界になる([checks.ja.md](checks.ja.md#パッケージは自分のスキーマだけを参照する-schemaspostgresql))。 + +PostgreSQLにだけあるもの: `postgres.Copy`と`postgres.MatView`([下](#ランタイム-pgx))、PL/pgSQL、ドメイン、複合型と配列、`-schemas`、`// sqlshape: type`、マイグレーションのseed表。 + +## Go型の表 + +pgxが実際にscan / encodeできる組み合わせを、稼働中のPostgreSQLで確認したもの。列を受けるときも、パラメータを渡すときも同じ表に従う。 + +| PostgreSQL | Go | +|---|---| +| `bool` | `bool` | +| `smallint` / `integer` / `bigint` | `int16` / `int32` / `int64` / `int`(狭いGo型で受けると`bigint into int32`のような注記が付く) | +| `real` / `double precision` | `float32` / `float64`(`double precision into float32`は注記付き) | +| `numeric` | `string`(全桁が保たれる)、`pgtype.Numeric`、`big.Rat`、`shopspring/decimal.Decimal`、`apd.Decimal`。floatや整数でも受けられるが精度の注記が付く | +| `text` / `varchar` / `char` / `name` / `citext`などテキスト系の拡張型 | `string`(`string`はパラメータとしてはどの型にも渡せる) | +| `bytea` | `[]byte` | +| `uuid` | `uuid.UUID`(パッケージは問わない)、`[16]byte`、`string` | +| `timestamptz` / `timestamp` / `date` | `time.Time`(`timestamp`と`date`はタイムゾーンや時刻が失われるので`-strict`で注記) | +| `time` | `time.Time`、`string` | +| `interval` | `pgtype.Interval`(月・日・マイクロ秒を分けたまま保持する)。`time.Duration`はこれらを固定長に均してしまうため、PostgreSQL自身のカレンダー演算と日単位でずれ得る旨の常時の注記が付く | +| `json` / `jsonb` | `[]byte`、`json.RawMessage`、`string`、またはpgxがunmarshalできる構造体・スライス・マップ | +| `inet` | `netip.Addr` / `netip.Prefix` | +| `cidr` | `netip.Prefix` | +| `macaddr` | `net.HardwareAddr` / `string` | +| `hstore` | `map[string]*string` | +| `T[]` | `[]Go(T)`(各要素は単体の`T`のパラメータ/列と同じ判定を受ける。注記も含めて)。PostgreSQLは配列の要素自体がNOT NULLであることを保証しない――列自身の`NOT NULL`は配列値全体が`NULL`になることを禁じるだけである――ため、`NULL`を受けられない要素型(ポインタでないもの)には常時の注記が付き、`-strict`では拒否としても報告される。`integer[]`に対する`[]int32`、複合型の配列に対する`[]Item`はどちらも`NULL`要素を安全に受けるために`[]*int32`/`[]*Item`が要る(複合型の要素はより厄介で、pgxは`NULL`要素をエラーにせずゼロ値の構造体として黙って復号する)。要素が決して`NULL`にならないと検査器が証明できるときは注記も`-strict`の拒否も付かない: それ自体が`NULL`にならない`x`の`array_agg(x)`(行コンストラクタ`(a, b)::T`、内部結合した`NOT NULL`列)、要素がすべて非NULLのリテラル`ARRAY[...]`、単一の`NOT NULL`出力列にかかる`ARRAY(SELECT ...)`、そしてそれらを定義に持つビューを通して。素の配列列は対象外である。PostgreSQLは列の要素についてそのような宣言を持たないので、自分の方が正しいと分かっているならフィールドの`col:",notnull"`が要素についても主張する | +| 範囲型 | `pgtype.Range[T]`。`T`はサブタイプと照合される(ユーザー定義の範囲型も同様) | +| 多重範囲型 | `pgtype.Multirange[pgtype.Range[T]]` | +| `bit` / `point` / `tsvector` | `pgtype`の対応する型 | +| `xml` / `money` / `tsquery` / `jsonpath` / `timetz` | `string` | +| `oid` | `uint32` | +| enum、seed済みlookupテーブルのキー、CHECKによる値集合 | Goのnamed string type([型に意味を持たせる](checks.ja.md#型に意味を持たせる)) | +| ドメイン | 基底型に対応するGo型、またはドメインに結びつけたnamed type | +| 複合型、レコード | 構造体 | + +表に無い型、あるいは自前の型で受けたい型は、Go側の型にdocコメントで対応するPostgreSQL型を宣言する: + +```go +// sqlshape: type money_amount +type Money struct{ ... } // sql.Scanner / driver.Valuer を実装する +``` + +検査器は、SQL側が`money_amount`(その配列と、それを基底型とするドメインを含む)である位置でだけ`Money`を受け入れ、それ以外の位置では報告する。値の変換は型自身の`sql.Scanner` / `driver.Valuer`に任せる(Scannerにはテキスト形式が渡る)。 + +## 制約の名前 + +名前を付けた制約はその名前で呼ぶ。名前を付けなかった制約にはPostgreSQLが付ける名前がそのまま使われるので、診断・expect行・実行時エラーで同じ文字列になる。 + +| 制約 | キー | 例 | +|---|---|---| +| `PRIMARY KEY` | `
_pkey` | `orders_pkey` | +| `UNIQUE (a, b)` | `
___key` | `customers_email_key` | +| 列`(a)`の`REFERENCES` | `
__fkey` | `orders_customer_id_fkey` | +| 列をちょうど1つだけ参照するテーブルの`CHECK` `(a)` | `
__check`(複数列を参照するか、列を参照しないCHECKは`
_check`) | `orders_total_check` | +| ドメインの`CHECK` | `_check` | `yen_check` | +| `EXCLUDE (a, b)` | `
___excl` | `reservations_room_during_excl` | +| `NOT NULL` | `
.` | `orders.total` | +| ドメインの`NOT NULL` | ドメインの名前(`public`以外はスキーマ修飾) | `email` | +| トリガーが送出するエラー | SQLSTATE、または`-- sqlshape: error`で付けた名前 | `P0401`、`OrderTooLarge` | +| ビューの`WITH CHECK OPTION` | SQLSTATE(PostgreSQL自身の44000エラーは制約名を持たない) | `44000` | + +同じ名前になる制約が2つあると、PostgreSQLと同様に番号が付く(`orders_total_check1`)。生成した名前がPostgreSQLの63バイトという識別子の上限を超える場合は、マルチバイト文字を途中で切らないよう、PostgreSQLと同じやり方で切り詰める。 + +自動更新可能ビューで`WITH CHECK OPTION`を宣言したものへの`UPDATE` / `INSERT`は、基底表の`require pinned(<列>)`を履行する(`Discharge.Path`は`ByView`): + +- ビュー自身のWHEREがその列を等値で固定しているとき。`WITH CHECK OPTION`単独ならCASCADEDなので下位の全ビューのWHEREも数える。`WITH LOCAL CHECK OPTION`はそのビュー自身で止まるが、自前のCHECK OPTIONを宣言した下位ビューはPostgreSQLが検査し続けるので数える +- 裏付けは上の44000そのもの。CHECK OPTIONを宣言していないビューはこの経路を持たない + +## ランタイム: pgx + +``` +$ go get github.com/kr9ly/sqlshape/v2 # Query / One: 検査器が読む宣言 +$ go get github.com/kr9ly/sqlshape/postgres/v2 # pgxの上で実行する +``` + +`github.com/kr9ly/sqlshape/postgres/v2`は宣言した文をpgxの上で実行する。実行先の`postgres.DB`は`*pgx.Conn`、`*pgxpool.Pool`、`pgx.Tx`のどれでもよいので、同じ文をトランザクションの中でもそのまま実行できる。`{{.X}}`はワイヤ上で`$n`パラメータになる。 + +```go +for o, err := range postgres.Run(ctx, db, ListOrders, p) { ... } // iter.Seq2[Order, error]、逐次読み出し +orders, err := postgres.Collect(ctx, db, ListOrders, p) // []Order +first, err := postgres.First(ctx, db, ListOrders, p) // 先頭行。無ければ ErrNoRows +tag, err := postgres.Exec(ctx, db, MarkPaid, p) // pgconn.CommandTag。行は捨てる + +u, err := postgres.Get(ctx, db, UserByEmail, p) // One: 無ければ ErrNoRows +u, ok, err := postgres.Find(ctx, db, UserByEmail, p) // One: ok が有無 +tag, err := postgres.ExecOne(ctx, db, MarkPaid, p) // One: 1行も触らなければ ErrNoRows +``` + +どのランタイムでも同じこと(行のマッピング、`One`、検査済みのSQLだけが走る保証、expect行の名前で返るエラー)は[runtime.ja.md](runtime.ja.md)にある。pgxで足されるもの: + +- `Stmt.Unprepared()`はサーバ側のprepared statementを使わずに実行する複製を返す。プランナが毎回実際の値でプランを作るので、パラメータの分布が偏っていてpgxのstatement cacheが汎用プランに落ち着いてしまう文に使う。それ以外はpgxのprepared statementキャッシュが展開ごとに働く。 +- ネストした行とユーザー定義型。`array_agg(row(o.id, o.total))`、`array_agg(o)`、`row(...)`、複合型の列は、構造体か構造体のスライスにフィールドごとに読み込む。無名のrecordは位置で、名前付きの複合型はその列順で対応する。複合型のパラメータも構造体か構造体のスライスから同じ規則でエンコードする。pgxはユーザー定義型(enum、複合型、ドメイン、範囲、多重範囲、それらの配列)を知らないと復号できない。`Run`は結果に必要な型を初めて出会った接続でロードするが、無名recordの中にネストした型は読む前に見えないので、その場合とプールでは一度全部登録する: + + ```go + cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { + return postgres.LoadUserTypes(ctx, conn) + } + ``` + + pgx自身のローダーが読まない拡張のスカラー型(`citext`、`hstore`、`ltree`など)も登録する。`hstore`はpgxのhstoreコーデック(`map[string]*string`)、他はテキストとして。`// sqlshape: type money_amount`で束縛を宣言した型は、その型自身の`sql.Scanner` / `driver.Valuer`に任せる。ランタイムはその列についてPostgreSQLにテキスト形式を要求し、要求は文ごとに記憶する。その型がその後にdropして作り直されていたら(マイグレーション。同じ名前で新しいOID)、PostgreSQLの「cached plan must not change result type」に気づいて要求を1回やり直す。 +- エラー。制約違反(SQLSTATEのクラス23)は`*postgres.ConstraintError`として返る。PostgreSQLが報告した`Code`、`Constraint`、`Table`、`Column`、`Detail`を持ち、`*pgconn.PgError`を包む。`Key()`はexpect行の綴りである。ドメインが起こす`NOT NULL`だけはPostgreSQLが表も列も返さないので、`Key()`はドメインの名前になる。`postgres.Violates(err, key)`で判定する。`postgres.ErrNoRows`は`pgx.ErrNoRows`である。 +- `Batch`は複数の文を1往復で送る(`pgx.Batch`): + + ```go + b := postgres.NewBatch() + orders := postgres.Queue(b, ListOrders, ListParams{Status: &paid}) + paid := postgres.QueueOne(b, MarkPaid, struct{ ID int64 }{id}) + if err := b.Send(ctx, db); err != nil { ... } + rows, err := orders.Rows() // []Order。First() で先頭行 + tag, err := paid.Tag() + ``` + + `BatchDB`は`SendBatch`を持つもの、つまり接続・プール・トランザクションのどれでもよい。バッチの途中では型をロードできないので、ユーザー定義のenumや複合型を使うなら先に`LoadUserTypes`を呼ぶ。行に`sql.Scanner`型を持つ文はpgxのバッチに乗せられない(バッチはテキスト形式を要求しない)ので、`Send`がバッチの直後にキューの順で普通のクエリとして実行する。キューに入れた文の違反は`Run`と同じ規則で`*ConstraintError`になる。`Send`は最初の違反を返し、その後ろの文は実行されない(`ErrNotSent`)。 +- `Copy`は`pgx.CopyFrom`による`COPY ... FROM`: + + ```go + var loadItems = postgres.Copy[Item]("order_items", "order_id", "line_no", "sku", "qty") + + n, err := loadItems.From(ctx, db, items) // []Item + n, err := loadItems.FromSeq(ctx, db, seq) // iter.Seq[Item] + ``` + + 各列には`R`のうちその列に対応するフィールドが入る。列を指定しなければ全フィールドが自分の名前の列に入り、スカラーの`R`は1列に入る。検査器はテーブル、列、各列の型とフィールドの型、指定しなかった列に既定値があることを確かめる([checks.ja.md](checks.ja.md#copyで一括ロードするpostgresql))。 +- `MatView`はマテリアライズドビューを名指す。`Refresh`と`RefreshConcurrently`(後者はビューに一意インデックスが要る。`-strict`が確かめる)。 + + ```go + var OrderStats = postgres.MatView("order_stats") + + err := OrderStats.Refresh(ctx, db) + err := OrderStats.RefreshConcurrently(ctx, db) + ``` + +## マイグレーション + +`sqlshape diff`、`apply`、`verify-schema`は、データベースと`schema.sql`の差分からDDLを導き、そのDDLが本当に`schema.sql`に至ることを確かめてから実行する。両側を`pg_dump`の出力として比較するので、宣言したバージョンの`pg_dump`が`PATH`に要り、`schema.sql`を読むために宣言したバージョンの埋め込みPostgreSQL(初回にダウンロードされ、`~/.cache/sqlshape`にキャッシュされる)を起動する。コマンドは[migrations.ja.md](migrations.ja.md)にあり、MySQLで違うところはそのMySQLの節にある。 + +## License + +The PostgreSQL side of the checker (`check/postgres`) embeds `pg_catalog` data and validation +rules ported from PostgreSQL under the PostgreSQL License, see +[check/postgres/NOTICE](../check/postgres/NOTICE). The runtime (`postgres`) is Apache License 2.0, +like the declarations. diff --git a/docs/postgres.md b/docs/postgres.md new file mode 100644 index 0000000..1541f51 --- /dev/null +++ b/docs/postgres.md @@ -0,0 +1,257 @@ +# PostgreSQL + +[日本語](postgres.ja.md) + +Everything about sqlshape that is PostgreSQL's: the version the schema declares, what the checker +embeds and how its verdicts are verified, the runtime on pgx, and the migration commands. The rules themselves are in [checks.md](checks.md) and are the same for every database; +this page is where the names, numbers and types in those rules come from when the schema declares +`postgres`. + +## The schema declares the version + +```sql +-- sqlshape: postgres 17 +CREATE TABLE ... +``` + +`schema.sql` names, once, the PostgreSQL major version it is written for: 17 or 18. The +declaration decides how everything else is judged: the grammar that parses the schema and every +statement, the catalog of types, functions and operators they resolve against, and the PostgreSQL +that the migration commands boot. A schema without it is not read. + +A statement that only a newer version accepts (`RETURNING old.*`, `WITHOUT OVERLAPS`, `NOT +ENFORCED`, `VIRTUAL` generated columns are 18's) is a syntax error under an older declaration, as +it is on that server. Moving to a new PostgreSQL is changing the number and reading what the +checker reports. + +## What the checker embeds + +The parser is libpg_query of the declared version, embedded as WebAssembly and run on wazero, so +everything the server parses, the checker parses the same way: SELECT and DML, MERGE, CTEs, +window functions, GROUPING SETS, SQL/JSON, ranges, `RETURNING old` / `new` and temporal keys on +18, extensions such as citext and hstore, and DDL including views, functions (SQL and PL/pgSQL +bodies), triggers and policies. The analyzer is a pure-Go implementation built from that +version's `pg_catalog`; checking never connects to a PostgreSQL. There is no C compiler to +install and no library to link; the first run compiles the module (about a second) and caches +the result under the user cache directory (`~/.cache/sqlshape` on Linux). + +The verdicts are backed by PostgreSQL's own regression suite. The statements of +`src/test/regress` are run through the analyzer and a real PostgreSQL of the same version side by +side, and parameter types, result columns and errors must agree. On 17 they disagree on 19 of +22,103 statements, on 18 on 31 of 23,384; every one is listed +(`check/postgres/analyze/testdata/regress_baseline_.txt`), and each is either something +static analysis cannot decide (row-level security recursion, permissions, server internals) or a +case where the checker is right and the server's Describe cannot say (the NULLs of `RETURNING old` +after an INSERT). The comparison is part of `go test ./...`, so a new disagreement fails the +build. + +## What the rules use + +- Types. The Go type for each PostgreSQL type, as pgx scans and encodes it, is + [the Go type table below](#the-go-type-table). A type not in the table, or one you receive + with a type of your own, is bound with `// sqlshape: type ` on the Go type. +- Constraint names. A failure mode is named as PostgreSQL names the constraint + ([the table below](#constraint-names)); the rules are in + [Preparing for a write to fail](checks.md#preparing-for-a-write-to-fail). +- The `One` proof rests on primary keys, `UNIQUE` constraints, unique indexes, partial unique + indexes whose predicate the statement repeats, and temporal keys (`WITHOUT OVERLAPS`, 18) fixed + at a known point; a `DEFERRABLE` key never proves anything + ([Returning one row](checks.md#returning-one-row-one)). +- The schema itself is checked: the bodies of SQL and PL/pgSQL functions, views, triggers and + row-level security policies are analyzed at load, and a PL/pgSQL `RAISE` joins the failure modes + of the statements that call the function. +- PostgreSQL schemas (`CREATE SCHEMA app`) are part of a relation's name; `-schemas=a_api,b_private` + restricts the schemas a package may reference, a service boundary over one database + ([checks.md](checks.md#a-package-references-only-its-schemas--schemas-postgresql)). + +Only on PostgreSQL: `postgres.Copy` and `postgres.MatView` ([below](#the-runtime-pgx)), PL/pgSQL, +domains, composite types and arrays as first-class types, `-schemas`, `// sqlshape: type`, seeded +tables in migrations. + +## The Go type table + +What pgx actually scans and encodes, verified against a running PostgreSQL. The same table applies +to receiving columns and to passing parameters. + +| PostgreSQL | Go | +|---|---| +| `bool` | `bool` | +| `smallint` / `integer` / `bigint` | `int16` / `int32` / `int64` / `int` (a narrower Go type is accepted with a note such as `bigint into int32`) | +| `real` / `double precision` | `float32` / `float64` (`double precision into float32` is noted) | +| `numeric` | `string` (keeps every digit), `pgtype.Numeric`, `big.Rat`, `shopspring/decimal.Decimal`, `apd.Decimal`; a float or an integer is accepted with a precision note | +| `text` / `varchar` / `char` / `name` / `citext` and other text-like extension types | `string` (a `string` also encodes as a parameter of any type) | +| `bytea` | `[]byte` | +| `uuid` | `uuid.UUID` (any package), `[16]byte`, `string` | +| `timestamptz` / `timestamp` / `date` | `time.Time` (`-strict` notes that `timestamp` and `date` lose the zone or the time) | +| `time` | `time.Time`, `string` | +| `interval` | `pgtype.Interval` (keeps months, days and microseconds apart); `time.Duration` flattens them into a fixed span and carries a standing note that this can disagree with PostgreSQL's own calendar arithmetic on the same interval by whole days | +| `json` / `jsonb` | `[]byte`, `json.RawMessage`, `string`, or any struct, slice or map pgx unmarshals into | +| `inet` | `netip.Addr` / `netip.Prefix` | +| `cidr` | `netip.Prefix` | +| `macaddr` | `net.HardwareAddr` / `string` | +| `hstore` | `map[string]*string` | +| `T[]` | `[]Go(T)` (each element is checked the same way a plain `T` parameter or column is, notes included). PostgreSQL never guarantees an array's elements are themselves not null -- a column's own `NOT NULL` only forbids the array value as a whole from being `NULL` -- so a result element type that cannot itself carry `NULL` (not already a pointer) carries a standing note, and `-strict` additionally reports it as rejected: `[]int32` for `integer[]` and `[]Item` for a composite array both need `[]*int32` / `[]*Item` to receive a `NULL` element safely (a composite element is worse: pgx silently decodes a `NULL` one as a zero-valued struct instead of erroring). The checker skips the note (and the `-strict` rejection) when it can prove the elements can never be `NULL`: `array_agg(x)` where `x` itself is never `NULL` (a row constructor `(a, b)::T`, an inner-joined `NOT NULL` column), a literal `ARRAY[...]` whose elements are all not null, or `ARRAY(SELECT ...)` over a `NOT NULL` single output column, and through a view whose defining query does. A plain array column never qualifies -- PostgreSQL declares no such thing about a column's own elements -- so when you know better, `col:",notnull"` on the field asserts the elements too | +| ranges | `pgtype.Range[T]`, with `T` checked against the subtype (user-defined ranges too) | +| multiranges | `pgtype.Multirange[pgtype.Range[T]]` | +| `bit` / `point` / `tsvector` | the `pgtype` value | +| `xml` / `money` / `tsquery` / `jsonpath` / `timetz` | `string` | +| `oid` | `uint32` | +| enums, seeded lookup keys, CHECK value sets | a Go named string type ([Giving types a meaning](checks.md#giving-types-a-meaning)) | +| domains | the base type's Go type, or a named type bound to the domain | +| composites, records | a struct | + +A type not in the table, or one you want to receive with your own type, is declared on the Go type +in its doc comment: + +```go +// sqlshape: type money_amount +type Money struct{ ... } // implements sql.Scanner / driver.Valuer +``` + +The checker then accepts `Money` exactly where the SQL has `money_amount` (its arrays and domains +over it included) and reports it anywhere else. Conversion is left to the type's own +`sql.Scanner` / `driver.Valuer`; the Scanner receives the text form. + +## Constraint names + +A named constraint goes by its name. An unnamed one goes by the name PostgreSQL gives it, so the +diagnostic, the expect line and the run-time error all carry the same string. + +| constraint | key | example | +|---|---|---| +| `PRIMARY KEY` | `
_pkey` | `orders_pkey` | +| `UNIQUE (a, b)` | `
___key` | `customers_email_key` | +| `REFERENCES` on `(a)` | `
__fkey` | `orders_customer_id_fkey` | +| table `CHECK` referencing exactly one column `(a)` | `
__check` (a CHECK on several columns, or on none, is `
_check`) | `orders_total_check` | +| domain `CHECK` | `_check` | `yen_check` | +| `EXCLUDE (a, b)` | `
___excl` | `reservations_room_during_excl` | +| `NOT NULL` | `
.` | `orders.total` | +| domain `NOT NULL` | the domain's name, schema-qualified unless `public` | `email` | +| an error raised by a trigger | the SQLSTATE, or the name given with `-- sqlshape: error` | `P0401`, `OrderTooLarge` | +| `WITH CHECK OPTION` on a view | the SQLSTATE (PostgreSQL's own 44000 error names no constraint) | `44000` | + +A second constraint that would get the same name is numbered, as PostgreSQL does +(`orders_total_check1`). A generated name over PostgreSQL's 63-byte identifier limit is cut down the +same way PostgreSQL cuts it, without splitting a multibyte character. + +An UPDATE / INSERT through an auto-updatable view declared `WITH CHECK OPTION` discharges +a base table's `require pinned()` (`Discharge.Path` `ByView`): + +- when the view's own WHERE fixes the column by equality; `WITH CHECK OPTION` alone is + CASCADED, so every underlying view's WHERE counts too; `WITH LOCAL CHECK OPTION` stops at the + view itself except for an underlying view with a check option of its own, which PostgreSQL + keeps enforcing; +- the 44000 above is what makes the pin genuine: a view with no CHECK OPTION never discharges + it. + +## The runtime: pgx + +``` +$ go get github.com/kr9ly/sqlshape/v2 # Query / One: the declarations the checker reads +$ go get github.com/kr9ly/sqlshape/postgres/v2 # running them on pgx +``` + +`github.com/kr9ly/sqlshape/postgres/v2` runs a declared statement on pgx. `postgres.DB` is what a +statement runs against, and `*pgx.Conn`, `*pgxpool.Pool` and `pgx.Tx` all satisfy it, so a +statement runs on a transaction unchanged. `{{.X}}` becomes a `$n` parameter on the wire. + +```go +for o, err := range postgres.Run(ctx, db, ListOrders, p) { ... } // iter.Seq2[Order, error], streamed +orders, err := postgres.Collect(ctx, db, ListOrders, p) // []Order +first, err := postgres.First(ctx, db, ListOrders, p) // the first row, ErrNoRows when none +tag, err := postgres.Exec(ctx, db, MarkPaid, p) // pgconn.CommandTag, rows discarded + +u, err := postgres.Get(ctx, db, UserByEmail, p) // One: ErrNoRows when absent +u, ok, err := postgres.Find(ctx, db, UserByEmail, p) // One: ok reports presence +tag, err := postgres.ExecOne(ctx, db, MarkPaid, p) // One: ErrNoRows when no row was touched +``` + +What every runtime does the same way (row mapping, `One`, the guarantee that only checked SQL +runs, errors under the expect line's names) is in [runtime.md](runtime.md). What pgx adds: + +- `Stmt.Unprepared()` returns a copy that runs without a server-side prepared statement, so the + planner makes a custom plan for the actual values every time. Use it for statements whose + parameters have skewed distributions, where pgx's statement cache would settle on a generic + plan. Otherwise prepared-statement caching is pgx's, per expansion. +- Nested rows and user types. `array_agg(row(o.id, o.total))`, `array_agg(o)`, `row(...)` and + composite-typed columns are scanned into a struct or a slice of structs field by field, + positionally for an anonymous record and by the composite's column order for a named one. + Composite parameters are encoded from a struct or a slice of structs the same way. pgx has to + know a user-defined type (enum, composite, domain, range, multirange, and their arrays) before + it can decode it: `Run` loads the types a result needs on the connection the first time it + meets them; types nested inside an anonymous record cannot be seen before scanning, so for + those, and for pools, register everything once: + + ```go + cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { + return postgres.LoadUserTypes(ctx, conn) + } + ``` + + Extension scalar types pgx's own loader does not read (`citext`, `hstore`, `ltree`, ...) are + registered too: `hstore` with pgx's hstore codec (`map[string]*string`), the others as text. + A type with a declared binding (`// sqlshape: type money_amount`) is left to its own + `sql.Scanner` / `driver.Valuer`; the runtime asks PostgreSQL for the text format on those + columns and remembers the request per statement. If the type was dropped and recreated since + (a migration, same name, new OID), the runtime notices PostgreSQL's "cached plan must not + change result type" and re-derives the request once. +- Errors. A constraint violation (SQLSTATE class 23) comes back as a `*postgres.ConstraintError` + with the `Code`, `Constraint`, `Table`, `Column` and `Detail` PostgreSQL reported, wrapping the + `*pgconn.PgError`. `Key()` is the violation as the expect line spells it; a `NOT NULL` a domain + raises is the one case PostgreSQL reports with no table or column, so `Key()` is the domain's + name there. `postgres.Violates(err, key)` tests for it. `postgres.ErrNoRows` is `pgx.ErrNoRows`. +- `Batch` sends several statements in one round trip (`pgx.Batch`): + + ```go + b := postgres.NewBatch() + orders := postgres.Queue(b, ListOrders, ListParams{Status: &paid}) + paid := postgres.QueueOne(b, MarkPaid, struct{ ID int64 }{id}) + if err := b.Send(ctx, db); err != nil { ... } + rows, err := orders.Rows() // []Order; First() for the first row + tag, err := paid.Tag() + ``` + + A `BatchDB` is anything with `SendBatch`: connection, pool or transaction. Types cannot be + loaded mid-batch, so with user enums or composites in play call `LoadUserTypes` first. A + statement whose rows carry a `sql.Scanner` type cannot ride in a pgx batch, which never asks + for text format; `Send` runs it as a plain query right after the batch, in queue order. A + queued statement's violations are mapped to `*ConstraintError` the same way `Run` maps them; + `Send` returns the first, and the statements after it were not executed (`ErrNotSent`). +- `Copy` is `COPY ... FROM` through `pgx.CopyFrom`: + + ```go + var loadItems = postgres.Copy[Item]("order_items", "order_id", "line_no", "sku", "qty") + + n, err := loadItems.From(ctx, db, items) // []Item + n, err := loadItems.FromSeq(ctx, db, seq) // iter.Seq[Item] + ``` + + Each column is fed by the field of `R` that binds to it; with no columns given every field feeds + the column of its name; a scalar `R` feeds one column. The checker verifies the table, the + columns, each column's type against its field, and that every column left out has a default + ([checks.md](checks.md#bulk-loading-with-copy-postgresql)). +- `MatView` names a materialized view: `Refresh` and `RefreshConcurrently` (the latter needs a + unique index on the view, which `-strict` verifies). + + ```go + var OrderStats = postgres.MatView("order_stats") + + err := OrderStats.Refresh(ctx, db) + err := OrderStats.RefreshConcurrently(ctx, db) + ``` + +## Migrations + +`sqlshape diff`, `apply` and `verify-schema` derive the DDL from the difference between a +database and `schema.sql`, check that the DDL leads to `schema.sql`, and run it. They compare +both sides as `pg_dump` output, so they need the declared version's `pg_dump` on `PATH`, and they +boot an embedded PostgreSQL of the declared version (downloaded on first use, cached under +`~/.cache/sqlshape`) to read `schema.sql` through it. [migrations.md](migrations.md) has the +commands; its MySQL section says what differs there. + +## License + +The PostgreSQL side of the checker (`check/postgres`) embeds `pg_catalog` data and validation +rules ported from PostgreSQL under the PostgreSQL License, see +[check/postgres/NOTICE](../check/postgres/NOTICE). The runtime (`postgres`) is Apache License 2.0, +like the declarations. diff --git a/docs/runtime.ja.md b/docs/runtime.ja.md index d436c5a..73dff9e 100644 --- a/docs/runtime.ja.md +++ b/docs/runtime.ja.md @@ -2,106 +2,53 @@ [English](runtime.md) -`sqlshape`パッケージは、検査済みの文をpgxの上で実行する。実行先の`DB`は`*pgx.Conn`、`*pgxpool.Pool`、`pgx.Tx`のどれでもよいので、同じ文をトランザクションの中でもそのまま実行できる。 +文は`sqlshape`パッケージで宣言し(`Query`、`One`。依存は無い)、使うデータベースのランタイムモジュールで実行する。`github.com/kr9ly/sqlshape/postgres/v2`はpgxの上([postgres.ja.md](postgres.ja.md#ランタイム-pgx))、`github.com/kr9ly/sqlshape/mysql/v2`は`database/sql`の上([mysql.ja.md](mysql.ja.md#ランタイム-databasesql))。このページは両方が同じにやることを書く。例はpostgresの関数で示すが、mysqlの関数も同じ名前と同じ形をしている。検査器が読むのは宣言だけなので、自前のランタイムで実行してもよい。その場合に得られないものは末尾に書く。 ## 文の実行 ```go var ListOrders = sqlshape.Query[Order, ListParams](`...`) -for o, err := range ListOrders.Run(ctx, db, p) { ... } // iter.Seq2[Order, error]、逐次読み出し -orders, err := ListOrders.Collect(ctx, db, p) // []Order -first, err := ListOrders.First(ctx, db, p) // 最初の行。無ければ ErrNoRows -tag, err := ListOrders.Exec(ctx, db, p) // pgconn.CommandTag。行は捨てる +for o, err := range postgres.Run(ctx, db, ListOrders, p) { ... } // iter.Seq2[Order, error]、逐次読み出し +orders, err := postgres.Collect(ctx, db, ListOrders, p) // []Order +first, err := postgres.First(ctx, db, ListOrders, p) // 最初の行。無ければ ErrNoRows +tag, err := postgres.Exec(ctx, db, MarkPaid, p) // ドライバの結果(pgconn.CommandTag / sql.Result)。行は捨てる ``` -`One[R, P]`は`Single`を返す。`Render`と`Unprepared`は`Stmt`と同じで、実行方法は3つある: +`One[R, P]`は`Single`を返す。`Render`は`Stmt`と同じで、実行方法は3つある: ```go var UserByEmail = sqlshape.One[User, struct{ Email string }](`...`) -u, err := UserByEmail.Get(ctx, db, p) // 無ければ ErrNoRows -u, ok, err := UserByEmail.Find(ctx, db, p) // ok が有無を表す -tag, err := MarkPaid.Exec(ctx, db, p) // 1行も対象にならなければ ErrNoRows +u, err := postgres.Get(ctx, db, UserByEmail, p) // 無ければ ErrNoRows +u, ok, err := postgres.Find(ctx, db, UserByEmail, p) // ok が有無を表す +tag, err := postgres.ExecOne(ctx, db, MarkPaid, p) // 1行も対象にならなければ ErrNoRows ``` 3つとも、2行目が返ってきたら`ErrManyRows`を返す。検査器は1行以下しか返らないことをスキーマから証明しているので、これが起きるのは、証明の根拠になった一意制約が実際のデータベースでは外れているときである。 -`Stmt.Unprepared()`は、サーバー側のprepared statementを使わずに実行するコピーを返す。プランナーが毎回、実際のパラメータ値でプランを作る。パラメータの値の分布が偏っていて、pgxのstatement cacheが汎用プランに固定されると遅くなる文に使う。それ以外の文では、prepared statementのキャッシュは展開ごとにpgxが管理する。 - ## 行のマッピング -結果列は名前でフィールドに対応づけられる。`col:"..."`タグ、次に`db:"..."`タグ、次にフィールド名をsnake_caseにしたものの順で探す。埋め込み構造体のフィールドは平坦化される。NULLを受けられるフィールド(ポインタ、スライス、マップ、`sql.Null*`、`pgtype.*`)はNULLをゼロ値として受ける。この展開の結果に列が無いフィールド(一部の分岐だけが選ぶ列)もゼロ値のままになる。`R`がスカラーなら1列を直接受ける。`numeric`は`string`で受けると全桁が保たれる。 +結果列は名前でフィールドに対応づけられる。`col:"..."`タグ、次に`db:"..."`タグ、次にフィールド名をsnake_caseにしたものの順で探す。埋め込み構造体のフィールドは平坦化される。NULLを受けられるフィールド(ポインタ、スライス、マップ、`sql.Null*`、`pgtype.*`のようなドライバのNULL可の値型)はNULLをゼロ値として受ける。この展開の結果に列が無いフィールド(一部の分岐だけが選ぶ列)もゼロ値のままになる。`R`がスカラーなら1列を直接受ける。`numeric` / `DECIMAL`は`string`で受けると全桁が保たれる。列やパラメータをどのGo型で受けられるかはデータベースごとの表にある([postgres.ja.md](postgres.ja.md#規則が使うもの)、[mysql.ja.md](mysql.ja.md#go型の表))。 Goのenum型に`Known() bool`を実装しておくと(`Labelled`インターフェース)、マッパーはこのビルドが知らないラベルを受け取ったとき、switchで扱えない値をアプリケーションに渡す代わりに`*UnknownLabelError`を返す。 -## ネストした行とユーザー定義型 - -`array_agg(row(o.id, o.total))`、`array_agg(o)`、`row(...)`、複合型の列は、構造体または構造体のスライスにフィールドごとに読み込まれる。無名のレコードは位置で、名前付きの複合型は列の順序で対応づける。複合型のパラメータ(SQL側が`money_amount`を期待する位置の`{{.Price}}`、`order_items[]`を期待する位置の`{{.Items}}`)も、同じ規則で構造体や構造体のスライスからencodeされる。 - -pgxはユーザー定義型(enum、複合型、ドメイン、範囲型、多重範囲型、およびそれらの配列)をdecodeする前に、その型を知っていなければならない。`Run`は結果に必要な型を、初めて出会った時点でその接続に登録する。ただし無名レコードの内側にネストした型はscanする前には見えないので、その場合とプールを使う場合は、接続時にまとめて登録する: - -```go -cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { - return sqlshape.LoadUserTypes(ctx, conn) -} -``` - -pgx自身の型ローダーが読まない拡張のスカラー型(`citext`、`hstore`、`ltree`など)も登録される。`hstore`はpgxのhstore codec(`map[string]*string`)で、それ以外はテキストとして(`string`、配列なら`[]string`)扱う。`money`のようにpgxにcodecの無い型を持つテーブルも読み込める。 - -宣言型バインディング(`// sqlshape: type money_amount`)を持つ型は、その型自身の`sql.Scanner` / `driver.Valuer`で変換される。ランタイムはその列についてPostgreSQLにテキスト形式を要求するので、Scannerには値のテキスト表現が渡る。この要求は文ごとに覚えておくので、以降の実行では余計な往復が省ける。もしその後に型が削除・再作成されていたら(マイグレーション、名前は同じでOIDだけ変わる)、ランタイムはPostgreSQLが返す「cached plan must not change result type」に気づいて要求を1回だけ作り直すので、再起動なしに文は動き続ける。 - ## エラー -制約違反(SQLSTATEクラス23)は`*ConstraintError`として返る。PostgreSQLが報告した`Code`、`Constraint`、`Table`、`Column`、`Detail`を持ち、元の`*pgconn.PgError`を包んでいる。`Key()`はexpect行に書くのと同じ表記の名前を返す。制約名、またはNOT NULLなら`table.column`である。expect行で名指したSQLSTATE(トリガーの`P0401`、または`-- sqlshape: error`で付けた名前)も同じように包まれる。 +制約違反はランタイムの`*ConstraintError`として返る(`postgres.ConstraintError`は`*pgconn.PgError`を、`mysql.ConstraintError`はドライバのエラーを包む)。サーバが報告した内容を持ち、`Key()`はexpect行に書くのと同じ表記の名前を返す。データベースが制約に付ける名前、またはNOT NULLなら`table.column`である([postgres.ja.md](postgres.ja.md#規則が使うもの)、[mysql.ja.md](mysql.ja.md#制約名と失敗モード))。expect行で名指したSQLSTATE(PostgreSQLのトリガの`P0401`、MySQLのSIGNAL自身のコード)も同じように包まれる。PostgreSQLはschema.sqlを読まないので`-- sqlshape: error`注釈のNameを検査器が突き合わせたコードに戻せず、expect行が何か書いてある文が受け取ったカスタムSQLSTATEはすべて包む——検査器がその文について予告済みと確認できたものだけに限らない。 ```go -_, err := CreateCustomer.First(ctx, db, p) -if sqlshape.Violates(err, "customers_email_key") { +_, err := postgres.First(ctx, db, CreateCustomer, p) +if postgres.Violates(err, "customers_email_key") { return ErrEmailTaken } ``` -この名前は検査器が列挙したものと同じなので、コードが処理していない違反があれば、それはexpect行に書いてあるのに扱っていない違反である([checks.ja.md](checks.ja.md#書き込みの失敗に備える))。`ErrNoRows`は`pgx.ErrNoRows`と同じもので、`IsNoRows(err)`で判定できる。 - -## バッチ - -`Batch`は複数の文を1往復で送る(`pgx.Batch`): - -```go -b := sqlshape.NewBatch() -orders := sqlshape.Queue(b, ListOrders, ListParams{Status: &paid}) -paid := sqlshape.QueueOne(b, MarkPaid, struct{ ID int64 }{id}) -if err := b.Send(ctx, db); err != nil { ... } -rows, err := orders.Rows() // []Order。First() なら最初の行 -tag, err := paid.Tag() -``` - -`BatchDB`は`SendBatch`を持つもので、接続・プール・トランザクションのどれでもよい。バッチの途中では型を登録できないので、ユーザー定義のenumや複合型を使う文があるなら、先に`LoadUserTypes`を呼んでおく(プールなら`AfterConnect`で)。結果に`sql.Scanner`型が含まれる文はpgxのバッチには乗せられない(バッチではテキスト形式を要求できない)ので、`Send`はそれをバッチの直後に、キューに入れた順で通常のクエリとして実行する。 +この名前は検査器が列挙したものと同じなので、コードが処理していない違反があれば、それはexpect行に書いてあるのに扱っていない違反である([checks.ja.md](checks.ja.md#書き込みの失敗に備える))。`ErrNoRows`はドライバのもの(`pgx.ErrNoRows`、`sql.ErrNoRows`)で、`IsNoRows(err)`で判定できる。 -キューに入れた文の制約違反や`-- sqlshape: expect`のSQLSTATEは、`Run`と同じ規則で`*ConstraintError`に包まれる。行を返す文として積んだか、`Exec`(`RETURNING`なし)として積んだかは関係ない。`Send`は最初に失敗した文のエラーを返し、それより後にキューへ入れた文は実行されない。それらの`Rows` / `Tag`は`ErrNotSent`を返す。 +`-- sqlshape: error = `注釈のNameは、`sqlshape.Error(code)`でGoに写し取られる([checks.ja.md](checks.ja.md#トリガーが送出するエラーには名前を付ける))。これは`sqlshape.Failure`——コードそのものを保持する`~string`——を返す。`Violates`は`Failure`も生の文字列も同じに扱い(`Violates[K ~string](err error, key K) bool`)、渡されたコードだけで判定する。スキーマを読まないので、Nameとたまたま同じ文字列との区別が付かず、両者を解決し合うこともない。`OrderTooLarge`が`sqlshape.Error("P0401")`なら、`Violates(err, OrderTooLarge)`と`Violates(err, "P0401")`はまったく同じ呼び出しである。 -## 一括ロード - -```go -var loadItems = sqlshape.Copy[Item]("order_items", "order_id", "line_no", "sku", "qty") - -n, err := loadItems.From(ctx, db, items) // []Item -n, err := loadItems.FromSeq(ctx, db, seq) // iter.Seq[Item] -``` - -`Copy`は`pgx.CopyFrom`による`COPY ... FROM`である。各列には、それに対応する`R`のフィールド(タグまたはsnake_case、埋め込み構造体は平坦化)から値が入る。列を指定しなければ各フィールドが自分の名前の列に入り、`R`がスカラーなら1列に入る。検査器はテーブルと列の存在、各列の型とフィールドの対応、指定しなかった列に既定値があることを確かめる。 - -## マテリアライズドビュー - -```go -var OrderStats = sqlshape.MatView("order_stats") - -err := OrderStats.Refresh(ctx, db) // 完了まで読み手はブロックされる -err := OrderStats.RefreshConcurrently(ctx, db) // ビューに一意インデックスが必要 -``` - -検査器は名前が`schema.sql`にあることを確かめ、`-strict`では`RefreshConcurrently`を呼んでいるビューに一意インデックスがあることも確かめる。 +`Run` / `Exec`の外で走らせた文(pgxを直接、マイグレーションスクリプト、テスト)のエラーは`postgres.WrapError(err)`で同じ包み方になり、`Violates`で判定できる。そこでは文自身のexpectのコードは分からないので、制約のクラスだけを包む。 ## 検査済みのSQLのみが実行できる @@ -115,19 +62,10 @@ sqlshape: rendered SQL differs from the checked expansion [if@64:then]: the runt 確認できない場合が2つある。`{{range}}`が3要素以上のとき(検査は2要素までで行う)と、分岐の組み合わせが256を超えて代表だけが検査されたとき([templates.ja.md](templates.ja.md#分岐が多いとき))。この2つでは、分岐の形が検査したものと同じであることだけを確認して実行する。 -## 本物のPostgreSQLでのテスト +## データベースのランタイムが足すもの -`pgtest`は`schema.sql`を適用した埋め込みPostgreSQL(初回にダウンロードされ、`~/.cache/sqlshape`にキャッシュされる)を一時ディレクトリに起動する。`Close`で消える: +上の関数の他に、各ランタイムはそのドライバとデータベースにあって相手に無いものを持つ。pgxでは`Unprepared`、ネストした行とユーザー定義型と`LoadUserTypes`、`Batch`、`Copy`、`MatView`([postgres.ja.md](postgres.ja.md#ランタイム-pgx))。`database/sql`では`ExecOne`の`RowsAffected`の読み方と、サーバ設定を確かめる`mysql.Verify`([mysql.ja.md](mysql.ja.md#ランタイム-databasesql))。 -```go -schemaSQL, _ := pgtest.ReadSchema("schema.sql") // schema/ ディレクトリでもよい -db, err := pgtest.Start(ctx, schemaSQL) -defer db.Close() - -if err := db.Verify(ctx, ListOrders, UserByEmail, CreateCustomer); err != nil { - t.Fatal(err) -} -conn := db.Conn() // *pgx.Conn。db.ConnString() もある -``` +## 自前のランタイムでは得られないもの -`Verify`は各文のすべての展開をサーバーでprepareし、PostgreSQLが返すパラメータ型、結果列の名前と型、または構文エラーを、検査器の結論と比較する。不一致があれば、その文についての検査器の判定は信用できないということであり、エラーには不一致ごとにSQLと両者の判定が列挙される。アプリケーションのテストの隣に置いておけば、静的検査の結論が実際に使うPostgreSQLで成り立つことをテストスイートが保証する。`db.Conn()`はビュー・関数・トリガーを直接叩くための接続である。 +検査器が認識するのは宣言であってランタイムではない(`-query`で自前のマーカー関数を登録できる。[flags.ja.md](flags.ja.md))。次の3つの約束はランタイムのもので、`sqlshape/postgres`か`sqlshape/mysql`で実行したときだけ成立する: 送る SQL が検査器の確かめた展開とバイト単位で一致すること(上の節)、違反が expect 行の綴りの名前で`ConstraintError`として返ること、`One`の文が2行目を返したらエラーになること。 diff --git a/docs/runtime.md b/docs/runtime.md index 7f3cfbd..bf46a60 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -2,152 +2,90 @@ [日本語](runtime.ja.md) -The `sqlshape` package runs checked statements on pgx. `DB` is what a statement runs against; -`*pgx.Conn`, `*pgxpool.Pool` and `pgx.Tx` all satisfy it, so a statement runs on a transaction -unchanged. +A statement is declared with the `sqlshape` package (`Query`, `One`: no dependencies) and run +with the runtime module for its database: `github.com/kr9ly/sqlshape/postgres/v2` on pgx +([postgres.md](postgres.md#the-runtime-pgx)), `github.com/kr9ly/sqlshape/mysql/v2` on +`database/sql` ([mysql.md](mysql.md#the-runtime-databasesql)). This page is what both do the +same way; the examples use the postgres functions, and the mysql ones have the same names and +shapes. The checker reads only the declarations, so a program may also run them through a +runtime of its own; what such a runtime does not get is listed at the end. ## Statements ```go var ListOrders = sqlshape.Query[Order, ListParams](`...`) -for o, err := range ListOrders.Run(ctx, db, p) { ... } // iter.Seq2[Order, error], streamed -orders, err := ListOrders.Collect(ctx, db, p) // []Order -first, err := ListOrders.First(ctx, db, p) // the first row, ErrNoRows when none -tag, err := ListOrders.Exec(ctx, db, p) // pgconn.CommandTag, rows discarded +for o, err := range postgres.Run(ctx, db, ListOrders, p) { ... } // iter.Seq2[Order, error], streamed +orders, err := postgres.Collect(ctx, db, ListOrders, p) // []Order +first, err := postgres.First(ctx, db, ListOrders, p) // the first row, ErrNoRows when none +tag, err := postgres.Exec(ctx, db, MarkPaid, p) // the driver's result (pgconn.CommandTag / sql.Result), rows discarded ``` -`One[R, P]` gives a `Single` with the same `Render` and `Unprepared` and three ways to run: +`One[R, P]` gives a `Single` with the same `Render` and three ways to run: ```go var UserByEmail = sqlshape.One[User, struct{ Email string }](`...`) -u, err := UserByEmail.Get(ctx, db, p) // ErrNoRows when absent -u, ok, err := UserByEmail.Find(ctx, db, p) // ok reports presence -tag, err := MarkPaid.Exec(ctx, db, p) // ErrNoRows when no row was touched +u, err := postgres.Get(ctx, db, UserByEmail, p) // ErrNoRows when absent +u, ok, err := postgres.Find(ctx, db, UserByEmail, p) // ok reports presence +tag, err := postgres.ExecOne(ctx, db, MarkPaid, p) // ErrNoRows when no row was touched ``` All three return `ErrManyRows` if a second row arrives. The checker proved from the schema that there cannot be one, so this happens only when the unique constraint the proof rested on is not actually in place in the database. -`Stmt.Unprepared()` returns a copy that runs without a server-side prepared statement, so the -planner makes a custom plan for the actual values every time. Use it for statements whose -parameters have skewed distributions, where pgx's statement cache would settle on a generic -plan. Otherwise prepared-statement caching is pgx's, per expansion. - ## Row mapping Result columns are mapped to fields by name: the `col:"..."` tag, then `db:"..."`, then the field name in snake_case. Embedded structs flatten. A nullable field (pointer, slice, map, `sql.Null*`, -`pgtype.*`) receives NULL as its zero value; a nullable field whose column is absent from this +the driver's nullable value types such as `pgtype.*`) receives NULL as its zero value; a nullable field whose column is absent from this expansion's result stays zero (a column only some branches select). A scalar `R` receives the -single column. `numeric` into `string` keeps every digit. - -A Go enum type may implement `Known() bool` (the `Labelled` interface); the mapper then rejects a -label this build does not know with `*UnknownLabelError` instead of handing the application a -value it cannot switch on. - -## Nested rows and user types - -`array_agg(row(o.id, o.total))`, `array_agg(o)`, `row(...)` and composite-typed columns are -scanned into a struct or a slice of structs field by field, positionally for an anonymous record -and by the composite's column order for a named one. Composite parameters (`{{.Price}}` where -SQL expects `money_amount`, `{{.Items}}` where it expects `order_items[]`) are encoded from a -struct or a slice of structs the same way. - -pgx has to know a user-defined type (enum, composite, domain, range, multirange, and their -arrays) before it can decode it. `Run` loads the types a result needs on the connection the -first time it meets them; types nested inside an anonymous record cannot be seen before -scanning, so for those, and for pools, register everything once: - -```go -cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { - return sqlshape.LoadUserTypes(ctx, conn) -} -``` +single column. A `numeric` / `DECIMAL` into `string` keeps every digit. Which Go types a column or +a parameter may take is the database's table: [postgres.md](postgres.md#what-the-rules-use), +[mysql.md](mysql.md#the-go-type-table). -Extension scalar types that pgx's own loader does not read (`citext`, `hstore`, `ltree`, ...) -are registered too: `hstore` with pgx's hstore codec (`map[string]*string`), the others as text -(`string`, `[]string` for arrays). Tables carrying `money` and other types pgx has no codec for -still load. - -A type with a declared binding (`// sqlshape: type money_amount`) is left to its own -`sql.Scanner` / `driver.Valuer`; the runtime asks PostgreSQL for the text format on those -columns, so the Scanner receives the value's text form. That request is remembered per -statement so later runs skip the extra round trip; if the type was dropped and recreated -since (a migration, same name, new OID), the runtime notices PostgreSQL's resulting -"cached plan must not change result type" and re-derives the request once, so the -statement keeps working without a restart. +A Go enum type may implement `Known() bool` (the `sqlshape.Labelled` interface); the mapper then +rejects a label this build does not know with `*sqlshape.UnknownLabelError` instead of handing +the application a value it cannot switch on. The binding rules (`sqlshape.Fields`) are the root +module's, shared by the checker and every runtime. ## Errors -A constraint violation (SQLSTATE class 23) comes back as a `*ConstraintError` with the -`Code`, `Constraint`, `Table`, `Column` and `Detail` PostgreSQL reported, wrapping the -`*pgconn.PgError`. Its `Key()` is the violation as the template's expect line spells it: the -constraint's name, or `table.column` for NOT NULL. A SQLSTATE the expect line names (a -trigger's `P0401`, or the name given to it with `-- sqlshape: error`) is wrapped the same way. +A constraint violation comes back as a `*ConstraintError` of the runtime (`postgres.ConstraintError` +wrapping the `*pgconn.PgError`, `mysql.ConstraintError` wrapping the driver's error) with what +the server reported. Its `Key()` is the violation as the template's expect line spells it: the +constraint's name as the database names it, or `table.column` for NOT NULL +([postgres.md](postgres.md#what-the-rules-use), [mysql.md](mysql.md#constraint-names-and-failure-modes)). +A SQLSTATE the expect line names (a PostgreSQL trigger's `P0401`, or a MySQL SIGNAL's own +code) is wrapped the same way; on PostgreSQL, since the runtime does not read schema.sql and so +cannot resolve a `-- sqlshape: error` annotation's Name back to the code the checker matched it +against, it wraps any custom SQLSTATE a statement with an expect line at all receives, not only +one the checker is known to have predicted for it. ```go -_, err := CreateCustomer.First(ctx, db, p) -if sqlshape.Violates(err, "customers_email_key") { +_, err := postgres.First(ctx, db, CreateCustomer, p) +if postgres.Violates(err, "customers_email_key") { return ErrEmailTaken } ``` The names are the ones the checker listed, so a violation the code does not handle is one the -expect line announced ([checks.md](checks.md#preparing-for-a-write-to-fail)). -`ErrNoRows` is `pgx.ErrNoRows`; `IsNoRows(err)` tests for it. - -## Batches - -`Batch` sends several statements in one round trip (`pgx.Batch`): - -```go -b := sqlshape.NewBatch() -orders := sqlshape.Queue(b, ListOrders, ListParams{Status: &paid}) -paid := sqlshape.QueueOne(b, MarkPaid, struct{ ID int64 }{id}) -if err := b.Send(ctx, db); err != nil { ... } -rows, err := orders.Rows() // []Order; First() for the first row -tag, err := paid.Tag() -``` - -A `BatchDB` is anything with `SendBatch`: connection, pool or transaction. Types cannot be loaded -mid-batch, so with user enums or composites in play call `LoadUserTypes` first (pools: -`AfterConnect`). A statement whose rows carry a `sql.Scanner` type cannot ride in a pgx batch, -which never asks for text format; `Send` runs it as a plain query right after the batch, in -queue order. - -A queued statement's constraint violations and `-- sqlshape: expect` SQLSTATEs are mapped to -`*ConstraintError` the same way `Run` maps them, whether it was queued as a row-returning -statement or as an `Exec` (no `RETURNING`). `Send` returns the first such error; the queued -statements after the failing one were not executed, and their `Rows` / `Tag` return `ErrNotSent`. - -## Bulk loads - -```go -var loadItems = sqlshape.Copy[Item]("order_items", "order_id", "line_no", "sku", "qty") - -n, err := loadItems.From(ctx, db, items) // []Item -n, err := loadItems.FromSeq(ctx, db, seq) // iter.Seq[Item] -``` - -`Copy` is `COPY ... FROM` through `pgx.CopyFrom`: each column is fed by the field of `R` that -binds to it (tag or snake_case, embedded structs flattened); with no columns given every field -feeds the column of its name; a scalar `R` feeds one column. The checker verifies the table, the -columns, each column's type against its field, and that every column left out has a default. - -## Materialized views - -```go -var OrderStats = sqlshape.MatView("order_stats") - -err := OrderStats.Refresh(ctx, db) // readers block until done -err := OrderStats.RefreshConcurrently(ctx, db) // needs a unique index on the view -``` - -The checker verifies the name against schema.sql and, with `-strict`, that a unique index -exists when `RefreshConcurrently` is called. +expect line announced ([checks.md](checks.md#preparing-for-a-write-to-fail)). `ErrNoRows` is the +driver's (`pgx.ErrNoRows`, `sql.ErrNoRows`); `IsNoRows(err)` tests for it. + +A `-- sqlshape: error = ` annotation's Name is mirrored into Go with +`sqlshape.Error(code)` ([checks.md](checks.md#name-the-errors-a-trigger-raises)), giving a +`sqlshape.Failure` -- a `~string` holding the code itself. `Violates` takes a `Failure` or a +plain string identically (`Violates[K ~string](err error, key K) bool`), judging only by the +code it is given; it does not read the schema, so it cannot tell a Name from an arbitrary +string that happens to equal it, and never resolves one to the other. `Violates(err, +OrderTooLarge)` and `Violates(err, "P0401")` are exactly the same call once `OrderTooLarge` is +`sqlshape.Error("P0401")`. + +A statement run outside `Run` / `Exec` (pgx directly, a migration script, a test) gets the same +wrapping from `postgres.WrapError(err)`, so `Violates` judges its error too; a statement's own +expect codes are not known there, only the constraint classes. ## Only checked SQL runs @@ -166,25 +104,18 @@ and a template whose branch combinations exceeded 256 so that only a representat ([templates.md](templates.md#many-branches)). In those, only the shape of the branches is confirmed before running. -## Tests on a real PostgreSQL +## What the database's runtime adds -`pgtest` starts an embedded PostgreSQL (downloaded on first use, cached under -`~/.cache/sqlshape`) with schema.sql applied, in a temporary directory that dies with `Close`: +Beyond the functions above, each runtime has what its driver and database offer and the other +does not: on pgx, `Unprepared`, nested rows and user-defined types with `LoadUserTypes`, +`Batch`, `Copy` and `MatView` ([postgres.md](postgres.md#the-runtime-pgx)); on `database/sql`, +`ExecOne`'s reading of `RowsAffected` and `mysql.Verify` for the server's settings +([mysql.md](mysql.md#the-runtime-databasesql)). -```go -schemaSQL, _ := pgtest.ReadSchema("schema.sql") // or a schema/ directory -db, err := pgtest.Start(ctx, schemaSQL) -defer db.Close() - -if err := db.Verify(ctx, ListOrders, UserByEmail, CreateCustomer); err != nil { - t.Fatal(err) -} -conn := db.Conn() // *pgx.Conn, also db.ConnString() -``` +## What a runtime of your own does not get -`Verify` prepares every expansion of each statement on the server and compares PostgreSQL's -parameter types, result column names and types, or its rejection, with what the checker -concluded. A disagreement means the checker's verdict on that statement cannot be trusted; the -error lists each with the SQL and both descriptions. Put it in a test next to the application's -own tests, so the suite carries the evidence that the static checks hold for the PostgreSQL the -code runs on. `db.Conn()` is a plain connection for exercising views, functions and triggers. +The checker recognizes the declarations, not the runtime (`-query` registers a marker function +of your own, see [flags.md](flags.md)). Three promises are the runtime's, and hold only when the +statement runs through `sqlshape/postgres` or `sqlshape/mysql`: that the SQL sent is byte for byte the SQL the checker +verified (the section above), that a violation comes back as a `ConstraintError` under the name +the expect line spells, and that a `One` statement returning a second row is an error. diff --git a/docs/templates.ja.md b/docs/templates.ja.md index 3621892..8e94a44 100644 --- a/docs/templates.ja.md +++ b/docs/templates.ja.md @@ -2,17 +2,14 @@ [English](templates.md) -SQLはGoの`text/template`のサブセットで書く。入力はパラメータ型`P`の値で、`{{.X}}`と書いた箇所が`$n`のプレースホルダになり、`{{if}}`や`{{range}}`で文の形を切り替えられる。検査器は分岐の全組み合わせを展開して検査し、ランタイムは検査済みのSQLだけを実行する。展開はlint時に行うので、テンプレートは文字列定数でなければならない。 +SQLはGoの`text/template`のサブセットで書く。入力はパラメータ型`P`の値で、`{{.X}}`と書いた箇所がプレースホルダ(PostgreSQLでは`$n`、MySQLでは`?`)になり、`{{if}}`や`{{range}}`で文の形を切り替えられる。検査器は分岐の全組み合わせを展開して検査し、ランタイムは検査済みのSQLだけを実行する。展開はlint時に行うので、テンプレートは文字列定数でなければならない。 ## 使える構文 -値。`{{.Field}}`、`{{.Outer.Inner}}`、`{{.}}`、`range`の中の`{{$x}}`。それぞれがSQLの中では`$n`パラメータになり、値が文字列として埋め込まれることはない。関数呼び出し、メソッド呼び出し、パイプラインは値の位置では使えない。 - -分岐。`{{if}}` / `{{else if}}` / `{{else}}` / `{{end}}`、`{{with}}`、`{{range}}`。`{{switch}}`は無いので`{{if eq .Sort "a"}} … {{else if eq .Sort "b"}} … {{end}}`と書く。 - -条件式。組み込みの`not`、`and`、`or`、`eq`、`ne`、`lt`、`le`、`gt`、`ge`、`len`、`index`と、文字列・数値の定数、フィールド参照。nilポインタ、空のスライスやマップ、0、空文字列は`text/template`と同じく偽。 - -使えないもの。`{{define}}` / `{{template}}`(代わりにGoの定数を連結する。後述)、独自関数、rangeの要素以外の変数。 +- 値: `{{.Field}}`、`{{.Outer.Inner}}`、`{{.}}`、`range`の中の`{{$x}}`。それぞれがSQLの中ではパラメータになり、値が文字列として埋め込まれることはない。関数呼び出し、メソッド呼び出し、パイプラインは値の位置では使えない +- 分岐: `{{if}}` / `{{else if}}` / `{{else}}` / `{{end}}`、`{{with}}`、`{{range}}`。`{{switch}}`は無いので`{{if eq .Sort "a"}} … {{else if eq .Sort "b"}} … {{end}}`と書く +- 条件式: 組み込みの`not`、`and`、`or`、`eq`、`ne`、`lt`、`le`、`gt`、`ge`、`len`、`index`と、文字列・数値の定数、フィールド参照。nilポインタ、空のスライスやマップ、0、空文字列は`text/template`と同じく偽 +- 使えないもの: `{{define}}` / `{{template}}`(代わりにGoの定数を連結する。後述)、独自関数、rangeの要素以外の変数 ```sql SELECT o.id, o.total, o.created_at @@ -46,7 +43,7 @@ SELECT id FROM products | ディレクティブ | 意味 | |---|---| | `-- sqlshape: expect users_email_key, orders.total, P0401` | この書き込みが違反しうる制約、NOT NULL列、SQLSTATEの一覧。検査器はこの一覧が正確であることを保つ([checks.ja.md](checks.ja.md#書き込みの失敗に備える)) | -| `-- sqlshape: not null total, note` | これらの結果列はNULLにならない、と検査器の判定を上書きする(`col:",notnull"`タグのSQL側版) | +| `-- sqlshape: not null total, note` | これらの結果列はNULLにならない、と検査器の判定を上書きする(`col:",notnull"`タグのSQL側バージョン) | | `-- sqlshape: unfiltered memos` | この文は意図的に`memos`を`visible where`の条件なしで読む(述語型の義務だけを解除する) | | `-- sqlshape: waive orders pinned(tenant_id), audit` | この文は`orders`の義務を1つ(宣言どおりの綴りで指定する)、`audit`の義務を全部解除する。解除したことは`-strict`で報告される([checks.ja.md](checks.ja.md#宣言の仕組み)) | @@ -56,17 +53,17 @@ SELECT id FROM products |---|---|---| | `-- sqlshape: visible where deleted_at IS NULL` | `CREATE TABLE`の直上 | このテーブルを読む文はすべてこの条件を持たなければならない(`require deleted_at IS NULL on read`の略記) | | `-- sqlshape: require pinned(tenant_id)` / `require pinned(version) on update, delete` | `CREATE TABLE` / `CREATE VIEW`の直上 | そのリレーションに触る文すべてへの義務。述語、`pinned(列)`、`immutable(列)`、`via view`、`never`、`paired(表)`、`single`のいずれかに、任意で`on select, insert, update, delete`を付ける([checks.ja.md](checks.ja.md#宣言の仕組み)) | -| `-- sqlshape: aggregate orders (order_items, order_notes) [lock version]` | ルートの`CREATE TABLE`の直上 | これらの表で1つの集約を成す。子表はルートの鍵で固定し、1文は1集約にしか触らない。`lock`を付ければ書き込みはルートの版を名指しする([checks.ja.md](checks.ja.md#宣言の仕組み)) | +| `-- sqlshape: aggregate orders (order_items, order_notes) [lock version]` | ルートの`CREATE TABLE`の直上 | これらの表で1つの集約を成す。子表はルートの鍵で固定し、1文は1集約にしか触らない。`lock`を付ければ書き込みはルートのバージョンを名指しする([checks.ja.md](checks.ja.md#宣言の仕組み)) | | `-- sqlshape: transitions status: draft -> submitted, submitted -> paid \| cancelled` | `CREATE TABLE`の直上 | この列は状態機械。SETするUPDATEはWHEREで現在の状態を前状態に固定する | | `-- sqlshape: sensitive pii: email, phone` | `CREATE TABLE`の直上 | この列はラベルを持つ。`may read pii`の文脈だけが参照できる(ビュー経由も同じ。マスクの式でラベルは外れる) | | `-- sqlshape: context ops: waive pinned(tenant_id); require id = $1 on delete` | `CREATE TABLE` / `CREATE VIEW`の直上 | 名前つき文脈での義務の差分。パッケージコメントの`// sqlshape: context ops`、vetの`-context`、`check -context`のいずれかで選ぶ([checks.ja.md](checks.ja.md#宣言の仕組み)) | | `-- sqlshape: unfiltered orders` / `waive orders pinned(tenant_id)` | `CREATE VIEW`の直上 | ビュー定義自身が文としてopt-outする | | `-- sqlshape: not null` | `CREATE FUNCTION`の直上 | この関数の戻り値はNULLにならない | | `-- sqlshape: error P0401 = OrderTooLarge` | 関数の`CREATE FUNCTION`の直上 | この関数が送出するSQLSTATEに名前を付け、expect行と`Violates`でその名前を使えるようにする。PL/pgSQL本体の`RAISE`は注釈なしでもコードで検出される | -| `-- sqlshape: seed` | `INSERT ... VALUES`の直上 | このseedは追加のみ。宣言に無い行もテーブルに残す([migrations.ja.md](migrations.ja.md#seed済みテーブル)) | +| `-- sqlshape: seed` | `INSERT ... VALUES`の直上 | このseedは追加のみ。宣言に無い行もテーブルに残す([migrations.ja.md](migrations.ja.md#seed済みテーブルpostgresql)) | | `-- @migrate ...` | どこでも | マイグレーションの意図の宣言([migrations.ja.md](migrations.ja.md#diffだけでは決められないことを宣言する)) | -Goのコードの中では、型宣言のdocコメントに`// sqlshape: type money_amount`と書くと、その型をPostgreSQLの型に結びつけられる([checks.ja.md](checks.ja.md#go型の表))。パッケージコメントの`// sqlshape: context ops`は、そのパッケージが判定される義務の文脈を選ぶ。 +Goのコードの中では、型宣言のdocコメントに`// sqlshape: type money_amount`と書くと、その型をPostgreSQLの型に結びつけられる([checks.ja.md](postgres.ja.md#go型の表)。PostgreSQLのみ。MySQLには結びつける先の名前付きの型が無い)。パッケージコメントの`// sqlshape: context ops`は、そのパッケージが判定される義務の文脈を選ぶ。 ## SQLを共有する diff --git a/docs/templates.md b/docs/templates.md index 79c77cc..2848b68 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -3,26 +3,23 @@ [日本語](templates.ja.md) SQL is written as a subset of Go's `text/template`. The input is a value of the parameter type `P`; -each `{{.X}}` becomes a `$n` placeholder, and `{{if}}` and `{{range}}` switch the shape of the +each `{{.X}}` becomes a placeholder (`$n` on PostgreSQL, `?` on MySQL), and `{{if}}` and `{{range}}` switch the shape of the statement. The checker expands every combination of branches and checks each one; the runtime executes only SQL the checker has seen. Expansion happens at lint time, so the template must be a string constant. ## What can be written -Values. `{{.Field}}`, `{{.Outer.Inner}}`, `{{.}}`, and `{{$x}}` inside a `range`. Each becomes a -`$n` parameter in the SQL; a value is never spliced in as text. Function calls, method calls and -pipelines are not allowed in value position. - -Branches. `{{if}}` / `{{else if}}` / `{{else}}` / `{{end}}`, `{{with}}`, `{{range}}`. There is no -`{{switch}}`; write `{{if eq .Sort "a"}} … {{else if eq .Sort "b"}} … {{end}}`. - -Conditions. The builtins `not`, `and`, `or`, `eq`, `ne`, `lt`, `le`, `gt`, `ge`, `len`, `index`, -string and number constants, and field references. A nil pointer, an empty slice or map, zero and -the empty string are false, as in `text/template`. - -Not available. `{{define}}` / `{{template}}` (concatenate Go constants instead, see below), custom -functions, variables other than the range element. +- Values: `{{.Field}}`, `{{.Outer.Inner}}`, `{{.}}`, and `{{$x}}` inside a `range`. Each becomes + a parameter in the SQL; a value is never spliced in as text. Function calls, method calls and + pipelines are not allowed in value position. +- Branches: `{{if}}` / `{{else if}}` / `{{else}}` / `{{end}}`, `{{with}}`, `{{range}}`. There is + no `{{switch}}`; write `{{if eq .Sort "a"}} … {{else if eq .Sort "b"}} … {{end}}`. +- Conditions: the builtins `not`, `and`, `or`, `eq`, `ne`, `lt`, `le`, `gt`, `ge`, `len`, + `index`, string and number constants, and field references. A nil pointer, an empty slice or + map, zero and the empty string are false, as in `text/template`. +- Not available: `{{define}}` / `{{template}}` (concatenate Go constants instead, see below), + custom functions, variables other than the range element. ```sql SELECT o.id, o.total, o.created_at @@ -79,11 +76,11 @@ In `schema.sql`: | `-- sqlshape: unfiltered orders` / `waive orders pinned(tenant_id)` | `CREATE VIEW` | the view's own definition opts out, as a statement would | | `-- sqlshape: not null` | `CREATE FUNCTION` | the function's result is never NULL | | `-- sqlshape: error P0401 = OrderTooLarge` | a function's `CREATE FUNCTION` | names a SQLSTATE the function raises, so expect lines and `Violates` can use the name; a PL/pgSQL body's `RAISE` statements are found without it, under their code | -| `-- sqlshape: seed` | `INSERT ... VALUES` | the seed is additive: rows the declaration does not list stay ([migrations.md](migrations.md#seeded-tables)) | +| `-- sqlshape: seed` | `INSERT ... VALUES` | the seed is additive: rows the declaration does not list stay ([migrations.md](migrations.md#seeded-tables-postgresql)) | | `-- @migrate ...` | anywhere | a migration intent ([migrations.md](migrations.md#declaring-what-a-diff-cannot-see)) | In Go, `// sqlshape: type money_amount` in a type's doc comment binds the type to that PostgreSQL -type ([checks.md](checks.md#the-go-type-table)), and `// sqlshape: context ops` in a package comment +type ([checks.md](postgres.md#the-go-type-table); PostgreSQL only, MySQL has no named types to bind to), and `// sqlshape: context ops` in a package comment selects the obligation context the package is judged under. ## Sharing SQL diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..5f74edf --- /dev/null +++ b/errors.go @@ -0,0 +1,25 @@ +package sqlshape + +// Failure is a statement's failure mode as the program names it: a schema.sql `-- +// sqlshape: error = ` annotation's Name, brought into Go by Error. A +// runtime's Violates(err, key) takes a Failure or a plain string (both spell the same +// thing to it: the code the database itself reports), so a program can hand its own +// named value to the same call that used to take the raw code. +// +// // schema.sql, above the trigger / routine that raises it: +// // -- sqlshape: error 30001 = OrderTooLarge +// var OrderTooLarge = sqlshape.Error("30001") +// // ... +// if mysql.Violates(err, OrderTooLarge) { ... } +// +// vet checks both directions: every `sqlshape.Error(code)` declaration names a code the +// schema actually declares, under the Name the schema gave it, and every code the schema +// declares has a Go declaration for it somewhere in the program. +type Failure string + +// Error declares a program's Go name for a schema's `-- sqlshape: error = ` +// annotation. code is the annotation's key exactly as schema.sql spells it: a SQLSTATE +// (PostgreSQL, "P0401") or a MYSQL_ERRNO in decimal or a SQLSTATE (MySQL, "30001" or +// "45000"). vet requires the declaration's own name (the identifier `var X = ...` binds) +// to be the schema's Name for that code. +func Error(code string) Failure { return Failure(code) } diff --git a/eval_test.go b/eval_test.go index f4abd6c..f4b53c0 100644 --- a/eval_test.go +++ b/eval_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/kr9ly/sqlshape" + "github.com/kr9ly/sqlshape/v2" ) // EvalP exercises every kind the runtime evaluator (render.go) has to handle: diff --git a/examples/1-tables/app.go b/examples/1-tables/app.go index be510d3..92c6a67 100644 --- a/examples/1-tables/app.go +++ b/examples/1-tables/app.go @@ -7,11 +7,11 @@ import ( "github.com/jackc/pgx/v5" - "github.com/kr9ly/sqlshape" + "github.com/kr9ly/sqlshape/postgres/v2" ) // PlaceOrder creates an order with its lines in one transaction and returns its id. -// pgx.Tx satisfies sqlshape.DB, so statements run on the transaction unchanged. +// pgx.Tx satisfies postgres.DB, so statements run on the transaction unchanged. func PlaceOrder(ctx context.Context, conn *pgx.Conn, customerID int64, note *string, items []NewItem) (int64, error) { tx, err := conn.Begin(ctx) if err != nil { @@ -19,35 +19,35 @@ func PlaceOrder(ctx context.Context, conn *pgx.Conn, customerID int64, note *str } defer tx.Rollback(ctx) - id, err := CreateOrder.First(ctx, tx, NewOrder{CustomerID: customerID, Note: note}) + id, err := postgres.First(ctx, tx, CreateOrder, NewOrder{CustomerID: customerID, Note: note}) if err != nil { - if sqlshape.Violates(err, "orders_customer_id_fkey") { + if postgres.Violates(err, "orders_customer_id_fkey") { return 0, fmt.Errorf("customer %d does not exist", customerID) } return 0, err } for i, it := range items { it.OrderID, it.LineNo = id, int16(i+1) - if _, err := AddItem.Exec(ctx, tx, it); err != nil { - if sqlshape.Violates(err, "order_items_qty_check") { + if _, err := postgres.Exec(ctx, tx, AddItem, it); err != nil { + if postgres.Violates(err, "order_items_qty_check") { return 0, fmt.Errorf("line %d: quantity must be positive", i+1) } return 0, err } } - if _, err := RecomputeTotal.Exec(ctx, tx, struct{ OrderID int64 }{id}); err != nil { + if _, err := postgres.ExecOne(ctx, tx, RecomputeTotal, struct{ OrderID int64 }{id}); err != nil { return 0, err } return id, tx.Commit(ctx) } // Pay moves an order to paid. A missing order is a distinct error from a database failure. -func Pay(ctx context.Context, db sqlshape.DB, orderID int64) error { - _, err := SetStatus.Exec(ctx, db, struct { +func Pay(ctx context.Context, db postgres.DB, orderID int64) error { + _, err := postgres.ExecOne(ctx, db, SetStatus, struct { ID int64 Status OrderStatus }{orderID, Paid}) - if errors.Is(err, sqlshape.ErrNoRows) { + if errors.Is(err, postgres.ErrNoRows) { return fmt.Errorf("order %d does not exist", orderID) } return err diff --git a/examples/1-tables/app_test.go b/examples/1-tables/app_test.go index 8ffb24b..a81703b 100644 --- a/examples/1-tables/app_test.go +++ b/examples/1-tables/app_test.go @@ -10,8 +10,9 @@ import ( "github.com/jackc/pgx/v5" - "github.com/kr9ly/sqlshape" - "github.com/kr9ly/sqlshape/pgtest" + "github.com/kr9ly/sqlshape/pgtest/v2" + "github.com/kr9ly/sqlshape/postgres/v2" + "github.com/kr9ly/sqlshape/v2" ) func TestOrderBook(t *testing.T) { @@ -34,11 +35,11 @@ func TestOrderBook(t *testing.T) { } conn := db.Conn() - alice, err := CreateCustomer.First(ctx, conn, NewCustomer{Email: "alice@example.com", Name: "Alice"}) + alice, err := postgres.First(ctx, conn, CreateCustomer, NewCustomer{Email: "alice@example.com", Name: "Alice"}) if err != nil { t.Fatal(err) } - if _, err := CreateCustomer.First(ctx, conn, NewCustomer{Email: "alice@example.com", Name: "Alice again"}); !sqlshape.Violates(err, "customers_email_key") { + if _, err := postgres.First(ctx, conn, CreateCustomer, NewCustomer{Email: "alice@example.com", Name: "Alice again"}); !postgres.Violates(err, "customers_email_key") { t.Fatalf("duplicate email: %v", err) } @@ -55,7 +56,7 @@ func TestOrderBook(t *testing.T) { } // a negative price is rejected by the database too, but it is not the quantity // check PlaceOrder names specially: the generic path returns the raw error - if _, err := PlaceOrder(ctx, conn, alice, nil, []NewItem{{Sku: "BOOK", Qty: 1, Price: "-1.00"}}); !sqlshape.Violates(err, "order_items_price_check") { + if _, err := PlaceOrder(ctx, conn, alice, nil, []NewItem{{Sku: "BOOK", Qty: 1, Price: "-1.00"}}); !postgres.Violates(err, "order_items_price_check") { t.Fatalf("price check: %v", err) } // a context already done fails at Begin, before any statement runs; use a @@ -71,7 +72,7 @@ func TestOrderBook(t *testing.T) { } deadConn.Close(ctx) - o, err := OrderByID.Get(ctx, conn, struct{ ID int64 }{id}) + o, err := postgres.Get(ctx, conn, OrderByID, struct{ ID int64 }{id}) if err != nil || o.Total != "28.00" || o.Status != Pending || o.Note == nil || *o.Note != "gift wrap" { t.Fatalf("order: %v %+v", err, o) } @@ -82,23 +83,23 @@ func TestOrderBook(t *testing.T) { t.Fatal("paying a missing order should fail") } // cancelling a paid order changes nothing: One.Exec says so with ErrNoRows - if _, err := Cancel.Exec(ctx, conn, struct{ ID int64 }{id}); !errors.Is(err, sqlshape.ErrNoRows) { + if _, err := postgres.ExecOne(ctx, conn, Cancel, struct{ ID int64 }{id}); !errors.Is(err, postgres.ErrNoRows) { t.Fatalf("cancel paid: %v", err) } paid := []OrderStatus{Paid, Shipped} - rows, err := ListOrders.Collect(ctx, conn, ListOrdersParams{CustomerID: &alice, Statuses: paid, Sort: "total", Limit: 10}) + rows, err := postgres.Collect(ctx, conn, ListOrders, ListOrdersParams{CustomerID: &alice, Statuses: paid, Sort: "total", Limit: 10}) if err != nil || len(rows) != 1 || rows[0].ID != id { t.Fatalf("list: %v %+v", err, rows) } if got := Describe(rows[0]); got != "order #1: 28.00 (paid, preparing shipment)" { t.Errorf("describe: %q", got) } - items, err := ItemsOf.Collect(ctx, conn, struct{ OrderID int64 }{id}) + items, err := postgres.Collect(ctx, conn, ItemsOf, struct{ OrderID int64 }{id}) if err != nil || len(items) != 2 || items[1].Sku != "PEN" { t.Fatalf("items: %v %+v", err, items) } - totals, err := TotalsByCustomer.Collect(ctx, conn, struct{ MinOrders int64 }{0}) + totals, err := postgres.Collect(ctx, conn, TotalsByCustomer, struct{ MinOrders int64 }{0}) if err != nil || len(totals) != 1 || totals[0].Spent != "28.00" || totals[0].LastOrder == nil { t.Fatalf("totals: %v %+v", err, totals) } @@ -111,7 +112,7 @@ func TestOrderBook(t *testing.T) { t.Fatal(err) } var unknown *sqlshape.UnknownLabelError - if _, err := OrderByID.Get(ctx, conn, struct{ ID int64 }{id}); !errors.As(err, &unknown) || unknown.Value != "refunded" { + if _, err := postgres.Get(ctx, conn, OrderByID, struct{ ID int64 }{id}); !errors.As(err, &unknown) || unknown.Value != "refunded" { t.Fatalf("unknown label: %v", err) } @@ -120,7 +121,7 @@ func TestOrderBook(t *testing.T) { if err != nil { t.Fatal(err) } - po, err := OrderByID.Get(ctx, conn, struct{ ID int64 }{pending}) + po, err := postgres.Get(ctx, conn, OrderByID, struct{ ID int64 }{pending}) if err != nil || po.Status != Pending { t.Fatalf("pending order: %v %+v", err, po) } @@ -128,13 +129,13 @@ func TestOrderBook(t *testing.T) { t.Errorf("describe pending: got %q want %q", got, want) } - if _, err := SetStatus.Exec(ctx, conn, struct { + if _, err := postgres.ExecOne(ctx, conn, SetStatus, struct { ID int64 Status OrderStatus }{pending, Shipped}); err != nil { t.Fatal(err) } - so, err := OrderByID.Get(ctx, conn, struct{ ID int64 }{pending}) + so, err := postgres.Get(ctx, conn, OrderByID, struct{ ID int64 }{pending}) if err != nil || so.Status != Shipped { t.Fatalf("shipped order: %v %+v", err, so) } @@ -146,10 +147,10 @@ func TestOrderBook(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := Cancel.Exec(ctx, conn, struct{ ID int64 }{cancelled}); err != nil { + if _, err := postgres.ExecOne(ctx, conn, Cancel, struct{ ID int64 }{cancelled}); err != nil { t.Fatal(err) } - co, err := OrderByID.Get(ctx, conn, struct{ ID int64 }{cancelled}) + co, err := postgres.Get(ctx, conn, OrderByID, struct{ ID int64 }{cancelled}) if err != nil || co.Status != Cancelled { t.Fatalf("cancelled order: %v %+v", err, co) } @@ -157,7 +158,7 @@ func TestOrderBook(t *testing.T) { t.Errorf("describe cancelled: got %q want %q", got, want) } - if _, err := DeleteCustomer.Exec(ctx, conn, struct{ ID int64 }{alice}); !sqlshape.Violates(err, "orders_customer_id_fkey") { + if _, err := postgres.ExecOne(ctx, conn, DeleteCustomer, struct{ ID int64 }{alice}); !postgres.Violates(err, "orders_customer_id_fkey") { t.Fatalf("delete customer with orders: %v", err) } } diff --git a/examples/1-tables/doc.go b/examples/1-tables/doc.go index 6c55247..5f8d2b9 100644 --- a/examples/1-tables/doc.go +++ b/examples/1-tables/doc.go @@ -18,7 +18,7 @@ // constraints it may violate; the checker keeps the list exact, and at run time a // violation arrives as a ConstraintError keyed by the same names. // - Transactions are pgx's: PlaceOrder runs its statements on a pgx.Tx, which satisfies -// sqlshape.DB the same way a connection or a pool does. +// postgres.DB the same way a connection or a pool does. // - Tests on a real PostgreSQL: pgtest.Start applies schema.sql to an embedded server, // and db.Verify checks every statement against it, so the test suite carries the // evidence that the static checks hold for the PostgreSQL the code runs on. diff --git a/examples/1-tables/queries.go b/examples/1-tables/queries.go index 6febc99..7dcdffd 100644 --- a/examples/1-tables/queries.go +++ b/examples/1-tables/queries.go @@ -3,7 +3,7 @@ package tables import ( "time" - "github.com/kr9ly/sqlshape" + "github.com/kr9ly/sqlshape/v2" ) // OrderStatus is the Go side of the order_statuses lookup table. The checker binds the diff --git a/examples/2-views/app.go b/examples/2-views/app.go index d9333b7..d275749 100644 --- a/examples/2-views/app.go +++ b/examples/2-views/app.go @@ -7,7 +7,7 @@ import ( "github.com/jackc/pgx/v5" - "github.com/kr9ly/sqlshape" + "github.com/kr9ly/sqlshape/postgres/v2" ) // PlaceOrder creates an order with its lines in one transaction and returns its id. @@ -18,35 +18,35 @@ func PlaceOrder(ctx context.Context, conn *pgx.Conn, customerID int64, note *str } defer tx.Rollback(ctx) - id, err := CreateOrder.First(ctx, tx, NewOrder{CustomerID: customerID, Note: note}) + id, err := postgres.First(ctx, tx, CreateOrder, NewOrder{CustomerID: customerID, Note: note}) if err != nil { - if sqlshape.Violates(err, "orders_customer_id_fkey") { + if postgres.Violates(err, "orders_customer_id_fkey") { return 0, fmt.Errorf("customer %d does not exist", customerID) } return 0, err } for i, it := range items { it.OrderID, it.LineNo = id, int16(i+1) - if _, err := AddItem.Exec(ctx, tx, it); err != nil { - if sqlshape.Violates(err, "order_items_qty_check") { + if _, err := postgres.Exec(ctx, tx, AddItem, it); err != nil { + if postgres.Violates(err, "order_items_qty_check") { return 0, fmt.Errorf("line %d: quantity must be positive", i+1) } return 0, err } } - if _, err := RecomputeTotal.Exec(ctx, tx, struct{ OrderID int64 }{id}); err != nil { + if _, err := postgres.ExecOne(ctx, tx, RecomputeTotal, struct{ OrderID int64 }{id}); err != nil { return 0, err } return id, tx.Commit(ctx) } // Pay moves an order to paid. A missing (or archived) order is a distinct error. -func Pay(ctx context.Context, db sqlshape.DB, orderID int64) error { - _, err := SetStatus.Exec(ctx, db, struct { +func Pay(ctx context.Context, db postgres.DB, orderID int64) error { + _, err := postgres.ExecOne(ctx, db, SetStatus, struct { ID int64 Status OrderStatus }{orderID, Paid}) - if errors.Is(err, sqlshape.ErrNoRows) { + if errors.Is(err, postgres.ErrNoRows) { return fmt.Errorf("order %d does not exist", orderID) } return err diff --git a/examples/2-views/app_test.go b/examples/2-views/app_test.go index 236fc27..072a48c 100644 --- a/examples/2-views/app_test.go +++ b/examples/2-views/app_test.go @@ -9,8 +9,9 @@ import ( "github.com/jackc/pgx/v5" - "github.com/kr9ly/sqlshape" - "github.com/kr9ly/sqlshape/pgtest" + "github.com/kr9ly/sqlshape/pgtest/v2" + "github.com/kr9ly/sqlshape/postgres/v2" + "github.com/kr9ly/sqlshape/v2" ) func TestOrderBookThroughViews(t *testing.T) { @@ -32,15 +33,15 @@ func TestOrderBookThroughViews(t *testing.T) { } conn := db.Conn() - statuses, err := ListStatuses.Collect(ctx, conn, struct{}{}) + statuses, err := postgres.Collect(ctx, conn, ListStatuses, struct{}{}) if err != nil || len(statuses) != 4 || statuses[0].Code != Pending || statuses[0].Label != "Awaiting payment" { t.Fatalf("statuses: %v %+v", err, statuses) } - alice, err := CreateCustomer.First(ctx, conn, NewCustomer{Email: "alice@example.com", Name: "Alice"}) + alice, err := postgres.First(ctx, conn, CreateCustomer, NewCustomer{Email: "alice@example.com", Name: "Alice"}) if err != nil { t.Fatal(err) } - c, err := CustomerByEmail.Get(ctx, conn, struct{ Email string }{"alice@example.com"}) + c, err := postgres.Get(ctx, conn, CustomerByEmail, struct{ Email string }{"alice@example.com"}) if err != nil || c.ID != alice { t.Fatalf("by email: %v %+v", err, c) } @@ -58,7 +59,7 @@ func TestOrderBookThroughViews(t *testing.T) { } // a negative price is rejected by the database too, but PlaceOrder only special-cases // the quantity check: the generic path returns the raw error - if _, err := PlaceOrder(ctx, conn, alice, nil, []NewItem{{Sku: "BOOK", Qty: 1, Price: "-1.00"}}); !sqlshape.Violates(err, "order_items_price_check") { + if _, err := PlaceOrder(ctx, conn, alice, nil, []NewItem{{Sku: "BOOK", Qty: 1, Price: "-1.00"}}); !postgres.Violates(err, "order_items_price_check") { t.Fatalf("price check: %v", err) } // a context already done fails at Begin, before any statement runs; use a @@ -74,47 +75,47 @@ func TestOrderBookThroughViews(t *testing.T) { } deadConn.Close(ctx) - o, err := OrderByID.Get(ctx, conn, struct{ ID int64 }{id}) + o, err := postgres.Get(ctx, conn, OrderByID, struct{ ID int64 }{id}) if err != nil || o.Total != "28.00" || o.Status != Pending || o.StatusLabel != "Awaiting payment" || o.CustomerEmail != "alice@example.com" { t.Fatalf("order: %v %+v", err, o) } if err := Pay(ctx, conn, id); err != nil { t.Fatal(err) } - if _, err := Cancel.Exec(ctx, conn, struct{ ID int64 }{id}); !errors.Is(err, sqlshape.ErrNoRows) { + if _, err := postgres.ExecOne(ctx, conn, Cancel, struct{ ID int64 }{id}); !errors.Is(err, postgres.ErrNoRows) { t.Fatalf("cancel paid: %v", err) } - rows, err := ListOrders.Collect(ctx, conn, ListOrdersParams{CustomerID: &alice, Statuses: []OrderStatus{Paid, Shipped}, Sort: "total", Limit: 10}) + rows, err := postgres.Collect(ctx, conn, ListOrders, ListOrdersParams{CustomerID: &alice, Statuses: []OrderStatus{Paid, Shipped}, Sort: "total", Limit: 10}) if err != nil || len(rows) != 1 || rows[0].ID != id { t.Fatalf("list: %v %+v", err, rows) } if got := Describe(rows[0]); got != "order #1 for alice@example.com: 28.00 (Paid)" { t.Errorf("describe: %q", got) } - items, err := ItemsOf.Collect(ctx, conn, struct{ OrderID int64 }{id}) + items, err := postgres.Collect(ctx, conn, ItemsOf, struct{ OrderID int64 }{id}) if err != nil || len(items) != 2 || items[0].Amount != "25.00" { t.Fatalf("items: %v %+v", err, items) } - totals, err := TotalsByCustomer.Collect(ctx, conn, struct{ MinOrders int64 }{1}) + totals, err := postgres.Collect(ctx, conn, TotalsByCustomer, struct{ MinOrders int64 }{1}) if err != nil || len(totals) != 1 || totals[0].Spent != "28.00" { t.Fatalf("totals: %v %+v", err, totals) } // archived: gone from every view but archived_orders, and not there to be paid - if _, err := Archive.Exec(ctx, conn, struct{ ID int64 }{id}); err != nil { + if _, err := postgres.ExecOne(ctx, conn, Archive, struct{ ID int64 }{id}); err != nil { t.Fatal(err) } - if _, err := OrderByID.Get(ctx, conn, struct{ ID int64 }{id}); !errors.Is(err, sqlshape.ErrNoRows) { + if _, err := postgres.Get(ctx, conn, OrderByID, struct{ ID int64 }{id}); !errors.Is(err, postgres.ErrNoRows) { t.Fatalf("archived order still visible: %v", err) } if err := Pay(ctx, conn, id); err == nil { t.Fatal("paying an archived order should fail") } - archived, err := ArchivedOrders.Collect(ctx, conn, struct{}{}) + archived, err := postgres.Collect(ctx, conn, ArchivedOrders, struct{}{}) if err != nil || len(archived) != 1 || archived[0].ID != id || archived[0].Status != Paid { t.Fatalf("archived: %v %+v", err, archived) } - totals, err = TotalsByCustomer.Collect(ctx, conn, struct{ MinOrders int64 }{0}) + totals, err = postgres.Collect(ctx, conn, TotalsByCustomer, struct{ MinOrders int64 }{0}) if err != nil || len(totals) != 1 || totals[0].Orders != 0 { t.Fatalf("totals after archive: %v %+v", err, totals) } @@ -131,10 +132,10 @@ func TestOrderBookThroughViews(t *testing.T) { t.Fatal(err) } var unknown *sqlshape.UnknownLabelError - if _, err := OrderByID.Get(ctx, conn, struct{ ID int64 }{id2}); !errors.As(err, &unknown) || unknown.Value != "refunded" { + if _, err := postgres.Get(ctx, conn, OrderByID, struct{ ID int64 }{id2}); !errors.As(err, &unknown) || unknown.Value != "refunded" { t.Fatalf("unknown label: %v", err) } - if _, err := DeleteCustomer.Exec(ctx, conn, struct{ ID int64 }{alice}); !sqlshape.Violates(err, "orders_customer_id_fkey") { + if _, err := postgres.ExecOne(ctx, conn, DeleteCustomer, struct{ ID int64 }{alice}); !postgres.Violates(err, "orders_customer_id_fkey") { t.Fatalf("delete customer with orders: %v", err) } } diff --git a/examples/2-views/queries.go b/examples/2-views/queries.go index fdc73f2..8dcdd22 100644 --- a/examples/2-views/queries.go +++ b/examples/2-views/queries.go @@ -3,7 +3,7 @@ package views import ( "time" - "github.com/kr9ly/sqlshape" + "github.com/kr9ly/sqlshape/v2" ) // OrderStatus is bound to order_statuses.code by use (through the views' status column diff --git a/examples/3-database-api/app.go b/examples/3-database-api/app.go index 61af2e1..b900aa7 100644 --- a/examples/3-database-api/app.go +++ b/examples/3-database-api/app.go @@ -6,7 +6,7 @@ import ( "github.com/jackc/pgx/v5" - "github.com/kr9ly/sqlshape" + "github.com/kr9ly/sqlshape/postgres/v2" ) // Beginner starts transactions: pgx.Conn and pgxpool.Pool both do. @@ -16,7 +16,7 @@ type Beginner interface { // Checkout places an order with its lines through the database's functions, in one // transaction: a rejected line leaves no half-built order behind. pgx.Tx satisfies -// sqlshape.DB, so the statements run on the transaction unchanged. The limit on open +// postgres.DB, so the statements run on the transaction unchanged. The limit on open // orders is enforced by a trigger; the application only has to name the outcome. func Checkout(ctx context.Context, db Beginner, customerID int64, shipping Yen, lines []NewLine) (int64, error) { tx, err := db.Begin(ctx) @@ -25,19 +25,19 @@ func Checkout(ctx context.Context, db Beginner, customerID int64, shipping Yen, } defer tx.Rollback(ctx) // a no-op after Commit - id, err := PlaceOrder.Get(ctx, tx, NewOrder{CustomerID: customerID, Shipping: shipping}) + id, err := postgres.Get(ctx, tx, PlaceOrder, NewOrder{CustomerID: customerID, Shipping: shipping}) switch { - case sqlshape.Violates(err, "OS001"): + case postgres.Violates(err, TooManyOpenOrders): return 0, fmt.Errorf("customer %d has too many open orders", customerID) - case sqlshape.Violates(err, "orders_customer_id_fkey"): + case postgres.Violates(err, "orders_customer_id_fkey"): return 0, fmt.Errorf("customer %d does not exist", customerID) case err != nil: return 0, err } for _, l := range lines { l.OrderID = *id - if _, err := AddLine.Get(ctx, tx, l); err != nil { - if sqlshape.Violates(err, "order_items_qty_check") { + if _, err := postgres.Get(ctx, tx, AddLine, l); err != nil { + if postgres.Violates(err, "order_items_qty_check") { return 0, fmt.Errorf("%s: quantity must be positive", l.Sku) } return 0, err diff --git a/examples/3-database-api/app_test.go b/examples/3-database-api/app_test.go index 7d49740..77d2281 100644 --- a/examples/3-database-api/app_test.go +++ b/examples/3-database-api/app_test.go @@ -7,8 +7,8 @@ import ( "github.com/jackc/pgx/v5" - "github.com/kr9ly/sqlshape" - "github.com/kr9ly/sqlshape/pgtest" + "github.com/kr9ly/sqlshape/pgtest/v2" + "github.com/kr9ly/sqlshape/postgres/v2" ) func TestDatabaseAPI(t *testing.T) { @@ -29,21 +29,21 @@ func TestDatabaseAPI(t *testing.T) { } conn := db.Conn() - alice, err := CreateCustomer.Get(ctx, conn, NewCustomer{Email: "alice@example.com", Name: "Alice", Tier: Free}) + alice, err := postgres.Get(ctx, conn, CreateCustomer, NewCustomer{Email: "alice@example.com", Name: "Alice", Tier: Free}) if err != nil || alice == nil { t.Fatalf("create customer: %v", err) } - if _, err := CreateCustomer.Get(ctx, conn, NewCustomer{Email: "not-an-email", Name: "X", Tier: Free}); !sqlshape.Violates(err, "email_check") { + if _, err := postgres.Get(ctx, conn, CreateCustomer, NewCustomer{Email: "not-an-email", Name: "X", Tier: Free}); !postgres.Violates(err, "email_check") { t.Fatalf("email domain: %v", err) } street, city, zip := "1-1 Chiyoda", "Tokyo", "100-0001" - if _, err := SetAddress.Exec(ctx, conn, struct { + if _, err := postgres.ExecOne(ctx, conn, SetAddress, struct { CustomerID int64 Address Address }{*alice, Address{&street, &city, &zip}}); err != nil { t.Fatalf("set address: %v", err) } - c, err := CustomerByEmail.Get(ctx, conn, struct{ Email string }{"alice@example.com"}) + c, err := postgres.Get(ctx, conn, CustomerByEmail, struct{ Email string }{"alice@example.com"}) if err != nil || c.Tier != Free || c.ShipsTo == nil || *c.ShipsTo.City != "Tokyo" { t.Fatalf("customer: %v %+v", err, c) } @@ -52,14 +52,14 @@ func TestDatabaseAPI(t *testing.T) { if err != nil { t.Fatal(err) } - o, err := OrderByID.Get(ctx, conn, struct{ ID int64 }{id}) + o, err := postgres.Get(ctx, conn, OrderByID, struct{ ID int64 }{id}) if err != nil || o.Subtotal != 2700 || o.Total != 3200 || o.Lines != 2 || o.CustomerEmail != "alice@example.com" { t.Fatalf("order view: %v %+v", err, o) } if Format(o.Total) != "¥3200" { t.Errorf("format: %s", Format(o.Total)) } - lines, err := LinesOf.Collect(ctx, conn, struct{ OrderID int64 }{id}) + lines, err := postgres.Collect(ctx, conn, LinesOf, struct{ OrderID int64 }{id}) if err != nil || len(lines) != 2 || lines[0].Amount != 2400 { t.Fatalf("lines: %v %+v", err, lines) } @@ -69,7 +69,7 @@ func TestDatabaseAPI(t *testing.T) { } // shipping is a yen: the domain's CHECK (>= 0) is not one of Checkout's named // outcomes, so the generic path returns the raw error - if _, err := Checkout(ctx, conn, *alice, -1, nil); !sqlshape.Violates(err, "yen_check") { + if _, err := Checkout(ctx, conn, *alice, -1, nil); !postgres.Violates(err, "yen_check") { t.Fatalf("negative shipping: %v", err) } // a context already done fails at Begin, before any statement runs; use a @@ -87,7 +87,7 @@ func TestDatabaseAPI(t *testing.T) { // a failing line rolls the whole Checkout back: neither of these leaves a // pending order behind for the trigger test below to trip over - if _, err := AddLine.Get(ctx, conn, NewLine{OrderID: id, Sku: "BOOK", Qty: 0, UnitPrice: 100}); !sqlshape.Violates(err, "order_items_qty_check") { + if _, err := postgres.Get(ctx, conn, AddLine, NewLine{OrderID: id, Sku: "BOOK", Qty: 0, UnitPrice: 100}); !postgres.Violates(err, "order_items_qty_check") { t.Fatalf("add line qty check: %v", err) } if _, err := Checkout(ctx, conn, *alice, 0, []NewLine{{Sku: "BOOK", Qty: 0, UnitPrice: 100}}); err == nil || err.Error() != "BOOK: quantity must be positive" { @@ -95,7 +95,7 @@ func TestDatabaseAPI(t *testing.T) { } // a negative unit price is a yen too: not a quantity problem, so the generic // path returns the raw error - if _, err := Checkout(ctx, conn, *alice, 0, []NewLine{{Sku: "BOOK", Qty: 1, UnitPrice: -1}}); !sqlshape.Violates(err, "yen_check") { + if _, err := Checkout(ctx, conn, *alice, 0, []NewLine{{Sku: "BOOK", Qty: 1, UnitPrice: -1}}); !postgres.Violates(err, "yen_check") { t.Fatalf("checkout negative unit price: %v", err) } @@ -108,19 +108,19 @@ func TestDatabaseAPI(t *testing.T) { if _, err := Checkout(ctx, conn, *alice, 0, nil); err == nil || err.Error() != "customer 1 has too many open orders" { t.Fatalf("open order limit: %v", err) } - if n, err := OpenOrders.Get(ctx, conn, struct{ CustomerID int64 }{*alice}); err != nil || n != 3 { + if n, err := postgres.Get(ctx, conn, OpenOrders, struct{ CustomerID int64 }{*alice}); err != nil || n != 3 { t.Fatalf("open orders: %v %d", err, n) } - st, err := PayOrder.Get(ctx, conn, struct{ OrderID int64 }{id}) + st, err := postgres.Get(ctx, conn, PayOrder, struct{ OrderID int64 }{id}) if err != nil || st == nil || *st != Paid { t.Fatalf("pay: %v %v", err, st) } - if st, err := PayOrder.Get(ctx, conn, struct{ OrderID int64 }{id}); err != nil || st != nil { + if st, err := postgres.Get(ctx, conn, PayOrder, struct{ OrderID int64 }{id}); err != nil || st != nil { t.Fatalf("pay twice: %v %v", err, st) } min := Yen(3000) - rows, err := ListOrders.Collect(ctx, conn, ListOrdersParams{CustomerID: alice, Statuses: []OrderStatus{Paid}, MinTotal: &min}) + rows, err := postgres.Collect(ctx, conn, ListOrders, ListOrdersParams{CustomerID: alice, Statuses: []OrderStatus{Paid}, MinTotal: &min}) if err != nil || len(rows) != 1 || rows[0].ID != id { t.Fatalf("list: %v %+v", err, rows) } @@ -128,7 +128,7 @@ func TestDatabaseAPI(t *testing.T) { if err := Sales.Refresh(ctx, conn); err != nil { t.Fatal(err) } - sales, err := SalesSince.Collect(ctx, conn, struct{ Since time.Time }{time.Now().AddDate(0, 0, -1)}) + sales, err := postgres.Collect(ctx, conn, SalesSince, struct{ Since time.Time }{time.Now().AddDate(0, 0, -1)}) if err != nil || len(sales) != 1 || sales[0].Revenue != 2700 { t.Fatalf("sales: %v %+v", err, sales) } diff --git a/examples/3-database-api/doc.go b/examples/3-database-api/doc.go index 1bb8df9..cbcdc48 100644 --- a/examples/3-database-api/doc.go +++ b/examples/3-database-api/doc.go @@ -19,12 +19,12 @@ // received as Address, a function parameter passed as Address. // - A trigger raising its own SQLSTATE (OS001) is annotated `-- sqlshape: error OS001 = // TooManyOpenOrders`; the checker adds it to the failure modes of the write that fires -// it, and Go reads it back with sqlshape.Violates(err, "OS001"). +// it, and Go reads it back with postgres.Violates(err, "OS001"). // - `-- sqlshape: not null` on open_orders tells the checker a function result is never // NULL, so Go can receive an int64 rather than a *int64. -// - sqlshape.MatView("sales_by_day").Refresh is checked against the schema. +// - postgres.MatView("sales_by_day").Refresh is checked against the schema. // - Transactions are pgx's: Checkout runs place_order and add_line on one pgx.Tx, which -// satisfies sqlshape.DB like a connection or a pool does. +// satisfies postgres.DB like a connection or a pool does. // - Run the checker with -no-tables to enforce the boundary: `go run ../../cmd/sqlshape // -no-tables .` reports any statement that reads or writes a table directly. package dbapi diff --git a/examples/3-database-api/queries.go b/examples/3-database-api/queries.go index c5a8530..f50dc1b 100644 --- a/examples/3-database-api/queries.go +++ b/examples/3-database-api/queries.go @@ -3,7 +3,8 @@ package dbapi import ( "time" - "github.com/kr9ly/sqlshape" + "github.com/kr9ly/sqlshape/postgres/v2" + "github.com/kr9ly/sqlshape/v2" ) // Yen meets the yen domain and is bound to it: a Yen passed where a plain bigint or @@ -119,7 +120,7 @@ type DailySales struct { var SalesSince = sqlshape.Query[DailySales, struct{ Since time.Time }](` SELECT day, orders, revenue FROM sales_by_day WHERE day >= {{.Since}} ORDER BY day`) -var Sales = sqlshape.MatView("sales_by_day") +var Sales = postgres.MatView("sales_by_day") // --- writes: functions ------------------------------------------------------------- @@ -146,9 +147,13 @@ type NewOrder struct { Shipping Yen } -// OS001 is the trigger's SQLSTATE: the order limit for free-tier customers. +// TooManyOpenOrders is the trigger's own OS001: the order limit for free-tier customers, +// named by the `-- sqlshape: error` line above it in schema.sql. The expect line below +// could say OS001 just as well; Key and Name are interchangeable. +var TooManyOpenOrders = sqlshape.Error("OS001") + var PlaceOrder = sqlshape.One[*int64, NewOrder](` --- sqlshape: expect orders_customer_id_fkey, yen_check, OS001 +-- sqlshape: expect orders_customer_id_fkey, yen_check, TooManyOpenOrders SELECT place_order({{.CustomerID}}, {{.Note}}, {{.Shipping}})`) type NewLine struct { diff --git a/examples/4-everything/app.go b/examples/4-everything/app.go index d4f8b83..97a1952 100644 --- a/examples/4-everything/app.go +++ b/examples/4-everything/app.go @@ -9,7 +9,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" - "github.com/kr9ly/sqlshape" + "github.com/kr9ly/sqlshape/postgres/v2" ) // Open connects a pool whose every connection knows the schema's user types (the enum, @@ -20,23 +20,23 @@ func Open(ctx context.Context, connString string) (*pgxpool.Pool, error) { return nil, err } cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { - return sqlshape.LoadUserTypes(ctx, conn) + return postgres.LoadUserTypes(ctx, conn) } return pgxpool.NewWithConfig(ctx, cfg) } // Reserve books a room and records the event; the two invariants live in triggers and // come back by name. -func Reserve(ctx context.Context, db sqlshape.DB, t Tenanted, roomID RoomID, memberID MemberID, from, until time.Time, attendees []string) (BookingID, error) { +func Reserve(ctx context.Context, db postgres.DB, t Tenanted, roomID RoomID, memberID MemberID, from, until time.Time, attendees []string) (BookingID, error) { slot := pgtype.Range[time.Time]{Lower: from, Upper: until, LowerType: pgtype.Inclusive, UpperType: pgtype.Exclusive, Valid: true} if attendees == nil { attendees = []string{} // a nil slice would be NULL, and the column is NOT NULL } - id, err := Book.Get(ctx, db, NewBooking{Tenanted: t, RoomID: roomID, MemberID: memberID, Slot: slot, Attendees: attendees}) + id, err := postgres.Get(ctx, db, Book, NewBooking{Tenanted: t, RoomID: roomID, MemberID: memberID, Slot: slot, Attendees: attendees}) switch { - case sqlshape.Violates(err, "BK001"): + case postgres.Violates(err, SlotTaken): return 0, fmt.Errorf("room %d is taken between %s and %s", roomID, from.Format(time.Kitchen), until.Format(time.Kitchen)) - case sqlshape.Violates(err, "BK002"): + case postgres.Violates(err, OverCapacity): return 0, fmt.Errorf("room %d cannot seat %d people", roomID, len(attendees)) case err != nil: return 0, err @@ -51,11 +51,11 @@ type Dashboard struct { Members []Member } -func LoadDashboard(ctx context.Context, db sqlshape.BatchDB, t Tenanted) (Dashboard, error) { - b := sqlshape.NewBatch() - rooms := sqlshape.Queue(b, Rooms, t) - schedule := sqlshape.Queue(b, Schedule, t) - members := sqlshape.Queue(b, Members, t) +func LoadDashboard(ctx context.Context, db postgres.BatchDB, t Tenanted) (Dashboard, error) { + b := postgres.NewBatch() + rooms := postgres.Queue(b, Rooms, t) + schedule := postgres.Queue(b, Schedule, t) + members := postgres.Queue(b, Members, t) if err := b.Send(ctx, db); err != nil { return Dashboard{}, err } @@ -72,12 +72,12 @@ func LoadDashboard(ctx context.Context, db sqlshape.BatchDB, t Tenanted) (Dashbo } // Track appends analytics events in bulk. -func Track(ctx context.Context, db sqlshape.CopyDB, events []Event) (int64, error) { +func Track(ctx context.Context, db postgres.CopyDB, events []Event) (int64, error) { return AppendEvents.From(ctx, db, events) } // Search runs the booking search without a prepared statement: which filters are on // changes the plan, and a generic plan for skewed tenants is the wrong plan. -func Search(ctx context.Context, db sqlshape.DB, p SearchParams) ([]Booking, error) { - return SearchBookings.Unprepared().Collect(ctx, db, p) +func Search(ctx context.Context, db postgres.DB, p SearchParams) ([]Booking, error) { + return postgres.Collect(ctx, db, SearchBookings.Unprepared(), p) } diff --git a/examples/4-everything/app_test.go b/examples/4-everything/app_test.go index 9cbd83a..4c0396e 100644 --- a/examples/4-everything/app_test.go +++ b/examples/4-everything/app_test.go @@ -8,8 +8,8 @@ import ( "github.com/jackc/pgx/v5/pgtype" - "github.com/kr9ly/sqlshape" - "github.com/kr9ly/sqlshape/pgtest" + "github.com/kr9ly/sqlshape/pgtest/v2" + "github.com/kr9ly/sqlshape/postgres/v2" ) func TestEverything(t *testing.T) { @@ -38,7 +38,7 @@ func TestEverything(t *testing.T) { defer pool.Close() team, trial := Team, Trial - tid, err := CreateTenant.Get(ctx, pool, struct { + tid, err := postgres.Get(ctx, pool, CreateTenant, struct { Slug string Plan *Plan }{"acme", &team}) @@ -46,19 +46,19 @@ func TestEverything(t *testing.T) { t.Fatal(err) } tenant := Tenanted{TenantID: *tid} - if _, err := CreateTenant.Get(ctx, pool, struct { + if _, err := postgres.Get(ctx, pool, CreateTenant, struct { Slug string Plan *Plan - }{"ACME", &trial}); !sqlshape.Violates(err, "tenants_slug_key") { + }{"ACME", &trial}); !postgres.Violates(err, "tenants_slug_key") { t.Fatalf("citext unique: %v", err) // slugs compare case-insensitively } - tn, err := TenantBySlug.Get(ctx, pool, struct{ Slug string }{"Acme"}) + tn, err := postgres.Get(ctx, pool, TenantBySlug, struct{ Slug string }{"Acme"}) if err != nil || tn.Plan != Team || tn.Settings == nil { t.Fatalf("tenant: %v %+v", err, tn) } ip := netip.MustParseAddr("203.0.113.7") - ann, err := AddMember.Get(ctx, pool, struct { + ann, err := postgres.Get(ctx, pool, AddMember, struct { Tenanted Email string Name string @@ -67,7 +67,7 @@ func TestEverything(t *testing.T) { if err != nil { t.Fatal(err) } - bob, err := AddMember.Get(ctx, pool, struct { + bob, err := postgres.Get(ctx, pool, AddMember, struct { Tenanted Email string Name string @@ -77,14 +77,14 @@ func TestEverything(t *testing.T) { t.Fatal(err) } - n, err := ImportRooms.Get(ctx, pool, struct { + n, err := postgres.Get(ctx, pool, ImportRooms, struct { Tenanted Rooms []RoomIn }{tenant, []RoomIn{{"Small", 4, Money{"20.00", "EUR"}}, {"Large", 12, Money{"55.00", "EUR"}}}}) if err != nil || *n != 2 { t.Fatalf("import rooms: %v %v", err, n) } - rooms, err := Rooms.Collect(ctx, pool, tenant) + rooms, err := postgres.Collect(ctx, pool, Rooms, tenant) if err != nil || len(rooms) != 2 || rooms[0].Name != "Large" || rooms[0].Hourly.String() != "55.00 EUR" || rooms[0].TenantID != *tid { t.Fatalf("rooms: %v %+v", err, rooms) } @@ -107,10 +107,10 @@ func TestEverything(t *testing.T) { } // a member that does not exist is neither BK001 nor BK002: the generic path // returns the raw foreign key error - if _, err := Reserve(ctx, pool, tenant, large, MemberID("00000000-0000-0000-0000-000000000000"), at(13), at(14), nil); !sqlshape.Violates(err, "bookings_member_id_fkey") { + if _, err := Reserve(ctx, pool, tenant, large, MemberID("00000000-0000-0000-0000-000000000000"), at(13), at(14), nil); !postgres.Violates(err, "bookings_member_id_fkey") { t.Fatalf("reserve unknown member: %v", err) } - if _, err := TagBooking.Exec(ctx, pool, struct { + if _, err := postgres.ExecOne(ctx, pool, TagBooking, struct { Tenanted BookingID BookingID Tags map[string]string @@ -118,7 +118,7 @@ func TestEverything(t *testing.T) { t.Fatal(err) } - bk, err := BookingByID.Get(ctx, pool, struct { + bk, err := postgres.Get(ctx, pool, BookingByID, struct { Tenanted ID BookingID }{tenant, b1}) @@ -156,14 +156,14 @@ func TestEverything(t *testing.T) { if err := UtilizationView.Refresh(ctx, pool); err != nil { t.Fatal(err) } - u, err := UtilizationOf.Collect(ctx, pool, struct { + u, err := postgres.Collect(ctx, pool, UtilizationOf, struct { Tenanted Since pgtype.Date }{tenant, pgtype.Date{Time: day, Valid: true}}) if err != nil || len(u) != 2 || u[0].BookedMinutes+u[1].BookedMinutes != 240 { t.Fatalf("utilization: %v %+v", err, u) } - if m, err := BookedMinutes.Get(ctx, pool, struct { + if m, err := postgres.Get(ctx, pool, BookedMinutes, struct { Tenanted RoomID RoomID Day pgtype.Date @@ -171,39 +171,39 @@ func TestEverything(t *testing.T) { t.Fatalf("booked minutes: %v %d", err, m) } - took := 40 * time.Millisecond + took := pgtype.Interval{Microseconds: (40 * time.Millisecond).Microseconds(), Valid: true} if n, err := Track(ctx, pool, []Event{ {Tenanted: tenant, At: at(9), Kind: Booked, BookingID: &b1, ClientIP: &ip, Took: &took}, {Tenanted: tenant, At: at(10), Kind: Viewed}, }); err != nil || n != 2 { t.Fatalf("copy: %v %d", err, n) } - if counts, err := EventCounts.Collect(ctx, pool, tenant); err != nil || len(counts) != 2 || counts[0].Kind != Booked { + if counts, err := postgres.Collect(ctx, pool, EventCounts, tenant); err != nil || len(counts) != 2 || counts[0].Kind != Booked { t.Fatalf("event counts: %v %+v", err, counts) } - if id, err := CancelBooking.Get(ctx, pool, struct { + if id, err := postgres.Get(ctx, pool, CancelBooking, struct { Tenanted BookingID BookingID }{tenant, b1}); err != nil || id == nil || *id != b1 { t.Fatalf("cancel: %v %v", err, id) } - if id, err := CancelBooking.Get(ctx, pool, struct { + if id, err := postgres.Get(ctx, pool, CancelBooking, struct { Tenanted BookingID BookingID }{tenant, b1}); err != nil || id != nil { t.Fatalf("cancel twice: %v %v", err, id) } - if _, err := RemoveMember.Exec(ctx, pool, struct { + if _, err := postgres.ExecOne(ctx, pool, RemoveMember, struct { Tenanted MemberID MemberID }{tenant, *bob}); err != nil { t.Fatal(err) } - if ms, err := Members.Collect(ctx, pool, tenant); err != nil || len(ms) != 1 || ms[0].LastIP == nil || *ms[0].LastIP != ip { + if ms, err := postgres.Collect(ctx, pool, Members, tenant); err != nil || len(ms) != 1 || ms[0].LastIP == nil || *ms[0].LastIP != ip { t.Fatalf("members: %v %+v", err, ms) } - if gone, err := DeletedMembers.Collect(ctx, pool, tenant); err != nil || len(gone) != 1 || gone[0].Email != "bob@acme.example" { + if gone, err := postgres.Collect(ctx, pool, DeletedMembers, tenant); err != nil || len(gone) != 1 || gone[0].Email != "bob@acme.example" { t.Fatalf("deleted members: %v %+v", err, gone) } } diff --git a/examples/4-everything/queries.go b/examples/4-everything/queries.go index 5c3ce17..e92621e 100644 --- a/examples/4-everything/queries.go +++ b/examples/4-everything/queries.go @@ -9,7 +9,8 @@ import ( "github.com/jackc/pgx/v5/pgtype" - "github.com/kr9ly/sqlshape" + "github.com/kr9ly/sqlshape/postgres/v2" + "github.com/kr9ly/sqlshape/v2" ) // --- Go types for the schema's types ------------------------------------------------------- @@ -142,7 +143,7 @@ type Booking struct { BookedBy string Slot pgtype.Range[time.Time] Minutes Minutes - Attendees []string + Attendees []string `col:",notnull"` // text[] NOT NULL says nothing about the elements; the tag asserts none is NULL Tags map[string]*string Note *string CreatedAt time.Time @@ -223,7 +224,7 @@ var UtilizationOf = sqlshape.Query[Utilization, struct { -- sqlshape: not null booked_minutes SELECT room_id, day, booked_minutes, bookings FROM app.utilization WHERE tenant_id = {{.TenantID}} AND day >= {{.Since}} ORDER BY day, room_id`) -var UtilizationView = sqlshape.MatView("app.utilization") +var UtilizationView = postgres.MatView("app.utilization") var BookedMinutes = sqlshape.One[int64, struct { Tenanted @@ -280,10 +281,15 @@ type NewBooking struct { Note *string } +// SlotTaken and OverCapacity name the booking function's own BK001 / BK002, from the `-- +// sqlshape: error` lines above them in schema.sql. +var SlotTaken = sqlshape.Error("BK001") +var OverCapacity = sqlshape.Error("BK002") + // Slot is a pgtype.Range (Valid: false is NULL) and Attendees a slice (nil is NULL), so // both NOT NULL columns stay possible failure modes; the Go side keeps them impossible. var Book = sqlshape.One[*BookingID, NewBooking](` --- sqlshape: expect bookings_tenant_id_fkey, bookings_room_id_fkey, bookings_member_id_fkey, bookings.slot, bookings.attendees, BK001, BK002 +-- sqlshape: expect bookings_tenant_id_fkey, bookings_room_id_fkey, bookings_member_id_fkey, bookings.slot, bookings.attendees, SlotTaken, OverCapacity SELECT app.book({{.TenantID}}, {{.RoomID}}, {{.MemberID}}, {{.Slot}}, {{.Attendees}}, {{.Note}})`) var TagBooking = sqlshape.One[struct{}, struct { @@ -305,10 +311,10 @@ type Event struct { Kind EventKind BookingID *BookingID ClientIP *netip.Addr - Took *time.Duration + Took *pgtype.Interval // interval keeps months, days and microseconds apart; time.Duration would flatten them (the checker says so) } -var AppendEvents = sqlshape.Copy[Event]("app.events", "tenant_id", "at", "kind", "booking_id", "client_ip", "took") +var AppendEvents = postgres.Copy[Event]("app.events", "tenant_id", "at", "kind", "booking_id", "client_ip", "took") type EventCount struct { Kind EventKind diff --git a/examples/5-mysql/app_test.go b/examples/5-mysql/app_test.go new file mode 100644 index 0000000..06d2bf2 --- /dev/null +++ b/examples/5-mysql/app_test.go @@ -0,0 +1,72 @@ +package mysqlexample + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/kr9ly/sqlshape/mysql/v2" + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +func TestOrders(t *testing.T) { + ctx := context.Background() + schema, err := os.ReadFile("schema.sql") + if err != nil { + t.Fatal(err) + } + srv, err := mysqltest.Start(ctx, string(schema)) + if errors.Is(err, mysqltest.ErrNoServer) { + t.Skip("no mysqld on PATH") + } + if err != nil { + t.Fatal(err) + } + defer srv.Close() + db := srv.Conn() + + if _, err := mysql.Exec(ctx, db, CreateCustomer, NewCustomer{Email: "alice@example.com", Name: "Alice"}); err != nil { + t.Fatal(err) + } + if _, err := mysql.Exec(ctx, db, CreateCustomer, NewCustomer{Email: "alice@example.com", Name: "Twin"}); !mysql.Violates(err, "customers_email_key") { + t.Fatalf("duplicate email: %v", err) + } + alice, err := mysql.Get(ctx, db, CustomerByEmail, struct{ Email string }{"alice@example.com"}) + if err != nil { + t.Fatal(err) + } + if _, err := mysql.Exec(ctx, db, CreateOrder, NewOrder{CustomerID: alice.ID, Total: "12.50"}); err != nil { + t.Fatal(err) + } + if _, err := mysql.Exec(ctx, db, CreateOrder, NewOrder{CustomerID: 999, Total: "1.00"}); !mysql.Violates(err, "fk_orders_customer") { + t.Fatalf("unknown customer: %v", err) + } + // the trigger's own SIGNAL, named OrderTotalTooLarge: Violates judges by the code + // either way, so the Go name and the raw "30001" both work. + if _, err := mysql.Exec(ctx, db, CreateOrder, NewOrder{CustomerID: alice.ID, Total: "999999.00"}); !mysql.Violates(err, OrderTotalTooLarge) { + t.Fatalf("order total too large: %v", err) + } + orders, err := mysql.Collect(ctx, db, ListOrders, ListOrdersParams{CustomerID: alice.ID, Limit: 10}) + if err != nil || len(orders) != 1 || orders[0].Status != Pending || orders[0].Total != "12.50" || orders[0].Note != nil { + t.Fatalf("orders: %+v %v", orders, err) + } + if _, err := mysql.ExecOne(ctx, db, SetStatus, struct { + ID uint64 + Status Status + }{orders[0].ID, Paid}); err != nil { + t.Fatal(err) + } + paid := Paid + if got, err := mysql.Collect(ctx, db, ListOrders, ListOrdersParams{CustomerID: alice.ID, Status: &paid, Limit: 10}); err != nil || len(got) != 1 { + t.Fatalf("paid orders: %d %v", len(got), err) + } + totals, err := mysql.Collect(ctx, db, OrderTotals, struct{}{}) + if err != nil || len(totals) != 1 || totals[0].N != 1 || totals[0].Total != "12.50" { + t.Fatalf("totals: %+v %v", totals, err) + } + total, err := mysql.Get(ctx, db, CustomerOrderTotal, struct{ CustomerID uint64 }{alice.ID}) + if err != nil || total.Total == nil || *total.Total != "12.50" { + t.Fatalf("customer_order_total: %+v %v", total, err) + } +} diff --git a/examples/5-mysql/doc.go b/examples/5-mysql/doc.go new file mode 100644 index 0000000..0593b2f --- /dev/null +++ b/examples/5-mysql/doc.go @@ -0,0 +1,12 @@ +// Package mysqlexample is the MySQL counterpart of 1-tables: the same declarations +// (sqlshape.Query / One) judged by the MySQL analyzer because schema.sql declares +// `-- sqlshape: mysql 8.4`, and run through the database/sql runtime +// github.com/kr9ly/sqlshape/mysql/v2. The tests boot a real mysqld with mysqltest and skip +// when none is on PATH. +// +// What differs from PostgreSQL is only what MySQL itself does differently: `?` +// placeholders on the wire (the templates still write {{.X}}), DECIMAL received as its text, +// an ENUM column as a string type the application can bind (Status), and the constraint +// names MySQL reports (the UNIQUE key's name, the FOREIGN KEY's CONSTRAINT name). One is proved +// from the schema's keys as on PostgreSQL (the customer's UNIQUE email, the order's primary key). +package mysqlexample diff --git a/examples/5-mysql/main_test.go b/examples/5-mysql/main_test.go new file mode 100644 index 0000000..4ab3906 --- /dev/null +++ b/examples/5-mysql/main_test.go @@ -0,0 +1,12 @@ +package mysqlexample + +import ( + "os" + "testing" + + "github.com/kr9ly/sqlshape/mysqltest/v2" +) + +// One mysqld per set of server settings serves the whole package (mysqltest.Main); a test +// gets it with the database dropped, not a server of its own. +func TestMain(m *testing.M) { os.Exit(mysqltest.Main(m)) } diff --git a/examples/5-mysql/queries.go b/examples/5-mysql/queries.go new file mode 100644 index 0000000..77d7a4c --- /dev/null +++ b/examples/5-mysql/queries.go @@ -0,0 +1,105 @@ +package mysqlexample + +import ( + "time" + + "github.com/kr9ly/sqlshape/v2" +) + +// Status is the orders.status enum; Known lets the runtime reject a label this build does not know. +type Status string + +const ( + Pending Status = "pending" + Paid Status = "paid" + Cancelled Status = "cancelled" +) + +func (s Status) Known() bool { + switch s { + case Pending, Paid, Cancelled: + return true + } + return false +} + +type Customer struct { + ID uint64 + Email string + Name string + CreatedAt time.Time +} + +type Order struct { + ID uint64 + CustomerID uint64 + Status Status + Total string // DECIMAL arrives as its text; a money type of your own can wrap it + Note *string +} + +type NewCustomer struct{ Email, Name string } + +var CreateCustomer = sqlshape.Query[struct{}, NewCustomer](` +-- sqlshape: expect customers_email_key + INSERT INTO customers (email, name) VALUES ({{.Email}}, {{.Name}})`) + +var CustomerByEmail = sqlshape.One[Customer, struct{ Email string }](` + SELECT id, email, name, created_at FROM customers WHERE email = {{.Email}}`) + +type NewOrder struct { + CustomerID uint64 + Total string + Note *string +} + +// OrderTotalTooLarge names the trigger's own 30001, from the `-- sqlshape: error` line +// above it in schema.sql. +var OrderTotalTooLarge = sqlshape.Error("30001") + +// CreateOrder's failure modes reach past its own table: orders_before_insert (a BEFORE +// INSERT trigger) SIGNALs 30001 when NEW.total is too large, named OrderTotalTooLarge by +// the `-- sqlshape: error` line above the trigger -- the expect line below names it +// OrderTotalTooLarge (it could just as well say 30001; the two are interchangeable) -- +// and its own INSERT INTO order_audit may violate that table's NOT NULL columns -- both +// become failure modes of the statement that fires the trigger, the way the trigger's +// own constraints do. +var CreateOrder = sqlshape.Query[struct{}, NewOrder](` +-- sqlshape: expect OrderTotalTooLarge, fk_orders_customer, orders_total_check, order_audit.customer_id, order_audit.total + INSERT INTO orders (customer_id, total, note) VALUES ({{.CustomerID}}, {{.Total}}, {{.Note}})`) + +type ListOrdersParams struct { + CustomerID uint64 + Status *Status + Limit int64 +} + +var ListOrders = sqlshape.Query[Order, ListOrdersParams](` + SELECT o.id, o.customer_id, o.status, o.total, o.note + FROM orders o + WHERE o.customer_id = {{.CustomerID}} + {{if .Status}} AND o.status = {{.Status}} {{end}} + ORDER BY o.id + LIMIT {{.Limit}}`) + +var SetStatus = sqlshape.One[struct{}, struct { + ID uint64 + Status Status +}](`UPDATE orders SET status = {{.Status}} WHERE id = {{.ID}}`) + +var OrderTotals = sqlshape.Query[struct { + CustomerID uint64 + N int64 + Total string +}, struct{}](` + SELECT customer_id, COUNT(*) AS n, COALESCE(SUM(total), 0) AS total + FROM orders GROUP BY customer_id ORDER BY customer_id`) + +// CustomerOrderTotal calls the schema's own stored FUNCTION: its result type comes from +// RETURNS, always nullable to the checker regardless of the declared type or the body +// (there is no static proof otherwise, and here the body's own SUM is genuinely NULL for a +// customer with no orders). The call has no failure mode of its own to expect: its body is +// a single READS SQL DATA SELECT ... INTO of an aggregate without GROUP BY, which is always +// exactly one row (never NOT FOUND, never SIGNALs). +var CustomerOrderTotal = sqlshape.One[struct{ Total *string }, struct{ CustomerID uint64 }](` + SELECT customer_order_total({{.CustomerID}}) AS total`) diff --git a/examples/5-mysql/schema.sql b/examples/5-mysql/schema.sql new file mode 100644 index 0000000..b7b7581 --- /dev/null +++ b/examples/5-mysql/schema.sql @@ -0,0 +1,42 @@ +-- sqlshape: mysql 8.4 +CREATE TABLE customers ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL, + name VARCHAR(100) NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + UNIQUE KEY customers_email_key (email) +) ENGINE=InnoDB; + +CREATE TABLE orders ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + customer_id BIGINT UNSIGNED NOT NULL, + status ENUM('pending', 'paid', 'cancelled') NOT NULL DEFAULT 'pending', + total DECIMAL(10,2) NOT NULL DEFAULT 0, + note TEXT, + CONSTRAINT orders_total_check CHECK (total >= 0), + CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers (id) +) ENGINE=InnoDB; + +CREATE TABLE order_audit ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + customer_id BIGINT UNSIGNED NOT NULL, + total DECIMAL(10,2) NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) +) ENGINE=InnoDB; + +-- sqlshape: error 30001 = OrderTotalTooLarge +CREATE TRIGGER orders_before_insert BEFORE INSERT ON orders FOR EACH ROW +BEGIN + IF NEW.total > 100000 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'order total too large', MYSQL_ERRNO = 30001; + END IF; + INSERT INTO order_audit (customer_id, total) VALUES (NEW.customer_id, NEW.total); +END; + +CREATE FUNCTION customer_order_total(cust_id BIGINT UNSIGNED) RETURNS DECIMAL(10,2) +READS SQL DATA +BEGIN + DECLARE result DECIMAL(10,2); + SELECT SUM(total) INTO result FROM orders WHERE customer_id = cust_id; + RETURN result; +END; diff --git a/examples/go.mod b/examples/go.mod new file mode 100644 index 0000000..874f71d --- /dev/null +++ b/examples/go.mod @@ -0,0 +1,12 @@ +module github.com/kr9ly/sqlshape/examples/v2 + +go 1.26.1 + +// The examples are a module of their own so that the root module stays free of database +// dependencies. They are not released: the workspace (go.work) resolves the sqlshape +// modules they import, and the smoke test vets them in it. + +require ( + github.com/go-sql-driver/mysql v1.10.1 + github.com/jackc/pgx/v5 v5.10.0 +) diff --git a/fields.go b/fields.go new file mode 100644 index 0000000..5231df3 --- /dev/null +++ b/fields.go @@ -0,0 +1,256 @@ +package sqlshape + +// The row-mapping contract shared by the checker and every runtime: how a Go struct's +// fields bind to result columns (`col:"name"` / `db:"name"`, else snake_case; embedded +// structs flattened; a named struct field is a nested row), and the Labelled contract of +// a Go enum type. A runtime (sqlshape/postgres, sqlshape/mysql) maps rows with these; the +// checker verifies the same binding statically. + +import ( + "fmt" + "reflect" + "strconv" + "strings" + "sync" + "unicode" +) + +// Labelled is implemented by a Go enum type (a named string type bound to a PG enum) to +// say which labels the application knows. When a scanned value answers false the row is +// rejected with an *UnknownLabelError: the database has a label this build predates. +// +// func (s OrderStatus) Known() bool { switch s { case Pending, Paid: return true }; return false } +type Labelled interface{ Known() bool } + +// UnknownLabelError reports a scanned enum value the application does not know. +type UnknownLabelError struct { + Type reflect.Type + Value string +} + +var labelledType = reflect.TypeOf((*Labelled)(nil)).Elem() + +// HasLabelled reports whether t (or anything it contains) implements Labelled. +func HasLabelled(t reflect.Type, seen map[reflect.Type]bool) bool { + if seen[t] { + return false + } + seen[t] = true + if t.Implements(labelledType) { + return true + } + switch t.Kind() { + case reflect.Pointer, reflect.Slice, reflect.Array: + return HasLabelled(t.Elem(), seen) + case reflect.Struct: + for i := 0; i < t.NumField(); i++ { + if t.Field(i).IsExported() && HasLabelled(t.Field(i).Type, seen) { + return true + } + } + } + return false +} + +// CheckLabels validates every Labelled value inside v. +func CheckLabels(v reflect.Value) error { + if !v.IsValid() { + return nil + } + if v.Type().Implements(labelledType) && v.Kind() == reflect.String { + if s := v.String(); s != "" && !v.Interface().(Labelled).Known() { + return &UnknownLabelError{Type: v.Type(), Value: s} + } + return nil + } + switch v.Kind() { + case reflect.Pointer: + if v.IsNil() { + return nil + } + return CheckLabels(v.Elem()) + case reflect.Slice, reflect.Array: + for i := 0; i < v.Len(); i++ { + if err := CheckLabels(v.Index(i)); err != nil { + return err + } + } + case reflect.Struct: + for i := 0; i < v.NumField(); i++ { + if v.Type().Field(i).IsExported() { + if err := CheckLabels(v.Field(i)); err != nil { + return err + } + } + } + } + return nil +} + +// Field is one scan target of a struct: an exported, non-skipped field, with the +// fields of embedded structs promoted (flattened) into their parent. +type Field struct { + Index []int // for reflect's FieldByIndex + Name string // Go name, dotted through embedded structs (Base.ID) + Column string // result column it binds to + Type reflect.Type +} + +var fieldsCache sync.Map // reflect.Type → []Field or error + +// Fields lists the scan targets of a struct type in declaration order. An embedded +// struct (anonymous field, no col / db tag) is flattened: its fields are the parent's, +// so `type Row struct { Base; Extra string }` receives base columns and extra. A named +// struct field is a nested row instead. Two fields binding the same column is an error. +func Fields(t reflect.Type) ([]Field, error) { + if v, ok := fieldsCache.Load(t); ok { + if err, isErr := v.(error); isErr { + return nil, err + } + return v.([]Field), nil + } + var out []Field + seen := map[string]string{} + var walk func(st reflect.Type, prefix string, index []int) error + walk = func(st reflect.Type, prefix string, index []int) error { + for i := 0; i < st.NumField(); i++ { + f := st.Field(i) + if !f.IsExported() && !f.Anonymous { + continue + } + idx := append(append([]int{}, index...), i) + if f.Anonymous && embeddedStruct(f) != nil { + if err := walk(embeddedStruct(f), prefix+f.Name+".", idx); err != nil { + return err + } + continue + } + if !f.IsExported() { + continue + } + col := fieldColumn(f) + if col == "-" { + continue + } + if prev, dup := seen[col]; dup { + return fmt.Errorf("sqlshape: fields %s.%s and %s.%s both bind to column %q", t, prev, t, prefix+f.Name, col) + } + seen[col] = prefix + f.Name + out = append(out, Field{Index: idx, Name: prefix + f.Name, Column: col, Type: f.Type}) + } + return nil + } + if err := walk(t, "", nil); err != nil { + fieldsCache.Store(t, err) + return nil, err + } + fieldsCache.Store(t, out) + return out, nil +} + +// embeddedStruct returns the struct type an anonymous field flattens into, or nil when +// the field is a leaf: not a struct, time.Time, a Scanner, or tagged with a column name. +func embeddedStruct(f reflect.StructField) reflect.Type { + if !f.Anonymous { + return nil + } + if _, ok := f.Tag.Lookup("col"); ok { + return nil + } + if _, ok := f.Tag.Lookup("db"); ok { + return nil + } + t := f.Type + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct || !IsRow(t) { + return nil + } + return t +} + +// FieldByIndex is reflect.Value.FieldByIndex that allocates nil embedded pointers on the way. +func FieldByIndex(v reflect.Value, index []int) reflect.Value { + for i, x := range index { + if i > 0 && v.Kind() == reflect.Pointer { + if v.IsNil() { + v.Set(reflect.New(v.Type().Elem())) + } + v = v.Elem() + } + v = v.Field(x) + } + return v +} + +// fieldColumn is the column a struct field binds to: `col:"name"` / `db:"name"`, else snake_case. +// (Kept in sync with the analyzer's rule.) +func fieldColumn(f reflect.StructField) string { + for _, key := range []string{"col", "db"} { + if v, ok := f.Tag.Lookup(key); ok { + name := strings.Split(v, ",")[0] + if name == "" { + return snake(f.Name) + } + return name + } + } + return snake(f.Name) +} + +func snake(s string) string { + var b strings.Builder + rs := []rune(s) + for i, r := range rs { + if unicode.IsUpper(r) { + if i > 0 && (unicode.IsLower(rs[i-1]) || (i+1 < len(rs) && unicode.IsLower(rs[i+1]) && unicode.IsUpper(rs[i-1]))) { + b.WriteByte('_') + } + b.WriteRune(unicode.ToLower(r)) + } else { + b.WriteRune(r) + } + } + return b.String() +} + +func (e *UnknownLabelError) Error() string { + return "sqlshape: unknown " + e.Type.String() + " label " + strconv.Quote(e.Value) + " received (the database has a value this build does not know)" +} + +// Nested rows at runtime. +// +// A record / composite column is scanned positionally into a Go struct (and record[] +// into a slice of structs): the checker has verified that the struct's exported fields +// line up with the row type's columns, so the runtime only has to hand pgx one scan +// target per position. pgx decodes composites field by field from the binary format, +// which carries each field's type OID; user-defined types (enums, composites, their +// arrays) must be registered in the connection's type map for that, so Run loads any +// result type the connection does not know yet (once per connection) before scanning, +// and LoadUserTypes does it wholesale for pool AfterConnect hooks. + +// IsRow reports whether a Go type receives a row (struct) or rows (slice of structs). +func IsRow(t reflect.Type) bool { + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() == reflect.Slice || t.Kind() == reflect.Array { + t = t.Elem() + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + } + return t.Kind() == reflect.Struct && !ScalarStruct(t) && + !reflect.PointerTo(t).Implements(reflect.TypeOf((*interface{ Scan(any) error })(nil)).Elem()) +} + +// ScalarStruct reports whether a struct type is a scalar pgx decodes itself rather than a +// row: time.Time, netip.Addr / Prefix, and every pgtype value (Range[T], Bits, Point, ...). +func ScalarStruct(t reflect.Type) bool { + switch p := t.PkgPath(); { + case p == "time", p == "net/netip", strings.HasSuffix(p, "jackc/pgx/v5/pgtype"): + return true + } + return false +} diff --git a/go.mod b/go.mod index e0fc65c..010820d 100644 --- a/go.mod +++ b/go.mod @@ -1,23 +1,3 @@ -module github.com/kr9ly/sqlshape +module github.com/kr9ly/sqlshape/v2 go 1.26.1 - -require ( - github.com/fergusstrange/embedded-postgres v1.34.0 - github.com/jackc/pgx/v5 v5.10.0 - github.com/tetratelabs/wazero v1.12.0 - golang.org/x/tools v0.49.0 - google.golang.org/protobuf v1.36.12 -) - -require ( - github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/lib/pq v1.10.9 // indirect - github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect - golang.org/x/mod v0.39.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.29.0 // indirect -) diff --git a/go.work b/go.work new file mode 100644 index 0000000..f6296d0 --- /dev/null +++ b/go.work @@ -0,0 +1,13 @@ +go 1.26.1 + +use ( + . + ./check/mysql + ./check/postgres + ./cmd/sqlshape + ./examples + ./mysql + ./mysqltest + ./pgtest + ./postgres +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..ee6bc1c --- /dev/null +++ b/go.work.sum @@ -0,0 +1,8 @@ +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kr9ly/sqlshape/mysql/v2 v2.0.0-rc.2/go.mod h1:WTxO5/2/MUqYtFh+qC4Rkcey+yva+OgifJXOxvtiBX4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/cli/consumers.go b/internal/cli/consumers.go deleted file mode 100644 index f5bf3c2..0000000 --- a/internal/cli/consumers.go +++ /dev/null @@ -1,115 +0,0 @@ -package cli - -import ( - "fmt" - "os" - "strings" - - "golang.org/x/tools/go/analysis" - "golang.org/x/tools/go/analysis/checker" - "golang.org/x/tools/go/packages" - - "github.com/kr9ly/sqlshape/internal/consumers" - "github.com/kr9ly/sqlshape/internal/diff" - "github.com/kr9ly/sqlshape/internal/pgparse" - "github.com/kr9ly/sqlshape/internal/vet" -) - -// indexConsumers runs the vet analyzer over the packages matching patterns and merges -// the per-package consumer indexes. The statements are read against currentSQL, the -// schema as it is before the change: a column the target no longer has cannot be resolved -// against the target, and it is exactly the statements still naming it that matter. -func indexConsumers(patterns []string, v pgparse.Version, currentSQL string) (*consumers.Index, error) { - f, err := os.CreateTemp("", "sqlshape-current-*.sql") - if err != nil { - return nil, err - } - defer os.Remove(f.Name()) - // the canonical text comes from pg_dump, which writes no declaration: the packages are - // judged against the version the target schema declares - if _, err := fmt.Fprintf(f, "-- sqlshape: postgres %d\n%s", int(v.Or()), currentSQL); err != nil { - return nil, err - } - if err := f.Close(); err != nil { - return nil, err - } - if err := vet.Analyzer.Flags.Set("schema", f.Name()); err != nil { - return nil, err - } - pkgs, err := packages.Load(&packages.Config{Mode: packages.LoadAllSyntax}, patterns...) - if err != nil { - return nil, fmt.Errorf("load packages: %w", err) - } - if packages.PrintErrors(pkgs) > 0 { - return nil, fmt.Errorf("packages did not load") - } - graph, err := checker.Analyze([]*analysis.Analyzer{vet.Analyzer}, pkgs, nil) - if err != nil { - return nil, err - } - index := consumers.New() - for _, act := range graph.Roots { - if act.Err != nil { - return nil, fmt.Errorf("%s: %w", act.Package.PkgPath, act.Err) - } - if x, ok := act.Result.(*consumers.Index); ok && x != nil { - index.Merge(x) - } - } - return index, nil -} - -// impact is a change and the Go sites that depend on what it removes or retypes. -type impact struct { - change diff.Change - sites []consumers.Site -} - -// impacts lists, for each change that drops a relation or a column or changes a column's -// type, the consumers the index knows. -func impacts(changes []diff.Change, index *consumers.Index) []impact { - var out []impact - for _, c := range changes { - table, column, ok := changeTarget(c) - if !ok { - continue - } - reaches := c.Op == diff.Drop - if c.Op == diff.Alter && column != "" { - for _, f := range c.Fields { - if f.Name == "type" { - reaches = true - } - } - } - if !reaches { - continue - } - var sites []consumers.Site - if column == "" { - sites = index.Relation(table) - } else { - sites = index.Column(table, column) - } - if len(sites) > 0 { - out = append(out, impact{c, sites}) - } - } - return out -} - -// String renders the impacts as a report: the change's first line, then its sites. -func impactText(list []impact, prefix string) string { - var b strings.Builder - for _, im := range list { - head := im.change.String() - if i := strings.Index(head, "\n"); i > 0 { - head = head[:i] - } - fmt.Fprintf(&b, "%s%s reaches %d statement(s):\n", prefix, head, len(im.sites)) - for _, s := range im.sites { - fmt.Fprintf(&b, "%s %s\n", prefix, s) - } - } - return b.String() -} diff --git a/internal/expand/expand.go b/internal/expand/expand.go deleted file mode 100644 index 8063f80..0000000 --- a/internal/expand/expand.go +++ /dev/null @@ -1,462 +0,0 @@ -// Package expand enumerates the finite set of SQL strings a query template can -// produce. Control flow (if / else / with / range) is expanded exhaustively; -// every value action `{{.Field}}` becomes a `$n` placeholder whose Go-side -// origin is recorded as a field path on the parameter struct. -package expand - -import ( - "fmt" - "strconv" - "strings" - "text/template/parse" -) - -// MaxExpansions caps the full branch product. Beyond it the expansion falls back to a -// sparse set (Result.Sparse): every branch off, every branch on, and each branch on -// alone. Independent AND predicates are then still fully type-checked; a fragment that -// depends on another (a JOIN one `if` introduces, a column another `if` uses) shows up -// as an error in its "alone" form, which is the cue to restructure the template. -const MaxExpansions = 256 - -// Path is a field path from the parameter struct root: each element is a field -// name, or "[]" for "element of the preceding slice / array / map". -type Path []string - -func (p Path) String() string { - var b strings.Builder - for _, e := range p { - if e == "[]" { - b.WriteString("[]") - } else { - b.WriteString(".") - b.WriteString(e) - } - } - if b.Len() == 0 { - return "." - } - return b.String() -} - -// Param is one `$n` in an expansion. -type Param struct { - N int // 1-based placeholder number - Path Path // origin on P - Pos int // byte offset of the action in the template text -} - -// Expansion is one concrete SQL string. -type Expansion struct { - SQL string - Params []Param - // Branch names the control-flow choices that produced this expansion, e.g. "if@31:then range@114:x2". - Branch string - segs []segment -} - -// segment maps a run of expanded bytes back to the template. -type segment struct { - expStart, tmplStart, n int -} - -// TemplatePos maps a byte offset in SQL back to a template offset (best effort). -func (e *Expansion) TemplatePos(sqlOff int) int { - for _, s := range e.segs { - if sqlOff >= s.expStart && sqlOff < s.expStart+s.n { - return s.tmplStart + (sqlOff - s.expStart) - } - } - if len(e.segs) > 0 { - last := e.segs[len(e.segs)-1] - return last.tmplStart + last.n - } - return 0 -} - -// Result of expanding one template. -type Result struct { - Expansions []Expansion - // Controls are the paths read by if / with / range conditions (they must exist on P). - Controls []Path - // Sparse is set when the branch product exceeded MaxExpansions and Expansions holds - // the sparse set instead of every combination (see MaxExpansions). - Sparse bool - // Combinations is the size of the full branch product. - Combinations int -} - -// Error is a template problem with a template byte offset. -type Error struct { - Pos int - Msg string -} - -func (e *Error) Error() string { return fmt.Sprintf("template: %s (at %d)", e.Msg, e.Pos) } - -// Expand parses and expands tmpl. -func Expand(tmpl string) (*Result, error) { - trees, err := parse.Parse("q", tmpl, "{{", "}}", builtins) - if err != nil { - return nil, &Error{Pos: 0, Msg: strings.TrimPrefix(err.Error(), "template: q:")} - } - root := trees["q"].Root - branches := branchNodes(root) - combos := 1 - for _, b := range branches { - if b.kind == 'r' { - combos *= 3 - } else { - combos *= 2 - } - if combos > MaxExpansions { - break - } - } - res := &Result{Combinations: combos} - if combos <= MaxExpansions { - x := &expander{res: res} - out, err := x.list(root, []*state{{dot: Path{}, vars: map[string]Path{"$": {}}}}) - if err != nil { - return nil, err - } - x.emit(out) - return res, nil - } - // sparse: all off, all on, each on alone - res.Sparse = true - policies := []map[parse.Pos]int{{}, {}} - for _, b := range branches { - on := 1 - if b.kind == 'r' { - on = 2 - } - policies[1][b.pos] = on - policies = append(policies, map[parse.Pos]int{b.pos: 1}) - } - for _, pol := range policies { - x := &expander{res: res, choose: func(pos parse.Pos) int { return pol[pos] }} - out, err := x.list(root, []*state{{dot: Path{}, vars: map[string]Path{"$": {}}}}) - if err != nil { - return nil, err - } - x.emit(out) - } - return res, nil -} - -func (x *expander) emit(out []*state) { - for _, st := range out { - x.res.Expansions = append(x.res.Expansions, Expansion{ - SQL: st.sql.String(), Params: st.params, Branch: strings.TrimSpace(st.branch), segs: st.segs, - }) - } -} - -type branchNode struct { - pos parse.Pos - kind byte // 'i' if, 'w' with, 'r' range -} - -// branchNodes lists the control nodes of a tree in source order. -func branchNodes(l *parse.ListNode) []branchNode { - var out []branchNode - var walk func(l *parse.ListNode) - walk = func(l *parse.ListNode) { - if l == nil { - return - } - for _, n := range l.Nodes { - switch v := n.(type) { - case *parse.IfNode: - out = append(out, branchNode{v.Pos, 'i'}) - walk(v.List) - walk(v.ElseList) - case *parse.WithNode: - out = append(out, branchNode{v.Pos, 'w'}) - walk(v.List) - walk(v.ElseList) - case *parse.RangeNode: - out = append(out, branchNode{v.Pos, 'r'}) - walk(v.List) - walk(v.ElseList) - } - } - } - walk(l) - return out -} - -// builtins lets conditions use the usual comparison / boolean helpers. They are never evaluated. -var builtins = map[string]any{ - "eq": func() {}, "ne": func() {}, "lt": func() {}, "le": func() {}, "gt": func() {}, "ge": func() {}, - "and": func() {}, "or": func() {}, "not": func() {}, "len": func() {}, "index": func() {}, -} - -type expander struct { - res *Result - // choose, when set (sparse mode), picks the single alternative to take at a control - // node: 0 else / no iteration, 1 then / one iteration, 2 two iterations - choose func(pos parse.Pos) int -} - -// state is one partial expansion being built. -type state struct { - sql strings.Builder - params []Param - segs []segment - branch string - dot Path - vars map[string]Path - // paramKeys dedupes identical origins within one expansion so the same value gets one $n - paramKeys map[string]int - // iter is the stack of active range iteration indexes (disambiguates $n across iterations) - iter []int -} - -func (s *state) clone() *state { - c := &state{dot: s.dot, branch: s.branch} - c.sql.WriteString(s.sql.String()) - c.params = append([]Param{}, s.params...) - c.segs = append([]segment{}, s.segs...) - c.vars = map[string]Path{} - for k, v := range s.vars { - c.vars[k] = v - } - c.paramKeys = map[string]int{} - for k, v := range s.paramKeys { - c.paramKeys[k] = v - } - c.iter = append([]int{}, s.iter...) - return c -} - -func (s *state) text(t string, tmplPos int) { - s.segs = append(s.segs, segment{expStart: s.sql.Len(), tmplStart: tmplPos, n: len(t)}) - s.sql.WriteString(t) -} - -func (s *state) param(p Path, tmplPos int) { - key := p.String() + fmt.Sprint(s.iter) - if s.paramKeys == nil { - s.paramKeys = map[string]int{} - } - n, ok := s.paramKeys[key] - if !ok { - n = len(s.params) + 1 - s.paramKeys[key] = n - s.params = append(s.params, Param{N: n, Path: p, Pos: tmplPos}) - } - s.text("$"+strconv.Itoa(n), tmplPos) -} - -func (x *expander) list(l *parse.ListNode, states []*state) ([]*state, error) { - if l == nil || len(states) == 0 { - return states, nil - } - var err error - for _, n := range l.Nodes { - states, err = x.node(n, states) - if err != nil { - return nil, err - } - if len(states) > MaxExpansions { - return nil, &Error{Pos: int(n.Position()), Msg: fmt.Sprintf("more than %d expansions; split the template", MaxExpansions)} - } - } - return states, nil -} - -func (x *expander) node(n parse.Node, states []*state) ([]*state, error) { - switch v := n.(type) { - case *parse.TextNode: - for _, s := range states { - s.text(string(v.Text), int(v.Pos)) - } - return states, nil - case *parse.ActionNode: - if len(v.Pipe.Decl) > 0 { - // {{$x := .Y}} - p, err := x.pipePath(v.Pipe, states[0], int(v.Pos)) - if err != nil { - return nil, err - } - for _, s := range states { - s.vars[v.Pipe.Decl[0].Ident[0]] = p - } - return states, nil - } - p, err := x.pipePath(v.Pipe, states[0], int(v.Pos)) - if err != nil { - return nil, err - } - for _, s := range states { - s.param(x.rebase(p, s), int(v.Pos)) - } - return states, nil - case *parse.IfNode: - x.control(v.Pipe, states[0]) - return x.branches(&v.BranchNode, states, "if", nil) - case *parse.WithNode: - p, err := x.pipePath(v.Pipe, states[0], int(v.Pos)) - if err != nil { - return nil, err - } - x.res.Controls = append(x.res.Controls, p) - if len(v.Pipe.Decl) > 0 { - // {{with $y := .X}}: $y is .X inside - for _, s := range states { - s.vars[v.Pipe.Decl[0].Ident[0]] = p - } - } - return x.branches(&v.BranchNode, states, "with", &p) - case *parse.RangeNode: - return x.rangeNode(v, states) - case *parse.CommentNode: - return states, nil - case *parse.TemplateNode: - return nil, &Error{Pos: int(v.Pos), Msg: "{{template}} is not supported"} - } - return nil, &Error{Pos: int(n.Position()), Msg: fmt.Sprintf("unsupported template node %T", n)} -} - -// branches expands then/else lists. withDot, if set, becomes dot inside the then-branch. -func (x *expander) branches(b *parse.BranchNode, states []*state, kind string, withDot *Path) ([]*state, error) { - var out []*state - takeThen, takeElse := true, true - if x.choose != nil { - takeThen = x.choose(b.Pos) >= 1 - takeElse = !takeThen - } - then := make([]*state, 0, len(states)) - for _, s := range states { - c := s.clone() - c.branch += fmt.Sprintf(" %s@%d:then", kind, b.Pos) - if withDot != nil { - c.dot = x.rebase(*withDot, s) - } - then = append(then, c) - } - if !takeThen { - then = nil - } - then, err := x.list(b.List, then) - if err != nil { - return nil, err - } - for _, s := range then { - if withDot != nil { - s.dot = states[0].dot - } - } - out = append(out, then...) - if !takeElse { - return out, nil - } - els := make([]*state, 0, len(states)) - for _, s := range states { - c := s.clone() - c.branch += fmt.Sprintf(" %s@%d:else", kind, b.Pos) - els = append(els, c) - } - els, err = x.list(b.ElseList, els) - if err != nil { - return nil, err - } - return append(out, els...), nil -} - -// rangeNode expands {{range}} as 0, 1 and 2 iterations. -func (x *expander) rangeNode(r *parse.RangeNode, states []*state) ([]*state, error) { - p, err := x.pipePath(r.Pipe, states[0], int(r.Pos)) - if err != nil { - return nil, err - } - x.res.Controls = append(x.res.Controls, p) - var out []*state - counts := []int{0, 1, 2} - if x.choose != nil { - counts = []int{x.choose(r.Pos)} - } - for _, count := range counts { - batch := make([]*state, 0, len(states)) - for _, s := range states { - c := s.clone() - c.branch += fmt.Sprintf(" range@%d:x%d", r.Pos, count) - batch = append(batch, c) - } - if count == 0 { - batch, err = x.list(r.ElseList, batch) - if err != nil { - return nil, err - } - out = append(out, batch...) - continue - } - for i := 0; i < count; i++ { - for _, c := range batch { - elem := append(append(Path{}, x.rebase(p, c)...), "[]") - c.dot = elem - c.iter = append(c.iter, i) - // {{range $i, $v := .X}} / {{range $v := .X}} - switch len(r.Pipe.Decl) { - case 1: - c.vars[r.Pipe.Decl[0].Ident[0]] = elem - case 2: - c.vars[r.Pipe.Decl[0].Ident[0]] = Path{"#index"} - c.vars[r.Pipe.Decl[1].Ident[0]] = elem - } - } - batch, err = x.list(r.List, batch) - if err != nil { - return nil, err - } - for _, c := range batch { - c.iter = c.iter[:len(c.iter)-1] - } - } - for _, c := range batch { - c.dot = states[0].dot - } - out = append(out, batch...) - } - return out, nil -} - -// control records paths read by a condition (no expansion effect). -func (x *expander) control(pipe *parse.PipeNode, s *state) { - for _, cmd := range pipe.Cmds { - for _, arg := range cmd.Args { - switch a := arg.(type) { - case *parse.FieldNode: - x.res.Controls = append(x.res.Controls, x.rebase(append(append(Path{}, s.dot...), a.Ident...), s)) - case *parse.DotNode: - x.res.Controls = append(x.res.Controls, s.dot) - } - } - } -} - -// pipePath extracts the single field-chain a value action must be. Relative paths (from dot) -// are returned with dot prepended; variables resolve through s.vars. -func (x *expander) pipePath(pipe *parse.PipeNode, s *state, pos int) (Path, error) { - if len(pipe.Cmds) != 1 || len(pipe.Cmds[0].Args) != 1 { - return nil, &Error{Pos: pos, Msg: "value actions must be a plain field reference like {{.Field}}"} - } - switch a := pipe.Cmds[0].Args[0].(type) { - case *parse.FieldNode: - return append(append(Path{}, s.dot...), a.Ident...), nil - case *parse.DotNode: - return append(Path{}, s.dot...), nil - case *parse.VariableNode: - base, ok := s.vars[a.Ident[0]] - if !ok { - return nil, &Error{Pos: pos, Msg: fmt.Sprintf("undefined variable %s", a.Ident[0])} - } - return append(append(Path{}, base...), a.Ident[1:]...), nil - } - return nil, &Error{Pos: pos, Msg: "value actions must be a plain field reference like {{.Field}}"} -} - -// rebase is the identity today; paths are already absolute. Kept as the single place to -// change if relative resolution is needed. -func (x *expander) rebase(p Path, s *state) Path { return p } diff --git a/internal/migrate/migrate.go b/internal/migrate/migrate.go deleted file mode 100644 index f3960da..0000000 --- a/internal/migrate/migrate.go +++ /dev/null @@ -1,830 +0,0 @@ -// Package migrate turns the difference between two schemas into DDL, and checks DDL by -// its end state: applied to a PostgreSQL holding the current schema, does the database -// read back as the target? -// -// Both schemas should be canonical (dump.Canonical / dump.Load): the target's statement -// texts are then PostgreSQL's own, fully qualified, and its objects come in dependency -// order, which the plan follows for additions. Whole objects are created from the text -// that declared them (Relation.Definition and friends); parts (columns, constraints, -// labels) are rendered from the model. -// -// Seeded tables (schema.Relation.Seed) are brought to their declared rows last, with a -// MERGE per table whose content differs (seed.go). -// -// The plan is a proposal: it does not know how to rename, which value an enum label -// should map to when it goes, or what to backfill a new NOT NULL column with. Those show -// up as statements PostgreSQL will refuse or as a leftover difference in Verify, and are -// the intent declarations' job to settle. -package migrate - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/kr9ly/sqlshape/internal/analyze" - "github.com/kr9ly/sqlshape/internal/diff" - "github.com/kr9ly/sqlshape/internal/dump" - "github.com/kr9ly/sqlshape/internal/schema" -) - -// Plan lists the statements that turn from into to: drops (dependents first), the -// declared renames, then alterations, then additions in the target's order, then the -// rows of the seeded tables (a MERGE per table whose content differs). The intents -// (ParseIntents of the target's source) decide what the diff cannot: a table or column -// that disappears must be declared dropped or renamed, an enum label that disappears -// must say which label its values become, and backfills fill new columns before they -// turn NOT NULL. The error lists every declaration the schemas do not bear out and every -// change no declaration explains; the DDL is still returned for reading. -func Plan(from, to *schema.Schema, list []Intent) ([]string, error) { - p := &planner{from: from, to: to, recreated: map[string]bool{}, backfilled: map[string]bool{}} - p.readIntents(list) - p.enumRecreates() - p.drops() - p.renames() - p.alters() - p.adds() - p.seeds() - if len(p.in.problems) > 0 { - return p.out, errors.New(strings.Join(p.in.problems, "\n")) - } - return p.out, nil -} - -// check type-checks a data statement of the plan against the target schema. -func (p *planner) check(sql string) error { - _, err := analyze.Analyze(p.to, sql) - return err -} - -// Verify applies ddl to a fresh database holding currentSQL and lists what still -// differs from target. An error is a statement PostgreSQL refused (or the server -// failing); no changes and no error means the DDL reaches the target. Column order is -// tolerated (diff.Change.OrderOnly) and returned separately as notes. -func Verify(ctx context.Context, c dump.Canonicalizer, currentSQL, ddl string, target *schema.Schema) (changes, notes []diff.Change, err error) { - // the current schema is a dump, whose session has search_path emptied; the plan's - // rendered statements name public objects unqualified - got, _, err := c.Canonical(ctx, currentSQL+"\nRESET search_path;\n"+ddl, target) - if err != nil { - return nil, nil, err - } - for _, c := range diff.Compare(got, target) { - if c.OrderOnly() { - notes = append(notes, c) - } else { - changes = append(changes, c) - } - } - return changes, notes, nil -} - -type planner struct { - from, to *schema.Schema - out []string - // recreated are relations dropped in the drop phase that the add phase creates anew - recreated map[string]bool - in *intents - // fromRels / toRels cache relations(); backfilled marks "rel.col" backfills emitted - fromRels, toRels map[string]*schema.Relation - backfilled map[string]bool - // enumRecreate: enums whose labels shrink, recreated under the same name with the - // declared label mapping; the tables whose columns carry them, and the views over - // those tables (dropped first, created anew) - enumRecreate map[string]diff.UserType -} - -func (p *planner) emit(format string, args ...any) { - p.out = append(p.out, fmt.Sprintf(format, args...)+";") -} - -// note leaves a comment for what the plan cannot do. -func (p *planner) note(format string, args ...any) { - p.out = append(p.out, "-- "+fmt.Sprintf(format, args...)) -} - -// --- naming -------------------------------------------------------------------- - -// q quotes an identifier. -func q(name string) string { return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` } - -// qrel is a relation's fully qualified, quoted name. -func qrel(r *schema.Relation) string { return q(r.Schema) + "." + q(r.Name) } - -// lit is a string literal. -func lit(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" } - -func qlist(names []string) string { - out := make([]string, len(names)) - for i, n := range names { - out[i] = q(n) - } - return strings.Join(out, ", ") -} - -// --- lookups ------------------------------------------------------------------- - -func relations(s *schema.Schema) (map[string]*schema.Relation, []*schema.Relation) { - m := map[string]*schema.Relation{} - var order []*schema.Relation - for _, r := range s.Relations { - if r.Temp || r.Kind == 'c' { - continue - } - m[r.FullName()] = r - order = append(order, r) - } - return m, order -} - -func functions(s *schema.Schema) (map[string]*schema.Function, []*schema.Function) { - m := map[string]*schema.Function{} - var order []*schema.Function - for _, f := range s.Functions { - if f.Language == "internal" { - continue // a range/multirange constructor: PostgreSQL creates and drops it with its type - } - m[diff.Signature(s, f)] = f - order = append(order, f) - } - return m, order -} - -func triggers(s *schema.Schema) map[string]*schema.Trigger { - m := map[string]*schema.Trigger{} - for _, t := range s.Triggers { - m[t.Table+"."+t.Name] = t - } - return m -} - -func constraints(r *schema.Relation) map[string]*schema.Constraint { - idx := map[string]bool{} - for _, i := range r.Indexes { - idx[i.Name] = true - } - m := map[string]*schema.Constraint{} - for _, c := range r.Constraints { - if c.Kind == schema.Unique && idx[c.Name] { - continue // the loader's mirror of a unique index - } - m[c.Name] = c - } - return m -} - -func indexes(r *schema.Relation) map[string]*schema.Index { - m := map[string]*schema.Index{} - for _, i := range r.Indexes { - m[i.Name] = i - } - return m -} - -func columnsOf(r *schema.Relation) map[string]*schema.Column { - m := map[string]*schema.Column{} - for _, c := range r.Columns { - m[c.Name] = c - } - return m -} - -// same reports whether two objects compare equal under diff (their properties match). -func same(a, b *schema.Schema, x, y any) bool { - return len(diff.Props(a, x)) > 0 && fmt.Sprint(diff.Props(a, x)) == fmt.Sprint(diff.Props(b, y)) -} - -// --- drops ------------------------------------------------------------------------- - -func (p *planner) drops() { - fromRels, fromOrder := relations(p.from) - toRels, _ := relations(p.to) - fromFns, _ := functions(p.from) - toFns, _ := functions(p.to) - fromTrg, toTrg := triggers(p.from), triggers(p.to) - - // parts of relations that survive: triggers, rules, indexes, constraints - for _, n := range sortedKeys(fromTrg) { - t := fromTrg[n] - if toRels[p.toName(t.Table)] == nil { - continue // goes with the table - } - if tt := toTrg[p.toName(t.Table)+"."+t.Name]; tt == nil || !same(p.from, p.to, t, tt) { - p.emit("DROP TRIGGER %s ON %s", q(t.Name), qrel(fromRels[t.Table])) - } - } - for _, r := range fromOrder { - tr := p.toOf(r) - if tr == nil || tr.Kind != r.Kind { - continue - } - toRules := tr.Rules() - for _, n := range sortedKeys(r.Rules()) { - if rd, ok := toRules[n]; !ok || !same(p.from, p.to, r.Rules()[n], rd) { - p.emit("DROP RULE %s ON %s", q(n), qrel(r)) - } - } - for _, pol := range r.Policies { - if tp := tr.Policy(pol.Name); tp == nil || !same(p.from, p.to, pol, tp) { - p.emit("DROP POLICY %s ON %s", q(pol.Name), qrel(r)) - } - } - toIdx := indexes(tr) - for _, n := range sortedKeys(indexes(r)) { - if ti, ok := toIdx[n]; !ok || !same(p.from, p.to, indexes(r)[n], ti) { - p.emit("DROP INDEX %s", q(r.Schema)+"."+q(n)) - } - } - toCon := constraints(tr) - // foreign keys first: they may reference keys dropped below - for pass := 0; pass < 2; pass++ { - for _, n := range sortedKeys(constraints(r)) { - c := constraints(r)[n] - if (c.Kind == schema.ForeignKey) != (pass == 0) { - continue - } - if tc, ok := toCon[n]; !ok || !same(p.from, p.to, c, tc) { - p.emit("ALTER TABLE %s DROP CONSTRAINT %s", qrel(r), q(n)) - } - } - } - } - // views and materialized views, latest declared first (they may depend on each other) - for i := len(fromOrder) - 1; i >= 0; i-- { - r := fromOrder[i] - if r.Kind != schema.View && r.Kind != schema.MatView { - continue - } - tr := p.toOf(r) - if tr != nil && tr.Kind == r.Kind && !p.recreated[tr.FullName()] && (same(p.from, p.to, r, tr) || r.Kind == schema.View && replaceable(p.from, p.to, r, tr)) { - continue // a view that only grows is replaced in place; otherwise recreated - } - p.emit("DROP %s %s", relWord(r), qrel(r)) - if tr != nil { - p.recreated[tr.FullName()] = true - } - } - for _, n := range sortedKeys(fromFns) { - if toFns[n] == nil { - p.emit("DROP %s %s", fnWord(fromFns[n]), n) - } - } - // columns of tables that survive - for _, r := range fromOrder { - tr := p.toOf(r) - if tr == nil || r.Kind != schema.Table || tr.Kind != schema.Table { - continue - } - toCols := columnsOf(tr) - for _, c := range r.Columns { - if toCols[p.toCol(r, c.Name)] == nil { - if !p.in.dropOK[r.FullName()+"."+c.Name] { - p.problem("column %s.%s is dropped, which no @migrate declares: add `-- @migrate drop %s.%s` or `-- @migrate rename %s.%s -> ...`", r.FullName(), c.Name, r.FullName(), c.Name, r.FullName(), c.Name) - } - p.emit("ALTER TABLE %s DROP COLUMN %s", qrel(r), q(c.Name)) - } - } - } - // tables and sequences: referencing tables before the tables they reference - var gone []*schema.Relation - for i := len(fromOrder) - 1; i >= 0; i-- { - r := fromOrder[i] - if r.Kind != schema.Table && r.Kind != schema.Sequence { - continue - } - if r.Kind == schema.Sequence && r.OwnedBy != "" { - continue // goes with its column - } - if tr := p.toOf(r); tr == nil || tr.Kind != r.Kind { - gone = append(gone, r) - } - } - for _, r := range fkOrder(gone) { - if r.Kind == schema.Table && !p.in.dropOK[r.FullName()] { - p.problem("table %s is dropped, which no @migrate declares: add `-- @migrate drop %s` or `-- @migrate rename %s -> ...`", r.FullName(), r.FullName(), r.FullName()) - } - p.emit("DROP %s %s", relWord(r), qrel(r)) - } - // types - fromTypes, toTypes := diff.UserTypes(p.from), diff.UserTypes(p.to) - for _, n := range sortedKeys(fromTypes) { - if t, ok := toTypes[n]; !ok || t.Kind != fromTypes[n].Kind { - p.emit("DROP %s %s", typeWord(fromTypes[n].Kind), n) - } - } - // extensions, schemas - toExt := extensions(p.to) - for _, e := range p.from.Catalog.Extensions { - if !toExt[e.Name] { - p.emit("DROP EXTENSION %s", q(e.Name)) - } - } - toSchemas := set(p.to.Schemas()) - for _, n := range p.from.Schemas() { - if !toSchemas[n] { - p.emit("DROP SCHEMA %s", q(n)) - } - } -} - -// --- alters ------------------------------------------------------------------------ - -func (p *planner) alters() { - fromTypes, toTypes := diff.UserTypes(p.from), diff.UserTypes(p.to) - for _, n := range sortedKeys(toTypes) { - t := toTypes[n] - f, ok := fromTypes[n] - if !ok || f.Kind != t.Kind || fmt.Sprint(f.Props) == fmt.Sprint(t.Props) { - continue - } - p.alterType(n, f, t) - } - - _, toOrder := relations(p.to) - for _, r := range toOrder { - f := p.fromOf(r) - if f == nil || f.Kind != r.Kind { - continue - } - switch r.Kind { - case schema.Table: - p.alterTable(f, r) - p.backfillsLeft(r, f) - case schema.View: - if !same(p.from, p.to, f, r) && replaceable(p.from, p.to, f, r) { - p.emit("%s", strings.Replace(r.Definition, "CREATE VIEW", "CREATE OR REPLACE VIEW", 1)) - } - case schema.Sequence: - if f.OwnedBy != r.OwnedBy { - owner := "NONE" - if r.OwnedBy != "" { - parts := strings.Split(r.OwnedBy, ".") - owner = qdot(strings.Join(parts, ".")) - } - p.emit("ALTER SEQUENCE %s OWNED BY %s", qrel(r), owner) - } - } - } - - fromFns, _ := functions(p.from) - _, toFnOrder := functions(p.to) - for _, fn := range toFnOrder { - n := diff.Signature(p.to, fn) - f := fromFns[n] - if f == nil || same(p.from, p.to, f, fn) { - continue - } - def := fn.Definition - if fnProps(p.from, f)["returns"] != fnProps(p.to, fn)["returns"] || len(f.Args) != len(fn.Args) { - // CREATE OR REPLACE cannot change the return type or the parameter list - p.emit("DROP %s %s", fnWord(f), n) - } else { - def = strings.Replace(def, "CREATE FUNCTION", "CREATE OR REPLACE FUNCTION", 1) - def = strings.Replace(def, "CREATE PROCEDURE", "CREATE OR REPLACE PROCEDURE", 1) - } - p.emit("%s", def) - } -} - -func (p *planner) alterType(name string, f, t diff.UserType) { - switch t.Kind { - case "enum": - from, to := labels(f.Props["labels"]), labels(t.Props["labels"]) - have := set(to) - var gone []string - for _, l := range from { - if !have[l] { - gone = append(gone, l) - } - } - if len(gone) > 0 { - if _, ok := p.enumRecreate[name]; ok { - p.recreateEnum(name, f, t) - } else { - p.problem("enum %s: labels %s are removed, which no @migrate declares: add `-- @migrate enum %s: drop '
.` key cannot be formed for it. Per the +// arbitrated fix, ConstraintError.Key() falls back to PgError.DataTypeName — the +// domain's own name — for this one class-23 case, matching the failure mode the checker +// predicts (docs/checks.md's constraint-name table names a domain NOT NULL by the +// domain, not by the table/column it decorates). +// +// check/postgres/analyze/testdata/schema.sql declares `CREATE DOMAIN code AS text; ... +// ALTER DOMAIN code SET NOT NULL;` and table `spans (... cd code ...)` with cd left +// nullable at the column level (the domain itself carries the NOT NULL). Inserting a row +// without cd violates the domain's NOT NULL (SQLSTATE 23502), the same SQLSTATE a plain +// column NOT NULL violation raises. postgres.Violates(err, "code") — the domain's name — +// is expected to match. +var advInsertSpanNoCode = sqlshape.Query[struct{}, struct{ ID int64 }](`INSERT INTO spans (id) VALUES ({{.ID}})`) + +func TestAdvRuntime3_B1_DomainNotNullConstraintErrorKey(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + schema, err := os.ReadFile("../check/postgres/analyze/testdata/schema.sql") + if err != nil { + t.Fatal(err) + } + o, err := oracle.Start(ctx, string(schema)) + if err != nil { + t.Fatalf("oracle: %v", err) + } + defer o.Close() + db := o.Conn() + + _, err = postgres.Exec(ctx, db, advInsertSpanNoCode, struct{ ID int64 }{1}) + if err == nil { + t.Fatal("insert without the domain-typed NOT NULL column cd: want an error, got nil") + } + if !postgres.Violates(err, "code") { + t.Errorf("domain NOT NULL violation: want Violates(err, %q) (the domain's own name, PgError has no table/column to name), got false (err: %v)", "code", err) + } +} + +// B2: the same case for a domain declared `NOT NULL` directly in its own CREATE DOMAIN +// (rather than added later with ALTER DOMAIN ... SET NOT NULL): `email AS text NOT NULL +// CHECK (...)` on users.email (nullable at the column level: `email email UNIQUE`, no +// column-level NOT NULL). Key() falls back to the domain's own name here too: "email". +var advInsertUserNoEmail = sqlshape.Query[struct{}, struct{ Name string }](`INSERT INTO users (name) VALUES ({{.Name}})`) + +func TestAdvRuntime3_B2_DomainNotNullDeclaredInline(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + schema, err := os.ReadFile("../check/postgres/analyze/testdata/schema.sql") + if err != nil { + t.Fatal(err) + } + o, err := oracle.Start(ctx, string(schema)) + if err != nil { + t.Fatalf("oracle: %v", err) + } + defer o.Close() + db := o.Conn() + + _, err = postgres.Exec(ctx, db, advInsertUserNoEmail, struct{ Name string }{"nobody"}) + if err == nil { + t.Fatal("insert without the domain-typed NOT NULL column email: want an error, got nil") + } + if !postgres.Violates(err, "email") { + t.Errorf("domain NOT NULL violation: want Violates(err, %q) (the domain's own name), got false (err: %v)", "email", err) + } +} diff --git a/adv_runtime_test.go b/pgtest/runtime_adv_runtime_test.go similarity index 90% rename from adv_runtime_test.go rename to pgtest/runtime_adv_runtime_test.go index 222a5b3..216f875 100644 --- a/adv_runtime_test.go +++ b/pgtest/runtime_adv_runtime_test.go @@ -1,4 +1,4 @@ -package sqlshape_test +package pgtest_test // Adversarial tests for lane "runtime" (row mapper, error classification, user-type // registration, execution paths). See scratchpad/adv/COMMON.md and lane-runtime.md. @@ -17,8 +17,9 @@ import ( "time" "github.com/jackc/pgx/v5/pgtype" - "github.com/kr9ly/sqlshape" - "github.com/kr9ly/sqlshape/internal/oracle" + "github.com/kr9ly/sqlshape/check/postgres/v2/oracle" + "github.com/kr9ly/sqlshape/postgres/v2" + "github.com/kr9ly/sqlshape/v2" ) // A1: docs/runtime.md promises that "a nullable field (pointer, slice, map, sql.Null*, @@ -33,7 +34,7 @@ import ( func TestAdvRuntime_A1_NullableStructFieldMissingColumn(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - schema, err := os.ReadFile("internal/analyze/testdata/schema.sql") + schema, err := os.ReadFile("../check/postgres/analyze/testdata/schema.sql") if err != nil { t.Fatal(err) } @@ -49,7 +50,7 @@ func TestAdvRuntime_A1_NullableStructFieldMissingColumn(t *testing.T) { Note pgtype.Text // docs: nullable kind, column absent from this expansion should be fine } stmt := sqlshape.Query[rowWithPgtypeText, struct{}](`SELECT 1::bigint AS id`) - if rows, err := stmt.Collect(ctx, db, struct{}{}); err != nil { + if rows, err := postgres.Collect(ctx, db, stmt, struct{}{}); err != nil { t.Errorf("pgtype.Text field with an absent column: docs promise this stays zero, got error: %v", err) } else if len(rows) != 1 || rows[0].Note.Valid { t.Errorf("pgtype.Text field with an absent column: want zero value, got %+v", rows) @@ -60,7 +61,7 @@ func TestAdvRuntime_A1_NullableStructFieldMissingColumn(t *testing.T) { Note sql.NullString // docs: nullable kind (sql.Null*) } stmt2 := sqlshape.Query[rowWithSQLNullString, struct{}](`SELECT 1::bigint AS id`) - if rows, err := stmt2.Collect(ctx, db, struct{}{}); err != nil { + if rows, err := postgres.Collect(ctx, db, stmt2, struct{}{}); err != nil { t.Errorf("sql.NullString field with an absent column: docs promise this stays zero, got error: %v", err) } else if len(rows) != 1 || rows[0].Note.Valid { t.Errorf("sql.NullString field with an absent column: want zero value, got %+v", rows) @@ -110,7 +111,7 @@ func (p AdvPriceTag) Value() (driver.Value, error) { return p.Text, nil } func TestAdvRuntime_A2_StaleOIDFormatCacheAfterTypeRecreated(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - schema, err := os.ReadFile("internal/analyze/testdata/schema.sql") + schema, err := os.ReadFile("../check/postgres/analyze/testdata/schema.sql") if err != nil { t.Fatal(err) } @@ -138,7 +139,7 @@ func TestAdvRuntime_A2_StaleOIDFormatCacheAfterTypeRecreated(t *testing.T) { // first run: establishes the text-format request for adv_money's current OID and // decodes correctly (the composite's text form, "(42)"). - rows, err := getAmt.Collect(ctx, db, struct{}{}) + rows, err := postgres.Collect(ctx, db, getAmt, struct{}{}) if err != nil || len(rows) != 1 || rows[0].V.Text != "(42)" { t.Fatalf("first run (baseline): %v %+v", err, rows) } @@ -160,7 +161,7 @@ func TestAdvRuntime_A2_StaleOIDFormatCacheAfterTypeRecreated(t *testing.T) { // requesting text format for the OLD OID, which no longer names this column, so pgx // falls back to binary for the new OID and AdvPriceTag.Scan is handed the composite's // raw binary wire bytes instead of its text form. - rows2, err := getAmt.Collect(ctx, db, struct{}{}) + rows2, err := postgres.Collect(ctx, db, getAmt, struct{}{}) if err != nil { t.Fatalf("second run (after type recreation): unexpected error, docs promise text format always: %v", err) } @@ -193,7 +194,7 @@ UPDATE orders SET total = 2000000 WHERE id = {{.ID}}`) func TestAdvRuntime_A3_BatchExecBranchDoesNotMapExpectLineSQLSTATE(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - schema, err := os.ReadFile("internal/analyze/testdata/schema.sql") + schema, err := os.ReadFile("../check/postgres/analyze/testdata/schema.sql") if err != nil { t.Fatal(err) } @@ -207,27 +208,27 @@ func TestAdvRuntime_A3_BatchExecBranchDoesNotMapExpectLineSQLSTATE(t *testing.T) if _, err := db.Exec(ctx, `INSERT INTO users (email, name) VALUES ('a@x', 'A')`); err != nil { t.Fatal(err) } - id, err := insertOrder.First(ctx, db, NewOrder{UserID: 1, Total: "10.00"}) + id, err := postgres.First(ctx, db, insertOrder, NewOrder{UserID: 1, Total: "10.00"}) if err != nil { t.Fatalf("insert: %v", err) } // baseline, outside a batch: Violates(err, "P0401") holds via Exec's own wrapErr. - _, err = advUpdateTotal.Exec(ctx, db, struct{ ID int64 }{id}) - if !sqlshape.Violates(err, "P0401") { + _, err = postgres.Exec(ctx, db, advUpdateTotal, struct{ ID int64 }{id}) + if !postgres.Violates(err, "P0401") { t.Fatalf("baseline (non-batch) Exec P0401 mapping: want Violates to hold, got %v", err) } - b := sqlshape.NewBatch() - q := sqlshape.Queue(b, advUpdateTotal, struct{ ID int64 }{id}) + b := postgres.NewBatch() + q := postgres.Queue(b, advUpdateTotal, struct{ ID int64 }{id}) sendErr := b.Send(ctx, db) if sendErr == nil { t.Fatal("batch: want the trigger's error, got nil") } - if !sqlshape.Violates(sendErr, "P0401") { + if !postgres.Violates(sendErr, "P0401") { t.Errorf("batch (Exec branch): Send's error is not mapped to ConstraintError(P0401) as docs promise: %v", sendErr) } - if _, qErr := q.Tag(); !sqlshape.Violates(qErr, "P0401") { + if _, qErr := q.Tag(); !postgres.Violates(qErr, "P0401") { t.Errorf("batch (Exec branch): queued handle's error is not mapped to ConstraintError(P0401) as docs promise: %v", qErr) } } diff --git a/adv_template_test.go b/pgtest/runtime_adv_template_test.go similarity index 92% rename from adv_template_test.go rename to pgtest/runtime_adv_template_test.go index 23600b4..9fa0fa4 100644 --- a/adv_template_test.go +++ b/pgtest/runtime_adv_template_test.go @@ -1,4 +1,4 @@ -package sqlshape_test +package pgtest_test // Lane: template. Attack surface: expand vs. Render byte-identity, hazard detection in // internal/vet/hazards.go (checkActionPlacement), SQL-fragment splicing. @@ -34,8 +34,9 @@ import ( "testing" "time" - "github.com/kr9ly/sqlshape" - "github.com/kr9ly/sqlshape/internal/oracle" + "github.com/kr9ly/sqlshape/check/postgres/v2/oracle" + "github.com/kr9ly/sqlshape/postgres/v2" + "github.com/kr9ly/sqlshape/v2" ) type advSpliceParams struct { @@ -51,7 +52,7 @@ var advSpliceViaIfElse = sqlshape.Query[int64, advSpliceParams](`SELECT id FROM func TestAdvTemplateSpliceViaIfElseBreaksAtRuntime(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - schema, err := os.ReadFile("internal/analyze/testdata/schema.sql") + schema, err := os.ReadFile("../check/postgres/analyze/testdata/schema.sql") if err != nil { t.Fatal(err) } @@ -69,7 +70,7 @@ func TestAdvTemplateSpliceViaIfElseBreaksAtRuntime(t *testing.T) { // vet passed this template clean: no "inside a string literal" hazard for {{.Q}}. // A caller relying on that silence would reasonably expect Q to be bound safely // (e.g. as a WHERE-clause equality parameter). Confirm the runtime disagrees. - _, err = advSpliceViaIfElse.Collect(ctx, db, advSpliceParams{A: false, Q: "SECRET"}) + _, err = postgres.Collect(ctx, db, advSpliceViaIfElse, advSpliceParams{A: false, Q: "SECRET"}) if err == nil { t.Fatalf("want the driver to reject the bind-count mismatch produced by the splice, got no error") } @@ -107,7 +108,7 @@ var advBareOrderBySecondColumn = sqlshape.Query[int64, advOrderByParams](`SELECT func TestAdvTemplateBareOrderBySecondColumnIsIgnored(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - schema, err := os.ReadFile("internal/analyze/testdata/schema.sql") + schema, err := os.ReadFile("../check/postgres/analyze/testdata/schema.sql") if err != nil { t.Fatal(err) } @@ -125,7 +126,7 @@ func TestAdvTemplateBareOrderBySecondColumnIsIgnored(t *testing.T) { t.Fatal(err) } - ids, err := advBareOrderBySecondColumn.Collect(ctx, db, advOrderByParams{Sort: "does-not-exist-as-a-column"}) + ids, err := postgres.Collect(ctx, db, advBareOrderBySecondColumn, advOrderByParams{Sort: "does-not-exist-as-a-column"}) if err != nil { t.Fatalf("want this to run without error (that's the bug: nothing catches it): %v", err) } diff --git a/pgtest/runtime_adv_typefit_test.go b/pgtest/runtime_adv_typefit_test.go new file mode 100644 index 0000000..c792a13 --- /dev/null +++ b/pgtest/runtime_adv_typefit_test.go @@ -0,0 +1,178 @@ +package pgtest_test + +// Adversarial-testing lane "typefit" (x/dialect.Type / GoFit / gofit.go / typebind.go / +// check/postgres/dialect/types.go: the Go types vet accepts against what pgx actually +// scans / encodes). +// +// cmd/sqlshape/internal/vet's TestAdvTypefit (internal/vet/adv_typefit_test.go, package +// adv_typefit) records the diagnostics the checker should give for the three cases below +// and currently does not; this file reproduces each one against a real running +// PostgreSQL to show the diagnostics are not merely stylistic -- they are cases where +// the type sqlshape accepts silently either errors or (worse) silently corrupts data at +// run time. + +import ( + "context" + "testing" + "time" + + "github.com/kr9ly/sqlshape/check/postgres/v2/oracle" + "github.com/kr9ly/sqlshape/postgres/v2" + "github.com/kr9ly/sqlshape/v2" +) + +const advTypefitSchema = ` +-- sqlshape: postgres 17 +CREATE TYPE item AS ( + sku text, + qty integer +); + +CREATE TABLE t ( + id bigint PRIMARY KEY, + tags integer[], + items item[], + span interval +); +` + +// TestAdvTypefit_ScalarArrayNullElement: vet's adv_typefit.ScalarArrayRow (a []int32 +// field for an integer[] column) vets clean under -strict -- gofit.go's "[]$elem" branch +// recurses into int32's own fit against integer (an exact match) and never asks whether +// the array itself can hold a NULL regardless of the column's own NOT NULL. PostgreSQL +// never forbids that: ARRAY[1,NULL,3] is a legal value for `integer[]` whether or not the +// column is NOT NULL, since NOT NULL constrains the array value as a whole, not its +// elements. pgx's array codec cannot scan a NULL element into a non-pointer *int32 and +// errors decoding the whole row. +// +// Correct behavior: sqlshape should warn that []int32 (or any non-pointer element slice) +// cannot receive an array with a NULL element, the way it already warns a non-pointer +// scalar field about a nullable column. +func TestAdvTypefit_ScalarArrayNullElement(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + o, err := oracle.Start(ctx, advTypefitSchema) + if err != nil { + t.Skipf("PostgreSQL oracle: %v", err) + } + defer o.Close() + db := o.Conn() + + if _, err := db.Exec(ctx, `INSERT INTO t (id, tags) VALUES (1, ARRAY[1, NULL, 3])`); err != nil { + t.Fatalf("insert: %v", err) + } + + type ScalarArrayRow struct { + ID int64 + Tags []int32 + } + stmt := sqlshape.Query[ScalarArrayRow, struct{}](`SELECT id, tags FROM t WHERE id = 1`) + rows, err := postgres.Collect(ctx, db, stmt, struct{}{}) + if err == nil { + t.Fatalf("want a scan error for a NULL array element into []int32 (sqlshape.vet lets this Go type through unremarked), got rows=%+v", rows) + } + t.Logf("PostgreSQL/pgx correctly refuses at run time what vet accepted at check time: %v", err) +} + +// TestAdvTypefit_CompositeArrayNullElement: vet's adv_typefit.CompositeArrayRow (a +// []Item field for an item[] column, item a composite type) has the identical static +// gap, but the runtime failure mode is worse: pgx does not error on a NULL composite +// array element, it silently decodes it as a zero-valued Item{} -- indistinguishable +// from a real all-empty-fields row ROW(”, 0)::item. This is not a rejection sqlshape +// merely fails to warn about; it is silent data corruption sqlshape's type acceptance +// gives no signal for at all. +// +// Correct behavior: sqlshape should warn (at least as strongly as for the scalar case) +// that a NULL element of a composite array is received as a false zero value. +func TestAdvTypefit_CompositeArrayNullElement(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + o, err := oracle.Start(ctx, advTypefitSchema) + if err != nil { + t.Skipf("PostgreSQL oracle: %v", err) + } + defer o.Close() + db := o.Conn() + + if _, err := db.Exec(ctx, `INSERT INTO t (id, items) VALUES (1, ARRAY[ROW('a', 1)::item, NULL, ROW('b', 2)::item])`); err != nil { + t.Fatalf("insert: %v", err) + } + + type Item struct { + Sku string + Qty int32 + } + type CompositeArrayRow struct { + ID int64 + Items []Item + } + stmt := sqlshape.Query[CompositeArrayRow, struct{}](`SELECT id, items FROM t WHERE id = 1`) + rows, err := postgres.Collect(ctx, db, stmt, struct{}{}) + if err != nil { + t.Fatalf("Collect: %v", err) + } + if len(rows) != 1 || len(rows[0].Items) != 3 { + t.Fatalf("want 1 row with 3 items, got %+v", rows) + } + got := rows[0].Items[1] + want := Item{} // the real value is SQL NULL, not ("", 0) + if got != want { + t.Fatalf("unexpected decode of the NULL element: %+v", got) + } + // The point of the test: this zero value is byte-for-byte indistinguishable from a + // real ROW('', 0)::item, which item[] can just as legally hold -- []Item silently + // loses the fact that the element was ever NULL, and vet's type acceptance for + // []Item gives no warning that this can happen. + t.Logf("NULL item element silently decoded as the zero value %+v: sqlshape.vet accepted []Item for item[] with no warning that a NULL element is indistinguishable from a real empty row", got) +} + +// TestAdvTypefit_IntervalCalendar: vet's adv_typefit.IntervalRow (a time.Duration field +// for an interval column) vets clean under -strict with no Advice note, unlike every +// other zone- or precision-losing GoFit (timestamp without time zone, date, +// numeric->float64, ...). PostgreSQL's interval keeps months, days and microseconds as +// separate components and applies them to a calendar date one component at a time; pgx's +// Duration codec instead flattens interval to a fixed months*30days + days*24h + +// microseconds. This test shows the flattened value materially disagrees with +// PostgreSQL's own calendar arithmetic on the very same interval: adding `interval '1 +// month'` to 2024-02-01 (a leap February, 29 days) lands one calendar day apart between +// PostgreSQL's own date arithmetic and time.Time.Add() on the value sqlshape hands the Go +// side as "the" interval. +func TestAdvTypefit_IntervalCalendar(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + o, err := oracle.Start(ctx, advTypefitSchema) + if err != nil { + t.Skipf("PostgreSQL oracle: %v", err) + } + defer o.Close() + db := o.Conn() + + if _, err := db.Exec(ctx, `INSERT INTO t (id, span) VALUES (1, interval '1 month')`); err != nil { + t.Fatalf("insert: %v", err) + } + + type IntervalRow struct { + ID int64 + Span time.Duration + } + stmt := sqlshape.Query[IntervalRow, struct{}](`SELECT id, span FROM t WHERE id = 1`) + rows, err := postgres.Collect(ctx, db, stmt, struct{}{}) + if err != nil { + t.Fatalf("Collect: %v", err) + } + if len(rows) != 1 { + t.Fatalf("want 1 row, got %+v", rows) + } + span := rows[0].Span + + var pgResult time.Time + if err := db.QueryRow(ctx, `SELECT timestamptz '2024-02-01 00:00:00+00' + span FROM t WHERE id = 1`).Scan(&pgResult); err != nil { + t.Fatalf("PostgreSQL calendar arithmetic: %v", err) + } + goResult := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).Add(span) + + if pgResult.Equal(goResult) { + t.Fatalf("expected PostgreSQL's own calendar arithmetic to disagree with time.Time.Add(span) across a leap February, but both landed on %v (span=%v)", pgResult, span) + } + t.Logf("PostgreSQL: 2024-02-01 + interval '1 month' = %v; Go: 2024-02-01 + time.Duration(%v) (what sqlshape hands the application as the same interval) = %v -- sqlshape.vet gives no note that time.Duration cannot carry a calendar interval faithfully", pgResult, span, goResult) +} diff --git a/sqlshape_test.go b/pgtest/runtime_sqlshape_test.go similarity index 78% rename from sqlshape_test.go rename to pgtest/runtime_sqlshape_test.go index 74196f6..9ca2b9f 100644 --- a/sqlshape_test.go +++ b/pgtest/runtime_sqlshape_test.go @@ -1,4 +1,4 @@ -package sqlshape_test +package pgtest_test import ( "context" @@ -15,8 +15,9 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" - "github.com/kr9ly/sqlshape" - "github.com/kr9ly/sqlshape/internal/oracle" + "github.com/kr9ly/sqlshape/check/postgres/v2/oracle" + "github.com/kr9ly/sqlshape/postgres/v2" + "github.com/kr9ly/sqlshape/v2" ) type OrderStatus string @@ -214,7 +215,7 @@ var setPrice = sqlshape.Query[struct{}, struct { var itemSkus = sqlshape.Query[string, struct{ Items []ItemIn }](`SELECT sku FROM unnest({{.Items}}::order_items[]) ORDER BY line_no`) -var loadItems = sqlshape.Copy[ItemIn]("order_items", "order_id", "line_no", "sku", "qty", "discount") +var loadItems = postgres.Copy[ItemIn]("order_items", "order_id", "line_no", "sku", "qty", "discount") var itemCount = sqlshape.Query[int64, struct{ OrderID int64 }](`SELECT count(*) FROM order_items WHERE order_id = {{.OrderID}}`) @@ -321,7 +322,7 @@ func TestRender(t *testing.T) { func TestAgainstPostgres(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - schema, err := os.ReadFile("internal/analyze/testdata/schema.sql") + schema, err := os.ReadFile("../check/postgres/analyze/testdata/schema.sql") if err != nil { t.Fatal(err) } @@ -340,22 +341,22 @@ func TestAgainstPostgres(t *testing.T) { } note := "first" - id1, err := insertOrder.First(ctx, db, NewOrder{UserID: 1, Total: "10.50", Note: ¬e}) + id1, err := postgres.First(ctx, db, insertOrder, NewOrder{UserID: 1, Total: "10.50", Note: ¬e}) if err != nil { t.Fatalf("insert: %v", err) } - id2, err := insertOrder.First(ctx, db, NewOrder{UserID: 2, Total: "99.00"}) + id2, err := postgres.First(ctx, db, insertOrder, NewOrder{UserID: 2, Total: "99.00"}) if err != nil { t.Fatalf("insert 2: %v", err) } if id1 == 0 || id2 != id1+1 { t.Errorf("ids: %d %d", id1, id2) } - if tag, err := markPaid.Exec(ctx, db, struct{ ID int64 }{id2}); err != nil || tag.RowsAffected() != 1 { + if tag, err := postgres.Exec(ctx, db, markPaid, struct{ ID int64 }{id2}); err != nil || tag.RowsAffected() != 1 { t.Fatalf("exec: %v %v", tag, err) } - all, err := listOrders.Collect(ctx, db, ListParams{}) + all, err := postgres.Collect(ctx, db, listOrders, ListParams{}) if err != nil { t.Fatalf("collect: %v", err) } @@ -367,25 +368,25 @@ func TestAgainstPostgres(t *testing.T) { } paid := OrderStatus("paid") - rows, err := listOrders.Collect(ctx, db, ListParams{Status: &paid, Sort: "total"}) + rows, err := postgres.Collect(ctx, db, listOrders, ListParams{Status: &paid, Sort: "total"}) if err != nil || len(rows) != 1 || rows[0].ID != id2 { t.Errorf("enum scalar param: %v %+v", err, rows) } - rows, err = listOrders.Collect(ctx, db, ListParams{Statuses: []OrderStatus{"paid", "shipped"}}) + rows, err = postgres.Collect(ctx, db, listOrders, ListParams{Statuses: []OrderStatus{"paid", "shipped"}}) if err != nil || len(rows) != 1 { t.Errorf("enum array param: %v %+v", err, rows) } - rows, err = listOrders.Collect(ctx, db, ListParams{IDs: []int64{id1}, Limit: 5}) + rows, err = postgres.Collect(ctx, db, listOrders, ListParams{IDs: []int64{id1}, Limit: 5}) if err != nil || len(rows) != 1 || rows[0].ID != id2 { t.Errorf("range + with: %v %+v", err, rows) } - _, err = insertOrder.First(ctx, db, NewOrder{UserID: 999, Total: "1"}) - if !sqlshape.Violates(err, "orders_user_id_fkey") { + _, err = postgres.First(ctx, db, insertOrder, NewOrder{UserID: 999, Total: "1"}) + if !postgres.Violates(err, "orders_user_id_fkey") { t.Errorf("fk violation not mapped: %v", err) } - _, err = insertOrder.First(ctx, db, NewOrder{UserID: 1, Total: "1", Note: ¬e}) - var ce *sqlshape.ConstraintError + _, err = postgres.First(ctx, db, insertOrder, NewOrder{UserID: 1, Total: "1", Note: ¬e}) + var ce *postgres.ConstraintError if !errors.As(err, &ce) || ce.Code != "23505" || ce.Constraint != "orders_user_note_key" || ce.Table != "orders" { t.Errorf("unique violation not mapped: %v", err) } @@ -396,8 +397,8 @@ func TestAgainstPostgres(t *testing.T) { t.Errorf("ConstraintError.Unwrap() = %v, want %v", ce.Unwrap(), ce.Err) } // a NOT NULL violation: ConstraintError.Column set, Constraint empty (the other Error() form) - _, err = insertItemNoDiscount.Exec(ctx, db, struct{ OrderID int64 }{id1}) - var ceNN *sqlshape.ConstraintError + _, err = postgres.Exec(ctx, db, insertItemNoDiscount, struct{ OrderID int64 }{id1}) + var ceNN *postgres.ConstraintError if !errors.As(err, &ceNN) || ceNN.Column != "discount" || ceNN.Table != "order_items" || ceNN.Constraint != "" { t.Errorf("not null violation not mapped: %v", err) } @@ -407,64 +408,64 @@ func TestAgainstPostgres(t *testing.T) { if ceNN.Unwrap() != ceNN.Err { t.Errorf("ConstraintError.Unwrap() = %v, want %v", ceNN.Unwrap(), ceNN.Err) } - _, err = insertOrder.First(ctx, db, NewOrder{UserID: 1, Total: "-1"}) - if !sqlshape.Violates(err, "orders_total_check") { + _, err = postgres.First(ctx, db, insertOrder, NewOrder{UserID: 1, Total: "-1"}) + if !postgres.Violates(err, "orders_total_check") { t.Errorf("check violation not mapped: %v", err) } - _, err = insertOrder.First(ctx, db, NewOrder{UserID: 1, Total: "2000000"}) - if !sqlshape.Violates(err, "P0401") { + _, err = postgres.First(ctx, db, insertOrder, NewOrder{UserID: 1, Total: "2000000"}) + if !postgres.Violates(err, "P0401") { t.Errorf("trigger SQLSTATE not mapped: %v", err) } - one, err := orderByID.Get(ctx, db, struct{ ID int64 }{id2}) + one, err := postgres.Get(ctx, db, orderByID, struct{ ID int64 }{id2}) if err != nil || one.ID != id2 || one.Status != "paid" { t.Errorf("One.Get: %v %+v", err, one) } - if _, err := orderByID.Get(ctx, db, struct{ ID int64 }{id2 + 100}); !sqlshape.IsNoRows(err) { + if _, err := postgres.Get(ctx, db, orderByID, struct{ ID int64 }{id2 + 100}); !postgres.IsNoRows(err) { t.Errorf("One.Get missing: %v", err) } - if _, ok, err := orderByID.Find(ctx, db, struct{ ID int64 }{id2 + 100}); err != nil || ok { + if _, ok, err := postgres.Find(ctx, db, orderByID, struct{ ID int64 }{id2 + 100}); err != nil || ok { t.Errorf("One.Find missing: %v %v", ok, err) } if r, err := orderByID.Render(struct{ ID int64 }{id2}); err != nil || !strings.Contains(r.SQL, "$1") { t.Errorf("Single.Render: %v %+v", err, r) } - if row, err := orderByID.Unprepared().Get(ctx, db, struct{ ID int64 }{id2}); err != nil || row.ID != id2 { + if row, err := postgres.Get(ctx, db, orderByID.Unprepared(), struct{ ID int64 }{id2}); err != nil || row.ID != id2 { t.Errorf("Single.Unprepared: %v %+v", err, row) } if listOrders.SQLTemplate() == "" || orderByID.SQLTemplate() == "" { t.Error("SQLTemplate: empty") } // optionalKind: a field with no matching column is fine when it is optional (a pointer)... - if po, err := partialOrder.Get(ctx, db, struct{ ID int64 }{id2}); err != nil || po.Note != nil { + if po, err := postgres.Get(ctx, db, partialOrder, struct{ ID int64 }{id2}); err != nil || po.Note != nil { t.Errorf("partial result (optional field unset): %v %+v", err, po) } // ...and an error when it is not. - if _, err := badPartialOrder.Collect(ctx, db, struct{ ID int64 }{id2}); err == nil || !strings.Contains(err.Error(), "no result column") { + if _, err := postgres.Collect(ctx, db, badPartialOrder, struct{ ID int64 }{id2}); err == nil || !strings.Contains(err.Error(), "no result column") { t.Errorf("partial result (required field unset): %v", err) } // composite parameters, before any type is loaded (lazy registration on encode failure) items := []ItemIn{{OrderID: id1, LineNo: 2, Sku: "B", Qty: 1, Discount: "0.00"}, {OrderID: id1, LineNo: 1, Sku: "A", Qty: 2, Discount: "1.50"}} - if n, err := saveOrder.First(ctx, db, struct { + if n, err := postgres.First(ctx, db, saveOrder, struct { Price MoneyIn Items []ItemIn }{MoneyIn{"1.00", "JPY"}, items}); err != nil || n == nil || *n != 1 { t.Fatalf("composite params: %v %d", err, n) } - if skus, err := itemSkus.Collect(ctx, db, struct{ Items []ItemIn }{items}); err != nil || len(skus) != 2 || skus[0] != "A" || skus[1] != "B" { + if skus, err := postgres.Collect(ctx, db, itemSkus, struct{ Items []ItemIn }{items}); err != nil || len(skus) != 2 || skus[0] != "A" || skus[1] != "B" { t.Errorf("composite[] param round trip: %v %v", err, skus) } - if _, err := setPrice.Exec(ctx, db, struct { + if _, err := postgres.Exec(ctx, db, setPrice, struct { ID int64 Price *MoneyIn }{id2, &MoneyIn{"7.25", "USD"}}); err != nil { t.Fatalf("composite param in UPDATE: %v", err) } - if pr, err := priceOf.Get(ctx, db, struct{ ID int64 }{id2}); err != nil || pr.Price == nil || *pr.Price.Amount != "7.25" || *pr.Price.Currency != "USD" { + if pr, err := postgres.Get(ctx, db, priceOf, struct{ ID int64 }{id2}); err != nil || pr.Price == nil || *pr.Price.Amount != "7.25" || *pr.Price.Currency != "USD" { t.Errorf("composite param read back: %v %+v", err, pr.Price) } - if _, err := setPrice.Exec(ctx, db, struct { + if _, err := postgres.Exec(ctx, db, setPrice, struct { ID int64 Price *MoneyIn }{id2, nil}); err != nil { @@ -475,15 +476,15 @@ func TestAgainstPostgres(t *testing.T) { if n, err := loadItems.From(ctx, db, items); err != nil || n != 2 { t.Fatalf("copy: %v %d", err, n) } - if n, err := itemCount.First(ctx, db, struct{ OrderID int64 }{id1}); err != nil || n != 2 { + if n, err := postgres.First(ctx, db, itemCount, struct{ OrderID int64 }{id1}); err != nil || n != 2 { t.Errorf("copied rows: %v %d", err, n) } // One.Exec: exactly one row, or ErrNoRows - if _, err := markPaidOne.Exec(ctx, db, struct{ ID int64 }{id1}); err != nil { + if _, err := postgres.ExecOne(ctx, db, markPaidOne, struct{ ID int64 }{id1}); err != nil { t.Errorf("One.Exec: %v", err) } - if _, err := markPaidOne.Exec(ctx, db, struct{ ID int64 }{id1 + 100}); !sqlshape.IsNoRows(err) { + if _, err := postgres.ExecOne(ctx, db, markPaidOne, struct{ ID int64 }{id1 + 100}); !postgres.IsNoRows(err) { t.Errorf("One.Exec on no row: %v", err) } if _, err := db.Exec(ctx, `UPDATE orders SET status = 'pending' WHERE id = $1`, id1); err != nil { @@ -491,12 +492,12 @@ func TestAgainstPostgres(t *testing.T) { } // batch: one round trip, results per statement - b := sqlshape.NewBatch() - qAll := sqlshape.Queue(b, listOrders, ListParams{}) - qPaid := sqlshape.QueueOne(b, markPaidOne, struct{ ID int64 }{id1}) - qOne := sqlshape.QueueOne(b, orderByID, struct{ ID int64 }{id1}) - qNone := sqlshape.QueueOne(b, orderByID, struct{ ID int64 }{id1 + 100}) - if _, err := qAll.Rows(); !errors.Is(err, sqlshape.ErrNotSent) { + b := postgres.NewBatch() + qAll := postgres.Queue(b, listOrders, ListParams{}) + qPaid := postgres.QueueOne(b, markPaidOne, struct{ ID int64 }{id1}) + qOne := postgres.QueueOne(b, orderByID, struct{ ID int64 }{id1}) + qNone := postgres.QueueOne(b, orderByID, struct{ ID int64 }{id1 + 100}) + if _, err := qAll.Rows(); !errors.Is(err, postgres.ErrNotSent) { t.Errorf("before send: %v", err) } if err := b.Send(ctx, db); err != nil { @@ -511,7 +512,7 @@ func TestAgainstPostgres(t *testing.T) { if row, err := qOne.First(); err != nil || row.Status != "paid" { t.Errorf("batch one: %v %+v", err, row) } - if _, err := qNone.First(); !sqlshape.IsNoRows(err) { + if _, err := qNone.First(); !postgres.IsNoRows(err) { t.Errorf("batch one missing: %v", err) } if _, err := db.Exec(ctx, `UPDATE orders SET status = 'pending' WHERE id = $1`, id1); err != nil { @@ -519,23 +520,23 @@ func TestAgainstPostgres(t *testing.T) { } // Batch.Len: the pgx-batch statements plus the ones that must run as a plain query // after it (a sql.Scanner result needs text format, which a pgx batch never asks for) - bLen := sqlshape.NewBatch() + bLen := postgres.NewBatch() if bLen.Len() != 0 { t.Errorf("empty batch len: %d", bLen.Len()) } - sqlshape.Queue(bLen, listOrders, ListParams{}) + postgres.Queue(bLen, listOrders, ListParams{}) if bLen.Len() != 1 { t.Errorf("batch len after Queue: %d", bLen.Len()) } - sqlshape.QueueOne(bLen, priceTagOf, struct{ ID int64 }{id1}) + postgres.QueueOne(bLen, priceTagOf, struct{ ID int64 }{id1}) if bLen.Len() != 2 { t.Errorf("batch len after scanner Queue: %d", bLen.Len()) } // a failing statement surfaces as the same ConstraintError as Run - b = sqlshape.NewBatch() - sqlshape.Queue(b, insertOrder, NewOrder{UserID: 999, Total: "1"}) - if err := b.Send(ctx, db); !sqlshape.Violates(err, "orders_user_id_fkey") { + b = postgres.NewBatch() + postgres.Queue(b, insertOrder, NewOrder{UserID: 999, Total: "1"}) + if err := b.Send(ctx, db); !postgres.Violates(err, "orders_user_id_fkey") { t.Errorf("batch fk violation: %v", err) } @@ -544,7 +545,7 @@ func TestAgainstPostgres(t *testing.T) { t.Fatal(err) } var ule *sqlshape.UnknownLabelError - if _, err := listOrders.Collect(ctx, db, ListParams{}); !errors.As(err, &ule) || ule.Value != "cancelled" { + if _, err := postgres.Collect(ctx, db, listOrders, ListParams{}); !errors.As(err, &ule) || ule.Value != "cancelled" { t.Errorf("unknown label: %v", err) } if msg := ule.Error(); !strings.Contains(msg, "cancelled") || !strings.Contains(msg, "OrderStatus") { @@ -555,26 +556,26 @@ func TestAgainstPostgres(t *testing.T) { } // unprepared execution (custom plan every time) - if rows, err := listOrders.Unprepared().Collect(ctx, db, ListParams{}); err != nil || len(rows) != 2 { + if rows, err := postgres.Collect(ctx, db, listOrders.Unprepared(), ListParams{}); err != nil || len(rows) != 2 { t.Errorf("unprepared: %v %d", err, len(rows)) } // materialized view refresh - if err := sqlshape.MatView("order_stats").Refresh(ctx, db); err != nil { + if err := postgres.MatView("order_stats").Refresh(ctx, db); err != nil { t.Errorf("refresh: %v", err) } if _, err := db.Exec(ctx, `CREATE UNIQUE INDEX order_stats_user_idx ON order_stats (user_id)`); err != nil { t.Fatal(err) } - if err := sqlshape.MatView("order_stats").RefreshConcurrently(ctx, db); err != nil { + if err := postgres.MatView("order_stats").RefreshConcurrently(ctx, db); err != nil { t.Errorf("refresh concurrently: %v", err) } // procedures - if _, err := markPaidProc.Exec(ctx, db, struct{ ID int64 }{id1}); err != nil { + if _, err := postgres.Exec(ctx, db, markPaidProc, struct{ ID int64 }{id1}); err != nil { t.Errorf("CALL: %v", err) } - if st, err := settleProc.Get(ctx, db, struct{ ID int64 }{id1}); err != nil || st.PTotal != "10.50" { + if st, err := postgres.Get(ctx, db, settleProc, struct{ ID int64 }{id1}); err != nil || st.PTotal != "10.50" { t.Errorf("CALL with INOUT: %v %+v", err, st) } if _, err := db.Exec(ctx, `UPDATE orders SET status = 'pending' WHERE id = $1`, id1); err != nil { @@ -582,17 +583,17 @@ func TestAgainstPostgres(t *testing.T) { } // nested rows - uo, err := userOrders.Collect(ctx, db, struct{}{}) + uo, err := postgres.Collect(ctx, db, userOrders, struct{}{}) if err != nil || len(uo) != 2 || len(uo[0].Orders) != 1 || uo[0].Orders[0].ID != id1 || uo[0].Orders[0].Total != "10.50" { t.Fatalf("array_agg(row): %v %+v", err, uo) } - ufo, err := userFullOrders.Collect(ctx, db, struct{}{}) + ufo, err := postgres.Collect(ctx, db, userFullOrders, struct{}{}) if err != nil || len(ufo) != 2 || ufo[1].Orders[0].ID != id2 || ufo[1].Orders[0].Status != "paid" || ufo[1].Orders[0].Price != nil || ufo[1].Orders[0].CreatedAt.IsZero() { t.Fatalf("array_agg(o): %v %+v", err, ufo) } // LEFT JOIN: the user with no orders gets one NULL composite element (rowDest.ScanNull), // not a row of NULL fields - uao, err := userAnyOrders.Collect(ctx, db, struct{}{}) + uao, err := postgres.Collect(ctx, db, userAnyOrders, struct{}{}) if err != nil || len(uao) != 3 { t.Fatalf("array_agg(o) with LEFT JOIN: %v %+v", err, uao) } @@ -610,49 +611,49 @@ func TestAgainstPostgres(t *testing.T) { if !sawNullRow { t.Errorf("expected a NULL nested row: %+v", uao) } - eo, err := embeddedRows.First(ctx, db, ByUser{1}) + eo, err := postgres.First(ctx, db, embeddedRows, ByUser{1}) if err != nil || eo.ID != 1 || len(eo.Orders) != 1 || eo.Orders[0].ID != id1 || eo.Orders[0].Total != "10.50" || eo.Orders[0].Note == nil || *eo.Orders[0].Note != "first" { t.Errorf("embedded structs: %v %+v", err, eo) } if _, err := db.Exec(ctx, `UPDATE orders SET price = ROW(1.5, 'JPY') WHERE id = $1`, id1); err != nil { t.Fatal(err) } - pr, err := priceOf.Get(ctx, db, struct{ ID int64 }{id1}) + pr, err := postgres.Get(ctx, db, priceOf, struct{ ID int64 }{id1}) if err != nil || pr.Price == nil || *pr.Price.Amount != "1.50" || *pr.Price.Currency != "JPY" { t.Errorf("composite column: %v %+v", err, pr.Price) } - pr, err = priceOf.Get(ctx, db, struct{ ID int64 }{id2}) + pr, err = postgres.Get(ctx, db, priceOf, struct{ ID int64 }{id2}) if err != nil || pr.Price != nil { t.Errorf("NULL composite column: %v %+v", err, pr.Price) } // declared binding round trip: Value encodes the composite's text form, Scan receives it - if _, err := setPriceTag.Exec(ctx, db, struct { + if _, err := postgres.Exec(ctx, db, setPriceTag, struct { ID int64 Price PriceTag }{id2, PriceTag{"(7.25,USD)"}}); err != nil { t.Fatalf("declared type param: %v", err) } - if pt, err := priceTagOf.Get(ctx, db, struct{ ID int64 }{id2}); err != nil || pt.Price.Text != "(7.25,USD)" { + if pt, err := postgres.Get(ctx, db, priceTagOf, struct{ ID int64 }{id2}); err != nil || pt.Price.Text != "(7.25,USD)" { t.Errorf("declared type result: %v %+v", err, pt.Price) } - if pt, err := priceTagOf.Get(ctx, db, struct{ ID int64 }{id1 + 1000}); !sqlshape.IsNoRows(err) { + if pt, err := postgres.Get(ctx, db, priceTagOf, struct{ ID int64 }{id1 + 1000}); !postgres.IsNoRows(err) { t.Errorf("declared type missing row: %v %+v", err, pt) } if _, err := db.Exec(ctx, `UPDATE users SET handle = 'Alice', attrs = 'k=>v'`); err != nil { t.Fatal(err) } - et, err := extTypes.First(ctx, db, struct{ H string }{"alice"}) + et, err := postgres.First(ctx, db, extTypes, struct{ H string }{"alice"}) if err != nil || et.Handle == nil || *et.Handle != "Alice" || et.Attrs == nil || et.Attrs["k"] == nil || *et.Attrs["k"] != "v" || len(et.Tags) == 0 { t.Errorf("extension types before LoadUserTypes: %v %+v", err, et) } - if err := sqlshape.LoadUserTypes(ctx, db); err != nil { + if err := postgres.LoadUserTypes(ctx, db); err != nil { t.Fatalf("LoadUserTypes: %v", err) } - et, err = extTypes.First(ctx, db, struct{ H string }{"ALICE"}) + et, err = postgres.First(ctx, db, extTypes, struct{ H string }{"ALICE"}) if err != nil || et.Handle == nil || *et.Handle != "Alice" { t.Errorf("extension types after LoadUserTypes: %v %+v", err, et) } - sp, err := statusPairs.First(ctx, db, struct{}{}) + sp, err := postgres.First(ctx, db, statusPairs, struct{}{}) if err != nil || len(sp) != 2 || sp[1].Status != "paid" { t.Errorf("enum in record: %v %+v", err, sp) } @@ -673,10 +674,10 @@ func TestAgainstPostgres(t *testing.T) { Doc: pgtype.TSVector{Lexemes: []pgtype.TSVectorLexeme{{Word: "cat"}}, Valid: true}, Fee: &fee, AtTz: &atTz, } - if _, err := insertHost.Exec(ctx, db, h); err != nil { + if _, err := postgres.Exec(ctx, db, insertHost, h); err != nil { t.Fatalf("insert host: %v", err) } - got, err := hostByAddr.Get(ctx, db, struct { + got, err := postgres.Get(ctx, db, hostByAddr, struct { Addr string Span pgtype.Range[int32] }{"10.0.0.9", pgtype.Range[int32]{Lower: 4, Upper: 6, LowerType: pgtype.Inclusive, UpperType: pgtype.Exclusive, Valid: true}}) @@ -690,13 +691,13 @@ func TestAgainstPostgres(t *testing.T) { t.Errorf("host round trip: %+v", got) } - first, err := listOrders.First(ctx, db, ListParams{IDs: []int64{id1, id2}}) - if !sqlshape.IsNoRows(err) { + first, err := postgres.First(ctx, db, listOrders, ListParams{IDs: []int64{id1, id2}}) + if !postgres.IsNoRows(err) { t.Errorf("First on empty: %v %+v", err, first) } n := 0 - for row, err := range listOrders.Run(ctx, db, ListParams{}) { + for row, err := range postgres.Run(ctx, db, listOrders, ListParams{}) { if err != nil { t.Fatal(err) } @@ -704,7 +705,7 @@ func TestAgainstPostgres(t *testing.T) { _ = row break // early exit must release the connection } - counts, err := countByStatus.Collect(ctx, db, struct{}{}) + counts, err := postgres.Collect(ctx, db, countByStatus, struct{}{}) // enum ORDER BY follows declaration order: pending before paid if err != nil || len(counts) != 2 || counts[0].Status != "pending" || counts[1].Status != "paid" { t.Errorf("after early break: %v %+v", err, counts) @@ -715,7 +716,7 @@ func TestAgainstPostgres(t *testing.T) { // unnamed / generic branch, and Key()'s table.column form), with no database needed: // every field is exported, so a violation can be built by hand. func TestConstraintErrorDirect(t *testing.T) { - generic := &sqlshape.ConstraintError{Err: &pgconn.PgError{Message: "boom"}} + generic := &postgres.ConstraintError{Err: &pgconn.PgError{Message: "boom"}} if got := generic.Error(); got != "sqlshape: boom" { t.Errorf("Error() (no constraint, no column) = %q", got) } @@ -723,11 +724,11 @@ func TestConstraintErrorDirect(t *testing.T) { t.Errorf("Key() (no constraint, no column) = %q", got) } - notNull := &sqlshape.ConstraintError{Table: "order_items", Column: "discount", Err: &pgconn.PgError{Message: "not null"}} + notNull := &postgres.ConstraintError{Table: "order_items", Column: "discount", Err: &pgconn.PgError{Message: "not null"}} if got := notNull.Key(); got != "order_items.discount" { t.Errorf("Key() (table.column) = %q", got) } - if !sqlshape.Violates(notNull, "order_items.discount") { + if !postgres.Violates(notNull, "order_items.discount") { t.Error("Violates should match the table.column key") } } @@ -737,47 +738,24 @@ func TestConstraintErrorDirect(t *testing.T) { // with a nil CopyDB. func TestCopyLayoutErrors(t *testing.T) { ctx := context.Background() - if _, err := sqlshape.Copy[any]("t").From(ctx, nil, nil); err == nil || !strings.Contains(err.Error(), "must not be an interface") { + if _, err := postgres.Copy[any]("t").From(ctx, nil, nil); err == nil || !strings.Contains(err.Error(), "must not be an interface") { t.Errorf("interface R: %v", err) } - if _, err := sqlshape.Copy[int64]("t", "a", "b").From(ctx, nil, nil); err == nil || !strings.Contains(err.Error(), "feeds exactly one column") { + if _, err := postgres.Copy[int64]("t", "a", "b").From(ctx, nil, nil); err == nil || !strings.Contains(err.Error(), "feeds exactly one column") { t.Errorf("scalar R, wrong column count: %v", err) } - if _, err := sqlshape.Copy[ItemIn]("order_items", "nope").From(ctx, nil, nil); err == nil || !strings.Contains(err.Error(), "has no field") { + if _, err := postgres.Copy[ItemIn]("order_items", "nope").From(ctx, nil, nil); err == nil || !strings.Contains(err.Error(), "has no field") { t.Errorf("unknown column: %v", err) } type dupCol struct { A string `col:"x"` B string `col:"x"` } - if _, err := sqlshape.Copy[dupCol]("t").From(ctx, nil, nil); err == nil || !strings.Contains(err.Error(), "both bind to column") { + if _, err := postgres.Copy[dupCol]("t").From(ctx, nil, nil); err == nil || !strings.Contains(err.Error(), "both bind to column") { t.Errorf("duplicate column tags: %v", err) } } -// TestNormalizeArgNilComposite covers normalizeArg / compositeArg's handling of a nil -// element inside a slice of composite pointers, and of a nil slice itself: pure -// rendering, no database needed. -func TestNormalizeArgNilComposite(t *testing.T) { - stmt := sqlshape.Query[struct{}, struct{ Items []*ItemIn }]("SELECT {{.Items}}") - item := ItemIn{OrderID: 1, LineNo: 1, Sku: "A", Qty: 1, Discount: "0"} - r, err := stmt.Render(struct{ Items []*ItemIn }{Items: []*ItemIn{nil, &item}}) - if err != nil { - t.Fatalf("render: %v", err) - } - arr, ok := r.Args[0].([]pgtype.CompositeFields) - if !ok || len(arr) != 2 || arr[0] != nil || arr[1] == nil { - t.Errorf("composite slice with nil element: %#v", r.Args[0]) - } - r2, err := stmt.Render(struct{ Items []*ItemIn }{Items: nil}) - if err != nil { - t.Fatalf("render nil slice: %v", err) - } - if r2.Args[0] != nil { - t.Errorf("nil composite slice arg = %#v, want nil", r2.Args[0]) - } -} - // noConnDB wraps a *pgx.Conn without exposing Conn(), so sqlshape's connOf cannot reach // the underlying connection through it: withParamTypes then cannot register a missing // user type on the fly and reports the LoadUserTypes advice instead. @@ -790,7 +768,7 @@ func (d noConnDB) Exec(ctx context.Context, sql string, args ...any) (pgconn.Com return d.c.Exec(ctx, sql, args...) } -// noQueryDB implements sqlshape.BatchDB (SendBatch) but not sqlshape.DB: a batch +// noQueryDB implements postgres.BatchDB (SendBatch) but not postgres.DB: a batch // statement that must run after the batch as a plain query (a sql.Scanner result) // then cannot, and Batch.Send surfaces that error. type noQueryDB struct{ c *pgx.Conn } @@ -808,7 +786,7 @@ func (d noQueryDB) SendBatch(ctx context.Context, b *pgx.Batch) pgx.BatchResults func TestRuntimeGaps(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - schema, err := os.ReadFile("internal/analyze/testdata/schema.sql") + schema, err := os.ReadFile("../check/postgres/analyze/testdata/schema.sql") if err != nil { t.Fatal(err) } @@ -822,11 +800,11 @@ func TestRuntimeGaps(t *testing.T) { if _, err := db.Exec(ctx, `INSERT INTO users (email, name) VALUES ('a@x', 'A')`); err != nil { t.Fatal(err) } - id1, err := insertOrder.First(ctx, db, NewOrder{UserID: 1, Total: "10.00"}) + id1, err := postgres.First(ctx, db, insertOrder, NewOrder{UserID: 1, Total: "10.00"}) if err != nil { t.Fatalf("insert order 1: %v", err) } - id2, err := insertOrder.First(ctx, db, NewOrder{UserID: 1, Total: "20.00"}) + id2, err := postgres.First(ctx, db, insertOrder, NewOrder{UserID: 1, Total: "20.00"}) if err != nil { t.Fatalf("insert order 2: %v", err) } @@ -835,39 +813,39 @@ func TestRuntimeGaps(t *testing.T) { // R an interface type: reflect.TypeOf(zero) is nil. anyStmt := sqlshape.Query[any, struct{}]("SELECT 1") - if _, err := anyStmt.First(ctx, db, struct{}{}); err == nil || !strings.Contains(err.Error(), "must not be an interface") { + if _, err := postgres.First(ctx, db, anyStmt, struct{}{}); err == nil || !strings.Contains(err.Error(), "must not be an interface") { t.Errorf("interface R: %v", err) } // scalar R, wrong result column count. twoColStmt := sqlshape.Query[string, struct{}]("SELECT 'a', 'b'") - if _, err := twoColStmt.First(ctx, db, struct{}{}); err == nil || !strings.Contains(err.Error(), "returns 2 columns") { + if _, err := postgres.First(ctx, db, twoColStmt, struct{}{}); err == nil || !strings.Contains(err.Error(), "returns 2 columns") { t.Errorf("scalar wrong column count: %v", err) } // scalar R decoded by sql.Scanner: newMapper's scalar + userScanner branch (only a // non-struct R, or R == time.Time, is ever marked scalar). scalarTagStmt := sqlshape.One[ScalarTag, struct{}](`SELECT 'hello'`) - if tag, err := scalarTagStmt.Get(ctx, db, struct{}{}); err != nil || tag != "hello" { + if tag, err := postgres.Get(ctx, db, scalarTagStmt, struct{}{}); err != nil || tag != "hello" { t.Errorf("scalar Scanner result: %v %+v", err, tag) } // case-insensitive column fallback (byName misses, lower map hits). type idOnly struct{ ID int64 } lowerFallback := sqlshape.Query[idOnly, struct{}](`SELECT id AS "ID" FROM orders LIMIT 1`) - if rows, err := lowerFallback.Collect(ctx, db, struct{}{}); err != nil || len(rows) == 0 { + if rows, err := postgres.Collect(ctx, db, lowerFallback, struct{}{}); err != nil || len(rows) == 0 { t.Errorf("lowercase column fallback: %v %+v", err, rows) } // a result column with no matching field. extraCol := sqlshape.Query[idOnly, struct{}](`SELECT id, total FROM orders LIMIT 1`) - if _, err := extraCol.Collect(ctx, db, struct{}{}); err == nil || !strings.Contains(err.Error(), "has no field") { + if _, err := postgres.Collect(ctx, db, extraCol, struct{}{}); err == nil || !strings.Contains(err.Error(), "has no field") { t.Errorf("extra result column: %v", err) } // the same result column bound twice. dupCol := sqlshape.Query[idOnly, struct{}](`SELECT id, id FROM orders LIMIT 1`) - if _, err := dupCol.Collect(ctx, db, struct{}{}); err == nil || !strings.Contains(err.Error(), "matches") { + if _, err := postgres.Collect(ctx, db, dupCol, struct{}{}); err == nil || !strings.Contains(err.Error(), "matches") { t.Errorf("column bound twice: %v", err) } @@ -877,7 +855,7 @@ func TestRuntimeGaps(t *testing.T) { B string `col:"x"` } dupTag := sqlshape.Query[dupTagRow, struct{}](`SELECT 'a' AS x`) - if _, err := dupTag.Collect(ctx, db, struct{}{}); err == nil || !strings.Contains(err.Error(), "both bind to column") { + if _, err := postgres.Collect(ctx, db, dupTag, struct{}{}); err == nil || !strings.Contains(err.Error(), "both bind to column") { t.Errorf("duplicate col tag result: %v", err) } @@ -896,7 +874,7 @@ func TestRuntimeGaps(t *testing.T) { } embedVariants := sqlshape.Query[embedVariantsRow, struct{}]( `SELECT 1::bigint AS id, '1.00'::numeric AS total, 5::bigint AS cv, 6::bigint AS dv, now() AS time, 'x' AS extra`) - if rows, err := embedVariants.Collect(ctx, db, struct{}{}); err != nil || len(rows) != 1 || rows[0].ID != 1 || rows[0].Extra != "x" { + if rows, err := postgres.Collect(ctx, db, embedVariants, struct{}{}); err != nil || len(rows) != 1 || rows[0].ID != 1 || rows[0].Extra != "x" { t.Errorf("embedded variants (tagged / scalar leaves): %v %+v", err, rows) } @@ -909,7 +887,7 @@ func TestRuntimeGaps(t *testing.T) { Total string } ptrEmbedStmt := sqlshape.Query[rowWithPtrEmbed, struct{ ID int64 }](`SELECT id, total FROM orders WHERE id = {{.ID}}`) - if row, err := ptrEmbedStmt.First(ctx, db, struct{ ID int64 }{id1}); err != nil || row.PtrBase == nil || row.ID != id1 { + if row, err := postgres.First(ctx, db, ptrEmbedStmt, struct{ ID int64 }{id1}); err != nil || row.PtrBase == nil || row.ID != id1 { t.Errorf("embedded pointer allocation: %v %+v", err, row) } @@ -922,7 +900,7 @@ func TestRuntimeGaps(t *testing.T) { } userOrdersPtr := sqlshape.Query[userOrdersPtrs, struct{}](` SELECT u.id, array_agg(row(o.id, o.total) ORDER BY o.id) AS orders FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.id ORDER BY u.id`) - if rows, err := userOrdersPtr.Collect(ctx, db, struct{}{}); err != nil || len(rows) != 1 || len(rows[0].Orders) != 2 || rows[0].Orders[0] == nil { + if rows, err := postgres.Collect(ctx, db, userOrdersPtr, struct{}{}); err != nil || len(rows) != 1 || len(rows[0].Orders) != 2 || rows[0].Orders[0] == nil { t.Errorf("nested []*struct: %v %+v", err, rows) } @@ -935,7 +913,7 @@ SELECT u.id, array_agg(row(o.id, o.total) ORDER BY o.id) AS orders FROM users u } oneFieldStmt := sqlshape.Query[userOneFieldOrders, struct{}](` SELECT u.id, array_agg(row(o.id, o.total) ORDER BY o.id) AS orders FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.id ORDER BY u.id`) - if rows, err := oneFieldStmt.Collect(ctx, db, struct{}{}); err != nil || len(rows) != 1 || len(rows[0].Orders) != 2 || rows[0].Orders[0].ID == 0 { + if rows, err := postgres.Collect(ctx, db, oneFieldStmt, struct{}{}); err != nil || len(rows) != 1 || len(rows[0].Orders) != 2 || rows[0].Orders[0].ID == 0 { t.Errorf("nested row with dropped extra column: %v %+v", err, rows) } @@ -947,14 +925,14 @@ SELECT u.id, array_agg(row(o.id, o.total) ORDER BY o.id) AS orders FROM users u } nullArrayStmt := sqlshape.Query[userFullOrdersNull, struct{}](` SELECT u.id, array_agg(o ORDER BY o.id) FILTER (WHERE false) AS orders FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.id`) - if rows, err := nullArrayStmt.Collect(ctx, db, struct{}{}); err != nil || len(rows) != 1 || rows[0].Orders != nil { + if rows, err := postgres.Collect(ctx, db, nullArrayStmt, struct{}{}); err != nil || len(rows) != 1 || rows[0].Orders != nil { t.Errorf("NULL array_agg: %v %+v", err, rows) } // --- ConstraintError.Key() the table.column form, from a real violation -- - _, err = insertItemNoDiscount.Exec(ctx, db, struct{ OrderID int64 }{id1}) - if !sqlshape.Violates(err, "order_items.discount") { + _, err = postgres.Exec(ctx, db, insertItemNoDiscount, struct{ OrderID int64 }{id1}) + if !postgres.Violates(err, "order_items.discount") { t.Errorf("Violates table.column key: %v", err) } @@ -962,14 +940,14 @@ SELECT u.id, array_agg(o ORDER BY o.id) FILTER (WHERE false) AS orders FROM user ordersByUser := sqlshape.One[OrderRow, struct{ UserID int64 }](` SELECT o.id, o.status, o.total, o.note, o.created_at FROM orders o WHERE o.user_id = {{.UserID}}`) - if _, _, err := ordersByUser.Find(ctx, db, struct{ UserID int64 }{1}); !errors.Is(err, sqlshape.ErrManyRows) { + if _, _, err := postgres.Find(ctx, db, ordersByUser, struct{ UserID int64 }{1}); !errors.Is(err, postgres.ErrManyRows) { t.Errorf("Single.Find ErrManyRows: %v", err) } - if _, err := ordersByUser.Get(ctx, db, struct{ UserID int64 }{1}); !errors.Is(err, sqlshape.ErrManyRows) { + if _, err := postgres.Get(ctx, db, ordersByUser, struct{ UserID int64 }{1}); !errors.Is(err, postgres.ErrManyRows) { t.Errorf("Single.Get ErrManyRows: %v", err) } markPaidByUser := sqlshape.One[struct{}, struct{ UserID int64 }](`UPDATE orders SET status = 'paid' WHERE user_id = {{.UserID}}`) - if _, err := markPaidByUser.Exec(ctx, db, struct{ UserID int64 }{1}); !errors.Is(err, sqlshape.ErrManyRows) { + if _, err := postgres.ExecOne(ctx, db, markPaidByUser, struct{ UserID int64 }{1}); !errors.Is(err, postgres.ErrManyRows) { t.Errorf("Single.Exec ErrManyRows: %v", err) } if _, err := db.Exec(ctx, `UPDATE orders SET status = 'pending' WHERE user_id = $1`, int64(1)); err != nil { @@ -982,7 +960,7 @@ SELECT o.id, o.status, o.total, o.note, o.created_at FROM orders o WHERE o.user_ orderByIDErr := sqlshape.One[OrderRow, struct{ ID int64 }](` SELECT o.id, o.status, o.total, o.note, o.created_at FROM orders o WHERE o.id = {{.ID}}`) var ule *sqlshape.UnknownLabelError - if _, _, err := orderByIDErr.Find(ctx, db, struct{ ID int64 }{id1}); !errors.As(err, &ule) { + if _, _, err := postgres.Find(ctx, db, orderByIDErr, struct{ ID int64 }{id1}); !errors.As(err, &ule) { t.Errorf("Single.Find mid-iteration error: %v", err) } if _, err := db.Exec(ctx, `UPDATE orders SET status = 'paid' WHERE id = $1`, id1); err != nil { @@ -993,13 +971,13 @@ SELECT o.id, o.status, o.total, o.note, o.created_at FROM orders o WHERE o.id = type optStatusRow struct{ Status *OrderStatus } optStatus := sqlshape.Query[optStatusRow, struct{}](`SELECT NULL::order_status AS status`) - if rows, err := optStatus.Collect(ctx, db, struct{}{}); err != nil || len(rows) != 1 || rows[0].Status != nil { + if rows, err := postgres.Collect(ctx, db, optStatus, struct{}{}); err != nil || len(rows) != 1 || rows[0].Status != nil { t.Errorf("checkLabels nil pointer: %v %+v", err, rows) } if _, err := db.Exec(ctx, `UPDATE orders SET status = 'cancelled' WHERE id = $1`, id2); err != nil { t.Fatal(err) } - if _, err := statusPairs.First(ctx, db, struct{}{}); !errors.As(err, &ule) { + if _, err := postgres.First(ctx, db, statusPairs, struct{}{}); !errors.As(err, &ule) { t.Errorf("checkLabels error inside a slice: %v", err) } if _, err := db.Exec(ctx, `UPDATE orders SET status = 'paid' WHERE id = $1`, id2); err != nil { @@ -1009,22 +987,22 @@ SELECT o.id, o.status, o.total, o.note, o.created_at FROM orders o WHERE o.id = // --- Run / Exec: a Render error, and a plain (non-constraint) query error --- badField := sqlshape.Query[struct{}, struct{}]("SELECT {{.NoSuchField}}") - if _, err := badField.Collect(ctx, db, struct{}{}); err == nil || !strings.Contains(err.Error(), "has no field") { + if _, err := postgres.Collect(ctx, db, badField, struct{}{}); err == nil || !strings.Contains(err.Error(), "has no field") { t.Errorf("Run Render error: %v", err) } - if _, err := badField.Exec(ctx, db, struct{}{}); err == nil || !strings.Contains(err.Error(), "has no field") { + if _, err := postgres.Exec(ctx, db, badField, struct{}{}); err == nil || !strings.Contains(err.Error(), "has no field") { t.Errorf("Exec Render error: %v", err) } badSQL := sqlshape.Query[struct{}, struct{}]("SELECT no_such_column_xyz FROM orders LIMIT 1") var pgErr *pgconn.PgError - if _, err := badSQL.Collect(ctx, db, struct{}{}); !errors.As(err, &pgErr) || strings.HasPrefix(pgErr.Code, "23") { + if _, err := postgres.Collect(ctx, db, badSQL, struct{}{}); !errors.As(err, &pgErr) || strings.HasPrefix(pgErr.Code, "23") { t.Errorf("Run plain query error (not mapped to ConstraintError): %v", err) } // wrapPgErr: an error that is not a *pgconn.PgError at all passes through unchanged. cancelledCtx, cancelNow := context.WithCancel(ctx) cancelNow() - if _, err := markPaid.Exec(cancelledCtx, db, struct{ ID int64 }{id1}); !errors.Is(err, context.Canceled) { + if _, err := postgres.Exec(cancelledCtx, db, markPaid, struct{ ID int64 }{id1}); !errors.Is(err, context.Canceled) { t.Errorf("wrapPgErr non-PgError passthrough: %v", err) } @@ -1036,7 +1014,7 @@ SELECT o.id, o.status, o.total, o.note, o.created_at FROM orders o WHERE o.id = // withParamTypes cannot register the type and reports the LoadUserTypes advice. nc := noConnDB{c: db} ncItems := []ItemIn{{OrderID: id1, LineNo: 9, Sku: "N", Qty: 1, Discount: "0.00"}} - if _, err := saveOrder.First(ctx, nc, struct { + if _, err := postgres.First(ctx, nc, saveOrder, struct { Price MoneyIn Items []ItemIn }{MoneyIn{"1.00", "JPY"}, ncItems}); err == nil || !strings.Contains(err.Error(), "LoadUserTypes") { @@ -1045,14 +1023,14 @@ SELECT o.id, o.status, o.total, o.note, o.created_at FROM orders o WHERE o.id = // --- Copy: the no-columns-given path (every field's own column) --------- - loadItemsAllCols := sqlshape.Copy[ItemIn]("order_items") + loadItemsAllCols := postgres.Copy[ItemIn]("order_items") moreItems := []ItemIn{{OrderID: id2, LineNo: 1, Sku: "Z", Qty: 3, Discount: "0.00"}} if n, err := loadItemsAllCols.From(ctx, db, moreItems); err != nil || n != 1 { t.Errorf("Copy with no columns given: %v %d", err, n) } // a scalar R actually copied (layout's scalar row-picker func invoked, not just built). - loadNames := sqlshape.Copy[string]("users", "name") + loadNames := postgres.Copy[string]("users", "name") if n, err := loadNames.From(ctx, db, []string{"Eve", "Frank"}); err != nil || n != 2 { t.Errorf("scalar Copy: %v %d", err, n) } @@ -1061,13 +1039,13 @@ SELECT o.id, o.status, o.total, o.note, o.created_at FROM orders o WHERE o.id = // already-sent Send, Send with a pending render error, a scanner statement // actually running (and one that fails to, on a BatchDB that cannot Query) --- - b := sqlshape.NewBatch() - qBad := sqlshape.Queue(b, badField, struct{}{}) + b := postgres.NewBatch() + qBad := postgres.Queue(b, badField, struct{}{}) if _, err := qBad.Tag(); err == nil { t.Errorf("Tag before send should be the render error, got nil") } - qOK := sqlshape.Queue(b, listOrders, ListParams{}) - if _, err := qOK.Tag(); !errors.Is(err, sqlshape.ErrNotSent) { + qOK := postgres.Queue(b, listOrders, ListParams{}) + if _, err := qOK.Tag(); !errors.Is(err, postgres.ErrNotSent) { t.Errorf("Tag before send: %v", err) } if err := b.Send(ctx, db); err == nil || !strings.Contains(err.Error(), "has no field") { @@ -1076,7 +1054,7 @@ SELECT o.id, o.status, o.total, o.note, o.created_at FROM orders o WHERE o.id = if err := b.Send(ctx, db); err == nil || !strings.Contains(err.Error(), "already sent") { t.Errorf("Send twice: %v", err) } - if q := sqlshape.Queue(b, listOrders, ListParams{}); q == nil { + if q := postgres.Queue(b, listOrders, ListParams{}); q == nil { t.Error("Queue after send should still return a handle") } else if _, err := q.Rows(); err == nil || !strings.Contains(err.Error(), "already sent") { t.Errorf("Queue after send: %v", err) @@ -1088,8 +1066,8 @@ SELECT o.id, o.status, o.total, o.note, o.created_at FROM orders o WHERE o.id = if _, err := db.Exec(ctx, `UPDATE orders SET price = ROW(3, 'JPY') WHERE id = $1`, id1); err != nil { t.Fatal(err) } - b2 := sqlshape.NewBatch() - qScanner := sqlshape.QueueOne(b2, priceTagOf, struct{ ID int64 }{id1}) + b2 := postgres.NewBatch() + qScanner := postgres.QueueOne(b2, priceTagOf, struct{ ID int64 }{id1}) if err := b2.Send(ctx, db); err != nil { t.Fatalf("batch send (scanner after-path): %v", err) } @@ -1099,8 +1077,8 @@ SELECT o.id, o.status, o.total, o.note, o.created_at FROM orders o WHERE o.id = // a normal statement whose result mismatches its struct: the mapper error surfaces // from inside the pgx batch's own Query callback. - b2b := sqlshape.NewBatch() - qMismatch := sqlshape.Queue(b2b, badPartialOrder, struct{ ID int64 }{id1}) + b2b := postgres.NewBatch() + qMismatch := postgres.Queue(b2b, badPartialOrder, struct{ ID int64 }{id1}) if err := b2b.Send(ctx, db); err == nil || !strings.Contains(err.Error(), "no result column") { t.Errorf("batch mapper mismatch (send): %v", err) } @@ -1111,8 +1089,8 @@ SELECT o.id, o.status, o.total, o.note, o.created_at FROM orders o WHERE o.id = // hasUserScanner: R nil (interface) - Queue itself must not panic on a nil // reflect.Type, and routes through the normal pgx-batch path; the mapper then // fails the same way it does outside a batch (R must not be an interface type). - b3 := sqlshape.NewBatch() - sqlshape.Queue(b3, anyStmt, struct{}{}) + b3 := postgres.NewBatch() + postgres.Queue(b3, anyStmt, struct{}{}) if err := b3.Send(ctx, db); err == nil || !strings.Contains(err.Error(), "must not be an interface") { t.Errorf("batch with R=any: %v", err) } @@ -1120,15 +1098,15 @@ SELECT o.id, o.status, o.total, o.note, o.created_at FROM orders o WHERE o.id = // hasUserScanner: R with a duplicate col tag - flatFields errors, so hasUserScanner // reports false (routes through the normal pgx-batch path) rather than propagating // the error itself; the mapper then fails the same way it does outside a batch. - b3b := sqlshape.NewBatch() - sqlshape.Queue(b3b, dupTag, struct{}{}) + b3b := postgres.NewBatch() + postgres.Queue(b3b, dupTag, struct{}{}) if err := b3b.Send(ctx, db); err == nil || !strings.Contains(err.Error(), "both bind to column") { t.Errorf("batch with a duplicate-col-tag result type: %v", err) } // hasUserScanner: R itself (not merely a field of it) is a struct implementing // sql.Scanner. - sqlshape.Queue(sqlshape.NewBatch(), sqlshape.Query[PriceTag, struct{}]("SELECT 1"), struct{}{}) + postgres.Queue(postgres.NewBatch(), sqlshape.Query[PriceTag, struct{}]("SELECT 1"), struct{}{}) // a normal (non-scalar) batched query whose mapper builds fine but a row fails to // scan (UnknownLabelError): batch.go's own row-scan-error branch inside the pgx @@ -1136,8 +1114,8 @@ SELECT o.id, o.status, o.total, o.note, o.created_at FROM orders o WHERE o.id = if _, err := db.Exec(ctx, `UPDATE orders SET status = 'cancelled' WHERE id = $1`, id1); err != nil { t.Fatal(err) } - b3c := sqlshape.NewBatch() - qScanErr := sqlshape.Queue(b3c, listOrders, ListParams{}) + b3c := postgres.NewBatch() + qScanErr := postgres.Queue(b3c, listOrders, ListParams{}) if err := b3c.Send(ctx, db); !errors.As(err, &ule) { t.Errorf("batch row scan error: %v", err) } @@ -1151,8 +1129,8 @@ SELECT o.id, o.status, o.total, o.note, o.created_at FROM orders o WHERE o.id = // a BatchDB that cannot run a plain query: the scanner "after" statement fails, // and Batch.Send surfaces that error from its after-loop. nq := noQueryDB{c: db} - b4 := sqlshape.NewBatch() - sqlshape.QueueOne(b4, priceTagOf, struct{ ID int64 }{id1}) + b4 := postgres.NewBatch() + postgres.QueueOne(b4, priceTagOf, struct{ ID int64 }{id1}) if err := b4.Send(ctx, nq); err == nil || !strings.Contains(err.Error(), "cannot run a plain query") { t.Errorf("batch after-statement on a query-less BatchDB: %v", err) } diff --git a/postgres/LICENSE b/postgres/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/postgres/LICENSE @@ -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 [yyyy] [name of copyright owner] + + 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. diff --git a/postgres/args.go b/postgres/args.go new file mode 100644 index 0000000..b8ddcf1 --- /dev/null +++ b/postgres/args.go @@ -0,0 +1,70 @@ +package postgres + +import ( + "database/sql/driver" + "reflect" + + "github.com/jackc/pgx/v5/pgtype" + + "github.com/kr9ly/sqlshape/v2" +) + +// normalizeArg turns a template value into something pgx encodes for any target OID: what +// sqlshape.Normalize does (named strings to string, nil pointers to nil), and a struct that +// receives a composite (sqlshape.IsRow) becomes pgtype.CompositeFields in the order the +// checker verified (embedded structs flattened, `col:"-"` skipped), a slice of them a +// slice of those. +func normalizeArg(v reflect.Value) any { + if !v.IsValid() { + return nil + } + if v.Kind() == reflect.Pointer { + if v.IsNil() { + return nil + } + return normalizeArg(v.Elem()) + } + t := v.Type() + switch { + case t.Kind() == reflect.Struct && sqlshape.IsRow(t) && !t.Implements(valuerType): + return compositeArg(v) + case t.Kind() == reflect.Slice && sqlshape.IsRow(t) && !t.Elem().Implements(valuerType): + if v.IsNil() { + return nil + } + out := make([]pgtype.CompositeFields, v.Len()) + for i := range out { + out[i] = compositeArg(v.Index(i)) + } + return out + } + return sqlshape.Normalize(v.Interface()) +} + +var valuerType = reflect.TypeOf((*driver.Valuer)(nil)).Elem() + +// compositeArg lays a struct (or *struct) out as the fields of a composite value. +func compositeArg(v reflect.Value) pgtype.CompositeFields { + if v.Kind() == reflect.Pointer { + if v.IsNil() { + return nil + } + v = v.Elem() + } + flat, _ := sqlshape.Fields(v.Type()) + out := make(pgtype.CompositeFields, len(flat)) + for i, f := range flat { + out[i] = normalizeArg(v.FieldByIndex(f.Index)) + } + return out +} + +// pgArgs applies normalizeArg to rendered arguments (the root's Render normalizes what it +// knows; composites are this runtime's). +func pgArgs(args []any) []any { + out := make([]any, len(args)) + for i, a := range args { + out[i] = normalizeArg(reflect.ValueOf(a)) + } + return out +} diff --git a/postgres/args_test.go b/postgres/args_test.go new file mode 100644 index 0000000..ad86ff9 --- /dev/null +++ b/postgres/args_test.go @@ -0,0 +1,42 @@ +package postgres_test + +import ( + "testing" + + "github.com/jackc/pgx/v5/pgtype" + + "github.com/kr9ly/sqlshape/postgres/v2" + "github.com/kr9ly/sqlshape/v2" +) + +type ItemIn struct { + OrderID int64 + LineNo int32 + Sku string + Qty int32 + Discount string +} + +// TestNormalizeArgNilComposite covers normalizeArg / compositeArg's handling of a nil +// element inside a slice of composite pointers, and of a nil slice itself: pure +// rendering plus the runtime's argument pass, no database needed. +func TestNormalizeArgNilComposite(t *testing.T) { + stmt := sqlshape.Query[struct{}, struct{ Items []*ItemIn }]("SELECT {{.Items}}") + item := ItemIn{OrderID: 1, LineNo: 1, Sku: "A", Qty: 1, Discount: "0"} + r, err := stmt.Render(struct{ Items []*ItemIn }{Items: []*ItemIn{nil, &item}}) + if err != nil { + t.Fatalf("render: %v", err) + } + args := postgres.Args(r.Args) + arr, ok := args[0].([]pgtype.CompositeFields) + if !ok || len(arr) != 2 || arr[0] != nil || arr[1] == nil { + t.Errorf("composite slice with nil element: %#v", args[0]) + } + r2, err := stmt.Render(struct{ Items []*ItemIn }{Items: nil}) + if err != nil { + t.Fatalf("render nil slice: %v", err) + } + if a := postgres.Args(r2.Args); a[0] != nil { + t.Errorf("nil composite slice arg = %#v, want nil", a[0]) + } +} diff --git a/batch.go b/postgres/batch.go similarity index 91% rename from batch.go rename to postgres/batch.go index 973465c..03a0f34 100644 --- a/batch.go +++ b/postgres/batch.go @@ -1,4 +1,4 @@ -package sqlshape +package postgres import ( "context" @@ -6,7 +6,9 @@ import ( "reflect" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/kr9ly/sqlshape/v2" ) // BatchDB is what Batch.Send needs; pgx.Conn, pgxpool.Pool and pgx.Tx all provide it. @@ -87,7 +89,7 @@ func (q *Queued[R]) Tag() (pgconn.CommandTag, error) { } // Queue renders s for p and adds it to the batch. -func Queue[R, P any](b *Batch, s Stmt[R, P], p P) *Queued[R] { +func Queue[R, P any](b *Batch, s sqlshape.Stmt[R, P], p P) *Queued[R] { q := &Queued[R]{} if b.sent { q.err, q.done = errors.New("sqlshape: batch already sent"), true @@ -106,7 +108,7 @@ func Queue[R, P any](b *Batch, s Stmt[R, P], p P) *Queued[R] { b.after = append(b.after, func(ctx context.Context, db BatchDB) error { q.done = true if d, ok := db.(DB); ok { - q.rows, q.err = s.Collect(ctx, d, p) + q.rows, q.err = Collect(ctx, d, s, p) } else { q.err = errors.New("sqlshape: batch db cannot run a plain query") } @@ -114,7 +116,7 @@ func Queue[R, P any](b *Batch, s Stmt[R, P], p P) *Queued[R] { }) return q } - qq := b.b.Queue(r.SQL, r.Args...) + qq := b.b.Queue(r.SQL, pgArgs(r.Args)...) if rt := reflect.TypeOf(zero); rt != nil && rt.Kind() == reflect.Struct && rt.NumField() == 0 { // R = struct{}: a statement without rows. Set Fn directly rather than through // QueuedQuery.Exec: pgx's Exec wrapper calls its callback only when br.Exec() @@ -124,7 +126,7 @@ func Queue[R, P any](b *Batch, s Stmt[R, P], p P) *Queued[R] { tag, err := br.Exec() q.done = true if err != nil { - q.err = s.wrapErr(err) + q.err = wrapErr(s, err) return q.err } q.tag = tag @@ -152,7 +154,7 @@ func Queue[R, P any](b *Batch, s Stmt[R, P], p P) *Queued[R] { } q.tag = rows.CommandTag() if err := rows.Err(); err != nil { - q.err = s.wrapErr(err) + q.err = wrapErr(s, err) return q.err } return nil @@ -162,8 +164,8 @@ func Queue[R, P any](b *Batch, s Stmt[R, P], p P) *Queued[R] { // QueueOne renders the single-row statement s for p and adds it to the batch. After Send, // Rows holds at most one row (the checker's proof) and First returns it or ErrNoRows. -func QueueOne[R, P any](b *Batch, s Single[R, P], p P) *Queued[R] { - return Queue(b, s.stmt, p) +func QueueOne[R, P any](b *Batch, s sqlshape.Single[R, P], p P) *Queued[R] { + return Queue(b, s.Stmt(), p) } // Send runs the batch in one round trip. The first failing statement's error is returned @@ -197,18 +199,18 @@ func hasUserScanner(rt reflect.Type) bool { if rt == nil { return false } - if rt.Kind() != reflect.Struct || scalarStruct(rt) { + if rt.Kind() != reflect.Struct || sqlshape.ScalarStruct(rt) { return userScanner(rt) } if userScanner(rt) { return true } - flat, err := flatFields(rt) + flat, err := sqlshape.Fields(rt) if err != nil { return false } for _, f := range flat { - if userScanner(f.typ) { + if userScanner(f.Type) { return true } } diff --git a/copy.go b/postgres/copy.go similarity index 92% rename from copy.go rename to postgres/copy.go index 78a6f0b..6c658ac 100644 --- a/copy.go +++ b/postgres/copy.go @@ -1,4 +1,4 @@ -package sqlshape +package postgres import ( "context" @@ -8,6 +8,8 @@ import ( "strings" "github.com/jackc/pgx/v5" + + "github.com/kr9ly/sqlshape/v2" ) // CopyDB is what Copier.From needs; pgx.Conn, pgxpool.Pool and pgx.Tx all provide it. @@ -70,24 +72,24 @@ func (c Copier[R]) layout() ([]string, func(R) []any, error) { if rt == nil { return nil, nil, fmt.Errorf("sqlshape: Copy: R must not be an interface type") } - if rt.Kind() != reflect.Struct || scalarStruct(rt) { + if rt.Kind() != reflect.Struct || sqlshape.ScalarStruct(rt) { if len(c.Columns) != 1 { return nil, nil, fmt.Errorf("sqlshape: Copy[%s] into %s: a scalar R feeds exactly one column, %d given", rt, c.Table, len(c.Columns)) } return c.Columns, func(r R) []any { return []any{normalizeArg(reflect.ValueOf(r))} }, nil } - flat, err := flatFields(rt) + flat, err := sqlshape.Fields(rt) if err != nil { return nil, nil, err } - byCol := map[string]flatField{} + byCol := map[string]sqlshape.Field{} for _, f := range flat { - byCol[f.column] = f + byCol[f.Column] = f } cols := c.Columns if len(cols) == 0 { for _, f := range flat { - cols = append(cols, f.column) + cols = append(cols, f.Column) } } idx := make([][]int, len(cols)) @@ -96,7 +98,7 @@ func (c Copier[R]) layout() ([]string, func(R) []any, error) { if !ok { return nil, nil, fmt.Errorf("sqlshape: Copy into %s: column %q has no field in %s", c.Table, col, rt) } - idx[i] = f.index + idx[i] = f.Index } return cols, func(r R) []any { v := reflect.ValueOf(r) diff --git a/postgres/export_test.go b/postgres/export_test.go new file mode 100644 index 0000000..2bd9f1e --- /dev/null +++ b/postgres/export_test.go @@ -0,0 +1,4 @@ +package postgres + +// Args is pgArgs for the package's external tests. +var Args = pgArgs diff --git a/postgres/go.mod b/postgres/go.mod new file mode 100644 index 0000000..dec5b78 --- /dev/null +++ b/postgres/go.mod @@ -0,0 +1,19 @@ +module github.com/kr9ly/sqlshape/postgres/v2 + +go 1.26.1 + +// The PostgreSQL runtime over pgx: runs a sqlshape.Stmt (Run / Collect / First / Exec, +// Get / Find for One), maps rows, loads user types, Batch, Copy, MatView. Its shape is +// pgx's; the MySQL runtime has its own. The root module is required by version; +// scripts/release.sh keeps it at the release's version. + +require ( + github.com/jackc/pgx/v5 v5.10.0 + github.com/kr9ly/sqlshape/v2 v2.0.0-rc.2 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/postgres/go.sum b/postgres/go.sum new file mode 100644 index 0000000..8b0c2a3 --- /dev/null +++ b/postgres/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr9ly/sqlshape/v2 v2.0.0-rc.2 h1:BJLzDY0RI2+U3qcnyuRa6uLktWF+QdaEG2jmytF8Voc= +github.com/kr9ly/sqlshape/v2 v2.0.0-rc.2/go.mod h1:qMnSUVDy+MMtTjCCBq8TKTyPgLvzFCbvawmWFEiix8s= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/postgres/matview.go b/postgres/matview.go new file mode 100644 index 0000000..96cf6da --- /dev/null +++ b/postgres/matview.go @@ -0,0 +1,34 @@ +package postgres + +import ( + "context" + "strings" +) + +// MatView is a handle on a materialized view; the checker verifies the name against +// schema.sql (and, with -strict, that a unique index allows RefreshConcurrently). +// +// var OrderStats = postgres.MatView("order_stats") +// err := OrderStats.Refresh(ctx, db) +type MatView string + +// Refresh runs REFRESH MATERIALIZED VIEW: readers block until it completes. +func (m MatView) Refresh(ctx context.Context, db DB) error { + _, err := db.Exec(ctx, "REFRESH MATERIALIZED VIEW "+quoteQualified(string(m))) + return err +} + +// RefreshConcurrently refreshes without blocking readers; the view needs a unique index. +func (m MatView) RefreshConcurrently(ctx context.Context, db DB) error { + _, err := db.Exec(ctx, "REFRESH MATERIALIZED VIEW CONCURRENTLY "+quoteQualified(string(m))) + return err +} + +// quoteQualified quotes a possibly schema-qualified identifier. +func quoteQualified(name string) string { + parts := strings.Split(name, ".") + for i, p := range parts { + parts[i] = `"` + strings.ReplaceAll(p, `"`, `""`) + `"` + } + return strings.Join(parts, ".") +} diff --git a/nested.go b/postgres/nested.go similarity index 82% rename from nested.go rename to postgres/nested.go index d38181a..f1f1e07 100644 --- a/nested.go +++ b/postgres/nested.go @@ -1,51 +1,16 @@ -package sqlshape +package postgres import ( "context" "fmt" "reflect" - "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" -) - -// Nested rows at runtime. -// -// A record / composite column is scanned positionally into a Go struct (and record[] -// into a slice of structs): the checker has verified that the struct's exported fields -// line up with the row type's columns, so the runtime only has to hand pgx one scan -// target per position. pgx decodes composites field by field from the binary format, -// which carries each field's type OID; user-defined types (enums, composites, their -// arrays) must be registered in the connection's type map for that, so Run loads any -// result type the connection does not know yet (once per connection) before scanning, -// and LoadUserTypes does it wholesale for pool AfterConnect hooks. -// isNested reports whether a Go type receives a row (struct) or rows (slice of structs). -func isNested(t reflect.Type) bool { - if t.Kind() == reflect.Pointer { - t = t.Elem() - } - if t.Kind() == reflect.Slice || t.Kind() == reflect.Array { - t = t.Elem() - if t.Kind() == reflect.Pointer { - t = t.Elem() - } - } - return t.Kind() == reflect.Struct && !scalarStruct(t) && - !reflect.PointerTo(t).Implements(reflect.TypeOf((*interface{ Scan(any) error })(nil)).Elem()) -} - -// scalarStruct reports whether a struct type is a scalar pgx decodes itself rather than a -// row: time.Time, netip.Addr / Prefix, and every pgtype value (Range[T], Bits, Point, ...). -func scalarStruct(t reflect.Type) bool { - switch p := t.PkgPath(); { - case p == "time", p == "net/netip", strings.HasSuffix(p, "jackc/pgx/v5/pgtype"): - return true - } - return false -} + "github.com/kr9ly/sqlshape/v2" +) // nestedDest returns the scan target for a struct / slice-of-struct field. func nestedDest(v reflect.Value) any { @@ -72,8 +37,8 @@ func (d *rowDest) ScanIndex(i int) any { if i >= len(fields) { return nil } - fv := fieldByIndex(d.v, fields[i]) - if isNested(fv.Type()) { + fv := sqlshape.FieldByIndex(d.v, fields[i]) + if sqlshape.IsRow(fv.Type()) { return nestedDest(fv) } return fv.Addr().Interface() @@ -119,14 +84,14 @@ func (d *rowsDest) ScanIndexType() any { } // scanFields lists the scan targets of a nested-row struct in declaration order, -// embedded structs flattened (see flatFields). A duplicate column is impossible to reach +// embedded structs flattened (see sqlshape.Fields). A duplicate column is impossible to reach // here: the checker rejects it, and positional scanning never looks at names, so the // error is dropped and the fields that did resolve are used. func scanFields(t reflect.Type) [][]int { - flat, _ := flatFields(t) + flat, _ := sqlshape.Fields(t) out := make([][]int, len(flat)) for i, f := range flat { - out[i] = f.index + out[i] = f.Index } return out } diff --git a/runtime.go b/postgres/runtime.go similarity index 62% rename from runtime.go rename to postgres/runtime.go index 91b00ca..68310e0 100644 --- a/runtime.go +++ b/postgres/runtime.go @@ -1,4 +1,4 @@ -package sqlshape +package postgres import ( "context" @@ -10,10 +10,11 @@ import ( "strconv" "strings" "sync" - "unicode" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" + + "github.com/kr9ly/sqlshape/v2" ) // DB is what a Stmt runs against: *pgx.Conn, *pgxpool.Pool and pgx.Tx all satisfy it. @@ -49,23 +50,44 @@ func (e *ConstraintError) Error() string { func (e *ConstraintError) Unwrap() error { return e.Err } -// Key is the violation as the expect line spells it: the constraint name, or table.column for NOT NULL. +// Key is the violation as the expect line spells it: the constraint name, or table.column +// for NOT NULL. A NOT NULL raised by a domain (`CREATE DOMAIN ... NOT NULL` / `ALTER +// DOMAIN ... SET NOT NULL`) is the one class-23 case PostgreSQL reports with neither a +// TableName nor a ColumnName (nor a ConstraintName) — the violation happens inside the +// domain's own type coercion, before the value reaches a column PostgreSQL can name — so +// table.column cannot be formed. Key falls back to the domain's own name (PgError's +// DataTypeName) in that case, matching the failure mode the checker predicts for it. func (e *ConstraintError) Key() string { if e.Constraint != "" { return e.Constraint } + if e.Table == "" && e.Column == "" && e.Err != nil && e.Err.DataTypeName != "" { + return e.Err.DataTypeName + } return e.Table + "." + e.Column } -// Violates reports whether err is a violation of the named constraint (or table.column NOT NULL). -func Violates(err error, key string) bool { +// Violates reports whether err is a violation of the named constraint (or table.column +// NOT NULL). key is the constraint's name, a table.column, a raw SQLSTATE, or a +// sqlshape.Failure a program declared with sqlshape.Error(code) for a `-- sqlshape: +// error` annotation's code — Violates judges by the code either way, never by a +// schema-declared Name (it does not read the schema). +func Violates[K ~string](err error, key K) bool { var ce *ConstraintError - return errors.As(err, &ce) && ce.Key() == key + return errors.As(err, &ce) && ce.Key() == string(key) } // wrapErr maps integrity-constraint errors (SQLSTATE class 23) and the custom SQLSTATEs // the template's expect line names to ConstraintError; other errors pass through. -func (s Stmt[R, P]) wrapErr(err error) error { return wrapPgErr(err, s.expects) } +func wrapErr[R, P any](s sqlshape.Stmt[R, P], err error) error { + return wrapPgErr(err, expects(s.Template)) +} + +// WrapError is the error Run / Exec would return for err when it came from a statement run +// outside them (pgx directly, a migration script, a test): a *ConstraintError for a +// constraint violation (SQLSTATE class 23), so that Violates can judge it; err itself +// otherwise. A statement's own `-- sqlshape: expect` codes are not known here. +func WrapError(err error) error { return wrapPgErr(err, nil) } // wrapPgErr is wrapErr with the statement's expectations passed in (nil: class 23 only). // An error already mapped is left alone. @@ -99,25 +121,37 @@ func isStaleResultTypeErr(err error) bool { var expectRe = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*sqlshape:[ \t]*expect[ \t]+(.+?)[ \t]*$`) -// expects reports whether the template's `-- sqlshape: expect` line names the SQLSTATE. -func (s Stmt[R, P]) expects(code string) bool { - for _, m := range expectRe.FindAllStringSubmatch(s.Template, -1) { - for _, item := range strings.Split(m[1], ",") { - if strings.TrimSpace(item) == code { - return true +// expects reports, for a template, whether its `-- sqlshape: expect` line has thought +// about a custom SQLSTATE at all: an item spelling the code itself, or -- since the +// runtime does not read schema.sql, so it cannot resolve a `-- sqlshape: error = +// ` annotation's Name back to the code the checker matched it against statically -- +// any item at all, when the code itself is not one of them. A statement with no expect +// line is left alone: nothing there thought about a custom SQLSTATE, so an unanticipated +// one is not silently relabeled a declared failure. +func expects(template string) func(code string) bool { + return func(code string) bool { + var any bool + for _, m := range expectRe.FindAllStringSubmatch(template, -1) { + for _, item := range strings.Split(m[1], ",") { + if item = strings.TrimSpace(item); item != "" { + any = true + if item == code { + return true + } + } } } + return any } - return false } // ErrManyRows is returned by Get / Find when a statement declared with One produced // more than one row: the schema no longer backs the proof the checker made. var ErrManyRows = errors.New("sqlshape: statement declared with One returned more than one row") -// Run executes the statement and yields each row mapped into R. Iteration stops at +// Run executes the statement s and yields each row mapped into R. Iteration stops at // the first error, which is yielded with a zero R. Breaking out early closes the rows. -func (s Stmt[R, P]) Run(ctx context.Context, db DB, p P) iter.Seq2[R, error] { +func Run[R, P any](ctx context.Context, db DB, s sqlshape.Stmt[R, P], p P) iter.Seq2[R, error] { return func(yield func(R, error) bool) { var zero R r, err := s.Render(p) @@ -130,12 +164,12 @@ func (s Stmt[R, P]) Run(ctx context.Context, db DB, p P) iter.Seq2[R, error] { if f, ok := formatCache.Load(fkey); ok { formats = f.(pgx.QueryResultFormatsByOID) } - if s.runAttempt(ctx, db, r, formats, yield) == staleFormatCache { + if runAttempt(ctx, db, s, r, formats, yield) == staleFormatCache { // the cached result-format request named a column OID from a composite / // domain type DDL has since recreated (same name, new OID); nothing was // yielded yet, so it is safe to drop the entry and retry once from scratch. formatCache.Delete(fkey) - s.runAttempt(ctx, db, r, nil, yield) + runAttempt(ctx, db, s, r, nil, yield) } } } @@ -154,19 +188,19 @@ const ( // only when the very first thing the server reported was PostgreSQL's "cached plan must // not change result type" (0A000) for a request that came from formatCache: no row had // been produced yet, so the caller may retry with the entry dropped. -func (s Stmt[R, P]) runAttempt(ctx context.Context, db DB, r Rendered, formats pgx.QueryResultFormatsByOID, yield func(R, error) bool) runOutcome { +func runAttempt[R, P any](ctx context.Context, db DB, s sqlshape.Stmt[R, P], r sqlshape.Rendered, formats pgx.QueryResultFormatsByOID, yield func(R, error) bool) runOutcome { var zero R var rows pgx.Rows err := withParamTypes(ctx, db, len(r.Args), func() error { var qerr error - rows, qerr = db.Query(ctx, r.SQL, s.args(r.Args, formats)...) + rows, qerr = db.Query(ctx, r.SQL, args(s.IsUnprepared(), pgArgs(r.Args), formats)...) return qerr }) if err != nil { if formats != nil && isStaleResultTypeErr(err) { return staleFormatCache } - yield(zero, s.wrapErr(err)) + yield(zero, wrapErr(s, err)) return runDone } // result types the connection cannot decode yet (user enums, composites): load them and re-run @@ -177,11 +211,11 @@ func (s Stmt[R, P]) runAttempt(ctx context.Context, db DB, r Rendered, formats p yield(zero, err) return runDone } - if rows, err = db.Query(ctx, r.SQL, s.args(r.Args, formats)...); err != nil { + if rows, err = db.Query(ctx, r.SQL, args(s.IsUnprepared(), pgArgs(r.Args), formats)...); err != nil { if formats != nil && isStaleResultTypeErr(err) { return staleFormatCache } - yield(zero, s.wrapErr(err)) + yield(zero, wrapErr(s, err)) return runDone } } @@ -197,8 +231,8 @@ func (s Stmt[R, P]) runAttempt(ctx context.Context, db DB, r Rendered, formats p if len(m.textOIDs) > 0 && formats == nil { formatCache.Store(fkey, m.textOIDs) rows.Close() - if rows, err = db.Query(ctx, r.SQL, s.args(r.Args, m.textOIDs)...); err != nil { - yield(zero, s.wrapErr(err)) + if rows, err = db.Query(ctx, r.SQL, args(s.IsUnprepared(), pgArgs(r.Args), m.textOIDs)...); err != nil { + yield(zero, wrapErr(s, err)) return runDone } } @@ -226,15 +260,15 @@ func (s Stmt[R, P]) runAttempt(ctx context.Context, db DB, r Rendered, formats p if n == 0 && formats != nil && isStaleResultTypeErr(err) { return staleFormatCache } - yield(zero, s.wrapErr(err)) + yield(zero, wrapErr(s, err)) } return runDone } // Collect runs the statement and returns all rows. -func (s Stmt[R, P]) Collect(ctx context.Context, db DB, p P) ([]R, error) { +func Collect[R, P any](ctx context.Context, db DB, s sqlshape.Stmt[R, P], p P) ([]R, error) { var out []R - for row, err := range s.Run(ctx, db, p) { + for row, err := range Run(ctx, db, s, p) { if err != nil { return nil, err } @@ -244,8 +278,8 @@ func (s Stmt[R, P]) Collect(ctx context.Context, db DB, p P) ([]R, error) { } // First runs the statement and returns the first row, or ErrNoRows. -func (s Stmt[R, P]) First(ctx context.Context, db DB, p P) (R, error) { - for row, err := range s.Run(ctx, db, p) { +func First[R, P any](ctx context.Context, db DB, s sqlshape.Stmt[R, P], p P) (R, error) { + for row, err := range Run(ctx, db, s, p) { return row, err } var zero R @@ -253,17 +287,17 @@ func (s Stmt[R, P]) First(ctx context.Context, db DB, p P) (R, error) { } // Exec runs a statement that returns no rows (INSERT / UPDATE / DELETE without RETURNING). -func (s Stmt[R, P]) Exec(ctx context.Context, db DB, p P) (pgconn.CommandTag, error) { +func Exec[R, P any](ctx context.Context, db DB, s sqlshape.Stmt[R, P], p P) (pgconn.CommandTag, error) { r, err := s.Render(p) if err != nil { return pgconn.CommandTag{}, err } var tag pgconn.CommandTag err = withParamTypes(ctx, db, len(r.Args), func() error { - tag, err = db.Exec(ctx, r.SQL, s.args(r.Args, nil)...) + tag, err = db.Exec(ctx, r.SQL, args(s.IsUnprepared(), pgArgs(r.Args), nil)...) return err }) - return tag, s.wrapErr(err) + return tag, wrapErr(s, err) } // unknownParamRe matches pgx's client-side encode failure for a parameter whose type the @@ -308,9 +342,9 @@ func connOf(db DB) *pgx.Conn { // args prefixes the execution options pgx reads from the first arguments: the exec mode // for unprepared statements and the result formats Scanner-receiving columns need. -func (s Stmt[R, P]) args(a []any, formats pgx.QueryResultFormatsByOID) []any { +func args(unprepared bool, a []any, formats pgx.QueryResultFormatsByOID) []any { var opts []any - if s.unprepared { + if unprepared { opts = append(opts, pgx.QueryExecModeExec) } if len(formats) > 0 { @@ -322,9 +356,9 @@ func (s Stmt[R, P]) args(a []any, formats pgx.QueryResultFormatsByOID) []any { return append(opts, a...) } -// Get runs the single-row statement and returns its row, or ErrNoRows. -func (s Single[R, P]) Get(ctx context.Context, db DB, p P) (R, error) { - row, ok, err := s.Find(ctx, db, p) +// Get runs the single-row statement s and returns its row, or ErrNoRows. +func Get[R, P any](ctx context.Context, db DB, s sqlshape.Single[R, P], p P) (R, error) { + row, ok, err := Find(ctx, db, s, p) if err == nil && !ok { return row, ErrNoRows } @@ -332,11 +366,11 @@ func (s Single[R, P]) Get(ctx context.Context, db DB, p P) (R, error) { } // Find runs the single-row statement and reports whether a row was found. -func (s Single[R, P]) Find(ctx context.Context, db DB, p P) (R, bool, error) { +func Find[R, P any](ctx context.Context, db DB, s sqlshape.Single[R, P], p P) (R, bool, error) { var found R var zero R n := 0 - for row, err := range s.stmt.Run(ctx, db, p) { + for row, err := range Run(ctx, db, s.Stmt(), p) { if err != nil { return zero, false, err } @@ -349,12 +383,12 @@ func (s Single[R, P]) Find(ctx context.Context, db DB, p P) (R, bool, error) { return found, n == 1, nil } -// Exec runs a single-row statement that returns no rows (e.g. UPDATE ... WHERE id = $1). +// ExecOne runs a single-row statement that returns no rows (e.g. UPDATE ... WHERE id = $1). // An INSERT / UPDATE / DELETE / MERGE that touched no row returns ErrNoRows (the key did // not exist), one that touched more than one returns ErrManyRows (the checker's proof // that the statement affects at most one row did not hold). -func (s Single[R, P]) Exec(ctx context.Context, db DB, p P) (pgconn.CommandTag, error) { - tag, err := s.stmt.Exec(ctx, db, p) +func ExecOne[R, P any](ctx context.Context, db DB, s sqlshape.Single[R, P], p P) (pgconn.CommandTag, error) { + tag, err := Exec(ctx, db, s.Stmt(), p) if err != nil { return tag, err } @@ -369,9 +403,6 @@ func (s Single[R, P]) Exec(ctx context.Context, db DB, p P) (pgconn.CommandTag, return tag, nil } -// Render renders the template for p. -func (s Single[R, P]) Render(p P) (Rendered, error) { return s.stmt.Render(p) } - // --- row mapping ----------------------------------------------------------- // mapper binds result columns to the fields of R (or to R itself when it is a scalar). @@ -387,82 +418,6 @@ type mapper[R any] struct { textOIDs pgx.QueryResultFormatsByOID } -// Labelled is implemented by a Go enum type (a named string type bound to a PG enum) to -// say which labels the application knows. When a scanned value answers false the row is -// rejected with an *UnknownLabelError: the database has a label this build predates. -// -// func (s OrderStatus) Known() bool { switch s { case Pending, Paid: return true }; return false } -type Labelled interface{ Known() bool } - -// UnknownLabelError reports a scanned enum value the application does not know. -type UnknownLabelError struct { - Type reflect.Type - Value string -} - -func (e *UnknownLabelError) Error() string { - return "sqlshape: unknown " + e.Type.String() + " label " + strconv.Quote(e.Value) + " received (the database has a value this build does not know)" -} - -var labelledType = reflect.TypeOf((*Labelled)(nil)).Elem() - -// hasLabelled reports whether t (or anything it contains) implements Labelled. -func hasLabelled(t reflect.Type, seen map[reflect.Type]bool) bool { - if seen[t] { - return false - } - seen[t] = true - if t.Implements(labelledType) { - return true - } - switch t.Kind() { - case reflect.Pointer, reflect.Slice, reflect.Array: - return hasLabelled(t.Elem(), seen) - case reflect.Struct: - for i := 0; i < t.NumField(); i++ { - if t.Field(i).IsExported() && hasLabelled(t.Field(i).Type, seen) { - return true - } - } - } - return false -} - -// checkLabels validates every Labelled value inside v. -func checkLabels(v reflect.Value) error { - if !v.IsValid() { - return nil - } - if v.Type().Implements(labelledType) && v.Kind() == reflect.String { - if s := v.String(); s != "" && !v.Interface().(Labelled).Known() { - return &UnknownLabelError{Type: v.Type(), Value: s} - } - return nil - } - switch v.Kind() { - case reflect.Pointer: - if v.IsNil() { - return nil - } - return checkLabels(v.Elem()) - case reflect.Slice, reflect.Array: - for i := 0; i < v.Len(); i++ { - if err := checkLabels(v.Index(i)); err != nil { - return err - } - } - case reflect.Struct: - for i := 0; i < v.NumField(); i++ { - if v.Type().Field(i).IsExported() { - if err := checkLabels(v.Field(i)); err != nil { - return err - } - } - } - } - return nil -} - var mapperCache sync.Map // mapperKey → *mapper type mapperKey struct { @@ -484,22 +439,22 @@ func newMapper[R any](fds []pgconn.FieldDescription) (*mapper[R], error) { if m, ok := mapperCache.Load(key); ok { return m.(*mapper[R]), nil } - m := &mapper[R]{labelled: hasLabelled(rt, map[reflect.Type]bool{})} + m := &mapper[R]{labelled: sqlshape.HasLabelled(rt, map[reflect.Type]bool{})} if rt.Kind() != reflect.Struct || rt.PkgPath() == "time" { if len(fds) != 1 { return nil, fmt.Errorf("sqlshape: R is %s but the query returns %d columns", rt, len(fds)) } m.scalar = true } else { - flat, err := flatFields(rt) + flat, err := sqlshape.Fields(rt) if err != nil { return nil, err } byName := map[string]int{} lower := map[string]int{} for i, f := range flat { - byName[f.column] = i - lower[strings.ToLower(f.column)] = i + byName[f.Column] = i + lower[strings.ToLower(f.Column)] = i } m.fields = make([][]int, len(fds)) used := map[int]bool{} @@ -512,15 +467,15 @@ func newMapper[R any](fds []pgconn.FieldDescription) (*mapper[R], error) { return nil, fmt.Errorf("sqlshape: result column %q has no field in %s", fd.Name, rt) } if used[idx] { - return nil, fmt.Errorf("sqlshape: result column %q matches %s.%s twice", fd.Name, rt, flat[idx].name) + return nil, fmt.Errorf("sqlshape: result column %q matches %s.%s twice", fd.Name, rt, flat[idx].Name) } used[idx] = true - m.fields[i] = flat[idx].index + m.fields[i] = flat[idx].Index } for i, f := range flat { - if !used[i] && !optionalKind(f.typ) { + if !used[i] && !optionalKind(f.Type) { // a nullable field may be left unset by a branch that does not select it - return nil, fmt.Errorf("sqlshape: field %s.%s has no result column (%s)", rt.Name(), f.name, f.column) + return nil, fmt.Errorf("sqlshape: field %s.%s has no result column (%s)", rt.Name(), f.Name, f.Column) } } } @@ -555,7 +510,7 @@ func userScanner(t reflect.Type) bool { base = base.Elem() } } - if scalarStruct(base) || base.PkgPath() == "" { + if sqlshape.ScalarStruct(base) || base.PkgPath() == "" { return false } return reflect.PointerTo(base).Implements(scannerType) @@ -582,103 +537,6 @@ type formatKey struct { sql string } -// flatField is one scan target of a struct: an exported, non-skipped field, with the -// fields of embedded structs promoted (flattened) into their parent. -type flatField struct { - index []int // for reflect's FieldByIndex - name string // Go name, dotted through embedded structs (Base.ID) - column string // result column it binds to - typ reflect.Type -} - -var flatFieldsCache sync.Map // reflect.Type → []flatField or error - -// flatFields lists the scan targets of a struct type in declaration order. An embedded -// struct (anonymous field, no col / db tag) is flattened: its fields are the parent's, -// so `type Row struct { Base; Extra string }` receives base columns and extra. A named -// struct field is a nested row instead. Two fields binding the same column is an error. -func flatFields(t reflect.Type) ([]flatField, error) { - if v, ok := flatFieldsCache.Load(t); ok { - if err, isErr := v.(error); isErr { - return nil, err - } - return v.([]flatField), nil - } - var out []flatField - seen := map[string]string{} - var walk func(st reflect.Type, prefix string, index []int) error - walk = func(st reflect.Type, prefix string, index []int) error { - for i := 0; i < st.NumField(); i++ { - f := st.Field(i) - if !f.IsExported() && !f.Anonymous { - continue - } - idx := append(append([]int{}, index...), i) - if f.Anonymous && embeddedStruct(f) != nil { - if err := walk(embeddedStruct(f), prefix+f.Name+".", idx); err != nil { - return err - } - continue - } - if !f.IsExported() { - continue - } - col := fieldColumn(f) - if col == "-" { - continue - } - if prev, dup := seen[col]; dup { - return fmt.Errorf("sqlshape: fields %s.%s and %s.%s both bind to column %q", t, prev, t, prefix+f.Name, col) - } - seen[col] = prefix + f.Name - out = append(out, flatField{index: idx, name: prefix + f.Name, column: col, typ: f.Type}) - } - return nil - } - if err := walk(t, "", nil); err != nil { - flatFieldsCache.Store(t, err) - return nil, err - } - flatFieldsCache.Store(t, out) - return out, nil -} - -// embeddedStruct returns the struct type an anonymous field flattens into, or nil when -// the field is a leaf: not a struct, time.Time, a Scanner, or tagged with a column name. -func embeddedStruct(f reflect.StructField) reflect.Type { - if !f.Anonymous { - return nil - } - if _, ok := f.Tag.Lookup("col"); ok { - return nil - } - if _, ok := f.Tag.Lookup("db"); ok { - return nil - } - t := f.Type - if t.Kind() == reflect.Pointer { - t = t.Elem() - } - if t.Kind() != reflect.Struct || !isNested(t) { - return nil - } - return t -} - -// fieldByIndex is reflect.Value.FieldByIndex that allocates nil embedded pointers on the way. -func fieldByIndex(v reflect.Value, index []int) reflect.Value { - for i, x := range index { - if i > 0 && v.Kind() == reflect.Pointer { - if v.IsNil() { - v.Set(reflect.New(v.Type().Elem())) - } - v = v.Elem() - } - v = v.Field(x) - } - return v -} - func (m *mapper[R]) scan(rows pgx.Rows) (R, error) { var row R var err error @@ -688,8 +546,8 @@ func (m *mapper[R]) scan(rows pgx.Rows) (R, error) { v := reflect.ValueOf(&row).Elem() dests := make([]any, len(m.fields)) for i, idx := range m.fields { - fv := fieldByIndex(v, idx) - if isNested(fv.Type()) { + fv := sqlshape.FieldByIndex(v, idx) + if sqlshape.IsRow(fv.Type()) { dests[i] = nestedDest(fv) } else { dests[i] = fv.Addr().Interface() @@ -698,42 +556,11 @@ func (m *mapper[R]) scan(rows pgx.Rows) (R, error) { err = rows.Scan(dests...) } if err == nil && m.labelled { - err = checkLabels(reflect.ValueOf(row)) + err = sqlshape.CheckLabels(reflect.ValueOf(row)) } return row, err } -// fieldColumn is the column a struct field binds to: `col:"name"` / `db:"name"`, else snake_case. -// (Kept in sync with the analyzer's rule.) -func fieldColumn(f reflect.StructField) string { - for _, key := range []string{"col", "db"} { - if v, ok := f.Tag.Lookup(key); ok { - name := strings.Split(v, ",")[0] - if name == "" { - return snake(f.Name) - } - return name - } - } - return snake(f.Name) -} - -func snake(s string) string { - var b strings.Builder - rs := []rune(s) - for i, r := range rs { - if unicode.IsUpper(r) { - if i > 0 && (unicode.IsLower(rs[i-1]) || (i+1 < len(rs) && unicode.IsLower(rs[i+1]) && unicode.IsUpper(rs[i-1]))) { - b.WriteByte('_') - } - b.WriteRune(unicode.ToLower(r)) - } else { - b.WriteRune(r) - } - } - return b.String() -} - // IsNoRows reports whether err is ErrNoRows. func IsNoRows(err error) bool { return errors.Is(err, ErrNoRows) } @@ -755,7 +582,7 @@ func optionalKind(t reflect.Type) bool { return true // pgtype.* all carry Valid } // a Scanner sees NULL as Scan(nil) and represents it itself - if pkg != "" && !scalarStruct(t) && reflect.PointerTo(t).Implements(scannerType) { + if pkg != "" && !sqlshape.ScalarStruct(t) && reflect.PointerTo(t).Implements(scannerType) { return true } return false diff --git a/render.go b/render.go index 7dbd7ec..e3f31f5 100644 --- a/render.go +++ b/render.go @@ -1,7 +1,6 @@ package sqlshape import ( - "database/sql/driver" "fmt" "reflect" "strconv" @@ -9,8 +8,7 @@ import ( "sync" "text/template/parse" - "github.com/jackc/pgx/v5/pgtype" - "github.com/kr9ly/sqlshape/internal/expand" + "github.com/kr9ly/sqlshape/v2/x/expand" ) // Rendered is one concrete execution of a template: the SQL with $n placeholders @@ -549,11 +547,12 @@ func isFloatKind(v reflect.Value) bool { return v.Kind() == reflect.Float32 || v.Kind() == reflect.Float64 } -// normalizeArg turns a template value into something pgx encodes for any target OID: -// named string types (enums) become string, slices of them become []string; nil -// pointers become nil; a struct that receives a composite (see isNested) becomes -// pgtype.CompositeFields in the order the checker verified (embedded structs flattened, -// `col:"-"` skipped), a slice of them a slice of those. Everything else is passed through. +// Normalize turns a template value into what a driver encodes: named string types (enums) +// become string, slices of them []string, nil pointers nil, other pointers what they point +// to. Everything else is passed through; a runtime adds its own encodings on top (the +// PostgreSQL runtime lays a struct out as a composite). +func Normalize(v any) any { return normalizeArg(reflect.ValueOf(v)) } + func normalizeArg(v reflect.Value) any { if !v.IsValid() { return nil @@ -574,37 +573,8 @@ func normalizeArg(v reflect.Value) any { out[i] = v.Index(i).String() } return out - case t.Kind() == reflect.Struct && isNested(t) && !v.Type().Implements(valuerType): - return compositeArg(v) - case t.Kind() == reflect.Slice && isNested(t) && !t.Elem().Implements(valuerType): - if v.IsNil() { - return nil - } - out := make([]pgtype.CompositeFields, v.Len()) - for i := range out { - out[i] = compositeArg(v.Index(i)) - } - return out } return v.Interface() } -var valuerType = reflect.TypeOf((*driver.Valuer)(nil)).Elem() - -// compositeArg lays a struct (or *struct) out as the fields of a composite value. -func compositeArg(v reflect.Value) pgtype.CompositeFields { - if v.Kind() == reflect.Pointer { - if v.IsNil() { - return nil - } - v = v.Elem() - } - flat, _ := flatFields(v.Type()) - out := make(pgtype.CompositeFields, len(flat)) - for i, f := range flat { - out[i] = normalizeArg(v.FieldByIndex(f.index)) - } - return out -} - func stub() {} diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..823fd98 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# Release the modules of the repository in lockstep under one version. +# +# scripts/release.sh v1.3.0 # commit, tag, verify, push the commit and the three tags +# scripts/release.sh v1.3.0 --no-push # the same, stopping before the push +# +# On a clean tree at the commit to release it +# 1. checks CHANGELOG.md has a `## [X.Y.Z]` section (the release notes come from it); +# 2. sets every module's requires of the other sqlshape modules to the version, commits, +# and tags the commit vX.Y.Z and /vX.Y.Z for each nested module (the examples +# are not released: the workspace resolves what they import); +# 3. fills the nested modules' go.sum with the sums of those versions (and adds the +# requires a first release of a module still lacks: tidy finds the tag). The tags +# exist only here so far, so it points `go` at this clone (GOPRIVATE makes it fetch +# the module through git, and git is told to read it from here) in a fresh module +# cache; module zips hash by content, so the sums match what the public proxy computes +# once the tags are pushed. The commit is amended and the tags moved onto it, and the +# tidy is repeated against the moved tags until nothing changes (tidy may rewrite a +# module's go.mod, whose hash another module's go.sum records); +# 4. builds cmd/sqlshape the way `go install …/cmd/sqlshape@vX.Y.Z` will (outside the +# workspace, from the tagged modules), and runs the smoke test on the result; +# 5. pushes the commit and the tags, the root tag last and alone (GitHub raises no workflow +# event for a push of more than three tags). The root tag triggers .github/workflows/release.yml. +# +# Between step 2 and the push the workspace does not build: every `go` command reads the +# required versions' go.mod from the proxy, and they are not there yet. A failure before +# the push rolls the commit and tags back. Do not ask the public proxy about the version +# before the push (not even a curl of its .info): a miss is cached for a while and +# `go install` and the release workflow then fail on "unknown revision". +set -euo pipefail +cd "$(dirname "$0")/.." +root=$(pwd) + +version=${1:-} +push=1 +case "${2:-}" in + "") ;; + --no-push) push=0 ;; + *) echo "usage: scripts/release.sh vX.Y.Z [--no-push]" >&2; exit 2 ;; +esac +case "$version" in + v[0-9]*.[0-9]*.[0-9]*) ;; + *) echo "usage: scripts/release.sh vX.Y.Z [--no-push]" >&2; exit 2 ;; +esac +if [ -n "$(git status --porcelain)" ]; then + echo "release: the working tree is not clean" >&2; exit 1 +fi +# the nested modules, in dependency order; the root is the version's own tag +modules=(postgres mysql mysqltest check/postgres check/mysql pgtest cmd/sqlshape) +tags=("$version") +for m in "${modules[@]}"; do tags+=("$m/$version"); done +sums=() +for m in "${modules[@]}"; do sums+=("$m/go.mod" "$m/go.sum"); done +present() { for f in "${sums[@]}"; do [ -f "$f" ] && echo "$f"; done; } +for t in "${tags[@]}"; do + if git rev-parse -q --verify "refs/tags/$t" >/dev/null; then + echo "release: tag $t already exists" >&2; exit 1 + fi +done +bare=${version#v}; bare=${bare%%-*} +if ! grep -q "^## \[$bare\]" CHANGELOG.md; then + echo "release: CHANGELOG.md has no section '## [$bare]'" >&2; exit 1 +fi + +base=$(git rev-parse HEAD) +rollback() { + echo "release: rolling back to $base" >&2 + for t in "${tags[@]}"; do git tag -d "$t" >/dev/null 2>&1 || true; done + git reset -q --hard "$base" + git clean -q -f -- "${sums[@]}" +} +cache=$(mktemp -d) +cleanup() { + GOMODCACHE="$cache" go clean -modcache + rm -rf "$cache" +} +trap 'rollback' ERR + +# 2. requires, commit, tags: every require of a sqlshape module moves to the version +for m in "${modules[@]}"; do + sed -i -E "s#^(\s*(require )?github\.com/kr9ly/sqlshape(/[a-z0-9/]+)? )v[0-9][^ ]*#\1$version#" "$m/go.mod" +done +git add -- $(present) +# always a commit of its own, even when no require moved (a first release): the tidy step amends it +git commit -q --allow-empty -m "release: $version" +for t in "${tags[@]}"; do git tag -a "$t" -m "sqlshape $version"; done + +# 3. go.sum from this clone: `go` fetches github.com/kr9ly/sqlshape/v2 through git, and git is +# told to read it from here instead +trap 'rollback; cleanup' ERR +export GOWORK=off GOPRIVATE=github.com/kr9ly/sqlshape GOMODCACHE="$cache" +export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0="url.$root.insteadOf" GIT_CONFIG_VALUE_0=https://github.com/kr9ly/sqlshape +# Repeated until nothing changes: tidy may rewrite mysql/go.mod itself (a dependency that +# became direct), and cmd/sqlshape's go.sum records the hash of mysql/go.mod at the tag, so +# after an amend the tag has to move and cmd/sqlshape has to be tidied again against it. +# The root zip contains neither nested module, so its sums hold across the amends. +for _ in 1 2 3 4 5 6; do + for m in "${modules[@]}"; do (cd "$m" && go mod tidy); done + if [ -z "$(git status --porcelain)" ]; then break; fi + git add -- $(present) + git commit -q --amend --no-edit + for t in "${tags[@]}"; do git tag -f -a "$t" -m "sqlshape $version" >/dev/null; done + # the tags moved: what was fetched from them is stale, and so are the sums recorded for them + GOMODCACHE="$cache" go clean -modcache + for m in "${modules[@]}"; do [ -f "$m/go.sum" ] && sed -i '/^github.com\/kr9ly\/sqlshape/d' "$m/go.sum"; done +done +if [ -n "$(git status --porcelain)" ]; then + echo "release: go.mod / go.sum did not settle" >&2; false +fi + +# 4. the binary as `go install` will build it, and the smoke test over the examples +bindir=$(mktemp -d) +bin=$bindir/sqlshape +(cd cmd/sqlshape && CGO_ENABLED=0 go build -trimpath -ldflags "-X github.com/kr9ly/sqlshape/cmd/sqlshape/v2/internal/cli.version=$version" -o "$bin" .) +# the smoke test runs `go vet` in the workspace, which reads the same not-yet-public versions +GOWORK= .github/scripts/smoke.sh "$bin" "$version" +rm -rf "$bindir" +unset GOWORK GOPRIVATE GOMODCACHE GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 +cleanup +trap 'rollback' ERR + +echo "release: $version at $(git rev-parse --short HEAD), tags ${tags[*]}" +# The commit and the nested modules' tags go first, the root tag alone last: GitHub creates +# no workflow event for a push of more than three tags, and the root tag is what triggers +# release.yml. +if [ "$push" = 1 ]; then + git push origin HEAD "${tags[@]:1}" + git push origin "$version" +else + echo "release: not pushed; the workspace builds again once these are upstream:" + echo " git push origin HEAD ${tags[*]:1} && git push origin $version" + echo "or undo with: git tag -d ${tags[*]} && git reset --hard $base" +fi diff --git a/sqlshape.go b/sqlshape.go index c107f30..574dd9d 100644 --- a/sqlshape.go +++ b/sqlshape.go @@ -11,8 +11,11 @@ // LIMIT {{.Limit}} // `) // -// Stmt runs against pgx (Run / Collect / First / Exec); the analyzer in -// cmd/sqlshape checks every expansion of every Query in a program. +// This package is the declaration: it has no database dependency. A runtime executes a +// Stmt — sqlshape/postgres over pgx, sqlshape/mysql over database/sql — and the analyzer +// in cmd/sqlshape checks every expansion of every Query in a program. The markers the +// checker recognizes are configurable, so a program may run its statements through a +// runtime of its own. // // One declares a statement that returns at most one row, and the checker proves it // from the schema: the WHERE clause fixes a unique key of every FROM item by @@ -22,15 +25,10 @@ // var userByEmail = sqlshape.One[User, struct{ Email string }](` // SELECT id, name FROM users WHERE email = {{.Email}}`) // -// u, err := userByEmail.Get(ctx, db, p) // ErrNoRows when absent -// u, ok, err := userByEmail.Find(ctx, db, p) +// u, err := postgres.Get(ctx, db, userByEmail, p) // ErrNoRows when absent +// u, ok, err := postgres.Find(ctx, db, userByEmail, p) package sqlshape -import ( - "context" - "strings" -) - // Stmt is a checked SQL template. R is the result row type, P the parameter type. type Stmt[R, P any] struct { Template string @@ -53,9 +51,18 @@ func (s Single[R, P]) Unprepared() Single[R, P] { return s } +// IsUnprepared reports whether Unprepared was applied (a runtime that prepares reads it). +func (s Stmt[R, P]) IsUnprepared() bool { return s.unprepared } + // SQLTemplate is the template text; pgtest.Verify expands and checks it against a real PG. func (s Stmt[R, P]) SQLTemplate() string { return s.Template } +// Stmt is the statement under the single-row declaration. +func (s Single[R, P]) Stmt() Stmt[R, P] { return s.stmt } + +// Render renders the template for p. +func (s Single[R, P]) Render(p P) (Rendered, error) { return s.stmt.Render(p) } + // SQLTemplate is the template text (see Stmt.SQLTemplate). func (s Single[R, P]) SQLTemplate() string { return s.stmt.Template } @@ -75,31 +82,3 @@ type Single[R, P any] struct { func One[R, P any](template string) Single[R, P] { return Single[R, P]{stmt: Stmt[R, P]{Template: template}} } - -// MatView is a handle on a materialized view; the checker verifies the name against -// schema.sql (and, with -strict, that a unique index allows RefreshConcurrently). -// -// var OrderStats = sqlshape.MatView("order_stats") -// err := OrderStats.Refresh(ctx, db) -type MatView string - -// Refresh runs REFRESH MATERIALIZED VIEW: readers block until it completes. -func (m MatView) Refresh(ctx context.Context, db DB) error { - _, err := db.Exec(ctx, "REFRESH MATERIALIZED VIEW "+quoteQualified(string(m))) - return err -} - -// RefreshConcurrently refreshes without blocking readers; the view needs a unique index. -func (m MatView) RefreshConcurrently(ctx context.Context, db DB) error { - _, err := db.Exec(ctx, "REFRESH MATERIALIZED VIEW CONCURRENTLY "+quoteQualified(string(m))) - return err -} - -// quoteQualified quotes a possibly schema-qualified identifier. -func quoteQualified(name string) string { - parts := strings.Split(name, ".") - for i, p := range parts { - parts[i] = `"` + strings.ReplaceAll(p, `"`, `""`) + `"` - } - return strings.Join(parts, ".") -} diff --git a/x/boundary_test.go b/x/boundary_test.go new file mode 100644 index 0000000..891f135 --- /dev/null +++ b/x/boundary_test.go @@ -0,0 +1,113 @@ +package x + +import ( + "encoding/json" + "os/exec" + "sort" + "strings" + "testing" +) + +// The layering the repository promises, checked on the import graph: +// +// x/* the contracts and the shared parts: stdlib and x/* only +// cmd/sqlshape/internal/vet the Go frontend: x/* and the binary's own internals only +// check//dialect a dialect's adapter: its own check//* and x/* only +// +// An edge outside the rules fails this test unless it is listed in debt below, a list +// that must name only edges that still exist; it is empty, and meant to stay so. +const module = "github.com/kr9ly/sqlshape/" + +// debt are the known violations, package → imports it may still have. +var debt = map[string][]string{} + +func TestImportBoundary(t *testing.T) { + pkgs := list(t, "./...", "./cmd/sqlshape/...", "./check/postgres/dialect", "./check/mysql/dialect") + for _, p := range pkgs { + rel := strings.TrimPrefix(p.ImportPath, module) + var allow func(imp string) bool + switch { + case strings.HasPrefix(rel, "v2/x/"): + allow = func(imp string) bool { return strings.HasPrefix(imp, module+"v2/x/") } + case rel == "cmd/sqlshape/v2/internal/vet": + allow = func(imp string) bool { + return strings.HasPrefix(imp, module+"v2/x/") || strings.HasPrefix(imp, module+"cmd/sqlshape/v2/internal/") + } + case strings.HasPrefix(rel, "check/") && strings.HasSuffix(rel, "/dialect"): + own := module + rel[:strings.LastIndex(rel, "/dialect")] + "/" + allow = func(imp string) bool { return strings.HasPrefix(imp, module+"v2/x/") || strings.HasPrefix(imp, own) } + default: + continue + } + for _, imp := range p.Imports { + third := strings.Contains(imp, ".") && !strings.HasPrefix(imp, module) + if strings.HasPrefix(rel, "v2/x/") && third { + t.Errorf("%s imports %s: x/ has no dependencies", rel, imp) + continue + } + if !strings.HasPrefix(imp, module) || allow(imp) || owed(p.ImportPath, imp) { + continue + } + t.Errorf("%s imports %s: outside the layering (see debt in x/boundary_test.go)", rel, imp) + } + } + // the debt list names only edges that still exist, so that it cannot hide a repaid one + for pkg, imps := range debt { + for _, imp := range imps { + if !hasEdge(pkgs, pkg, imp) { + t.Errorf("debt lists %s → %s, which no longer exists: remove it", strings.TrimPrefix(pkg, module), strings.TrimPrefix(imp, module)) + } + } + } +} + +func owed(pkg, imp string) bool { + for _, d := range debt[pkg] { + if d == imp { + return true + } + } + return false +} + +type pkg struct { + ImportPath string + Imports []string +} + +func hasEdge(pkgs []pkg, from, to string) bool { + for _, p := range pkgs { + if p.ImportPath == from { + for _, imp := range p.Imports { + if imp == to { + return true + } + } + } + } + return false +} + +// list runs go list over the workspace (the repository root is this package's parent). +func list(t *testing.T, patterns ...string) []pkg { + cmd := exec.Command("go", append([]string{"list", "-json=ImportPath,Imports"}, patterns...)...) + cmd.Dir = ".." + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + t.Fatalf("go list: %v\n%s", err, ee.Stderr) + } + t.Fatal(err) + } + var pkgs []pkg + dec := json.NewDecoder(strings.NewReader(string(out))) + for dec.More() { + var p pkg + if err := dec.Decode(&p); err != nil { + t.Fatal(err) + } + pkgs = append(pkgs, p) + } + sort.Slice(pkgs, func(i, j int) bool { return pkgs[i].ImportPath < pkgs[j].ImportPath }) + return pkgs +} diff --git a/x/cardinality/cardinality.go b/x/cardinality/cardinality.go new file mode 100644 index 0000000..9a1fcaf --- /dev/null +++ b/x/cardinality/cardinality.go @@ -0,0 +1,335 @@ +// Package cardinality proves, from a statement's facts alone, that it touches at most one +// row: the One contract, written once for every dialect. +// +// An application that calls One asserts an interpretation ("this lookup hits at most one +// row") that the database can back with its constraints. The proof is the classic +// functional-dependency argument over the leaves of a scope: a leaf is single once some +// unique key of it (a partial one only where the statement repeats its predicate) is fixed +// by equalities to values known before the statement runs, and a single leaf makes all its +// columns known, which can fix other leaves through the join equalities (Edges). When +// every leaf is single the level is at most one row. Join direction is already in the +// facts: an outer join's ON fixes only its nullable side. A derived leaf is proved through +// its Body, seeded with the outputs the enclosing level fixes. Besides the key argument, a +// level says of itself when it is one row (Single: LIMIT 1, one VALUES row, an aggregate +// without GROUP BY) or many (Many: a set operation, several VALUES rows, FULL JOIN), and a +// GROUP BY level is one row when every grouping expression is pinned. A producer that can +// prove more sets Facts.AtMostOne itself. +package cardinality + +import ( + "fmt" + "strings" + + "github.com/kr9ly/sqlshape/v2/x/facts" +) + +const notAnalyzed = "the statement's shape is not analyzed for its cardinality" + +// AtMostOne reports whether f proves the statement touches at most one row; when it does +// not, why says what blocked the proof, the way the checker reports it. +func AtMostOne(f *facts.Facts) (bool, string) { + if f == nil { + return false, notAnalyzed + } + if f.AtMostOne { + return true, "" + } + sc := f.Top + if f.Kind == facts.Insert && f.Source != nil { + sc = f.Source + } + if sc == nil { + return false, notAnalyzed + } + return scopeSingle(sc, nil) +} + +// ScopeSingle is the proof over one scope, on its own. +func ScopeSingle(sc *facts.Scope) (bool, string) { return scopeSingle(sc, nil) } + +// TargetSingle proves that leaf i of sc alone is touched by at most one row, without +// requiring every leaf of the level to be single. A write statement (UPDATE / DELETE) only +// moves the rows of its own target leaf; a table joined into the same level purely to +// filter (an ON with no unique key of its own fixed) may see many rows there and still +// leave the target singular -- measured on MySQL 8.4: `DELETE o FROM orders o JOIN items i +// ON i.order_id = o.id WHERE o.id = 1` deletes exactly the one orders row regardless of how +// many items rows the join matches, since items is read, not written. The whole-level proof +// (scopeSingle, AtMostOne) stays as it is for SELECT, where every leaf's row count matters. +func TargetSingle(sc *facts.Scope, i int) (bool, string) { + if sc.Single { + return true, "" + } + if sc.Many != "" { + return false, sc.Many + } + if i < 0 || i >= len(sc.Leaves) { + return false, notAnalyzed + } + p := newProof(sc, nil) + p.fixpoint() + if p.single[i] { + return true, "" + } + return false, p.describe(sc.Leaves[i], i) +} + +// scopeSingle proves one level; seeds are its leaves' columns fixed from outside (the +// enclosing level's equalities on a derived leaf's outputs). +func scopeSingle(sc *facts.Scope, seeds []facts.ColRef) (bool, string) { + if sc.Single { + return true, "" // read first: LIMIT 1 over a UNION, count(*) over a FULL JOIN are one row + } + if sc.Many != "" { + return false, sc.Many + } + if len(sc.Leaves) == 0 { + return true, "" // a level without FROM is one row + } + p := newProof(sc, seeds) + p.fixpoint() + if sc.Groups != nil { + if sc.GroupingSets { + return false, "GROUPING SETS yield one row per set" + } + for _, g := range sc.Groups { + switch g.Kind { + case facts.Column: + if p.isKnown(g.Col) { + continue + } + return false, "GROUP BY yields one row per group (" + g.Col.Column + " is not pinned)" + case facts.Expr: + return false, "GROUP BY yields one row per group (" + g.Text + " is not pinned)" + } + } + return true, "" + } + for i, l := range sc.Leaves { + if !p.single[i] { + return false, p.describe(l, i) + } + } + return true, "" +} + +type proof struct { + sc *facts.Scope + known map[facts.ColRef]bool + single []bool + // pointIn: range columns holding a known point (a Contains predicate whose point is + // known, or another column once that is known) + pointIn map[facts.ColRef]bool + why map[int]string // derived leaf → why its body is not proved single +} + +func newProof(sc *facts.Scope, seeds []facts.ColRef) *proof { + p := &proof{sc: sc, known: map[facts.ColRef]bool{}, single: make([]bool, len(sc.Leaves)), pointIn: map[facts.ColRef]bool{}, why: map[int]string{}} + for _, c := range sc.Fixed { + p.known[c] = true + } + for _, c := range seeds { + p.known[c] = true + } + return p +} + +// isKnown: the column is fixed, or belongs to a leaf already fixed to one row. +func (p *proof) isKnown(c facts.ColRef) bool { + return p.known[c] || (c.Leaf >= 0 && c.Leaf < len(p.single) && p.single[c.Leaf]) +} + +func (p *proof) fixpoint() { + for changed := true; changed; { + changed = false + for _, e := range p.sc.Edges { + if !p.known[e.To] && p.isKnown(e.From) { + p.known[e.To] = true + changed = true + } + } + for _, pr := range p.sc.Preds { + if pr.Op != facts.Contains || p.pointIn[pr.Col] || !applies(pr, pr.Col.Leaf) { + continue + } + if pr.Term.Kind == facts.Column && !p.isKnown(pr.Term.Col) { + continue + } + p.pointIn[pr.Col] = true + changed = true + } + for i, l := range p.sc.Leaves { + if !p.single[i] && p.leafSingle(l, i) { + p.single[i] = true + changed = true + } + } + } +} + +// applies: the predicate restricts leaf i. +func applies(pr facts.Pred, i int) bool { + if pr.Restricts == nil { + return true + } + for _, r := range pr.Restricts { + if r == i { + return true + } + } + return false +} + +// leafSingle decides whether the known columns fix at most one row of the leaf. +func (p *proof) leafSingle(l facts.Leaf, i int) bool { + if l.Single { + return true + } + for _, key := range l.Keys { + if p.keyFixed(key, i) { + return true + } + } + if l.Body != nil { + var seeds []facts.ColRef + for _, o := range l.Outputs { + if p.isKnown(facts.ColRef{Leaf: i, Column: o.Name}) && !ambiguous(l.Outputs, o.Name) { + seeds = append(seeds, o.Col) + } + } + ok, why := scopeSingle(l.Body, seeds) + if !ok { + p.why[i] = why + } + return ok + } + return false +} + +func ambiguous(outs []facts.Output, name string) bool { + n := 0 + for _, o := range outs { + if o.Name == name { + n++ + } + } + return n > 1 +} + +func (p *proof) keyFixed(key facts.Key, i int) bool { + if len(key.Columns) == 0 { + return false + } + for k, col := range key.Columns { + c := facts.ColRef{Leaf: i, Column: col} + if p.isKnown(c) { + continue + } + // a temporal key's range column: a row whose range holds a known point is the only + // one for those key values, since no two rows' ranges overlap + if key.Temporal && k == len(key.Columns)-1 && p.pointIn[c] { + continue + } + return false + } + // a partial unique index applies only where the statement repeats its predicate + for _, w := range key.Where { + found := false + for _, pr := range p.sc.Preds { + if applies(pr, i) && samePred(pr, w) { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +// samePred: two predicates in the facts language say the same thing (a statement conjunct +// and an index predicate's). +func samePred(a, b facts.Pred) bool { + if a.Op != b.Op || a.Col != b.Col { + return false + } + switch a.Op { + case facts.Eq, facts.Contains: + return sameTerm(a.Term, b.Term) + case facts.IsNull, facts.IsNotNull: + return true + case facts.In: + if len(a.Terms) != len(b.Terms) { + return false + } + for _, t := range a.Terms { + found := false + for _, u := range b.Terms { + found = found || sameTerm(t, u) + } + if !found { + return false + } + } + return true + case facts.Opaque: + if a.Text != b.Text || len(a.Cols) != len(b.Cols) { + return false + } + for k := range a.Cols { + if a.Cols[k] != b.Cols[k] { + return false + } + } + return true + } + return false +} + +func sameTerm(a, b facts.Term) bool { + return a.Kind == b.Kind && a.Param == b.Param && a.Const == b.Const && a.Col == b.Col && a.Text == b.Text +} + +// describe says why a leaf could not be proved single. +func (p *proof) describe(l facts.Leaf, i int) string { + name := l.Alias + if name == "" { + name = l.Table + } + switch l.Kind { + case facts.Table: + if l.Body != nil { + return "view " + name + ": " + p.why[i] // written through an updatable view: the alias is the view's + } + if short := l.Table[strings.LastIndexByte(l.Table, '.')+1:]; short != name && l.Table != "" { + name = short + " " + name + } + if len(l.Keys) == 0 { + return name + " has no unique key" + } + keys := make([]string, len(l.Keys)) + for k, key := range l.Keys { + keys[k] = "(" + strings.Join(key.Columns, ", ") + ")" + if len(key.Where) > 0 { + keys[k] += " WHERE ..." + } + } + return fmt.Sprintf("%s: no unique key is fixed by equality (keys: %s)", name, strings.Join(keys, ", ")) + case facts.View, facts.MatView: + if l.Body == nil { + return "view " + name + ": its definition is not analyzed" + } + return "view " + name + ": " + p.why[i] + case facts.CTE: + if l.Body == nil { + return "CTE " + name + ": its body is not analyzed" + } + return "CTE " + name + ": " + p.why[i] + case facts.Function: + return "function " + name + " may return many rows" + } + if l.Body == nil { + return name + ": not proved to return one row" + } + return "subquery " + name + ": " + p.why[i] +} diff --git a/x/cardinality/cardinality_test.go b/x/cardinality/cardinality_test.go new file mode 100644 index 0000000..7cc753e --- /dev/null +++ b/x/cardinality/cardinality_test.go @@ -0,0 +1,56 @@ +package cardinality + +import ( + "testing" + + "github.com/kr9ly/sqlshape/v2/x/facts" +) + +func TestAtMostOne(t *testing.T) { + users := facts.Leaf{Table: "users", Alias: "u", Kind: facts.Table, Keys: []facts.Key{{Columns: []string{"id"}}, {Columns: []string{"email"}}}} + orders := facts.Leaf{Table: "orders", Alias: "o", Kind: facts.Table, Keys: []facts.Key{{Columns: []string{"id"}}, {Columns: []string{"user_id", "seq"}}}} + col := func(leaf int, c string) facts.ColRef { return facts.ColRef{Leaf: leaf, Column: c} } + cases := []struct { + name string + f *facts.Facts + want bool + }{ + {"nil", nil, false}, + {"producer proved", &facts.Facts{AtMostOne: true}, true}, + {"no from", &facts.Facts{Kind: facts.Select, Top: &facts.Scope{}}, true}, + {"pk fixed", &facts.Facts{Top: &facts.Scope{Leaves: []facts.Leaf{users}, Fixed: []facts.ColRef{col(0, "id")}}}, true}, + {"non-key fixed", &facts.Facts{Top: &facts.Scope{Leaves: []facts.Leaf{users}, Fixed: []facts.ColRef{col(0, "name")}}}, false}, + {"half of a composite key", &facts.Facts{Top: &facts.Scope{Leaves: []facts.Leaf{orders}, Fixed: []facts.ColRef{col(0, "user_id")}}}, false}, + {"composite key", &facts.Facts{Top: &facts.Scope{Leaves: []facts.Leaf{orders}, Fixed: []facts.ColRef{col(0, "user_id"), col(0, "seq")}}}, true}, + {"join carried by an edge", &facts.Facts{Top: &facts.Scope{Leaves: []facts.Leaf{users, orders}, Fixed: []facts.ColRef{col(1, "id")}, + Edges: []facts.Edge{{From: col(1, "user_id"), To: col(0, "id")}, {From: col(0, "id"), To: col(1, "user_id")}}}}, true}, + {"join not carried", &facts.Facts{Top: &facts.Scope{Leaves: []facts.Leaf{users, orders}, Fixed: []facts.ColRef{col(0, "id")}, + Edges: []facts.Edge{{From: col(1, "user_id"), To: col(0, "id")}, {From: col(0, "id"), To: col(1, "user_id")}}}}, false}, + {"derived not proved", &facts.Facts{Top: &facts.Scope{Leaves: []facts.Leaf{{Alias: "d", Kind: facts.Derived}}}}, false}, + {"derived proved inside", &facts.Facts{Top: &facts.Scope{Leaves: []facts.Leaf{{Alias: "d", Kind: facts.Derived, + Body: &facts.Scope{Leaves: []facts.Leaf{users}, Fixed: []facts.ColRef{col(0, "email")}}}}}}, true}, + {"derived seeded by the outer level", &facts.Facts{Top: &facts.Scope{Leaves: []facts.Leaf{{Alias: "d", Kind: facts.Derived, + Outputs: []facts.Output{{Name: "mail", Col: col(0, "email")}}, Body: &facts.Scope{Leaves: []facts.Leaf{users}}}}, Fixed: []facts.ColRef{col(0, "mail")}}}, true}, + {"single level", &facts.Facts{Top: &facts.Scope{Leaves: []facts.Leaf{users}, Single: true}}, true}, + {"many level", &facts.Facts{Top: &facts.Scope{Leaves: []facts.Leaf{users}, Fixed: []facts.ColRef{col(0, "id")}, Many: "UNION may combine rows"}}, false}, + {"group pinned", &facts.Facts{Top: &facts.Scope{Leaves: []facts.Leaf{orders}, Fixed: []facts.ColRef{col(0, "user_id")}, Groups: []facts.Term{{Kind: facts.Column, Col: col(0, "user_id")}}}}, true}, + {"group not pinned", &facts.Facts{Top: &facts.Scope{Leaves: []facts.Leaf{orders}, Fixed: []facts.ColRef{col(0, "note")}, Groups: []facts.Term{{Kind: facts.Column, Col: col(0, "user_id")}}}}, false}, + {"partial key repeated", &facts.Facts{Top: &facts.Scope{Leaves: []facts.Leaf{{Table: "t", Alias: "t", Kind: facts.Table, Keys: []facts.Key{{Columns: []string{"uid"}, Where: []facts.Pred{{Op: facts.IsNull, Col: col(0, "deleted_at")}}}}}}, + Fixed: []facts.ColRef{col(0, "uid")}, Preds: []facts.Pred{{Op: facts.IsNull, Col: col(0, "deleted_at")}}}}, true}, + {"partial key not repeated", &facts.Facts{Top: &facts.Scope{Leaves: []facts.Leaf{{Table: "t", Alias: "t", Kind: facts.Table, Keys: []facts.Key{{Columns: []string{"uid"}, Where: []facts.Pred{{Op: facts.IsNull, Col: col(0, "deleted_at")}}}}}}, + Fixed: []facts.ColRef{col(0, "uid")}}}, false}, + {"temporal key by a contained point", &facts.Facts{Top: &facts.Scope{Leaves: []facts.Leaf{{Table: "t", Alias: "t", Kind: facts.Table, Keys: []facts.Key{{Columns: []string{"id", "valid_at"}, Temporal: true}}}}, + Fixed: []facts.ColRef{col(0, "id")}, Preds: []facts.Pred{{Op: facts.Contains, Col: col(0, "valid_at"), Term: facts.Term{Kind: facts.Param, Param: 2}}}}}, true}, + {"insert reads its source", &facts.Facts{Kind: facts.Insert, Top: &facts.Scope{Leaves: []facts.Leaf{{Table: "orders", Alias: "orders", Kind: facts.Table, Role: facts.Target}}}, + Source: &facts.Scope{Leaves: []facts.Leaf{users}, Fixed: []facts.ColRef{col(0, "id")}}}, true}, + } + for _, c := range cases { + got, why := AtMostOne(c.f) + if got != c.want { + t.Errorf("%s: got %v (%s), want %v", c.name, got, why, c.want) + } + if !got && why == "" { + t.Errorf("%s: no reason given", c.name) + } + } +} diff --git a/x/dialect/dialect.go b/x/dialect/dialect.go new file mode 100644 index 0000000..dd3d6b2 --- /dev/null +++ b/x/dialect/dialect.go @@ -0,0 +1,459 @@ +// Package dialect is the seam between the checker (internal/vet) and a database's +// analyzer. A schema.sql names its dialect on its first directive line, `-- sqlshape: +// postgres 18` or `-- sqlshape: mysql 8.4`; the checker reads that line, asks the dialect +// registered under the name for an Analyzer over the schema text, and judges every +// statement through the dialect-neutral Result the Analyzer returns. +// +// PostgreSQL is built in and keeps its own, richer path in internal/vet for now; every +// other dialect registers here (a MySQL analyzer lives in the mysql module, which the +// sqlshape binary imports for its side effect). A dialect's package is linked into the +// binary, never loaded at run time. +// +// Placeholders: the template expander numbers every parameter `$n` whatever the +// dialect. An Analyzer receives SQL in that form and, where its database spells +// parameters differently (`?`), rewrites before parsing; Result.Params is indexed by n. +package dialect + +import ( + "fmt" + "github.com/kr9ly/sqlshape/v2/x/facts" + "github.com/kr9ly/sqlshape/v2/x/obligation" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" +) + +// Declaration is the schema's dialect line. +type Declaration struct { + Name string // "postgres", "mysql", ...; "" when the schema declares none + Version string // as written: "18", "8.4" +} + +// Postgres is the name of the built-in dialect. +const Postgres = "postgres" + +// directiveLine is any `-- sqlshape: word rest` line; only a registered dialect's name +// (or postgres) in the word position makes it a declaration, the rest are statement +// directives (`not null`, `require`, `unfiltered`, ...). +var directiveLine = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*sqlshape:[ \t]*([a-z][a-z0-9_]*)[ \t]+(\S+)[ \t]*$`) + +// Declared reads the schema's dialect declaration. Two declarations that disagree are an +// error; a schema without one declares nothing (the caller decides the default). +func Declared(schemaSQL string) (Declaration, error) { + var d Declaration + schemaSQL = strings.TrimPrefix(schemaSQL, "\ufeff") // a byte order mark is not part of the SQL + for _, m := range directiveLine.FindAllStringSubmatch(schemaSQL, -1) { + name := m[1] + if _, ok := Lookup(name); !ok && name != Postgres { + continue + } + if d.Name != "" && (d.Name != name || d.Version != m[2]) { + return Declaration{}, fmt.Errorf("schema: the dialect is declared twice, as `%s %s` and `%s %s`", d.Name, d.Version, name, m[2]) + } + d = Declaration{Name: name, Version: m[2]} + } + return d, nil +} + +// Analyzer judges statements against one loaded schema. +type Analyzer interface { + // Analyze types one statement, with its parameters as `$n`. A statement the database + // would reject comes back as an *Error; any other error is the analyzer's own limit. + Analyze(sql string) (*Result, error) + // Problems are what the loader could not apply of the schema text, each with its + // position, reported once per package by the checker. + Problems() []string + // Traits are the dialect's conventions for Go types. + Traits() Traits +} + +// Loader builds an Analyzer over a schema text (its dialect line included). +type Loader func(schemaSQL string) (Analyzer, error) + +// Result is the dialect-neutral analysis of one statement: what it returns, what it +// takes, what it touches, what it may fail on, and its facts for the contracts. +type Result struct { + // Params are the placeholders, indexed by n - 1. A parameter the analyzer could not + // type has an Unknown Type. + Params []Param + Columns []Column + // Notes are the analyzer's findings that are not errors: reported as they are, or only + // under -strict when Advisory. + Notes []Note + // Violations are the constraints the statement may violate, for the expect line. + Violations []Violation + // Relations are the schema objects the statement references, for the boundary checks + // (-schemas, -no-tables) and the consumers index. + Relations []RelationRef + // Uses are the relation columns the statement reads or assigns. + Uses []facts.Use + // Facts is the statement's record for the contracts written on facts (x/facts): the + // One proof (x/cardinality) and the obligations are judged on it, whatever the + // dialect. Nil when the analyzer does not record the statement's shape. + Facts *facts.Facts +} + +// Param is one placeholder: its type, and the column it stands for when the analyzer +// knows it (compared with or assigned to). +type Param struct { + Type Type + Source *Source +} + +// Column is one result column, or a field of a composite type. +type Column struct { + Name string + Type Type + Nullable bool + // Source is the table column this column reads unchanged, if any. + Source *Source +} + +// Source is a table column a result column or parameter stands for. +type Source struct { + Table string // as the facts spell it: schema-qualified unless in the default schema + Column string + NotNull bool + // Assigned: the parameter is stored into the column (INSERT / UPDATE), not compared. + Assigned bool + // Identity is the key the column carries when it is a key or references one, after + // following foreign keys to their root ("public.users.id"); "" for other columns. Two + // columns with the same Identity hold the same kind of value; the checker binds a Go + // type to it. + Identity string + // Values are the values the column may hold when the schema fixes them, and + // ValuesFrom says how: "check" for a CHECK (col IN (...)), "seed" for the key of a + // lookup table seeded in the schema (Identity names the key). Nil otherwise. + Values []string + ValuesFrom string + // HasDefault: the column has a DEFAULT; Generated: the database computes it (an + // identity or generated column). Both matter to a parameter assigned to the column. + HasDefault bool + Generated bool + // Comment is the column's COMMENT, "" for none. + Comment string +} + +// Note is a finding about the statement that is not an error. +type Note struct { + Message string + Position int // 0-based byte offset into the SQL; -1 when unknown + Advisory bool + // Param is the parameter the note is about (1-based), 0 for none; the message writes + // it as "$n" and the checker substitutes the parameter's Go path. + Param int +} + +// Violation is a constraint the statement may violate: what the runtime reports and the +// template's expect line names. +type Violation struct { + Key string // the name the expect line uses: the constraint's, or table.column for NOT NULL + // Name is the `-- sqlshape: error = ` annotation's name, "" when the + // violation carries none (every constraint but a trigger's / routine's raised + // SIGNAL). An expect line may name a violation by Key or by Name, whichever it + // spells; the runtime's Violates does not know the schema and still judges by the + // SQLSTATE / error number (Key), never by Name. + Name string + Code string // the database's error code (SQLSTATE 23505, MySQL 1062) + Table string + Columns []string + Constraint string + Detail string // how the checker describes it ("UNIQUE (email) on users, SQLSTATE 23505") + // Param is the parameter whose NULL would violate a NOT NULL (1-based), 0 otherwise: + // the checker drops the violation when the Go type cannot be NULL. + Param int +} + +// RelationRef is one relation the statement references. +type RelationRef struct { + Name string // as the facts spell it + Schema string // the namespace, for -schemas; "" when the dialect has none + Kind facts.RelKind + Position int // 0-based byte offset into the SQL; -1 when unknown + Target bool // written by the statement +} + +// Type is a value type as the dialect names it, with the Go types that carry it. It is a +// tree: an array has its Elem, a range its Elem (the subtype), a domain its Base, a +// composite or record its Fields. +type Type struct { + // Name is the dialect's spelling, for messages: "bigint unsigned", "varchar(20)". + Name string + // Named is the canonical name of a type the schema defines (an enum, a domain, a + // composite, a range), what a `// sqlshape: type X` declaration names; "" for a + // built-in type. + Named string + Kind TypeKind + // Elem is an array's element type or a range's subtype; Base a domain's base type. + Elem *Type + Base *Type + // ElemNotNull: for an Array, the analyzer proved the elements are never NULL (an + // array_agg() of a value it knows is never NULL, a literal ARRAY[...] whose elements + // are all not NULL, an ARRAY(SELECT ...) whose single output column is not NULL). + // False is the dialect's default and means "unknown", not "elements may be NULL": a + // dialect that cannot prove it leaves this false, and the checker's standing note + // about a possible NULL element still applies. PostgreSQL never declares an array + // column's own elements NOT NULL (only the array value as a whole), so this is always + // false for a plain column of array type. + ElemNotNull bool + // Fields are a composite type's columns. + Fields []Column + // Labels are an enum's labels, in order. + Labels []string + // Result and Param list the Go types a value scans into (a result column) and encodes + // from (a parameter), best first: the first is what a struct written from the SQL + // uses. Spelled as GoSpelling describes. Both nil: the dialect has no mapping (the + // checker accepts the field with a note). + Result []GoFit + Param []GoFit +} + +// TypeKind is the shape of a Type. +type TypeKind byte + +const ( + Scalar TypeKind = iota + Array + Composite // a named row type + Record // an anonymous row + Range // Elem is the subtype + Multirange // Elem is the range + Enum + Domain // Base is the underlying type + Void // a column that carries nothing (a procedure-like function's result) +) + +// Unknown reports a type the dialect has no Go mapping for. +func (t Type) Unknown() bool { return len(t.Result) == 0 && len(t.Param) == 0 } + +// GoFit is one Go type that carries a value, and what is lost when it does. +type GoFit struct { + // Go is a spelling of the Go type (GoSpelling). + Go string + // Lossy is why the mapping loses information ("numeric into float64 loses precision"), + // "" when it is faithful; the checker reports a lossy fit as a finding. + Lossy string + // Advice is what the mapping leaves to the application even when faithful ("timestamp + // without time zone into time.Time: the zone is the application's implicit choice"); + // the checker reports it under -strict. + Advice string +} + +// GoSpelling is the grammar of GoFit.Go, what the checker matches a Go type against: +// +// bool, int, int8 .. int64, uint .. uint64, float32, float64, string a basic type +// []byte, [16]byte byte slices and arrays +// time.Time, net/netip.Addr, github.com/google/uuid.UUID a named type, by package path and name +// struct any struct (a row: Fields decide the rest) +// map[string]string, map[string]*string maps +// json anything but a number or a bool +// []$elem, [N]$elem a slice / array whose element fits Elem +// github.com/jackc/pgx/v5/pgtype.Range[$elem] a generic named type whose argument fits Elem +// +// A Go type whose underlying type spells the same fits too (a named string carries a +// varchar). The checker also accepts, for every type, a Go type that decodes or encodes +// itself (sql.Scanner for a result, driver.Valuer for a parameter), and the +// NullWrappers of the dialect. +const GoSpelling = "see the documentation of GoSpelling" + +// Traits are the dialect's conventions the checker applies to every type. +type Traits struct { + // TextParams: a Go string encodes as a parameter of any type (the driver sends text). + TextParams bool + // NullWrappers are package paths whose types all carry a Valid flag (pgx's pgtype): a + // field of such a type receives NULL, and its value is not checked further. + NullWrappers []string +} + +// Schema is what the checker asks of a loaded schema beyond analyzing statements: its +// relations, for Copy and MatView and the doc comments it writes; the schema's own +// definitions, judged like statements; its advice. A dialect's Analyzer offers it by +// implementing Schemaed. +type Schema interface { + // Relation resolves a name the way a program writes it ("orders", "sales.orders"); + // nil when the schema has none. + Relation(name string) *Relation + // Definitions are the schema's own statements — view bodies, function bodies, policy + // predicates — each with the facts the obligations are judged on, its notes, and the + // error that stopped its analysis. + Definitions() []Definition + // Advice are the schema-level advisories (-strict): what the schema does less well than + // it looks. An advisory about a specific relation (Table set) is reported only for a + // package whose statements actually reference that relation (that column too, when + // Column is also set); Table == "" is a schema-wide advisory (about the schema as a + // whole, not any one relation) and is always reported, as every advisory always was + // before Advice carried a subject. + Advice() []Advice + // Type resolves a type by the name a `// sqlshape: type X` declaration writes. + Type(name string) (Type, bool) + // Source describes a column of a relation the way a statement's Source would (its + // identity, its value set, its default); nil when the column does not exist. + Source(table, column string) *Source + // Errors lists the schema's own `-- sqlshape: error = ` declarations + // (one per trigger / routine / function that raises one), for vet to check every + // `sqlshape.Error(code)` declaration in the program against: the code must be one the + // schema actually declares, under the schema's own Name for it. + Errors() []ErrorName +} + +// ErrorName is one `-- sqlshape: error = ` declaration read from the schema. +type ErrorName struct { + Code string + Name string + // Subject is what declared it, for messages: "trigger orders_before_insert", + // "function check_order_size". + Subject string +} + +// Advice is one -strict advisory the schema offers (see Schema.Advice): what it does less +// well than it looks. Table (schema-qualified as the facts spell it, matching Source.Table +// and RelationRef.Name) names the relation the advice is about, "" for a schema-wide +// advisory that is not about any one relation (row-level security's SECURITY DEFINER note, +// a function's own advisory, ...). Column further narrows a Table advisory to one column +// ("" advises about the whole relation, e.g. a materialized view's missing unique index). +type Advice struct { + Table string + Column string + Message string +} + +// Schemaed is an Analyzer that also gives the checker its Schema. +type Schemaed interface { + Schema() Schema +} + +// Contracted is an Analyzer whose schema the obligations can be declared on and judged +// against (x/obligation): its Contract, and the lowering of a declared predicate. +type Contracted interface { + Contract() obligation.Schema + Lower(expr string, rel obligation.Relation) ([]facts.Pred, error) +} + +// Relation is a table or view as the checker sees it. +type Relation struct { + Name string // as the facts spell it (schema-qualified unless in the default schema) + Schema string + Kind facts.RelKind + Columns []SchemaColumn + Comment string + // Unique: the relation has a unique index over whole columns without a predicate + // (what REFRESH MATERIALIZED VIEW CONCURRENTLY needs). + Unique bool +} + +// Column finds a column by name. +func (r *Relation) Column(name string) *SchemaColumn { + for i := range r.Columns { + if r.Columns[i].Name == name { + return &r.Columns[i] + } + } + return nil +} + +// SchemaColumn is a relation's column as declared. +type SchemaColumn struct { + Name string + Type Type + NotNull bool + HasDefault bool + Identity bool // the database numbers it (an identity / serial column); a value may still be given + Generated bool // a generated column: computed from other columns, never written + Comment string +} + +// Definition is one of the schema's own statements as the checker judges it. +type Definition struct { + What string // "view order_summary", "function pay: line 3", "policy p on t" + Facts *facts.Facts + Notes []Note + Err string // what stopped the analysis, "" when it went through +} + +// Error is a statement the database itself would reject. +type Error struct { + Message string + Code string // the database's own code, "" when it has none to give + Position int // 0-based byte offset into the SQL; -1 when unknown +} + +func (e *Error) Error() string { + if e.Code != "" { + return fmt.Sprintf("%s (%s)", e.Message, e.Code) + } + return e.Message +} + +var ( + mu sync.RWMutex + registry = map[string]Loader{} +) + +// Register makes a dialect available under name; a dialect package calls it from init. +func Register(name string, load Loader) { + mu.Lock() + defer mu.Unlock() + if _, dup := registry[name]; dup { + panic("dialect: " + name + " registered twice") + } + registry[name] = load +} + +// Lookup finds a registered dialect. +func Lookup(name string) (Loader, bool) { + mu.RLock() + defer mu.RUnlock() + l, ok := registry[name] + return l, ok +} + +// Names lists the registered dialects, postgres first. +func Names() []string { + mu.RLock() + defer mu.RUnlock() + names := make([]string, 0, len(registry)+1) + for n := range registry { + names = append(names, n) + } + sort.Strings(names) + return append([]string{Postgres}, names...) +} + +// ReadSchema reads the schema at path: a .sql file, or a directory whose *.sql files are +// concatenated in name order (so `schema/010_types.sql`, `schema/020_tables.sql`, ... +// apply like one file), each preceded by a comment naming it so that a problem's position +// can be traced back. +func ReadSchema(path string) (string, error) { + fi, err := os.Stat(path) + if err != nil { + return "", err + } + if !fi.IsDir() { + b, err := os.ReadFile(path) + return string(b), err + } + files, err := filepath.Glob(filepath.Join(path, "*.sql")) + if err != nil { + return "", err + } + if len(files) == 0 { + return "", fmt.Errorf("%s: no *.sql files", path) + } + sort.Strings(files) + var b strings.Builder + for _, f := range files { + src, err := os.ReadFile(f) + if err != nil { + return "", err + } + fmt.Fprintf(&b, "-- file: %s\n", filepath.Base(f)) + b.Write(src) + if len(src) > 0 && src[len(src)-1] != '\n' { + b.WriteByte('\n') + } + } + return b.String(), nil +} diff --git a/x/dialect/dialect_test.go b/x/dialect/dialect_test.go new file mode 100644 index 0000000..335d829 --- /dev/null +++ b/x/dialect/dialect_test.go @@ -0,0 +1,39 @@ +package dialect + +import "testing" + +type nopAnalyzer struct{} + +func (nopAnalyzer) Analyze(string) (*Result, error) { return &Result{}, nil } +func (nopAnalyzer) Problems() []string { return nil } +func (nopAnalyzer) Traits() Traits { return Traits{} } + +func TestDeclared(t *testing.T) { + Register("testdb", func(string) (Analyzer, error) { return nopAnalyzer{}, nil }) + cases := []struct { + sql string + want Declaration + err bool + }{ + {"-- sqlshape: postgres 18\nCREATE TABLE t (a int);", Declaration{"postgres", "18"}, false}, + {"\ufeff -- sqlshape: testdb 1.0 \n", Declaration{"testdb", "1.0"}, false}, + {"CREATE TABLE t (a int);\n-- sqlshape: not null a\n-- sqlshape: require x", Declaration{}, false}, + {"-- sqlshape: unknowndb 3\n", Declaration{}, false}, + {"-- sqlshape: postgres 17\n-- sqlshape: postgres 18\n", Declaration{}, true}, + {"-- sqlshape: postgres 17\n-- sqlshape: testdb 1.0\n", Declaration{}, true}, + {"-- sqlshape: postgres 17\n-- sqlshape: postgres 17\n", Declaration{"postgres", "17"}, false}, + } + for _, c := range cases { + got, err := Declared(c.sql) + if (err != nil) != c.err { + t.Errorf("%q: err = %v, want error %v", c.sql, err, c.err) + continue + } + if got != c.want { + t.Errorf("%q: got %+v, want %+v", c.sql, got, c.want) + } + } + if names := Names(); len(names) != 2 || names[0] != Postgres || names[1] != "testdb" { + t.Errorf("Names() = %v", names) + } +} diff --git a/x/dialect/settings.go b/x/dialect/settings.go new file mode 100644 index 0000000..fbaf6ab --- /dev/null +++ b/x/dialect/settings.go @@ -0,0 +1,77 @@ +package dialect + +import ( + "fmt" + "regexp" + "strings" +) + +// Setting is one `-- sqlshape: server = ` line: a server variable the +// schema declares the database runs with, because the variable changes how statements +// are judged (MySQL's sql_mode, lower_case_table_names). The syntax is the same for every +// dialect; which names a dialect accepts, and what each does to its judgments, is the +// dialect's (an unknown name is a problem of the schema). +type Setting struct { + Name string // lower-cased, as server variables are case-insensitive + Value string // the literal's content ('...' unquoted, '' unescaped) or the bare token + Position int // byte offset of the line (its first character) in the schema text +} + +var settingLine = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*sqlshape:[ \t]*server\b[ \t]*(.*?)[ \t]*$`) + +// Settings reads the schema's server settings, in order. A malformed line, or a variable +// declared twice, is an error. +func Settings(schemaSQL string) ([]Setting, error) { + var out []Setting + seen := map[string]bool{} + for _, m := range settingLine.FindAllStringSubmatchIndex(schemaSQL, -1) { + text := schemaSQL[m[2]:m[3]] + s, err := parseSetting(text) + if err != nil { + return nil, fmt.Errorf("schema: `-- sqlshape: server %s`: %w", text, err) + } + if seen[s.Name] { + return nil, fmt.Errorf("schema: `-- sqlshape: server %s`: %s is declared twice", text, s.Name) + } + seen[s.Name] = true + s.Position = m[0] + out = append(out, s) + } + return out, nil +} + +// IsSetting reports whether a directive's text (what follows `-- sqlshape:`) is a server +// setting: the loaders leave these out of a statement's directives. +func IsSetting(directive string) bool { + f := strings.Fields(directive) + return len(f) > 0 && strings.EqualFold(f[0], "server") +} + +var settingName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_.]*$`) + +func parseSetting(text string) (Setting, error) { + name, value, ok := strings.Cut(text, "=") + if !ok { + return Setting{}, fmt.Errorf("want ` = `") + } + name, value = strings.TrimSpace(name), strings.TrimSpace(value) + if !settingName.MatchString(name) { + return Setting{}, fmt.Errorf("%q is not a variable name", name) + } + if value == "" { + return Setting{}, fmt.Errorf("want a value after `=`") + } + if value[0] == '\'' { + if len(value) < 2 || value[len(value)-1] != '\'' { + return Setting{}, fmt.Errorf("the string literal is not closed") + } + body := value[1 : len(value)-1] + if strings.Contains(strings.ReplaceAll(body, "''", ""), "'") { + return Setting{}, fmt.Errorf("a quote inside the string literal is written ''") + } + value = strings.ReplaceAll(body, "''", "'") + } else if strings.ContainsAny(value, " \t") { + return Setting{}, fmt.Errorf("a value with spaces is written as a string literal '...'") + } + return Setting{Name: strings.ToLower(name), Value: value}, nil +} diff --git a/x/dialect/settings_test.go b/x/dialect/settings_test.go new file mode 100644 index 0000000..da9b007 --- /dev/null +++ b/x/dialect/settings_test.go @@ -0,0 +1,59 @@ +package dialect + +import ( + "reflect" + "testing" +) + +func TestSettings(t *testing.T) { + cases := []struct { + sql string + want []Setting + err string + }{ + {"-- sqlshape: mysql 8.4\n-- sqlshape: server sql_mode = 'ANSI_QUOTES,STRICT_ALL_TABLES'\n-- sqlshape: server lower_case_table_names = 1\nCREATE TABLE t (a int);", + []Setting{{"sql_mode", "ANSI_QUOTES,STRICT_ALL_TABLES", 23}, {"lower_case_table_names", "1", 86}}, ""}, + {" --sqlshape:server SQL_MODE='' \n", []Setting{{"sql_mode", "", 0}}, ""}, + {"-- sqlshape: server x = 'it''s'\n", []Setting{{"x", "it's", 0}}, ""}, + {"-- sqlshape: server sql_mode\n", nil, "want ` = `"}, + {"-- sqlshape: server sql_mode =\n", nil, "want a value after `=`"}, + {"-- sqlshape: server sql_mode = 'ANSI\n", nil, "not closed"}, + {"-- sqlshape: server sql_mode = 'a'b'\n", nil, "written ''"}, + {"-- sqlshape: server sql_mode = ANSI QUOTES\n", nil, "string literal"}, + {"-- sqlshape: server 1x = 2\n", nil, "not a variable name"}, + {"-- sqlshape: server a = 1\n-- sqlshape: server A = 2\n", nil, "declared twice"}, + {"-- sqlshape: servers = 1\n-- sqlshape: not null a\n", nil, ""}, + } + for _, c := range cases { + got, err := Settings(c.sql) + if c.err != "" { + if err == nil || !contains(err.Error(), c.err) { + t.Errorf("%q: err = %v, want %q", c.sql, err, c.err) + } + continue + } + if err != nil { + t.Errorf("%q: %v", c.sql, err) + continue + } + if !reflect.DeepEqual(got, c.want) { + t.Errorf("%q:\n got %+v\nwant %+v", c.sql, got, c.want) + } + } + if !IsSetting("server sql_mode = ''") || IsSetting("servers") || IsSetting("require a") { + t.Error("IsSetting") + } +} + +func contains(s, sub string) bool { + return len(sub) == 0 || (len(s) >= len(sub) && (s == sub || indexOf(s, sub) >= 0)) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/x/doc.go b/x/doc.go new file mode 100644 index 0000000..f055a65 --- /dev/null +++ b/x/doc.go @@ -0,0 +1,8 @@ +// Package x holds nothing itself: its subdirectories are the parts of the checker that +// every module of the repository shares and that have no dependencies of their own — +// expand (the template's finite expansion), dialect (the schema's dialect declaration and +// the analyzer registry), facts (the statement facts the obligations are judged on). They +// are exported because Go's internal rule stops at a module's major-version suffix +// (check/postgres/v2 cannot import sqlshape/v2/internal), and they are tool-facing: no +// compatibility promise beyond what cmd/sqlshape and the check modules need. +package x diff --git a/x/expand/expand.go b/x/expand/expand.go new file mode 100644 index 0000000..bbc8074 --- /dev/null +++ b/x/expand/expand.go @@ -0,0 +1,716 @@ +// Package expand enumerates the finite set of SQL strings a query template can +// produce. Control flow (if / else / with / range) is expanded exhaustively; +// every value action `{{.Field}}` becomes a `$n` placeholder whose Go-side +// origin is recorded as a field path on the parameter struct. +package expand + +import ( + "fmt" + "sort" + "strconv" + "strings" + "text/template/parse" +) + +// MaxExpansions caps the full branch product. Beyond it the expansion falls back to a +// sparse set (Result.Sparse): every branch off, every branch on, and each branch on +// alone. Independent AND predicates are then still fully type-checked; a fragment that +// depends on another (a JOIN one `if` introduces, a column another `if` uses) shows up +// as an error in its "alone" form, which is the cue to restructure the template. +const MaxExpansions = 256 + +// Path is a field path from the parameter struct root: each element is a field +// name, or "[]" for "element of the preceding slice / array / map". +type Path []string + +func (p Path) String() string { + var b strings.Builder + for _, e := range p { + if e == "[]" { + b.WriteString("[]") + } else { + b.WriteString(".") + b.WriteString(e) + } + } + if b.Len() == 0 { + return "." + } + return b.String() +} + +// Param is one `$n` in an expansion. +type Param struct { + N int // 1-based placeholder number + Path Path // origin on P + Pos int // byte offset of the action in the template text +} + +// Expansion is one concrete SQL string. +type Expansion struct { + SQL string + Params []Param + // Branch names the control-flow choices that produced this expansion, e.g. "if@31:then range@114:x2". + Branch string + // GuardedTrue lists the paths a plain {{if .X}} or {{with .X}} in this expansion + // decided true (the then branch was taken reading X itself, not a computed + // expression like `gt .X 0`). Reading X this way proves X was not the zero value at + // that point: for a pointer field, that means non-nil for the rest of the + // expansion's lineage. See Guarded. + GuardedTrue []Path + segs []segment +} + +// Guarded reports whether p is exactly one of e.GuardedTrue's paths. It is deliberately +// not a prefix match: {{with .Order}} proves .Order itself non-nil, but says nothing +// about .Order.ID's own nilness when ID is itself a pointer field -- that field can still +// be its own nil regardless of Order's. Only a condition reading that exact path (e.g. +// nested as {{if .ID}} inside the with) proves it. +func (e *Expansion) Guarded(p Path) bool { + for _, g := range e.GuardedTrue { + if len(p) != len(g) { + continue + } + match := true + for i, el := range g { + if p[i] != el { + match = false + break + } + } + if match { + return true + } + } + return false +} + +// segment maps a run of expanded bytes back to the template. +type segment struct { + expStart, tmplStart, n int +} + +// TemplatePos maps a byte offset in SQL back to a template offset (best effort). +func (e *Expansion) TemplatePos(sqlOff int) int { + for _, s := range e.segs { + if sqlOff >= s.expStart && sqlOff < s.expStart+s.n { + return s.tmplStart + (sqlOff - s.expStart) + } + } + if len(e.segs) > 0 { + last := e.segs[len(e.segs)-1] + return last.tmplStart + last.n + } + return 0 +} + +// Result of expanding one template. +type Result struct { + Expansions []Expansion + // Controls are the paths read by if / with / range conditions (they must exist on P). + Controls []Path + // Sparse is set when the branch product exceeded MaxExpansions and Expansions holds + // the sparse set instead of every combination (see MaxExpansions). + Sparse bool + // Combinations is the size of the full branch product. + Combinations int +} + +// Error is a template problem with a template byte offset. +type Error struct { + Pos int + Msg string +} + +func (e *Error) Error() string { return fmt.Sprintf("template: %s (at %d)", e.Msg, e.Pos) } + +// Expand parses and expands tmpl. +func Expand(tmpl string) (*Result, error) { + trees, err := parse.Parse("q", tmpl, "{{", "}}", builtins) + if err != nil { + return nil, &Error{Pos: 0, Msg: strings.TrimPrefix(err.Error(), "template: q:")} + } + root := trees["q"].Root + // branchNodes already collapses repeated occurrences of the same control path (see + // staticKey / addBranch): a condition read twice, e.g. an {{if .Status}} in a column + // list and again in the matching VALUES clause, is one branch, not two, since the two + // occurrences always agree (they read the same value). Only the *count* here is + // deduped; the actual tie enforcement happens per-expansion in branches / rangeNode via + // state.decisions, which is exact (dot/var-aware) where this static, syntax-only key is + // just an approximation used to size the branch product / build sparse policies. + branches := branchNodes(root) + combos := 1 + for _, b := range branches { + if b.kind == 'r' { + combos *= 3 + } else { + combos *= 2 + } + if combos > MaxExpansions { + break + } + } + res := &Result{Combinations: combos} + if combos <= MaxExpansions { + x := &expander{res: res} + out, err := x.list(root, []*state{newRootState()}) + if err != nil { + return nil, err + } + x.emit(out) + return res, nil + } + // sparse: all off, all on, each on alone + res.Sparse = true + policies := []map[parse.Pos]int{{}, {}} + for _, b := range branches { + on := 1 + if b.kind == 'r' { + on = 2 + } + policies[1][b.pos] = on + policies = append(policies, map[parse.Pos]int{b.pos: 1}) + } + for _, pol := range policies { + x := &expander{res: res, choose: func(pos parse.Pos) int { return pol[pos] }} + out, err := x.list(root, []*state{newRootState()}) + if err != nil { + return nil, err + } + x.emit(out) + } + return res, nil +} + +func newRootState() *state { + return &state{dot: Path{}, vars: map[string]Path{"$": {}}, decisions: map[string]int{}} +} + +func (x *expander) emit(out []*state) { + for _, st := range out { + var keys []string + for k := range st.guardedTrue { + keys = append(keys, k) + } + sort.Strings(keys) + var guarded []Path + for _, k := range keys { + guarded = append(guarded, st.guardedTrue[k]) + } + x.res.Expansions = append(x.res.Expansions, Expansion{ + SQL: st.sql.String(), Params: st.params, Branch: strings.TrimSpace(st.branch), + GuardedTrue: guarded, segs: st.segs, + }) + } +} + +type branchNode struct { + pos parse.Pos + kind byte // 'i' if, 'w' with, 'r' range +} + +// branchNodes lists the control nodes of a tree in source order. A control whose condition +// is a plain field/dot/variable reference (the same shape a value action {{.Field}} must +// have -- see pipePath) is deduped against any earlier control reading the same reference: +// repeated reads of one path always agree, so they count, and later get tied, as one branch +// rather than one per occurrence. Conditions that are not a plain reference (e.g. `gt .X 0`) +// cannot be tied this way (their value isn't known without evaluating them) and are always +// counted independently, as before. +func branchNodes(l *parse.ListNode) []branchNode { + var out []branchNode + seen := map[string]bool{} + var walk func(l *parse.ListNode) + walk = func(l *parse.ListNode) { + if l == nil { + return + } + for _, n := range l.Nodes { + switch v := n.(type) { + case *parse.IfNode: + addBranch(&out, seen, v.Pos, 'i', v.Pipe) + walk(v.List) + walk(v.ElseList) + case *parse.WithNode: + addBranch(&out, seen, v.Pos, 'w', v.Pipe) + walk(v.List) + walk(v.ElseList) + case *parse.RangeNode: + addBranch(&out, seen, v.Pos, 'r', v.Pipe) + walk(v.List) + walk(v.ElseList) + } + } + } + walk(l) + return out +} + +func addBranch(out *[]branchNode, seen map[string]bool, pos parse.Pos, kind byte, pipe *parse.PipeNode) { + if key, ok := staticKey(pipe); ok { + full := string(kind) + key + if seen[full] { + return + } + seen[full] = true + } + *out = append(*out, branchNode{pos, kind}) +} + +// staticKey renders the syntactic shape of a plain field/dot/variable reference pipe, the +// same shape pipePath accepts, for use as an approximate (dot/var-unaware) tie key. It is +// only used to size the branch product and build sparse policies; the exact, scope-correct +// tie key used to actually collapse expansions is computed live per state in pipePath. +func staticKey(pipe *parse.PipeNode) (string, bool) { + if len(pipe.Cmds) != 1 || len(pipe.Cmds[0].Args) != 1 { + return "", false + } + switch a := pipe.Cmds[0].Args[0].(type) { + case *parse.FieldNode: + return "." + strings.Join(a.Ident, "."), true + case *parse.DotNode: + return ".", true + case *parse.VariableNode: + return "$" + strings.Join(a.Ident, "."), true + } + return "", false +} + +// builtins lets conditions use the usual comparison / boolean helpers. They are never evaluated. +var builtins = map[string]any{ + "eq": func() {}, "ne": func() {}, "lt": func() {}, "le": func() {}, "gt": func() {}, "ge": func() {}, + "and": func() {}, "or": func() {}, "not": func() {}, "len": func() {}, "index": func() {}, +} + +type expander struct { + res *Result + // choose, when set (sparse mode), picks the single alternative to take at a control + // node: 0 else / no iteration, 1 then / one iteration, 2 two iterations + choose func(pos parse.Pos) int +} + +// state is one partial expansion being built. +type state struct { + sql strings.Builder + params []Param + segs []segment + branch string + dot Path + vars map[string]Path + // paramKeys dedupes identical origins within one expansion so the same value gets one $n + paramKeys map[string]int + // iter is the stack of active range iteration indexes (disambiguates $n across iterations) + iter []int + // decisions ties repeated reads of the same control path to the same outcome within one + // expansion: keyed "if:" / "with:" (1 = then, 0 = else) or "range:" + // (the iteration count, 0/1/2). Set the first time a path is read, consulted (instead of + // branching again) on every later read of the same path in this lineage. + decisions map[string]int + // guardedTrue records, keyed by Path.String(), every path a plain {{if}}/{{with}} in + // this lineage has decided true (see Expansion.GuardedTrue). + guardedTrue map[string]Path +} + +// guard records that p was read true by a plain if/with condition in this lineage. +func (s *state) guard(p Path) { + if s.guardedTrue == nil { + s.guardedTrue = map[string]Path{} + } + s.guardedTrue[p.String()] = p +} + +func (s *state) clone() *state { + c := &state{dot: s.dot, branch: s.branch} + c.sql.WriteString(s.sql.String()) + c.params = append([]Param{}, s.params...) + c.segs = append([]segment{}, s.segs...) + c.vars = map[string]Path{} + for k, v := range s.vars { + c.vars[k] = v + } + c.paramKeys = map[string]int{} + for k, v := range s.paramKeys { + c.paramKeys[k] = v + } + c.iter = append([]int{}, s.iter...) + c.decisions = map[string]int{} + for k, v := range s.decisions { + c.decisions[k] = v + } + c.guardedTrue = map[string]Path{} + for k, v := range s.guardedTrue { + c.guardedTrue[k] = v + } + return c +} + +func (s *state) text(t string, tmplPos int) { + s.segs = append(s.segs, segment{expStart: s.sql.Len(), tmplStart: tmplPos, n: len(t)}) + s.sql.WriteString(t) +} + +func (s *state) param(p Path, tmplPos int) { + key := p.String() + fmt.Sprint(s.iter) + if s.paramKeys == nil { + s.paramKeys = map[string]int{} + } + n, ok := s.paramKeys[key] + if !ok { + n = len(s.params) + 1 + s.paramKeys[key] = n + s.params = append(s.params, Param{N: n, Path: p, Pos: tmplPos}) + } + s.text("$"+strconv.Itoa(n), tmplPos) +} + +func (x *expander) list(l *parse.ListNode, states []*state) ([]*state, error) { + if l == nil || len(states) == 0 { + return states, nil + } + var err error + for _, n := range l.Nodes { + states, err = x.node(n, states) + if err != nil { + return nil, err + } + if len(states) > MaxExpansions { + return nil, &Error{Pos: int(n.Position()), Msg: fmt.Sprintf("more than %d expansions; split the template", MaxExpansions)} + } + } + return states, nil +} + +func (x *expander) node(n parse.Node, states []*state) ([]*state, error) { + switch v := n.(type) { + case *parse.TextNode: + for _, s := range states { + s.text(string(v.Text), int(v.Pos)) + } + return states, nil + case *parse.ActionNode: + if len(v.Pipe.Decl) > 0 { + // {{$x := .Y}} + p, err := x.pipePath(v.Pipe, states[0], int(v.Pos)) + if err != nil { + return nil, err + } + for _, s := range states { + s.vars[v.Pipe.Decl[0].Ident[0]] = p + } + return states, nil + } + p, err := x.pipePath(v.Pipe, states[0], int(v.Pos)) + if err != nil { + return nil, err + } + for _, s := range states { + s.param(x.rebase(p, s), int(v.Pos)) + } + return states, nil + case *parse.IfNode: + x.control(v.Pipe, states[0]) + return x.branches(&v.BranchNode, states, "if", false, func(s *state) (string, Path, bool) { + p, err := x.pipePath(v.Pipe, s, int(v.Pos)) + if err != nil { + return "", nil, false + } + return "if:" + p.String() + fmt.Sprint(s.iter), p, true + }) + case *parse.WithNode: + p, err := x.pipePath(v.Pipe, states[0], int(v.Pos)) + if err != nil { + return nil, err + } + x.res.Controls = append(x.res.Controls, p) + if len(v.Pipe.Decl) > 0 { + // {{with $y := .X}}: $y is .X inside + for _, s := range states { + s.vars[v.Pipe.Decl[0].Ident[0]] = p + } + } + return x.branches(&v.BranchNode, states, "with", true, func(s *state) (string, Path, bool) { + p, err := x.pipePath(v.Pipe, s, int(v.Pos)) + if err != nil { + return "", nil, false + } + return "with:" + p.String() + fmt.Sprint(s.iter), p, true + }) + case *parse.RangeNode: + return x.rangeNode(v, states) + case *parse.CommentNode: + return states, nil + case *parse.TemplateNode: + return nil, &Error{Pos: int(v.Pos), Msg: "{{template}} is not supported"} + } + return nil, &Error{Pos: int(n.Position()), Msg: fmt.Sprintf("unsupported template node %T", n)} +} + +// branchKey resolves, for one state, the tie key ("if:" / "with:") and the +// resolved path a control's condition reads, when that condition is a plain field/dot/ +// variable reference (ok=false otherwise, e.g. `gt .X 0`: not tie-able, always branches). +type branchKey func(s *state) (key string, path Path, ok bool) + +// branches expands then/else lists. withDot requests that the resolved path (from kf) +// become dot inside the then-branch ({{with}}); kf may be nil for controls that decline to +// resolve to a tie-able path in a given state (branches independently, as before). +// +// A state that already has a decision recorded for kf's key (because an earlier control in +// this same expansion read the identical path -- e.g. the same {{if .Status}} appearing +// twice) is not branched again: it is routed straight down the branch that decision already +// picked. This is what ties two occurrences of one condition to the same outcome instead of +// letting them vary independently (which would multiply the expansion count and could even +// produce impossible combinations, like a column list and its VALUES clause disagreeing on +// whether a column is present). +func (x *expander) branches(b *parse.BranchNode, states []*state, kind string, withDot bool, kf branchKey) ([]*state, error) { + takeThen, takeElse := true, true + if x.choose != nil { + takeThen = x.choose(b.Pos) >= 1 + takeElse = !takeThen + } + + var thenIn, elseIn []*state + for _, s := range states { + var key string + var path Path + var ok bool + if kf != nil { + key, path, ok = kf(s) + } + if ok { + if d, seen := s.decisions[key]; seen { + // Already decided by an earlier occurrence of the same path: follow it, + // don't re-branch this state. + s.branch += fmt.Sprintf(" %s@%d:%s", kind, b.Pos, thenElse(d == 1)) + if d == 1 { + s.guard(path) + if withDot { + s.dot = x.rebase(path, s) + } + thenIn = append(thenIn, s) + } else { + elseIn = append(elseIn, s) + } + continue + } + } + if takeThen { + c := s.clone() + if ok { + c.decisions[key] = 1 + c.guard(path) + } + if withDot { + c.dot = x.rebase(path, s) + } + c.branch += fmt.Sprintf(" %s@%d:then", kind, b.Pos) + thenIn = append(thenIn, c) + } + if takeElse { + c := s.clone() + if ok { + c.decisions[key] = 0 + } + c.branch += fmt.Sprintf(" %s@%d:else", kind, b.Pos) + elseIn = append(elseIn, c) + } + } + + then, err := x.list(b.List, thenIn) + if err != nil { + return nil, err + } + if withDot { + for _, s := range then { + s.dot = states[0].dot + } + } + els, err := x.list(b.ElseList, elseIn) + if err != nil { + return nil, err + } + return append(then, els...), nil +} + +func thenElse(then bool) string { + if then { + return "then" + } + return "else" +} + +// rangeNode expands {{range}} as 0, 1 and 2 iterations. States that range over the same +// resolved path as an earlier {{range}} in this expansion, in the same range iteration +// context (state.decisions["range:"], iter disambiguating an outer range's +// current element the way param does for $n), are tied to that earlier occurrence's +// iteration count instead of being re-branched into their own independent 0/1/2, for the +// same reason if/with ties repeated conditions: two reads of one path in the same element +// always agree. A path read again in a *different* element of an outer range (or an +// enclosing {{if}}/{{with}} branch iterating separately) is not tied to it: each element +// may hold a different value, so an {{if .Flag}} (or a nested {{range}}) inside +// {{range .Items}} varies independently per element -- iter, part of the key, keeps that +// distinct from the top-level tying of a path read twice outside any range. +func (x *expander) rangeNode(r *parse.RangeNode, states []*state) ([]*state, error) { + type group struct { + path Path + states []*state + } + byKey := map[string]*group{} + var order []string + for _, s := range states { + p, err := x.pipePath(r.Pipe, s, int(r.Pos)) + if err != nil { + return nil, err + } + key := p.String() + fmt.Sprint(s.iter) + g, ok := byKey[key] + if !ok { + g = &group{path: p} + byKey[key] = g + order = append(order, key) + } + g.states = append(g.states, s) + } + + var out []*state + for _, key := range order { + g := byKey[key] + x.res.Controls = append(x.res.Controls, g.path) + tieKey := "range:" + key + + byCount := map[int][]*state{} + var undecided []*state + for _, s := range g.states { + if c, ok := s.decisions[tieKey]; ok { + byCount[c] = append(byCount[c], s) + } else { + undecided = append(undecided, s) + } + } + for _, count := range []int{0, 1, 2} { + ss, ok := byCount[count] + if !ok { + continue + } + batch, err := x.runRange(r, g.path, ss, count, false, "") + if err != nil { + return nil, err + } + out = append(out, batch...) + } + if len(undecided) > 0 { + counts := []int{0, 1, 2} + if x.choose != nil { + counts = []int{x.choose(r.Pos)} + } + for _, count := range counts { + batch, err := x.runRange(r, g.path, undecided, count, true, tieKey) + if err != nil { + return nil, err + } + out = append(out, batch...) + } + } + } + return out, nil +} + +// runRange runs count iterations of r.List (or r.ElseList when count == 0) over states, +// cloning each first. path is the range's resolved path (computed once, before any dot +// mutation -- recomputing it after the first iteration would resolve against the element +// dot instead of the collection). When record is true, the chosen count is recorded under +// tieKey so a later {{range}} over the identical path reuses it instead of branching again. +func (x *expander) runRange(r *parse.RangeNode, path Path, states []*state, count int, record bool, tieKey string) ([]*state, error) { + parentDot := states[0].dot + batch := make([]*state, 0, len(states)) + for _, s := range states { + c := s.clone() + c.branch += fmt.Sprintf(" range@%d:x%d", r.Pos, count) + if record { + c.decisions[tieKey] = count + } + batch = append(batch, c) + } + var err error + if count == 0 { + batch, err = x.list(r.ElseList, batch) + if err != nil { + return nil, err + } + return batch, nil + } + for i := 0; i < count; i++ { + for _, c := range batch { + elem := append(append(Path{}, x.rebase(path, c)...), "[]") + c.dot = elem + c.iter = append(c.iter, i) + // {{range $i, $v := .X}} / {{range $v := .X}} + switch len(r.Pipe.Decl) { + case 1: + c.vars[r.Pipe.Decl[0].Ident[0]] = elem + case 2: + c.vars[r.Pipe.Decl[0].Ident[0]] = Path{"#index"} + c.vars[r.Pipe.Decl[1].Ident[0]] = elem + } + } + batch, err = x.list(r.List, batch) + if err != nil { + return nil, err + } + for _, c := range batch { + c.iter = c.iter[:len(c.iter)-1] + } + } + for _, c := range batch { + c.dot = parentDot + } + return batch, nil +} + +// control records paths read by a condition (no expansion effect). A condition argument +// may itself be a nested pipeline -- a parenthesized call like `(len .Xz)` in +// `{{if gt (len .Xz) 0}}` -- so field/dot references are hunted recursively through +// nested pipelines, not just directly on the top-level command. +func (x *expander) control(pipe *parse.PipeNode, s *state) { + for _, cmd := range pipe.Cmds { + for _, arg := range cmd.Args { + x.controlArg(arg, s) + } + } +} + +// controlArg records the paths read by one condition argument, recursing into a nested +// pipeline's own commands and arguments. +func (x *expander) controlArg(arg parse.Node, s *state) { + switch a := arg.(type) { + case *parse.FieldNode: + x.res.Controls = append(x.res.Controls, x.rebase(append(append(Path{}, s.dot...), a.Ident...), s)) + case *parse.DotNode: + x.res.Controls = append(x.res.Controls, s.dot) + case *parse.PipeNode: + x.control(a, s) + } +} + +// pipePath extracts the single field-chain a value action must be. Relative paths (from dot) +// are returned with dot prepended; variables resolve through s.vars. +func (x *expander) pipePath(pipe *parse.PipeNode, s *state, pos int) (Path, error) { + if len(pipe.Cmds) != 1 || len(pipe.Cmds[0].Args) != 1 { + return nil, &Error{Pos: pos, Msg: "value actions must be a plain field reference like {{.Field}}"} + } + switch a := pipe.Cmds[0].Args[0].(type) { + case *parse.FieldNode: + return append(append(Path{}, s.dot...), a.Ident...), nil + case *parse.DotNode: + return append(Path{}, s.dot...), nil + case *parse.VariableNode: + base, ok := s.vars[a.Ident[0]] + if !ok { + return nil, &Error{Pos: pos, Msg: fmt.Sprintf("undefined variable %s", a.Ident[0])} + } + return append(append(Path{}, base...), a.Ident[1:]...), nil + } + return nil, &Error{Pos: pos, Msg: "value actions must be a plain field reference like {{.Field}}"} +} + +// rebase is the identity today; paths are already absolute. Kept as the single place to +// change if relative resolution is needed. +func (x *expander) rebase(p Path, s *state) Path { return p } diff --git a/internal/expand/expand_test.go b/x/expand/expand_test.go similarity index 55% rename from internal/expand/expand_test.go rename to x/expand/expand_test.go index 5e3cf1a..0db6d23 100644 --- a/internal/expand/expand_test.go +++ b/x/expand/expand_test.go @@ -2,6 +2,7 @@ package expand import ( "fmt" + "sort" "strings" "testing" ) @@ -343,3 +344,256 @@ func TestIfErrorInElse(t *testing.T) { t.Error("expected an error from the if's else-body") } } + +// TestRepeatedConditionIsOneBranch: the same condition path read twice (a column list and +// its matching VALUES clause both gated on {{if .Status}}) is one branch, not two -- the two +// occurrences always agree, so there are 2 expansions (not 4) and neither ever disagrees. +func TestRepeatedConditionIsOneBranch(t *testing.T) { + res, err := Expand(`INSERT INTO orders (customer_id{{if .Status}}, status{{end}}) VALUES ({{.CustomerID}}{{if .Status}}, {{.Status}}{{end}})`) + if err != nil { + t.Fatal(err) + } + if res.Combinations != 2 { + t.Errorf("combinations = %d, want 2", res.Combinations) + } + if len(res.Expansions) != 2 { + t.Fatalf("expansions = %d, want 2", len(res.Expansions)) + } + for _, e := range res.Expansions { + hasCol := strings.Contains(e.SQL, ", status") + hasVal := strings.Contains(e.SQL, ", $") + if hasCol != hasVal { + t.Errorf("column list and VALUES disagree: %q", e.SQL) + } + } +} + +// TestRepeatedConditionInElseIf: an else-if reading the same path as the if it is chained +// off of is also tied -- a value that was false stays false when read again. +func TestRepeatedConditionInElseIf(t *testing.T) { + res, err := Expand(`{{if .A}}one{{else if .A}}two{{else}}three{{end}}`) + if err != nil { + t.Fatal(err) + } + // .A true => "one"; .A false => (re-read .A, still false) => "three". "two" is + // unreachable, so there are 2 expansions, not 3. + if len(res.Expansions) != 2 { + t.Fatalf("expansions = %d, want 2: %#v", len(res.Expansions), res.Expansions) + } + var got []string + for _, e := range res.Expansions { + got = append(got, e.SQL) + } + sort.Strings(got) + if strings.Join(got, ",") != "one,three" { + t.Errorf("expansions = %v, want [one three]", got) + } +} + +// TestNestedRepeatedConditionTiesToOuter: an if nested inside the then-branch of an if on +// the same path is forced to the same (true) outcome, not independently re-branched. +func TestNestedRepeatedConditionTiesToOuter(t *testing.T) { + res, err := Expand(`{{if .A}}outer{{if .A}}inner{{end}}{{end}}`) + if err != nil { + t.Fatal(err) + } + // .A true (only, since the outer determines the value) => "outerinner"; .A false => + // "" (outer not taken, inner never reached). 2 expansions, not 3. + if len(res.Expansions) != 2 { + t.Fatalf("expansions = %d, want 2: %#v", len(res.Expansions), res.Expansions) + } + var got []string + for _, e := range res.Expansions { + got = append(got, e.SQL) + } + sort.Strings(got) + if strings.Join(got, ",") != ",outerinner" { + t.Errorf("expansions = %v, want [\"\" outerinner]", got) + } +} + +// TestRepeatedWithConditionIsOneBranch: two {{with}} blocks over the same path are tied the +// same way {{if}} is. +func TestRepeatedWithConditionIsOneBranch(t *testing.T) { + res, err := Expand(`{{with .Page}}a={{.Limit}}{{end}} {{with .Page}}b={{.Offset}}{{end}}`) + if err != nil { + t.Fatal(err) + } + if len(res.Expansions) != 2 { + t.Fatalf("expansions = %d, want 2: %#v", len(res.Expansions), res.Expansions) + } + for _, e := range res.Expansions { + hasA := strings.Contains(e.SQL, "a=$") + hasB := strings.Contains(e.SQL, "b=$") + if hasA != hasB { + t.Errorf("the two with-blocks disagree: %q", e.SQL) + } + } +} + +// TestRepeatedRangeIsOneBranch: two {{range}} over the same path iterate the same number of +// times in every expansion (0/1/2), not independently. +func TestRepeatedRangeIsOneBranch(t *testing.T) { + res, err := Expand(`{{range .Tags}}a{{.}}{{end}} {{range .Tags}}b{{.}}{{end}}`) + if err != nil { + t.Fatal(err) + } + if len(res.Expansions) != 3 { + t.Fatalf("expansions = %d, want 3 (0/1/2 iterations): %#v", len(res.Expansions), res.Expansions) + } + for _, e := range res.Expansions { + na := strings.Count(e.SQL, "a$") + nb := strings.Count(e.SQL, "b$") + if na != nb { + t.Errorf("iteration counts disagree: %q", e.SQL) + } + } +} + +// TestUnrelatedConditionsStillBranchIndependently: two different paths are not tied to each +// other -- the fix must not collapse genuinely independent conditions. +func TestUnrelatedConditionsStillBranchIndependently(t *testing.T) { + res, err := Expand(`{{if .A}}a{{end}}{{if .B}}b{{end}}`) + if err != nil { + t.Fatal(err) + } + if len(res.Expansions) != 4 { + t.Fatalf("expansions = %d, want 4", len(res.Expansions)) + } +} + +// TestSparseTiesRepeatedCondition: even beyond MaxExpansions, where the expander falls back +// to a sparse set (all off / all on / each-alone), a path read twice still agrees with +// itself in every one of those passes. +func TestSparseTiesRepeatedCondition(t *testing.T) { + var b strings.Builder + b.WriteString("SELECT id FROM t WHERE true") + for i := 0; i < 10; i++ { + fmt.Fprintf(&b, " {{if .C%d}} AND c%d = {{.C%d}} {{end}}", i, i, i) + } + // .C0 read again, elsewhere in the template: must agree with its first read in every + // pass, including the "c0 alone" pass. + b.WriteString(" {{if .C0}} AND again{{end}}") + res, err := Expand(b.String()) + if err != nil { + t.Fatal(err) + } + if !res.Sparse { + t.Fatal("expected sparse fallback") + } + for _, e := range res.Expansions { + hasFirst := strings.Contains(e.SQL, "c0 = $") + hasAgain := strings.Contains(e.SQL, "AND again") + if hasFirst != hasAgain { + t.Errorf("repeated .C0 condition disagrees with itself: %q", e.SQL) + } + } +} + +// TestGuardedTrueIf: a plain {{if .X}} that takes the then branch proves .X true (a +// pointer non-nil) for the rest of that expansion's lineage; the else branch proves +// nothing (X could be any falsy value, not "known false" in a way worth recording). +func TestGuardedTrueIf(t *testing.T) { + res, err := Expand(`{{if .Status}}{{.Status}}{{else}}{{.Status}}{{end}}`) + if err != nil { + t.Fatal(err) + } + var then, els *Expansion + for i := range res.Expansions { + e := &res.Expansions[i] + if strings.Contains(e.Branch, "if@5:then") { + then = e + } else { + els = e + } + } + if then == nil || els == nil { + t.Fatal("expected then and else expansions") + } + if !then.Guarded(Path{"Status"}) { + t.Errorf("then branch should guard .Status, GuardedTrue=%v", then.GuardedTrue) + } + if els.Guarded(Path{"Status"}) { + t.Errorf("else branch should not guard .Status") + } +} + +// TestGuardedTrueWithNested: a {{with .Order}} that takes the then branch guards .Order +// itself, but NOT a path nested under it (e.g. .Order.ID read as {{.ID}} inside the with, +// which resolves to the absolute path .Order.ID): .Order being non-nil says nothing about +// whether .Order.ID -- if ID is itself a pointer field -- is nil. +func TestGuardedTrueWithNested(t *testing.T) { + res, err := Expand(`{{with .Order}}{{.ID}}{{end}}`) + if err != nil { + t.Fatal(err) + } + var then *Expansion + for i := range res.Expansions { + if strings.Contains(res.Expansions[i].Branch, "with@7:then") { + then = &res.Expansions[i] + } + } + if then == nil { + t.Fatal("expected a then expansion") + } + if !then.Guarded(Path{"Order"}) { + t.Errorf(".Order should be guarded") + } + if then.Guarded(Path{"Order", "ID"}) { + t.Errorf(".Order.ID (nested under .Order, but not itself the guarded path) should not be guarded") + } + if then.Guarded(Path{"Other"}) { + t.Errorf("unrelated path should not be guarded") + } +} + +// TestGuardedTrueRangeNotGuarded: a {{range}} does not add to GuardedTrue -- an element of +// a ranged-over collection is one particular element, not the whole path proven non-nil. +func TestGuardedTrueRangeNotGuarded(t *testing.T) { + res, err := Expand(`{{range .Tags}}{{.}}{{end}}`) + if err != nil { + t.Fatal(err) + } + for i := range res.Expansions { + e := &res.Expansions[i] + if e.Guarded(Path{"Tags"}) { + t.Errorf("range should not guard its path: %v", e.GuardedTrue) + } + } +} + +// TestConditionInsideRangeVariesPerIteration: an {{if}} reading a path inside a +// {{range}}'s body varies independently per element -- ties only apply within one +// iteration, never across iterations, since each element may hold a different value. +func TestConditionInsideRangeVariesPerIteration(t *testing.T) { + res, err := Expand(`{{range .Items}}{{if .Flag}}Y{{else}}N{{end}}{{end}}`) + if err != nil { + t.Fatal(err) + } + var got []string + for _, e := range res.Expansions { + got = append(got, e.SQL) + } + sort.Strings(got) + // 0, 1 (Y or N) and 2 iterations (every YN combination), not just the diagonal + // (YY/NN) a same-decision-across-iterations bug would produce. + want := []string{"", "N", "NN", "NY", "Y", "YN", "YY"} + sort.Strings(want) + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("expansions = %v, want %v", got, want) + } +} + +// TestConditionInsideRangeTiedWithinIteration: two reads of the same path within the same +// range element still tie, exactly as they do outside any range. +func TestConditionInsideRangeTiedWithinIteration(t *testing.T) { + res, err := Expand(`{{range .Items}}{{if .Flag}}a{{end}}{{if .Flag}}b{{end}}{{end}}`) + if err != nil { + t.Fatal(err) + } + for _, e := range res.Expansions { + if strings.Count(e.SQL, "a") != strings.Count(e.SQL, "b") { + t.Errorf("within one element, the repeated .Flag disagreed with itself: %q", e.SQL) + } + } +} diff --git a/internal/facts/facts.go b/x/facts/facts.go similarity index 60% rename from internal/facts/facts.go rename to x/facts/facts.go index cd183c3..c597100 100644 --- a/internal/facts/facts.go +++ b/x/facts/facts.go @@ -1,6 +1,6 @@ // Package facts is the data contract between a dialect's analyzer and the obligation // checker: what one analyzed statement (one expansion) provably does, written down -// without any parser node, so that internal/obligation can judge declarations against it +// without any parser node, so that x/obligation can judge declarations against it // without knowing which database the statement was written for. // // Everything here is "provable" in the sense of the One proof (analyze/card.go): an @@ -21,6 +21,9 @@ const ( // of the branch's own kind, so an obligation `on update` sees the UPDATE branch and // an INSERT-only MERGE owes nothing on update. Merge + // Call is a procedure call: no scope of its own (the body's reads and writes are the + // procedure's), and at most one row. + Call ) // Facts is the record of one statement. @@ -29,6 +32,10 @@ type Facts struct { // Top is the outermost scope: the FROM of a SELECT, or the target plus FROM / USING of // a write. Subqueries, CTEs and expanded view bodies hang under it as Children. Top *Scope + // Source is the query an INSERT ... SELECT reads, when the statement has one (also one + // of Top's Children): its row count is the statement's. Nil otherwise; an INSERT of a + // VALUES list says its row count through Top's Single / Many. + Source *Scope // Writes are the tables the statement stores into, with the columns it assigns. Writes []Write // AtMostOne: the statement provably touches at most one row (the One proof). @@ -78,6 +85,20 @@ type Scope struct { // At is the 0-based offset of this level's WHERE (or ON) clause, -1 without one: // where a missing predicate is reported. At int32 + // The level's row count beyond what its leaves say, read by the cardinality proof + // before the key argument. Single: at most one row whatever the leaves hold (a + // constant LIMIT 0 / 1, a single VALUES row, an aggregate without GROUP BY). Many: why + // the level may yield many rows whatever its predicates fix (a set operation, a VALUES + // list of several rows, a FULL JOIN); empty when nothing structural says so. Single is + // read first: LIMIT 1 over a UNION is one row. + Single bool + Many string + // Groups are the GROUP BY expressions when the level has a GROUP BY: it yields one row + // per group, so it is one row when every expression is pinned -- a Column term whose + // column is known, or a Param / Const / Known / Outer term; an Expr term never is. + // GroupingSets marks GROUPING SETS / ROLLUP / CUBE (one row per set). + Groups []Term + GroupingSets bool } // Leaf is one relation occurrence in a scope. @@ -93,10 +114,63 @@ type Leaf struct { // Waived are the obligation names the statement (or, inside a view body, the view's // own directives) opts out of for this table. Reported, never silently dropped. Waived []string - // View is the expanded body of a view leaf, when the producer expanded it; the body's - // own leaves live in that scope, and its predicates are inherited into the outer - // scope's Preds with Origin == FromView. - View *Scope + // Body is the defining query of a view, subquery or CTE leaf, when the producer + // analyzed it (a subquery's or CTE's body is also one of the enclosing scope's + // Children; a view's is reached only from here). The body's own leaves live in that + // scope; a view's predicates are inherited into the outer scope's Preds with Origin == + // FromView. Set for a table leaf too when the write goes through an automatically + // updatable view: the rows are the view's. + Body *Scope + // Outputs map the leaf's output columns, by name, to the Body column each projects + // plainly (`SELECT o.id ... ` makes the output id the body's o.id); an output that is + // an expression is absent. The cardinality proof seeds the body with the outputs the + // enclosing level fixes. + Outputs []Output + // CheckOption is a view leaf's WITH [LOCAL | CASCADED] CHECK OPTION, as the producer + // read it from the view's definition; NoCheckOption for a table, a subquery, a CTE or + // a view without one. LiftThroughView reads it at every level of a view chain. + CheckOption CheckOption + // Single: the leaf is at most one row on its own (a scalar function in FROM). + Single bool + // Keys are the enforced unique keys of a table leaf (the primary key, the UNIQUE + // constraints and unique indexes over whole columns), as the producer knows them from + // the schema: what the cardinality proof fixes rows by. A key not enforced at every + // statement (DEFERRABLE) is left out. Nil for a leaf that has none the producer can + // vouch for. + Keys []Key +} + +// CheckOption is a view's WITH CHECK OPTION kind. +type CheckOption byte + +const ( + NoCheckOption CheckOption = iota + // LocalCheckOption: the view's own WHERE is enforced on rows written through it; an + // underlying view's only when that view carries a check option of its own. + LocalCheckOption + // CascadedCheckOption: every view down the chain has its WHERE enforced (MySQL's + // plain WITH CHECK OPTION, and WITH CASCADED CHECK OPTION on both dialects). + CascadedCheckOption +) + +// Key is one unique key of a table leaf. +type Key struct { + Columns []string + // Where are the conjuncts of a partial unique index's predicate, in the same form as + // a scope's Preds and about this leaf (ColRef.Leaf is the leaf's own index in its + // scope): the key holds only where the statement repeats every one of them. Nil for + // a whole-table key. + Where []Pred + // Temporal: the last column is a range and the key is WITHOUT OVERLAPS -- no two rows + // with the other columns equal have overlapping ranges, so a row whose range Contains + // a known point is the only one for those values. + Temporal bool +} + +// Output is one output column of a derived leaf that is a plain column of its body. +type Output struct { + Name string + Col ColRef // a column of Body's leaves } // RelKind is what the leaf is. @@ -106,9 +180,13 @@ const ( Table RelKind = iota + 1 View MatView - // Derived is a subquery, CTE, VALUES or function in FROM: it has no obligations of - // its own, but its scope may. + // Derived is a subquery or VALUES in FROM: it has no obligations of its own, but its + // scope may. Derived + // CTE is a reference to a WITH item (the same kind of leaf as Derived, named). + CTE + // Function is a function call in FROM (a set-returning function, XMLTABLE, ...). + Function ) // Role is how the statement touches the leaf. @@ -191,6 +269,11 @@ const ( // In: Col holds one of Terms (a value list, or a disjunction of equalities on the // column). Weaker than Eq; enough to know the column's value is among a set. In + // Contains: Col, a range or multirange column, contains the point Term (`col @> $1`, + // `$1 <@ col`). The producer records it only when the point is visibly not a range + // itself (an empty range is contained by every range). With a Temporal key it fixes + // the row the way an equality does. + Contains Opaque ) @@ -199,7 +282,15 @@ type Term struct { Kind TermKind // Param is the 1-based $n for a Param term. Param int32 - // Const is the literal's text for a Const term. + // Const is the literal's text for a Const term, spelled as one type-tag byte followed + // by the bare value, with no surrounding quotes: 'i' + an integer ("i5"), 'f' + a float + // ("f1.5"), 's' + a string's content ("sdraft"), 'b' + "true"/"false", 'x' + anything + // else a producer cannot classify, or the literal text "NULL" for a null literal (not a + // tagged form -- there is no "value" to tag). Every producer of this package's facts + // (PostgreSQL: check/postgres/analyze/card.go's constText; MySQL: the corresponding + // termFacts/literalClass path) must spell a Const this way, since x/obligation's + // stateOf reads the tag off Const's first byte to recover a bare state name for the + // `transitions` obligation. Const string // Col is the other column for a Column term. Col ColRef @@ -221,6 +312,9 @@ const ( // correlated subquery's predicate. Known to the inner row, and the link by which the // inner scope witnesses something about the outer row. Outer + // Expr: an expression over the row the producer does not decompose (Text is its + // rendering); never a known value. Only in Scope.Groups. + Expr ) // Origin says where a Pred came from; the checker reports the discharge path with it. diff --git a/x/facts/lift.go b/x/facts/lift.go new file mode 100644 index 0000000..ac11592 --- /dev/null +++ b/x/facts/lift.go @@ -0,0 +1,93 @@ +package facts + +// LiftThroughView translates the WHERE conjuncts of a view a leaf reads through (l.Body, +// the view's own defining query) onto the leaf's own columns at index at in the enclosing +// scope, via l.Outputs (the leaf's output columns that are plain columns of the body). A +// conjunct about a column the view computes (absent from Outputs) is left out: WITH CHECK +// OPTION cannot be satisfied through a computed column either, since it cannot be +// assigned. The lifted predicates carry Origin FromView. +// +// Which levels of a view chain (a view defined over another view, possibly joined to +// other relations) are lifted follows both servers' own rule for WITH CHECK OPTION, +// measured on each: the written-through view's own WHERE always; an underlying view's +// WHERE when the view above it is CASCADED (l.CheckOption, or an intermediate view's own +// CascadedCheckOption once reached), and otherwise only when that underlying view carries +// a check option of its own -- LOCAL stops the cascade but does not switch off what a +// nested view enforces by itself. Every view leaf of a body is followed, not only a sole +// one: a CASCADED view over `v1 JOIN meta` still enforces v1's WHERE (measured, MySQL 8.4). +func LiftThroughView(l Leaf, at int) []Pred { + translate := func(col ColRef) (ColRef, bool) { + for _, o := range l.Outputs { + if o.Col == col { + return ColRef{Leaf: at, Column: o.Name}, true + } + } + return ColRef{}, false + } + return liftPreds(l.Body, translate, at, l.CheckOption == CascadedCheckOption) +} + +// liftPreds walks one level of a view's own body, translating its FromStatement +// conjuncts through translate (a view leaf's own numbering onto the enclosing statement's +// leaf at) and recursing into each nested view's body that the rule above reaches, by +// composing translate with the intermediate leaf's own Outputs. cascaded: a view above +// this body's leaves is CASCADED, so every nested view is followed. +func liftPreds(body *Scope, translate func(ColRef) (ColRef, bool), at int, cascaded bool) []Pred { + if body == nil { + return nil + } + var out []Pred + for _, p := range body.Preds { + if p.Origin != FromStatement { + continue + } + col, ok := translate(p.Col) + if !ok { + continue + } + np := p + np.Col = col + if p.Term.Kind == Column { + tc, ok2 := translate(p.Term.Col) + if !ok2 { + continue + } + np.Term.Col = tc + } + if len(p.Cols) > 0 { + cols := make([]ColRef, 0, len(p.Cols)) + bad := false + for _, c := range p.Cols { + tc, ok3 := translate(c) + if !ok3 { + bad = true + break + } + cols = append(cols, tc) + } + if bad { + continue + } + np.Cols = cols + } + np.Restricts = []int{at} + np.Origin = FromView + out = append(out, np) + } + for j := range body.Leaves { + nested := body.Leaves[j] + if nested.Body == nil || !(cascaded || nested.CheckOption != NoCheckOption) { + continue + } + nestedTranslate := func(r ColRef) (ColRef, bool) { + for _, o := range nested.Outputs { + if o.Col == r { + return translate(ColRef{Leaf: j, Column: o.Name}) + } + } + return ColRef{}, false + } + out = append(out, liftPreds(nested.Body, nestedTranslate, at, cascaded || nested.CheckOption == CascadedCheckOption)...) + } + return out +} diff --git a/x/facts/lift_test.go b/x/facts/lift_test.go new file mode 100644 index 0000000..160ec32 --- /dev/null +++ b/x/facts/lift_test.go @@ -0,0 +1,141 @@ +package facts + +import "testing" + +// TestLiftThroughView_DirectColumn: a view's own WHERE conjunct (Eq on a plain column it +// projects) is translated onto the writing statement's leaf, renamed through Outputs, and +// marked Origin FromView, restricted to that leaf. +func TestLiftThroughView_DirectColumn(t *testing.T) { + l := Leaf{ + Body: &Scope{ + Preds: []Pred{ + {Op: Eq, Col: ColRef{Leaf: 0, Column: "tenant_id"}, Term: Term{Kind: Const, Const: "1"}, Origin: FromStatement}, + }, + Leaves: []Leaf{{Kind: Table}}, + }, + Outputs: []Output{{Name: "tid", Col: ColRef{Leaf: 0, Column: "tenant_id"}}}, + } + got := LiftThroughView(l, 3) + if len(got) != 1 { + t.Fatalf("len = %d, want 1: %+v", len(got), got) + } + p := got[0] + if p.Col != (ColRef{Leaf: 3, Column: "tid"}) || p.Origin != FromView || len(p.Restricts) != 1 || p.Restricts[0] != 3 { + t.Errorf("got %+v", p) + } +} + +// TestLiftThroughView_UnprojectedColumnSkipped: a conjunct about a column the view does +// not project has no Output to translate through and is left out rather than guessed at. +func TestLiftThroughView_UnprojectedColumnSkipped(t *testing.T) { + l := Leaf{ + Body: &Scope{ + Preds: []Pred{ + {Op: Eq, Col: ColRef{Leaf: 0, Column: "tenant_id"}, Term: Term{Kind: Const, Const: "1"}, Origin: FromStatement}, + }, + Leaves: []Leaf{{Kind: Table}}, + }, + Outputs: []Output{{Name: "v", Col: ColRef{Leaf: 0, Column: "v"}}}, // tenant_id not projected + } + if got := LiftThroughView(l, 0); len(got) != 0 { + t.Errorf("got %+v, want none: tenant_id is not a projected column", got) + } +} + +// TestLiftThroughView_NonStatementOriginSkipped: only the view's own WHERE (Origin +// FromStatement in its body) is lifted -- a predicate already carried in from further down +// (FromView, FromPolicy) is not re-lifted here (liftPreds' recursion walks the nested +// scope directly instead). +func TestLiftThroughView_NonStatementOriginSkipped(t *testing.T) { + l := Leaf{ + Body: &Scope{ + Preds: []Pred{ + {Op: Eq, Col: ColRef{Leaf: 0, Column: "tenant_id"}, Term: Term{Kind: Const, Const: "1"}, Origin: FromPolicy}, + }, + Leaves: []Leaf{{Kind: Table}}, + }, + Outputs: []Output{{Name: "tenant_id", Col: ColRef{Leaf: 0, Column: "tenant_id"}}}, + } + if got := LiftThroughView(l, 0); len(got) != 0 { + t.Errorf("got %+v, want none", got) + } +} + +// TestLiftThroughView_Cascaded: a view defined over another view lifts the nested view's +// own WHERE too when cascaded, composing the two levels' Outputs; false (LOCAL) stops +// after the outer view. +func TestLiftThroughView_Cascaded(t *testing.T) { + // v2 (outer, the one written through) is "SELECT id, tenant_id FROM v1 WHERE v > 0"; + // v1 (nested) is "SELECT id, tenant_id, v FROM t WHERE tenant_id = 1". The nested + // leaf's own Outputs map v2's own names for v1's columns (id, tenant_id) onto v1's + // body (leaf 0 of v1's own scope, over t). + nestedBody := &Scope{ + Preds: []Pred{{Op: Eq, Col: ColRef{Leaf: 0, Column: "tenant_id"}, Term: Term{Kind: Const, Const: "1"}, Origin: FromStatement}}, + Leaves: []Leaf{{Kind: Table}}, // t + } + outerBody := &Scope{ + Preds: []Pred{{Op: Eq, Col: ColRef{Leaf: 0, Column: "v"}, Term: Term{Kind: Const, Const: "0"}, Origin: FromStatement}}, + Leaves: []Leaf{{ + Kind: View, + Body: nestedBody, + Outputs: []Output{ + {Name: "id", Col: ColRef{Leaf: 0, Column: "id"}}, + {Name: "tenant_id", Col: ColRef{Leaf: 0, Column: "tenant_id"}}, + {Name: "v", Col: ColRef{Leaf: 0, Column: "v"}}, + }, + }}, + } + l := Leaf{ + Body: outerBody, + Outputs: []Output{{Name: "tid", Col: ColRef{Leaf: 0, Column: "tenant_id"}}}, // v2 exposes v1's tenant_id as tid + } + + l.CheckOption = CascadedCheckOption + cascaded := LiftThroughView(l, 5) + found := false + for _, p := range cascaded { + if p.Col == (ColRef{Leaf: 5, Column: "tid"}) && p.Term.Const == "1" { + found = true + } + } + if !found { + t.Errorf("cascaded lift did not reach v1's WHERE tenant_id = 1 through v2's own tid: %+v", cascaded) + } + + l.CheckOption = LocalCheckOption + local := LiftThroughView(l, 5) + for _, p := range local { + if p.Term.Const == "1" { + t.Errorf("LOCAL must not reach a nested view's WHERE when that view has no check option of its own: %+v", local) + } + } + + // LOCAL over a nested view that is itself CASCADED: the nested view enforces its own + // WHERE regardless of what the view above asks for (both servers' rule, measured) + outerBody.Leaves[0].CheckOption = CascadedCheckOption + found = false + for _, p := range LiftThroughView(l, 5) { + if p.Col == (ColRef{Leaf: 5, Column: "tid"}) && p.Term.Const == "1" { + found = true + } + } + if !found { + t.Errorf("LOCAL over a CASCADED nested view must still lift the nested view's own WHERE") + } + outerBody.Leaves[0].CheckOption = NoCheckOption + + // a CASCADED view whose body joins the nested view to another relation still + // enforces the nested view's WHERE (the nested leaf is not the body's only one) + outerBody.Leaves = append([]Leaf{{Kind: Table}}, outerBody.Leaves...) // meta JOIN v1 + l.Outputs = []Output{{Name: "tid", Col: ColRef{Leaf: 1, Column: "tenant_id"}}} + l.CheckOption = CascadedCheckOption + found = false + for _, p := range LiftThroughView(l, 5) { + if p.Col == (ColRef{Leaf: 5, Column: "tid"}) && p.Term.Const == "1" { + found = true + } + } + if !found { + t.Errorf("CASCADED lift must follow a nested view that is joined to another leaf") + } +} diff --git a/internal/facts/print.go b/x/facts/print.go similarity index 91% rename from internal/facts/print.go rename to x/facts/print.go index 73c78c1..e2c8a04 100644 --- a/internal/facts/print.go +++ b/x/facts/print.go @@ -54,8 +54,8 @@ func (s *Scope) write(b *strings.Builder, ind string) { fmt.Fprintf(b, " waived %s", strings.Join(l.Waived, ",")) } fmt.Fprintf(b, " @%d\n", l.Position) - if l.View != nil { - l.View.write(b, ind+" ") + if l.Body != nil && (l.Kind == View || l.Kind == MatView) { + l.Body.write(b, ind+" ") // a derived leaf's body is written among the Children } } for _, p := range s.Preds { @@ -112,6 +112,8 @@ func (p Pred) String() string { parts[i] = t.String() } return p.Col.String() + " IN (" + strings.Join(parts, ", ") + ")" + case Contains: + return p.Col.String() + " @> " + p.Term.String() } return fmt.Sprintf("opaque %q cols %s", p.Text, refs(p.Cols)) } @@ -126,6 +128,8 @@ func (t Term) String() string { return t.Col.String() case Outer: return "outer " + t.Col.String() + case Expr: + return fmt.Sprintf("expr %q", t.Text) } return fmt.Sprintf("known %q", t.Text) } @@ -142,6 +146,8 @@ func (k StmtKind) String() string { return "delete" case Merge: return "merge" + case Call: + return "call" } return "none" } @@ -154,6 +160,10 @@ func (k RelKind) String() string { return "view" case MatView: return "matview" + case CTE: + return "cte" + case Function: + return "function" } return "derived" } diff --git a/internal/obligation/check.go b/x/obligation/check.go similarity index 83% rename from internal/obligation/check.go rename to x/obligation/check.go index c47ab99..1045b56 100644 --- a/internal/obligation/check.go +++ b/x/obligation/check.go @@ -4,14 +4,30 @@ import ( "fmt" "strings" - "github.com/kr9ly/sqlshape/internal/facts" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/cardinality" + "github.com/kr9ly/sqlshape/v2/x/facts" ) +// singleWrite: the write touches at most one row of its own target. An UPDATE / DELETE is +// judged by its target leaf alone (cardinality.TargetSingle): a table joined into the same +// level only to filter may see many rows there without the write itself moving past one +// row -- measured on MySQL 8.4, `DELETE o FROM orders o JOIN items i ON i.order_id = o.id +// WHERE o.id = 1` deletes exactly one orders row regardless of how many items rows the JOIN +// matches, since items is never a delete target. An INSERT keeps the whole-statement proof +// over its Source (cardinality.AtMostOne), unchanged. +func (c *checker) singleWrite(w facts.Write, sc *facts.Scope, li int) bool { + if (w.Kind == facts.Update || w.Kind == facts.Delete) && sc != nil { + ok, _ := cardinality.TargetSingle(sc, li) + return ok + } + ok, _ := cardinality.AtMostOne(c.f) + return ok +} + // Check judges every obligation against every leaf of f, at every depth, and returns // all applicable judgments in leaf order. Leaves inside a view's body are the view's // business (its definition was judged when the schema loaded) and are not visited. -func Check(s *schema.Schema, decls []Obligation, f *facts.Facts, l Lowerer) []Discharge { +func Check(s Schema, decls []Obligation, f *facts.Facts, l Lowerer) []Discharge { if f == nil || f.Top == nil { return nil } @@ -56,12 +72,12 @@ func (c *checker) writes(bySubject map[string][]*Obligation) { default: continue } - rel := relByFullName(c.s, w.Table) + rel := c.s.Relation(w.Table) if rel == nil { continue } sc, li := c.targetLeaf(c.f.Top, w.Table) - leaf := facts.Leaf{Table: w.Table, Alias: rel.Name, Kind: facts.Table, Role: facts.Target, Position: w.Position} + leaf := facts.Leaf{Table: w.Table, Alias: rel.Name(), Kind: facts.Table, Role: facts.Target, Position: w.Position} if sc != nil { leaf = sc.Leaves[li] } @@ -73,31 +89,31 @@ func (c *checker) writes(bySubject map[string][]*Obligation) { d := Discharge{Obligation: o, Leaf: leaf, Position: w.Position} if waived(leaf, o) { d.Path = Waived - d.Message = fmt.Sprintf("%s: `%s` is waived by this statement", rel.Name, o.Source) + d.Message = fmt.Sprintf("%s: `%s` is waived by this statement", rel.Name(), o.Source) c.out = append(c.out, d) continue } switch { case o.Body.Never: - d.Message = fmt.Sprintf("%s is declared `%s`: no statement may do this to it", rel.Name, o.Source) + d.Message = fmt.Sprintf("%s is declared `%s`: no statement may do this to it", rel.Name(), o.Source) case o.Body.Paired != "": - paired := relByFullName(c.s, o.Body.Paired) + paired := c.s.Relation(o.Body.Paired) for _, other := range c.f.Writes { if paired != nil && other.Table == paired.FullName() { d.Path = ByStatement } } if d.Path == 0 { - d.Message = fmt.Sprintf("a write to %s must also write %s in the same statement (a data-modifying WITH): %s", rel.Name, o.Body.Paired, o.Source) + d.Message = fmt.Sprintf("a write to %s must also write %s in the same statement (a data-modifying WITH): %s", rel.Name(), o.Body.Paired, o.Source) } case o.Body.Single: switch { case !main: - d.Message = fmt.Sprintf("%s requires a single-row %s, which cannot be proved for a write inside WITH", rel.Name, strings.ToUpper(w.Kind.String())) - case c.f.AtMostOne: + d.Message = fmt.Sprintf("%s requires a single-row %s, which cannot be proved for a write inside WITH", rel.Name(), strings.ToUpper(w.Kind.String())) + case c.singleWrite(w, sc, li): d.Path = ByStatement default: - d.Message = fmt.Sprintf("%s requires a single-row %s: fix a unique key by equality (the One proof)", rel.Name, strings.ToUpper(w.Kind.String())) + d.Message = fmt.Sprintf("%s requires a single-row %s: fix a unique key by equality (the One proof)", rel.Name(), strings.ToUpper(w.Kind.String())) } case o.Body.Transitions != nil: c.transition(sc, li, &w, o, rel, &d) @@ -108,7 +124,7 @@ func (c *checker) writes(bySubject map[string][]*Obligation) { if sc != nil && containsRef(sc.Fixed, facts.ColRef{Leaf: li, Column: o.Body.Pinned}) { d.Path = ByStatement } else { - d.Message = fmt.Sprintf("%s.%s is pinned: an UPDATE assigning it must also fix %s by equality in WHERE, or the row moves to another %s", rel.Name, o.Body.Pinned, o.Body.Pinned, o.Body.Pinned) + d.Message = fmt.Sprintf("%s.%s is pinned: an UPDATE assigning it must also fix %s by equality in WHERE, or the row moves to another %s", rel.Name(), o.Body.Pinned, o.Body.Pinned, o.Body.Pinned) } default: continue @@ -151,7 +167,7 @@ func (c *checker) targetLeaf(sc *facts.Scope, table string) (*facts.Scope, int) } type checker struct { - s *schema.Schema + s Schema f *facts.Facts lower Lowerer lowered map[string][]facts.Pred @@ -185,26 +201,16 @@ func (c *checker) sensitive(decls []*Obligation) { } table, col := u.Table, u.Column for depth := 0; depth < 16; depth++ { // follow a view column to the base column it passes through - rel := relByFullName(c.s, table) - if rel == nil || (rel.Kind != schema.View && rel.Kind != schema.MatView) { + rel := c.s.Relation(table) + if rel == nil || (rel.Kind() != facts.View && rel.Kind() != facts.MatView) { break } - var src *schema.ViewColumn - for i := range rel.Frozen { - if rel.Frozen[i].Name == col { - src = &rel.Frozen[i] - } - } - if src == nil || src.SrcTable == "" && src.SrcRel == nil { + srcTable, srcCol, ok := rel.ViewSource(col) + if !ok { table = "" // an expression column: no base column, no label break } - if src.SrcRel != nil { - table = src.SrcRel.FullName() - } else { - table = src.SrcTable - } - col = src.SrcColumn + table, col = srcTable, srcCol } if table == "" { continue @@ -297,10 +303,10 @@ func (c *checker) kindAt(leaf facts.Leaf) Kinds { func (c *checker) judge(sc *facts.Scope, i int, o *Obligation) { leaf := sc.Leaves[i] d := Discharge{Obligation: o, Leaf: leaf, Position: leaf.Position} - rel := relByFullName(c.s, leaf.Table) + rel := c.s.Relation(leaf.Table) if waived(leaf, o) { d.Path = Waived - d.Message = fmt.Sprintf("%s: `%s` is waived by this statement", rel.Name, o.Source) + d.Message = fmt.Sprintf("%s: `%s` is waived by this statement", rel.Name(), o.Source) c.out = append(c.out, d) return } @@ -351,7 +357,7 @@ func (c *checker) judge(sc *facts.Scope, i int, o *Obligation) { // predicate: every conjunct of the declared expression must be implied by the level's // predicates about this leaf. -func (c *checker) predicate(sc *facts.Scope, i int, o *Obligation, rel *schema.Relation, d *Discharge) { +func (c *checker) predicate(sc *facts.Scope, i int, o *Obligation, rel Relation, d *Discharge) { leaf := sc.Leaves[i] want, err := c.lowerPredicate(o, rel) if err != nil { @@ -367,24 +373,24 @@ func (c *checker) predicate(sc *facts.Scope, i int, o *Obligation, rel *schema.R } if !found { alias := leaf.Alias - if alias != rel.Name && alias != "" { - alias = rel.Name + " " + alias + if alias != rel.Name() && alias != "" { + alias = rel.Name() + " " + alias } if o.Kinds == OnRead { - d.Message = fmt.Sprintf("rows of %s are visible where %s: add that predicate for %s, or opt out with `-- sqlshape: unfiltered %s`", rel.Name, o.Body.Predicate, alias, rel.Name) + d.Message = fmt.Sprintf("rows of %s are visible where %s: add that predicate for %s, or opt out with `-- sqlshape: unfiltered %s`", rel.Name(), o.Body.Predicate, alias, rel.Name()) } else { - d.Message = fmt.Sprintf("%s requires %s here: add that predicate for %s, or opt out with `-- sqlshape: unfiltered %s`", rel.Name, o.Body.Predicate, alias, rel.Name) + d.Message = fmt.Sprintf("%s requires %s here: add that predicate for %s, or opt out with `-- sqlshape: unfiltered %s`", rel.Name(), o.Body.Predicate, alias, rel.Name()) } return } } d.Path = path - if path == ByPolicy && !rel.ForceRowSecurity { - d.Message = fmt.Sprintf("%s satisfies `%s` by row-security policy for roles subject to row security, not for the table's owner: FORCE ROW LEVEL SECURITY if the application connects as the owner", rel.Name, o.Body.Predicate) + if path == ByPolicy && !rel.ForceRowSecurity() { + d.Message = fmt.Sprintf("%s satisfies `%s` by row-security policy for roles subject to row security, not for the table's owner: FORCE ROW LEVEL SECURITY if the application connects as the owner", rel.Name(), o.Body.Predicate) } } -func (c *checker) lowerPredicate(o *Obligation, rel *schema.Relation) ([]facts.Pred, error) { +func (c *checker) lowerPredicate(o *Obligation, rel Relation) ([]facts.Pred, error) { key := o.Subject + "\x00" + o.Body.Predicate if p, ok := c.lowered[key]; ok { return p, c.lowerErr[key] @@ -398,7 +404,7 @@ func (c *checker) lowerPredicate(o *Obligation, rel *schema.Relation) ([]facts.P } // pinned: the column is equal to a known value (or, on a write target, assigned). -func (c *checker) pinned(sc *facts.Scope, i int, o *Obligation, rel *schema.Relation, d *Discharge) { +func (c *checker) pinned(sc *facts.Scope, i int, o *Obligation, rel Relation, d *Discharge) { leaf := sc.Leaves[i] col := o.Body.Pinned ref := facts.ColRef{Leaf: i, Column: col} @@ -409,7 +415,7 @@ func (c *checker) pinned(sc *facts.Scope, i int, o *Obligation, rel *schema.Rela d.Path = ByStatement return } - d.Message = fmt.Sprintf("%s.%s is not pinned: every statement on %s must fix %s by equality (or assign it)", rel.Name, col, rel.Name, col) + d.Message = fmt.Sprintf("%s.%s is not pinned: every statement on %s must fix %s by equality (or assign it)", rel.Name(), col, rel.Name(), col) return } if c.f.Kind == facts.Merge && leaf.Role == facts.Target && c.mergeInsertOnly(leaf.Table) && c.assignedBy(leaf.Table, col, facts.Insert) { @@ -431,15 +437,25 @@ func (c *checker) pinned(sc *facts.Scope, i int, o *Obligation, rel *schema.Rela for _, p := range sc.Preds { if p.Origin == facts.FromPolicy && applies(p, i) && p.Op == facts.Eq && p.Col == ref && p.Term.Kind != facts.Column { d.Path = ByPolicy - if !rel.ForceRowSecurity { - d.Message = fmt.Sprintf("%s.%s is pinned by policy %s for roles subject to row security, not for the table's owner: FORCE ROW LEVEL SECURITY if the application connects as the owner", rel.Name, col, p.Name) + if !rel.ForceRowSecurity() { + d.Message = fmt.Sprintf("%s.%s is pinned by policy %s for roles subject to row security, not for the table's owner: FORCE ROW LEVEL SECURITY if the application connects as the owner", rel.Name(), col, p.Name) } return } } - d.Message = fmt.Sprintf("%s.%s is not pinned: every statement on %s must fix %s by equality (or assign it)%s", rel.Name, col, rel.Name, col, occurrence(sc, i)) + for _, p := range sc.Preds { + // a write through a WITH CHECK OPTION view is pinned by the view's own WHERE + // (LiftThroughView already stopped short of a nested view when the option is + // LOCAL): the server refuses any row the view's WHERE would not accept, the same + // guarantee a policy's USING gives a row-security table. + if p.Origin == facts.FromView && applies(p, i) && p.Op == facts.Eq && p.Col == ref && p.Term.Kind != facts.Column { + d.Path = ByView + return + } + } + d.Message = fmt.Sprintf("%s.%s is not pinned: every statement on %s must fix %s by equality (or assign it)%s", rel.Name(), col, rel.Name(), col, occurrence(sc, i)) if root, ok := strings.CutPrefix(o.Source, "aggregate "); ok { - d.Message = fmt.Sprintf("%s is a child of aggregate %s: reach it through %s (fix %s.%s by equality, or join on %s's key)", rel.Name, root, root, rel.Name, col, root) + d.Message = fmt.Sprintf("%s is a child of aggregate %s: reach it through %s (fix %s.%s by equality, or join on %s's key)", rel.Name(), root, root, rel.Name(), col, root) } } @@ -462,9 +478,9 @@ func occurrence(sc *facts.Scope, i int) string { // viaForeignKey: a composite foreign key from this table whose other columns are joined // by equality to the referenced table's columns carries the referenced column's pin // over (the referenced key is unique, so the rows agree on every column of it). -func (c *checker) viaForeignKey(sc *facts.Scope, i int, rel *schema.Relation, col string) bool { - for _, con := range rel.Constraints { - if con.Kind != schema.ForeignKey || len(con.Columns) < 2 { +func (c *checker) viaForeignKey(sc *facts.Scope, i int, rel Relation, col string) bool { + for _, con := range rel.ForeignKeys() { + if len(con.Columns) < 2 { continue } at := -1 @@ -476,7 +492,7 @@ func (c *checker) viaForeignKey(sc *facts.Scope, i int, rel *schema.Relation, co if at < 0 { continue } - refRel := relByFullName(c.s, con.RefTable) + refRel := c.s.Relation(con.RefTable) if refRel == nil { continue } @@ -495,7 +511,13 @@ func (c *checker) viaForeignKey(sc *facts.Scope, i int, rel *schema.Relation, co } } if joined && containsRef(sc.Fixed, facts.ColRef{Leaf: j, Column: con.RefColumns[at]}) { - return true + // the propagated column itself must be non-NULL for the row: a NULL in any + // column of a foreign key exempts the row from the constraint (MATCH + // SIMPLE, measured on MySQL 8.4: items(order_id = 1, tenant_id = NULL) is + // accepted under a composite key onto orders(id, tenant_id)), so such a + // row joins the parent on the other columns while agreeing on nothing + // here. The joined columns are non-NULL already, equal to a fixed value. + return rel.NotNull(col) || containsRef(sc.NotNull, facts.ColRef{Leaf: i, Column: col}) } } } @@ -505,11 +527,8 @@ func (c *checker) viaForeignKey(sc *facts.Scope, i int, rel *schema.Relation, co // viaParent: an aggregate member's foreign-key column is joined by equality to the // referenced column of a leaf of its parent table. The parent leaf owes the aggregate's // obligations itself, so the chain up to the root is judged link by link. -func (c *checker) viaParent(sc *facts.Scope, i int, rel *schema.Relation, col string) bool { - for _, con := range rel.Constraints { - if con.Kind != schema.ForeignKey { - continue - } +func (c *checker) viaParent(sc *facts.Scope, i int, rel Relation, col string) bool { + for _, con := range rel.ForeignKeys() { at := -1 for k, cc := range con.Columns { if cc == col { @@ -519,7 +538,7 @@ func (c *checker) viaParent(sc *facts.Scope, i int, rel *schema.Relation, col st if at < 0 { continue } - parent := relByFullName(c.s, con.RefTable) + parent := c.s.Relation(con.RefTable) if parent == nil { continue } @@ -549,7 +568,7 @@ func equalIn(sc *facts.Scope, i int, a, b facts.ColRef) bool { // its WHERE, to one of that state's predecessors (compare-and-set); a non-constant target // is refused, since no predecessor set can be checked for it. Not assigning the column // is not a transition. -func (c *checker) transition(sc *facts.Scope, i int, w *facts.Write, o *Obligation, rel *schema.Relation, d *Discharge) { +func (c *checker) transition(sc *facts.Scope, i int, w *facts.Write, o *Obligation, rel Relation, d *Discharge) { tr := o.Body.Transitions var value *facts.Term for k, col := range w.Assigned { @@ -563,17 +582,21 @@ func (c *checker) transition(sc *facts.Scope, i int, w *facts.Write, o *Obligati return } if value.Kind != facts.Const { - d.Message = fmt.Sprintf("%s.%s is a state machine: SET it to a declared state (a literal), not to %s", rel.Name, tr.Column, value) + if value.Kind == facts.Known && value.Text == "DEFAULT" { + d.Message = fmt.Sprintf("%s.%s is a state machine: SET it to a declared state (a literal); DEFAULT is not one here, the column's default is not a literal", rel.Name(), tr.Column) + return + } + d.Message = fmt.Sprintf("%s.%s is a state machine: SET it to a declared state (a literal), not to %s", rel.Name(), tr.Column, value) return } to := stateOf(value.Const) froms, ok := tr.From[to] if !ok { - d.Message = fmt.Sprintf("%s.%s is a state machine: %q is not a state anything transitions to (declared: %s)", rel.Name, tr.Column, to, strings.Join(tr.Order, ", ")) + d.Message = fmt.Sprintf("%s.%s is a state machine: %q is not a state anything transitions to (declared: %s)", rel.Name(), tr.Column, to, strings.Join(tr.Order, ", ")) return } if sc == nil { - d.Message = fmt.Sprintf("%s.%s: the current state of an UPDATE inside WITH cannot be checked", rel.Name, tr.Column) + d.Message = fmt.Sprintf("%s.%s: the current state of an UPDATE inside WITH cannot be checked", rel.Name(), tr.Column) return } col := facts.ColRef{Leaf: i, Column: tr.Column} @@ -604,14 +627,14 @@ func (c *checker) transition(sc *facts.Scope, i int, w *facts.Write, o *Obligati declared = declared || f == from } if !declared { - d.Message = fmt.Sprintf("%s.%s: %s -> %s is not a declared transition (%s comes from %s)", rel.Name, tr.Column, from, to, to, strings.Join(froms, " | ")) + d.Message = fmt.Sprintf("%s.%s: %s -> %s is not a declared transition (%s comes from %s)", rel.Name(), tr.Column, from, to, to, strings.Join(froms, " | ")) return } } d.Path = ByStatement return } - d.Message = fmt.Sprintf("%s.%s: SET %s = '%s' must fix the current state in WHERE (%s = '%s')", rel.Name, tr.Column, tr.Column, to, tr.Column, strings.Join(froms, "' or '")) + d.Message = fmt.Sprintf("%s.%s: SET %s = '%s' must fix the current state in WHERE (%s = '%s')", rel.Name(), tr.Column, tr.Column, to, tr.Column, strings.Join(froms, "' or '")) } // stateOf reads a state name out of a constant's text: the analyzer spells a constant as @@ -624,9 +647,9 @@ func stateOf(c string) string { } // immutable: an UPDATE (or MERGE) may not assign the column. -func (c *checker) immutable(o *Obligation, rel *schema.Relation, d *Discharge) { +func (c *checker) immutable(o *Obligation, rel Relation, d *Discharge) { if c.assigned(rel.FullName(), o.Body.Immutable) { - d.Message = fmt.Sprintf("%s.%s is immutable: the statement must not assign it", rel.Name, o.Body.Immutable) + d.Message = fmt.Sprintf("%s.%s is immutable: the statement must not assign it", rel.Name(), o.Body.Immutable) return } d.Path = ByStatement @@ -857,6 +880,8 @@ func implies(p, q facts.Pred) bool { return q.Term.Kind == facts.Column && p.Term.Kind == facts.Column && p.Col == q.Term.Col && p.Term.Col == q.Col case facts.IsNull, facts.IsNotNull: return p.Col == q.Col + case facts.Contains: + return p.Col == q.Col && sameTerm(p.Term, q.Term) case facts.Opaque: if p.Text != q.Text || len(p.Cols) != len(q.Cols) { return false @@ -908,15 +933,3 @@ func containsRef(rs []facts.ColRef, r facts.ColRef) bool { } return false } - -// relByFullName resolves the schema-qualified-unless-public name the facts use. -func relByFullName(s *schema.Schema, name string) *schema.Relation { - sch, n, ok := strings.Cut(name, ".") - if !ok { - return s.Relation("", name) - } - if rel := s.Relation(sch, n); rel != nil { - return rel - } - return s.Relation("", name) -} diff --git a/x/obligation/checkoption_test.go b/x/obligation/checkoption_test.go new file mode 100644 index 0000000..4b494a6 --- /dev/null +++ b/x/obligation/checkoption_test.go @@ -0,0 +1,112 @@ +package obligation + +import ( + "testing" + + "github.com/kr9ly/sqlshape/v2/x/facts" +) + +// fakeRel is the minimal Relation a handwritten Facts needs: a name and nothing else +// pinned() reads (no foreign keys, no row security). +type fakeRel string + +func (r fakeRel) Name() string { return string(r) } +func (r fakeRel) FullName() string { return string(r) } +func (r fakeRel) Kind() facts.RelKind { return facts.Table } +func (r fakeRel) HasColumn(col string) bool { return true } +func (r fakeRel) NotNull(col string) bool { return true } +func (r fakeRel) Directives() []string { return nil } +func (r fakeRel) ForeignKeys() []ForeignKey { return nil } +func (r fakeRel) ViewSource(col string) (string, string, bool) { return "", "", false } +func (r fakeRel) ForceRowSecurity() bool { return false } + +type fakeSchema map[string]Relation + +func (s fakeSchema) Relations() []Relation { + var out []Relation + for _, r := range s { + out = append(out, r) + } + return out +} +func (s fakeSchema) Relation(name string) Relation { return s[name] } + +// TestPinnedByView: a hand-built Facts.Write against table "t", with the target leaf's +// scope carrying an Eq(tenant_id, const) predicate of Origin FromView (as +// facts.LiftThroughView would produce from a WITH CHECK OPTION view's WHERE) discharges +// `require pinned(tenant_id)` as ByView, the same way a row-security policy's Eq discharges +// it as ByPolicy. Without that predicate (or with it there but of a different Origin / +// referencing another column via a Column term, which pins nothing absolute) the +// obligation fails. +func TestPinnedByView(t *testing.T) { + s := fakeSchema{"t": fakeRel("t")} + decls, ok, err := Parse("t", "require pinned(tenant_id)") + if !ok || err != nil { + t.Fatalf("parse: ok=%v err=%v", ok, err) + } + + build := func(preds []facts.Pred) *facts.Facts { + return &facts.Facts{ + Kind: facts.Update, + Top: &facts.Scope{ + Leaves: []facts.Leaf{{Table: "t", Alias: "t", Kind: facts.Table, Role: facts.Target}}, + Preds: preds, + }, + Writes: []facts.Write{{Table: "t", Kind: facts.Update, Assigned: []string{"v"}}}, + } + } + + cases := []struct { + name string + preds []facts.Pred + want Path + }{ + { + name: "FromView Eq const pins", + preds: []facts.Pred{ + {Op: facts.Eq, Col: facts.ColRef{Leaf: 0, Column: "tenant_id"}, Term: facts.Term{Kind: facts.Const, Const: "1"}, Origin: facts.FromView}, + }, + want: ByView, + }, + { + name: "no predicate at all", + preds: nil, + want: 0, + }, + { + name: "FromStatement (not FromView) is judged by the statement's own WHERE closure, not this loop", + preds: []facts.Pred{ + {Op: facts.Eq, Col: facts.ColRef{Leaf: 0, Column: "tenant_id"}, Term: facts.Term{Kind: facts.Const, Const: "1"}, Origin: facts.FromStatement}, + }, + want: 0, // FromStatement equalities are read from sc.Fixed (via closeFixed), not sc.Preds; a + // handwritten Facts with no Fixed entry does not pin, showing the FromView loop is not a + // backdoor for every origin + }, + { + name: "FromView but a column term (a join equality, not an absolute value) pins nothing", + preds: []facts.Pred{ + {Op: facts.Eq, Col: facts.ColRef{Leaf: 0, Column: "tenant_id"}, Term: facts.Term{Kind: facts.Column, Col: facts.ColRef{Leaf: 0, Column: "other"}}, Origin: facts.FromView}, + }, + want: 0, + }, + { + name: "FromView restricted to a different leaf does not apply here", + preds: []facts.Pred{ + {Op: facts.Eq, Col: facts.ColRef{Leaf: 0, Column: "tenant_id"}, Term: facts.Term{Kind: facts.Const, Const: "1"}, Origin: facts.FromView, Restricts: []int{1}}, + }, + want: 0, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + f := build(c.preds) + var got Path + for _, d := range Check(s, []Obligation{decls}, f, nil) { + got = d.Path + } + if got != c.want { + t.Errorf("path = %v, want %v", got, c.want) + } + }) + } +} diff --git a/internal/obligation/obligation.go b/x/obligation/obligation.go similarity index 78% rename from internal/obligation/obligation.go rename to x/obligation/obligation.go index 18cdcd5..ea046b9 100644 --- a/internal/obligation/obligation.go +++ b/x/obligation/obligation.go @@ -15,8 +15,7 @@ package obligation import ( "strings" - "github.com/kr9ly/sqlshape/internal/facts" - "github.com/kr9ly/sqlshape/internal/schema" + "github.com/kr9ly/sqlshape/v2/x/facts" ) // Kinds is the set of statement classes an obligation applies to. @@ -142,7 +141,50 @@ func (b Body) Spec() string { // rebinds it to the leaf under judgment. A conjunct the language cannot decompose is // returned as Opaque with its canonical text. type Lowerer interface { - Lower(expr string, rel *schema.Relation) ([]facts.Pred, error) + Lower(expr string, rel Relation) ([]facts.Pred, error) +} + +// Schema is what the obligations need to know about the database, whatever the dialect: +// its relations, by name and in declaration order. A dialect's schema loader adapts to it +// (check/postgres/schema: Schema.Contract). +type Schema interface { + Relations() []Relation + // Relation resolves a name the way the facts spell it (schema-qualified unless in the + // default schema, else bare); nil when the schema has none. + Relation(name string) Relation +} + +// Relation is one table or view as the obligations see it. +type Relation interface { + // Name is the bare name, FullName the one the facts use as Leaf.Table. + Name() string + FullName() string + Kind() facts.RelKind + // HasColumn: a table's column, or a view's output column. + HasColumn(col string) bool + // NotNull: the column is declared NOT NULL (a table's column; false for a view's + // output or an unknown column). Foreign-key propagation needs it: with MATCH SIMPLE, + // both servers' only behaviour, a row whose foreign-key column is NULL is not checked + // against the parent at all. + NotNull(col string) bool + // Directives are the `-- sqlshape: ...` lines written above the CREATE, normalized. + Directives() []string + ForeignKeys() []ForeignKey + // ViewSource is the base column a view's output column passes through unchanged (a + // label travels along it); ok is false for a table, an expression column, or an + // unknown column. + ViewSource(col string) (table, column string, ok bool) + // ForceRowSecurity: the table's row-security policies bind its owner too (PostgreSQL's + // FORCE ROW LEVEL SECURITY); a policy then satisfies a predicate for every role. + ForceRowSecurity() bool +} + +// ForeignKey is a REFERENCES constraint as the obligations use it: to pin a child through +// its parent and to spell the aggregate's lock predicate. +type ForeignKey struct { + Columns []string + RefTable string // as FullName spells it + RefColumns []string } // Path is how an obligation was discharged, for audit output and diagnostics. diff --git a/internal/obligation/parse.go b/x/obligation/parse.go similarity index 87% rename from internal/obligation/parse.go rename to x/obligation/parse.go index 80c8492..410eb58 100644 --- a/internal/obligation/parse.go +++ b/x/obligation/parse.go @@ -2,9 +2,8 @@ package obligation import ( "fmt" + "github.com/kr9ly/sqlshape/v2/x/facts" "strings" - - "github.com/kr9ly/sqlshape/internal/schema" ) // Parse reads one schema.sql directive (whitespace-normalized, without the `-- sqlshape:` @@ -150,11 +149,11 @@ func parseKinds(list string) (Kinds, error) { // Declarations collects the obligations written in schema.sql: every relation's // `require ...` and `visible where ...` directives, and the expansion of each // `aggregate` declaration. Malformed ones come back as Problems. -func Declarations(s *schema.Schema) ([]Obligation, []Problem) { +func Declarations(s Schema) ([]Obligation, []Problem) { var out []Obligation var problems []Problem - for _, rel := range s.Relations { - for _, d := range rel.Directives { + for _, rel := range s.Relations() { + for _, d := range rel.Directives() { lower := strings.ToLower(d) if strings.HasPrefix(lower, "sensitive ") || strings.HasPrefix(lower, "transitions ") { o, err := columnRule(rel, d) @@ -191,11 +190,11 @@ func Declarations(s *schema.Schema) ([]Obligation, []Problem) { problems = append(problems, Problem{Subject: rel.FullName(), Source: d, Message: err.Error()}) continue } - if col := o.Body.Pinned + o.Body.Immutable; col != "" && !hasColumn(rel, col) { - problems = append(problems, Problem{Subject: rel.FullName(), Source: d, Message: fmt.Sprintf("%s has no column %s", rel.Name, col)}) + if col := o.Body.Pinned + o.Body.Immutable; col != "" && !rel.HasColumn(col) { + problems = append(problems, Problem{Subject: rel.FullName(), Source: d, Message: fmt.Sprintf("%s has no column %s", rel.Name(), col)}) continue } - if t := o.Body.Paired; t != "" && relByFullName(s, t) == nil { + if t := o.Body.Paired; t != "" && s.Relation(t) == nil { problems = append(problems, Problem{Subject: rel.FullName(), Source: d, Message: fmt.Sprintf("paired: relation %s does not exist", t)}) continue } @@ -205,23 +204,10 @@ func Declarations(s *schema.Schema) ([]Obligation, []Problem) { return out, problems } -// hasColumn: the relation has the column (a view's are its frozen output columns). -func hasColumn(rel *schema.Relation, col string) bool { - if rel.Column(col) != nil { - return true - } - for _, vc := range rel.Frozen { - if vc.Name == col { - return true - } - } - return false -} - // FromFlags expands vet's table-wide flags into per-table obligations, the sugar they // always were: -require-columns=a,b is `require pinned(a)` on every table that has the // column, -no-tables is `require via view on all`, -no-table-reads is `require via view`. -func FromFlags(s *schema.Schema, requireColumns string, noTables, noTableReads bool) []Obligation { +func FromFlags(s Schema, requireColumns string, noTables, noTableReads bool) []Obligation { var out []Obligation var cols []string for _, c := range strings.Split(requireColumns, ",") { @@ -229,8 +215,8 @@ func FromFlags(s *schema.Schema, requireColumns string, noTables, noTableReads b cols = append(cols, c) } } - for _, rel := range s.Relations { - if rel.Kind != schema.Table { + for _, rel := range s.Relations() { + if rel.Kind() != facts.Table { continue } switch { @@ -240,7 +226,7 @@ func FromFlags(s *schema.Schema, requireColumns string, noTables, noTableReads b out = append(out, Obligation{Subject: rel.FullName(), Kinds: OnRead, Body: Body{ViaView: true}, Source: "-no-table-reads"}) } for _, c := range cols { - if rel.Column(c) != nil { + if rel.HasColumn(c) { out = append(out, Obligation{Subject: rel.FullName(), Kinds: OnAll, Body: Body{Pinned: c}, Source: "-require-columns=" + requireColumns}) } } @@ -257,7 +243,7 @@ func FromFlags(s *schema.Schema, requireColumns string, noTables, noTableReads b // a grandchild, to the member it hangs off (a join on the parent's key pins it); // - one statement touches one aggregate: `alone` on the root and every child (read // across aggregates through a view). -func aggregate(s *schema.Schema, root *schema.Relation, directive string) ([]Obligation, error) { +func aggregate(s Schema, root Relation, directive string) ([]Obligation, error) { norm := strings.Join(strings.Fields(directive), " ") rest := strings.TrimSpace(norm[len("aggregate "):]) name, list, ok := strings.Cut(rest, "(") @@ -272,12 +258,12 @@ func aggregate(s *schema.Schema, root *schema.Relation, directive string) ([]Obl return nil, fmt.Errorf("%s: expected `lock ` after the children", norm) } lock = strings.TrimSpace(tail[len("lock "):]) - if root.Column(lock) == nil { + if !root.HasColumn(lock) { return nil, fmt.Errorf("%s: %s has no column %s", norm, root.FullName(), lock) } } name = strings.TrimSpace(name) - if name != root.Name && name != root.FullName() { + if name != root.Name() && name != root.FullName() { return nil, fmt.Errorf("%s: the declaration sits above %s, not %s", norm, root.FullName(), name) } src := "aggregate " + root.FullName() @@ -286,17 +272,13 @@ func aggregate(s *schema.Schema, root *schema.Relation, directive string) ([]Obl // the version is named in every write to the root ... out = append(out, Obligation{Subject: root.FullName(), Kinds: OnUpdate | OnDelete, Body: Body{Pinned: lock}, Source: src}) } - var children []*schema.Relation + var children []Relation for _, c := range strings.Split(strings.TrimSuffix(list, ")"), ",") { c = strings.TrimSpace(c) if c == "" { continue } - sch, n, _ := strings.Cut(c, ".") - if n == "" { - sch, n = "", sch - } - child := s.Relation(sch, n) + child := s.Relation(c) if child == nil { return nil, fmt.Errorf("%s: child %s does not exist", norm, c) } @@ -304,23 +286,23 @@ func aggregate(s *schema.Schema, root *schema.Relation, directive string) ([]Obl } // a child hangs off the root or off another member (a grandchild off its parent): the // foreign key it must pin is the one into the aggregate, the root's first - isRoot := func(t string) bool { return t == root.FullName() || t == root.Name } + isRoot := func(t string) bool { return t == root.FullName() || t == root.Name() } members := map[string]bool{} for _, ch := range children { - members[ch.FullName()], members[ch.Name] = true, true + members[ch.FullName()], members[ch.Name()] = true, true } for _, child := range children { - var fk *schema.Constraint - for _, con := range child.Constraints { - if con.Kind == schema.ForeignKey && isRoot(con.RefTable) { - fk = con + var fk *ForeignKey + for _, con := range child.ForeignKeys() { + if isRoot(con.RefTable) { + fk = &con break } } if fk == nil { - for _, con := range child.Constraints { - if con.Kind == schema.ForeignKey && members[con.RefTable] && con.RefTable != child.FullName() && con.RefTable != child.Name { - fk = con + for _, con := range child.ForeignKeys() { + if members[con.RefTable] && con.RefTable != child.FullName() && con.RefTable != child.Name() { + fk = &con break } } @@ -344,24 +326,24 @@ func aggregate(s *schema.Schema, root *schema.Relation, directive string) ([]Obl // lockPredicate spells the EXISTS a child of a locked aggregate owes on UPDATE / DELETE: // a root row at the version the writer saw, reached through the child's foreign key and, // for a grandchild, its parent's. -func lockPredicate(s *schema.Schema, root, child *schema.Relation, lock string) string { +func lockPredicate(s Schema, root, child Relation, lock string) string { var from []string var where []string cur := child prev := "" for depth := 0; depth < 8; depth++ { - var fk *schema.Constraint - for _, con := range cur.Constraints { - if con.Kind == schema.ForeignKey && (con.RefTable == root.FullName() || con.RefTable == root.Name) { - fk = con + var fk *ForeignKey + for _, con := range cur.ForeignKeys() { + if con.RefTable == root.FullName() || con.RefTable == root.Name() { + fk = &con break } } if fk == nil { - for _, con := range cur.Constraints { - if con.Kind == schema.ForeignKey && con.RefTable != cur.FullName() && con.RefTable != cur.Name { - if r := relByFullName(s, con.RefTable); r != nil && r != cur { - fk = con + for _, con := range cur.ForeignKeys() { + if con.RefTable != cur.FullName() && con.RefTable != cur.Name() { + if r := s.Relation(con.RefTable); r != nil && r.FullName() != cur.FullName() { + fk = &con break } } @@ -370,7 +352,10 @@ func lockPredicate(s *schema.Schema, root, child *schema.Relation, lock string) if fk == nil { break } - parent := relByFullName(s, fk.RefTable) + parent := s.Relation(fk.RefTable) + if parent == nil { + break + } alias := fmt.Sprintf("a%d", depth) from = append(from, parent.FullName()+" "+alias) for k := range fk.Columns { @@ -381,7 +366,7 @@ func lockPredicate(s *schema.Schema, root, child *schema.Relation, lock string) } where = append(where, lhs+" = "+rhs) } - if parent == root { + if parent.FullName() == root.FullName() { where = append(where, alias+"."+lock+" = $1") return "EXISTS (SELECT 1 FROM " + strings.Join(from, ", ") + " WHERE " + strings.Join(where, " AND ") + ")" } @@ -395,7 +380,7 @@ func lockPredicate(s *schema.Schema, root, child *schema.Relation, lock string) // ...`) and the base obligations the context lifts (`waive `). An entry point // selects one context (vet: the package's `// sqlshape: context ` or -context; // check: -context); InContext applies the selection. -func context(rel *schema.Relation, directive string) ([]Obligation, error) { +func context(rel Relation, directive string) ([]Obligation, error) { norm := strings.Join(strings.Fields(directive), " ") rest := strings.TrimSpace(norm[len("context "):]) name, items, ok := strings.Cut(rest, ":") @@ -483,7 +468,7 @@ func Contexts(decls []Obligation) []string { // // sensitive : a -> b, b -> c | d the column is a state machine -func columnRule(rel *schema.Relation, directive string) (Obligation, error) { +func columnRule(rel Relation, directive string) (Obligation, error) { norm := strings.Join(strings.Fields(directive), " ") o := Obligation{Subject: rel.FullName(), Kinds: OnAll, Source: norm} head, body, ok := strings.Cut(norm, ":") @@ -500,8 +485,8 @@ func columnRule(rel *schema.Relation, directive string) (Obligation, error) { sens := &Sensitive{Label: name} for _, c := range strings.Split(body, ",") { if c = strings.TrimSpace(c); c != "" { - if !hasColumn(rel, c) { - return Obligation{}, fmt.Errorf("%s: %s has no column %s", norm, rel.Name, c) + if !rel.HasColumn(c) { + return Obligation{}, fmt.Errorf("%s: %s has no column %s", norm, rel.Name(), c) } sens.Columns = append(sens.Columns, c) } @@ -511,8 +496,8 @@ func columnRule(rel *schema.Relation, directive string) (Obligation, error) { } o.Body.Sensitive = sens case "transitions": - if !hasColumn(rel, name) { - return Obligation{}, fmt.Errorf("%s: %s has no column %s", norm, rel.Name, name) + if !rel.HasColumn(name) { + return Obligation{}, fmt.Errorf("%s: %s has no column %s", norm, rel.Name(), name) } tr := &Transitions{Column: name, From: map[string][]string{}} for _, edge := range strings.Split(body, ",") { diff --git a/internal/obligation/parse_test.go b/x/obligation/parse_test.go similarity index 100% rename from internal/obligation/parse_test.go rename to x/obligation/parse_test.go diff --git a/x/obligation/waive.go b/x/obligation/waive.go new file mode 100644 index 0000000..3eb38ea --- /dev/null +++ b/x/obligation/waive.go @@ -0,0 +1,74 @@ +package obligation + +import ( + "regexp" + "strings" +) + +// Waivers reads the list of a `waive` directive: comma-separated (outside parentheses) +// entries of `
[]`, where the obligation is the body as declared +// (`pinned(tenant_id)`, `via view`, a predicate) and its absence waives every obligation +// on the table ("*"). The grammar is the directive's, so every dialect reads it here. +func Waivers(list string) map[string][]string { + out := map[string][]string{} + depth, start := 0, 0 + items := []string{} + for i := 0; i <= len(list); i++ { + if i == len(list) || (list[i] == ',' && depth == 0) { + items = append(items, strings.TrimSpace(list[start:i])) + start = i + 1 + continue + } + switch list[i] { + case '(': + depth++ + case ')': + depth-- + } + } + for _, it := range items { + if it == "" { + continue + } + table, spec, _ := strings.Cut(it, " ") + spec = strings.Join(strings.Fields(spec), " ") + if spec == "" { + spec = "*" + } + out = AddWaiver(out, table, spec) + } + return out +} + +// AddWaiver appends specs to the table's waivers. +func AddWaiver(m map[string][]string, table string, specs ...string) map[string][]string { + if m == nil { + m = map[string][]string{} + } + m[table] = append(m[table], specs...) + return m +} + +var sqlDirective = regexp.MustCompile(`(?m)^[ \t]*--[ \t]*sqlshape:[ \t]*([a-z ]+?)[ \t]+(.+?)[ \t]*$`) + +// StatementWaivers reads a statement's opt-outs from its `-- sqlshape:` lines: +// `unfiltered a, b` (the predicate obligations of a and b) and `waive a pinned(x), b` (one +// named obligation, or every obligation, of a table). Empty when it has none. +func StatementWaivers(sql string) map[string][]string { + out := map[string][]string{} + for _, m := range sqlDirective.FindAllStringSubmatch(sql, -1) { + switch m[1] { + case "unfiltered": + for _, it := range strings.Split(m[2], ",") { + if it = strings.TrimSpace(it); it != "" { + out = AddWaiver(out, it, "unfiltered") + } + } + case "waive": + for table, spec := range Waivers(m[2]) { + out = AddWaiver(out, table, spec...) + } + } + } + return out +} diff --git a/x/placeholder/placeholder.go b/x/placeholder/placeholder.go new file mode 100644 index 0000000..0113961 --- /dev/null +++ b/x/placeholder/placeholder.go @@ -0,0 +1,114 @@ +// Package placeholder rewrites sqlshape's numbered placeholders (`$n`, what the template +// expander emits for every dialect) into MySQL's positional `?`, and maps positions back. +package placeholder + +import "strings" + +// Map is the rewrite of `$n` placeholders into `?`, kept so that a byte offset in the +// MySQL text maps back to the text the caller gave, and a `?` maps to its n. +type Map struct { + // marks are the `?` positions in the MySQL text, in order, with their n + marks []mark + n int // the highest n +} + +type mark struct { + at int // offset of `?` in the MySQL text + n int + w int // width of the original `$n` +} + +// Rewrite turns `$n` into `?`. A `$` inside a quoted string, a quoted identifier or an +// identifier (`a$1` is a legal MySQL name) is left alone. +func Rewrite(sql string) (string, Map) { + var b strings.Builder + var pm Map + var quote byte + ident := false + for i := 0; i < len(sql); i++ { + c := sql[i] + switch { + case quote != 0: + b.WriteByte(c) + if c == '\\' && quote != '`' && i+1 < len(sql) { + i++ + b.WriteByte(sql[i]) + } else if c == quote { + quote = 0 + } + continue + case c == '\'' || c == '"' || c == '`': + quote = c + ident = false + b.WriteByte(c) + continue + case c == '$' && !ident: + j := i + 1 + for j < len(sql) && sql[j] >= '0' && sql[j] <= '9' { + j++ + } + if j > i+1 { + n := 0 + for _, d := range sql[i+1 : j] { + n = n*10 + int(d-'0') + } + pm.marks = append(pm.marks, mark{at: b.Len(), n: n, w: j - i}) + if n > pm.n { + pm.n = n + } + b.WriteByte('?') + i = j - 1 + continue + } + } + ident = isIdentByte(c) + b.WriteByte(c) + } + return b.String(), pm +} + +func isIdentByte(c byte) bool { + return c == '_' || c == '$' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= 0x80 +} + +// Count is the highest n: how many parameters the statement takes. +func (pm Map) Count() int { return pm.n } + +// Marks is how many `?` the rewrite produced: the same n written twice is two marks. +func (pm Map) Marks() int { return len(pm.marks) } + +// Back maps an offset in the MySQL text to the caller's text. +func (pm Map) Back(off int) int { + if off < 0 { + return -1 + } + shift := 0 + for _, m := range pm.marks { + if m.at >= off { + break + } + shift += m.w - 1 + } + return off + shift +} + +// Number is the n of the `?` at off, 0 when there is none. +func (pm Map) Number(off int) int { + for _, m := range pm.marks { + if m.at == off { + return m.n + } + } + return 0 +} + +// Order lists the n of each `?` in the MySQL text, in order: the argument a driver expecting +// positional `?` receives at each position is the caller's argument n (1-based). The same +// $n written twice appears twice. +func (pm Map) Order() []int { + out := make([]int, len(pm.marks)) + for i, m := range pm.marks { + out[i] = m.n + } + return out +} diff --git a/x/placeholder/placeholder_test.go b/x/placeholder/placeholder_test.go new file mode 100644 index 0000000..5982017 --- /dev/null +++ b/x/placeholder/placeholder_test.go @@ -0,0 +1,25 @@ +package placeholder + +import ( + "strings" + "testing" +) + +func TestPlaceholders(t *testing.T) { + text, pm := Rewrite("SELECT a$1, 'x$2', `c$3`, \"d$4\" FROM t WHERE x = $1 AND y = $12 AND z=$2") + want := "SELECT a$1, 'x$2', `c$3`, \"d$4\" FROM t WHERE x = ? AND y = ? AND z=?" + if text != want { + t.Fatalf("got %q\nwant %q", text, want) + } + if pm.Count() != 12 || len(pm.marks) != 3 { + t.Fatalf("count %d marks %d", pm.Count(), len(pm.marks)) + } + // the `?` for $12 is at index 56 in text; the original `$12` starts at 57 (one `$1` before it grew by 1) + if n := pm.Number(strings.Index(text, "y = ?") + 4); n != 12 { + t.Errorf("number = %d, want 12", n) + } + off := strings.Index(text, "z=?") + 2 + if back := pm.Back(off); back != off+1+2 { + t.Errorf("back(%d) = %d, want %d", off, back, off+3) + } +} diff --git a/x/sqlmode/sqlmode.go b/x/sqlmode/sqlmode.go new file mode 100644 index 0000000..4b31724 --- /dev/null +++ b/x/sqlmode/sqlmode.go @@ -0,0 +1,121 @@ +// Package sqlmode is MySQL's sql_mode as 8.4 defines it: the names, their bits, the +// combination modes and the server's default (sql/system_variables.h and the +// sql_mode_names / expand_sql_mode of sql/sys_vars.cc). The MySQL analyzer reads a schema's +// declared mode through it, and so do mysqltest (to start the server with it) and the MySQL +// runtime (to compare a connection's @@sql_mode with it); it lives in the root module so +// that all three read one table. +package sqlmode + +import ( + "fmt" + "strings" +) + +// Mode is a set of sql_mode flags, with the server's bit values. +type Mode uint64 + +const ( + RealAsFloat Mode = 1 + PipesAsConcat Mode = 2 + ANSIQuotes Mode = 4 + IgnoreSpace Mode = 8 + OnlyFullGroupBy Mode = 32 + NoUnsignedSubtraction Mode = 64 + NoDirInCreate Mode = 128 + ANSI Mode = 0x40000 + NoAutoValueOnZero Mode = ANSI * 2 + NoBackslashEscapes Mode = NoAutoValueOnZero * 2 + StrictTransTables Mode = NoBackslashEscapes * 2 + StrictAllTables Mode = StrictTransTables * 2 + NoZeroInDate Mode = StrictAllTables * 2 + NoZeroDate Mode = NoZeroInDate * 2 + AllowInvalidDates Mode = NoZeroDate * 2 + ErrorForDivisionByZero Mode = AllowInvalidDates * 2 + Traditional Mode = ErrorForDivisionByZero * 2 + HighNotPrecedence Mode = 1 << 29 + NoEngineSubstitution Mode = HighNotPrecedence * 2 + PadCharToFullLength Mode = 1 << 31 + TimeTruncateFractional Mode = 1 << 32 +) + +// Default is the server's default sql_mode (8.4). +const Default = NoEngineSubstitution | OnlyFullGroupBy | StrictTransTables | NoZeroInDate | NoZeroDate | ErrorForDivisionByZero + +// names is sql_mode_names in bit order; "" for an unused bit. +var names = []string{ + "REAL_AS_FLOAT", "PIPES_AS_CONCAT", "ANSI_QUOTES", "IGNORE_SPACE", "", "ONLY_FULL_GROUP_BY", + "NO_UNSIGNED_SUBTRACTION", "NO_DIR_IN_CREATE", "", "", "", "", "", "", "", "", "", "", + "ANSI", "NO_AUTO_VALUE_ON_ZERO", "NO_BACKSLASH_ESCAPES", "STRICT_TRANS_TABLES", "STRICT_ALL_TABLES", + "NO_ZERO_IN_DATE", "NO_ZERO_DATE", "ALLOW_INVALID_DATES", "ERROR_FOR_DIVISION_BY_ZERO", "TRADITIONAL", "", + "HIGH_NOT_PRECEDENCE", "NO_ENGINE_SUBSTITUTION", "PAD_CHAR_TO_FULL_LENGTH", "TIME_TRUNCATE_FRACTIONAL", +} + +// Parse reads a sql_mode value as SET sql_mode does: names separated by commas, in any +// case, spaces around them ignored, the empty string allowed. The combination modes are +// expanded (Expand), as the server stores them. +func Parse(s string) (Mode, error) { + var m Mode + for _, f := range strings.Split(s, ",") { + f = strings.TrimSpace(f) + if f == "" { + continue + } + bit, ok := lookup(f) + if !ok { + return 0, fmt.Errorf("sql_mode: %q is not a mode of MySQL 8.4", f) + } + m |= bit + } + return m.Expand(), nil +} + +func lookup(name string) (Mode, bool) { + for i, n := range names { + if n != "" && strings.EqualFold(n, name) { + return 1 << i, true + } + } + return 0, false +} + +// Expand adds what the combination modes stand for (expand_sql_mode): ANSI is REAL_AS_FLOAT, +// PIPES_AS_CONCAT, ANSI_QUOTES, IGNORE_SPACE and ONLY_FULL_GROUP_BY; TRADITIONAL is +// STRICT_TRANS_TABLES, STRICT_ALL_TABLES, NO_ZERO_IN_DATE, NO_ZERO_DATE, +// ERROR_FOR_DIVISION_BY_ZERO and NO_ENGINE_SUBSTITUTION. The combination flag stays set, as +// the server keeps it. +func (m Mode) Expand() Mode { + if m&ANSI != 0 { + m |= RealAsFloat | PipesAsConcat | ANSIQuotes | IgnoreSpace | OnlyFullGroupBy + } + if m&Traditional != 0 { + m |= StrictTransTables | StrictAllTables | NoZeroInDate | NoZeroDate | ErrorForDivisionByZero | NoEngineSubstitution + } + return m +} + +// Strict is THD::is_strict_mode: either strict flag. +func (m Mode) Strict() bool { return m&(StrictTransTables|StrictAllTables) != 0 } + +// StrictAll reports whether STRICT_ALL_TABLES is set: strict mode applies unconditionally, +// on every storage engine and every row. +func (m Mode) StrictAll() bool { return m&StrictAllTables != 0 } + +// StrictTransOnly reports whether STRICT_TRANS_TABLES is set without STRICT_ALL_TABLES: +// the manual's STRICT_TRANS_TABLES is strict only for a transactional storage engine, and +// for a nontransactional one behaves like no strict mode at all except that a single-row +// statement (or the first row of a multi-row one) still errors. +func (m Mode) StrictTransOnly() bool { return m&StrictAllTables == 0 && m&StrictTransTables != 0 } + +// Has reports whether every flag of f is set. +func (m Mode) Has(f Mode) bool { return m&f == f } + +// String spells the mode as @@sql_mode does: the set flags' names in bit order, comma-joined. +func (m Mode) String() string { + var out []string + for i, n := range names { + if n != "" && m&(1< 0 { + logf("generator (schema)", "%s\n%s", strings.Join(p, "\n"), m.DDL(o.Dialect)) + continue + } + v, class := o.judgeFailure(ctx, w, m, an) + for _, h := range v.Hit { + rep.Hit[h] = true + } + switch { + case class != "": + logf(class, "%s\n%s\n%s", v.Detail, v.SQL, m.DDL(o.Dialect)) + case v.Kind == "": + rep.Counts["pass"]++ + default: + rep.Counts["finding"]++ + v.Schema = m.DDL(o.Dialect) + strings.Join(m.Inserts(), "\n") + key := v.Kind + "\n" + v.Detail + if !seen[key] { + seen[key] = true + rep.Findings = append(rep.Findings, v) + } + } + if err := o.drop(ctx, m); err != nil { + return nil, fmt.Errorf("statement %d: dropping the tables: %w", i, err) + } + } + for _, e := range FailureAlphabet { + switch { + case rep.Hit[e]: + if _, known := o.KnownUnreached[e]; known { + rep.Stale = append(rep.Stale, e) + } + default: + if _, known := o.KnownUnreached[e]; !known { + rep.Missed = append(rep.Missed, e) + } + } + } + rep.alphabet = FailureAlphabet + return rep, nil +} + +// writeStmt is one generated write and the shapes it exercises. +type writeStmt struct { + sql string + params []Value + shapes []string +} + +// judgeFailure runs w against fresh tables and compares the server's error with the +// predicted violations. +func (o FailureOptions) judgeFailure(ctx context.Context, w *writeStmt, m *Schema, an dialect.Analyzer) (Verdict, string) { + v := Verdict{SQL: w.sql, Params: w.params, Hit: append([]string(nil), w.shapes...)} + for _, t := range m.Tables { + if t.uniqueName != "" || (t.check != nil && t.check.name != "") || (t.fk != nil && t.fk.name != "") { + v.Hit = append(v.Hit, "constraint named") + } + if (len(t.Uniques) > 0 && t.uniqueName == "") || (t.check != nil && t.check.name == "") || (t.fk != nil && t.fk.name == "") { + v.Hit = append(v.Hit, "constraint unnamed") + } + } + if err := o.apply(ctx, m); err != nil { + v.Detail = err.Error() + return v, "generator (schema)" + } + res, aerr := an.Analyze(w.sql) + serr := o.exec(ctx, w.sql, w.params) + switch { + case aerr != nil && serr != nil && !constraintError(serr): + v.Detail = fmt.Sprintf("analyzer: %v; server: %v", aerr, serr) + return v, "generator (statement)" + case aerr != nil: + v.Kind, v.Detail = "analyzer rejects what the server accepts", fmt.Sprintf("%v (the server: %v)", aerr, serr) + return v, "" + } + var predicted []string + for _, p := range res.Violations { + predicted = append(predicted, p.Key) + v.Hit = append(v.Hit, "predicted "+violationKind(p.Code, p.Key)) + } + sort.Strings(predicted) + if serr == nil { + return v, "" + } + if !constraintError(serr) { + v.Kind, v.Detail = "server rejects what the analyzer accepts", serr.Error() + return v, "" + } + wrapped := o.Wrap(serr) + for _, p := range res.Violations { + if o.Violates(wrapped, p.Key) { + v.Hit = append(v.Hit, "raised "+violationKind(p.Code, p.Key)) + return v, "" + } + } + // a failure the analyzer reports as certain rather than possible (PostgreSQL's "INSERT + // omits t.s, which is NOT NULL without a default: every execution fails" note) is + // predicted too, and more strongly than an expect line would + for _, n := range res.Notes { + if !n.Advisory && strings.Contains(n.Message, "every execution fails") { + v.Hit = append(v.Hit, "predicted always fails") + return v, "" + } + } + v.Kind = "unpredicted failure" + v.Detail = fmt.Sprintf("the server raised %v (wrapped: %v); the analyzer predicted %v", serr, wrapped, predicted) + return v, "" +} + +// violationKind names a predicted violation's kind from its dialect code (and its key, +// which tells the primary key from another unique key). +func violationKind(code, key string) string { + switch { + case strings.HasSuffix(code, "23505"), strings.HasSuffix(code, "1062"): + if key == "PRIMARY" || strings.HasSuffix(key, "_pkey") { + return "primary key" + } + return "unique" + case strings.HasSuffix(code, "23503"), strings.HasSuffix(code, "1452"), strings.HasSuffix(code, "1451"): + return "foreign key" + case strings.HasSuffix(code, "23502"), strings.HasSuffix(code, "1048"), strings.HasSuffix(code, "1364"), strings.HasSuffix(code, "1263"): + return "not null" + case strings.HasSuffix(code, "23514"), strings.HasSuffix(code, "3819"): + return "check" + } + return code +} + +// ---- generation ----------------------------------------------------------------------- + +// genFailSchema draws a parent p (id, b NOT NULL UNIQUE) and a child t (id, a, b NOT NULL +// UNIQUE, s NOT NULL, c CHECK (c > 0), pid REFERENCES p), each constraint named or left +// to the server, the foreign key's ON DELETE drawn, with rows. +func genFailSchema(r *rand.Rand, prefix string) *Schema { + p := &Table{Name: prefix + "p", Cols: []Col{{Name: "id", Kind: Int, NotNull: true}, {Name: "b", Kind: Int, NotNull: true}}, PK: []string{"id"}, Uniques: [][]string{{"b"}}} + if r.Intn(2) == 0 { + p.uniqueName = "uq_" + prefix + "p_b" + } + n := 2 + r.Intn(2) + for i := 0; i < n; i++ { + p.Rows = append(p.Rows, []Value{intValue(i + 1), intValue(10 + i)}) + } + t := &Table{Name: prefix + "t", Cols: []Col{{Name: "id", Kind: Int, NotNull: true}, {Name: "a", Kind: Int}, {Name: "b", Kind: Int, NotNull: true}, {Name: "s", Kind: Str, NotNull: true}, {Name: "c", Kind: Int}, {Name: "pid", Kind: Int}}, PK: []string{"id"}} + if r.Intn(3) > 0 { + t.Uniques = [][]string{{"b"}} + if r.Intn(2) == 0 { + t.uniqueName = "uq_" + prefix + "t_b" + } + } + if r.Intn(3) > 0 { + t.check = &checkDef{col: "c"} + if r.Intn(2) == 0 { + t.check.name = "chk_" + prefix + "c" + } + } + if r.Intn(4) > 0 { + t.fk = &fkDef{col: "pid", ref: p} + if r.Intn(2) == 0 { + t.fk.name = "fk_" + prefix + "t_pid" + } + switch r.Intn(3) { + case 1: + t.fk.onDelete = "CASCADE" + case 2: + t.fk.onDelete = "SET NULL" + } + } + rows := 2 + r.Intn(3) + for i := 0; i < rows; i++ { + pid := null + if r.Intn(3) > 0 { + pid = p.Rows[r.Intn(len(p.Rows))][0] + } + t.Rows = append(t.Rows, []Value{intValue(i + 1), genInt(r, true), intValue(20 + i), strValue("x"), intValue(1 + r.Intn(3)), pid}) + } + return &Schema{Tables: []*Table{p, t}} +} + +// genWrite draws a write over the failure schema, biased to collide with its constraints: +// an INSERT into t (a duplicate id or b, a NULL s, a non-positive c, an unknown pid, an +// omitted NOT NULL column; IGNORE / REPLACE / ON DUPLICATE KEY UPDATE on MySQL, ON +// CONFLICT on PostgreSQL), an UPDATE of t's columns or of p's referenced id, a DELETE of a +// parent row or a child row. +func genWrite(r *rand.Rand, m *Schema, d Dialect) *writeStmt { + p, t := m.Tables[0], m.Tables[1] + w := &writeStmt{} + value := func(c Col, v Value) string { + if r.Intn(4) == 0 { + w.params = append(w.params, v) + return fmt.Sprintf("$%d", len(w.params)) + } + return literal(c.Kind, v) + } + pick := func(rows [][]Value, i int) Value { return rows[r.Intn(len(rows))][i] } + switch r.Intn(10) { + case 0, 1, 2, 3: // INSERT + vals := map[string]Value{} + vals["id"] = intValue(len(t.Rows) + 1 + r.Intn(2)) + if r.Intn(3) == 0 { + vals["id"] = pick(t.Rows, 0) + } + vals["a"] = genInt(r, true) + vals["b"] = intValue(30 + r.Intn(3)) + if r.Intn(3) == 0 { + vals["b"] = pick(t.Rows, 2) + } + vals["s"] = strValue("y") + if r.Intn(4) == 0 { + vals["s"] = null + } + vals["c"] = intValue(1 + r.Intn(2)) + if r.Intn(3) == 0 { + vals["c"] = intValue(-r.Intn(2)) + } + vals["pid"] = null + switch r.Intn(3) { + case 0: + vals["pid"] = pick(p.Rows, 0) + case 1: + vals["pid"] = intValue(90 + r.Intn(3)) + } + var cols, rendered []string + omitted := r.Intn(4) == 0 + for _, c := range t.Cols { + if omitted && c.Name == "s" { + continue + } + cols = append(cols, c.Name) + rendered = append(rendered, value(c, vals[c.Name])) + } + w.shapes = append(w.shapes, "insert") + if omitted { + w.shapes = append(w.shapes, "insert omitted column") + } + head := "INSERT INTO " + tail := "" + switch r.Intn(5) { + case 0: + if d.Positional { + head, tail = "INSERT IGNORE INTO ", "" + } else { + tail = " ON CONFLICT DO NOTHING" + } + w.shapes = append(w.shapes, ifElse(d.Positional, "insert ignore", "upsert nothing")) + case 1: + if d.Positional { + head = "REPLACE INTO " + w.shapes = append(w.shapes, "replace") + } else { + tail = " ON CONFLICT (id) DO UPDATE SET a = " + value(t.Cols[1], genInt(r, true)) + w.shapes = append(w.shapes, "upsert update") + } + case 2: + if d.Positional { + tail = " ON DUPLICATE KEY UPDATE a = " + value(t.Cols[1], genInt(r, true)) + w.shapes = append(w.shapes, "upsert update") + } + } + w.sql = head + t.Name + " (" + strings.Join(cols, ", ") + ") VALUES (" + strings.Join(rendered, ", ") + ")" + tail + case 4, 5, 6: // UPDATE t + var sets []string + n := 1 + r.Intn(2) + used := map[string]bool{} + for i := 0; i < n; i++ { + c := t.Cols[1+r.Intn(len(t.Cols)-1)] + if used[c.Name] { + continue + } + used[c.Name] = true + var v Value + switch c.Name { + case "a": + v = genInt(r, true) + case "b": + v = intValue(30 + r.Intn(2)) + if r.Intn(2) == 0 { + v = pick(t.Rows, 2) + } + case "s": + v = strValue("z") + if r.Intn(3) == 0 { + v = null + } + case "c": + v = intValue(2 - r.Intn(4)) + case "pid": + v = pick(p.Rows, 0) + if r.Intn(2) == 0 { + v = intValue(90) + } + } + sets = append(sets, c.Name+" = "+value(c, v)) + } + w.shapes = append(w.shapes, "update") + w.sql = "UPDATE " + t.Name + " SET " + strings.Join(sets, ", ") + " WHERE id = " + value(t.Cols[0], pick(t.Rows, 0)) + case 7: // UPDATE p SET id: a referenced key + w.shapes = append(w.shapes, "update referenced key") + w.sql = "UPDATE " + p.Name + " SET id = " + value(p.Cols[0], intValue(50+r.Intn(2))) + " WHERE id = " + value(p.Cols[0], pick(p.Rows, 0)) + case 8: // DELETE a parent row + shape := "delete parent restrict" + if t.fk != nil { + switch t.fk.onDelete { + case "CASCADE": + shape = "delete parent cascade" + case "SET NULL": + shape = "delete parent set null" + } + } + w.shapes = append(w.shapes, shape) + w.sql = "DELETE FROM " + p.Name + " WHERE id = " + value(p.Cols[0], pick(p.Rows, 0)) + default: // DELETE a child row + w.shapes = append(w.shapes, "delete child") + w.sql = "DELETE FROM " + t.Name + " WHERE b = " + value(t.Cols[2], pick(t.Rows, 2)) + } + return w +} + +func ifElse(c bool, a, b string) string { + if c { + return a + } + return b +} diff --git a/x/stmtprobe/generate.go b/x/stmtprobe/generate.go new file mode 100644 index 0000000..e95aab7 --- /dev/null +++ b/x/stmtprobe/generate.go @@ -0,0 +1,784 @@ +package stmtprobe + +import ( + "fmt" + "math/rand" + "strings" +) + +// ---- the statement model ------------------------------------------------------------- +// +// A statement is a scope: leaves (table aliases joined together), conjuncts over them, and +// optionally LIMIT 1. Every conjunct carries its own three-valued evaluation over a row of +// the model, so the probe can (1) compute the rows the server should return, as a check on +// the generator itself, and (2) evaluate an Opaque predicate the facts only spell as text. + +// leaf is one relation occurrence under an alias: a table, a view (a Table the model +// materialized from its base, rendered as CREATE VIEW), a derived table or a CTE (both +// rendered from src, the model's rows in table). +type leaf struct { + alias string + table *Table + // src is the defining query of a derived table or CTE leaf (nil for a table or view): + // the base table under alias "d" and the conjuncts over it + src *derived + cte bool // src is written as a WITH item named table.Name, referenced by name + // outer: joined with LEFT JOIN (its columns may be NULL in a row); on is its join + // condition, over this and earlier leaves + outer bool + on []conj +} + +// derived is the body of a view, derived table or CTE: SELECT every column of base (as +// d) WHERE the conjuncts hold. +type derived struct { + base *Table + where []conj +} + +func (d *derived) sql() string { + var items []string + for _, c := range d.base.Cols { + items = append(items, "d."+c.Name) + } + return "SELECT " + strings.Join(items, ", ") + " FROM " + d.base.Name + " AS d WHERE " + conjSQL(d.where) +} + +// rows evaluates the body over the model. +func (d *derived) rows(m *Schema, params []Value) [][]Value { + var out [][]Value + for _, tr := range d.base.Rows { + r := row{}.with(leaf{alias: "d", table: d.base}, tr) + ok := true + for _, c := range d.where { + if c.eval(r, m, params) != yes { + ok = false + break + } + } + if ok { + out = append(out, tr) + } + } + return out +} + +// assign is one SET item of an UPDATE, or one column of an INSERT row: a literal or a +// parameter. +type assign struct { + col Col + sql string + value Value +} + +// tri is a three-valued truth. +type tri byte + +const ( + unknown tri = iota + yes + no +) + +func bool3(b bool) tri { + if b { + return yes + } + return no +} + +// conj is one conjunct: its SQL, the facts shape the generator expects it to produce (an +// alphabet entry), and its evaluation. +type conj struct { + sql string + kind string // the alphabet entry this conjunct is meant to exercise + eval func(r row, m *Schema, params []Value) tri + // cols are the alias.column references the conjunct reads (for minimization notes) +} + +// query is a generated statement: a SELECT over its leaves, or a single-table UPDATE / +// DELETE (leaves[0] is the target) or an INSERT of one row (leaves[0] the table). +type query struct { + kind string // "select", "update", "delete", "insert" + leaves []leaf + where []conj + limit1 bool + params []Value // bound values of $1..$n + // group: GROUP BY this column of leaves[0], with COUNT(*) beside it (the select list + // is then that column alone) + group *Col + // union: UNION ALL with a second branch selecting leaves[0]'s "a" from another table + union *leaf + // set are UPDATE's assignments, or INSERT's row (every column, in table order) + set []assign + // known are the values of the uncorrelated scalar subqueries the statement spells, + // by their normalized text, for a Known term the facts carry + known map[string]Value +} + +// normText lowercases a SQL fragment and drops its spaces and AS keywords, so a producer's +// rendering of a subquery compares with the generator's. +func normText(s string) string { + s = strings.ToLower(s) + s = strings.ReplaceAll(s, " as ", " ") + return strings.Trim(strings.ReplaceAll(s, " ", ""), "()") +} + +// sql renders the statement. A SELECT projects every column of every leaf, aliased +// alias__col, so the rows returned hold what the facts speak about; under GROUP BY only +// the grouping column (and COUNT(*) AS n); a UNION ALL branch projects "a" alone. +func (q *query) sql() string { + switch q.kind { + case "update": + var sets []string + for _, a := range q.set { + sets = append(sets, a.col.Name+" = "+a.sql) + } + return "UPDATE " + q.leaves[0].table.Name + " AS " + q.leaves[0].alias + " SET " + strings.Join(sets, ", ") + "\nWHERE " + conjSQL(q.where) + case "delete": + return "DELETE FROM " + q.leaves[0].table.Name + " AS " + q.leaves[0].alias + "\nWHERE " + conjSQL(q.where) + case "insert": + var cols, vals []string + for _, a := range q.set { + cols = append(cols, a.col.Name) + vals = append(vals, a.sql) + } + return "INSERT INTO " + q.leaves[0].table.Name + " (" + strings.Join(cols, ", ") + ") VALUES (" + strings.Join(vals, ", ") + ")" + } + var b strings.Builder + var ctes []string + for _, l := range q.leaves { + if l.cte { + ctes = append(ctes, l.table.Name+" AS ("+l.src.sql()+")") + } + } + if len(ctes) > 0 { + b.WriteString("WITH " + strings.Join(ctes, ",\n ") + "\n") + } + b.WriteString("SELECT ") + var items []string + if q.group != nil { + // GROUP BY t0.id: every column of t0 is functionally dependent on it, so all + // of them are projected (both dialects accept that); the other leaf's are not + for _, c := range q.leaves[0].table.Cols { + items = append(items, "t0."+c.Name+" AS t0__"+c.Name) + } + items = append(items, "COUNT(*) AS n") + } else if q.union != nil { + items = append(items, "t0.a AS t0__a") + } else { + for _, l := range q.leaves { + for _, c := range l.table.Cols { + items = append(items, l.alias+"."+c.Name+" AS "+l.alias+"__"+c.Name) + } + } + } + b.WriteString(strings.Join(items, ", ")) + b.WriteString("\nFROM ") + for i, l := range q.leaves { + if i > 0 { + if l.outer { + b.WriteString("\n LEFT JOIN ") + } else { + b.WriteString("\n JOIN ") + } + } + switch { + case l.src != nil && !l.cte: + b.WriteString("(" + l.src.sql() + ") AS " + l.alias) + default: + b.WriteString(l.table.Name + " AS " + l.alias) + } + if i > 0 { + b.WriteString(" ON " + conjSQL(l.on)) + } + } + if len(q.where) > 0 { + b.WriteString("\nWHERE " + conjSQL(q.where)) + } + if q.group != nil { + b.WriteString("\nGROUP BY t0." + q.group.Name) + } + if q.union != nil { + b.WriteString("\nUNION ALL SELECT u.a FROM " + q.union.table.Name + " AS u") + } + if q.limit1 { + b.WriteString("\nLIMIT 1") + } + return b.String() +} + +// witness is the SELECT that returns the rows a write touches: its target's columns under +// the same WHERE. +func (q *query) witness() string { + sel := &query{kind: "select", leaves: q.leaves[:1], where: q.where, params: q.params} + return sel.sql() +} + +func conjSQL(cs []conj) string { + if len(cs) == 0 { + return "TRUE" + } + parts := make([]string, len(cs)) + for i, c := range cs { + parts[i] = c.sql + } + return strings.Join(parts, " AND ") +} + +// rows evaluates the query over the model: the rows the server should return (order +// aside; LIMIT 1 is checked by count, not by which row). +func (q *query) rows(m *Schema) []row { + var out []row + var walk func(i int, r row) + walk = func(i int, r row) { + if i == len(q.leaves) { + for _, c := range q.where { + if c.eval(r, m, q.params) != yes { + return + } + } + out = append(out, r) + return + } + l := q.leaves[i] + matched := false + for _, tr := range l.table.Rows { + nr := r.with(l, tr) + ok := true + for _, c := range l.on { + if c.eval(nr, m, q.params) != yes { + ok = false + break + } + } + if ok { + matched = true + walk(i+1, nr) + } + } + if l.outer && !matched { + walk(i+1, r.with(l, nil)) + } + } + walk(0, row{}) + if q.group != nil { + groups := map[string]int{} + first := map[string]row{} + var order []string + for _, r := range out { + k := r.get("t0", "id").String() + if _, ok := groups[k]; !ok { + order = append(order, k) + first[k] = r + } + groups[k]++ + } + out = nil + for _, k := range order { + g := row{"n": intValue(groups[k])} + for _, c := range q.leaves[0].table.Cols { + g["t0."+c.Name] = first[k].get("t0", c.Name) + } + out = append(out, g) + } + } + if q.union != nil { + var projected []row + for _, r := range out { + projected = append(projected, row{"t0.a": r.get("t0", "a")}) + } + for _, tr := range q.union.table.Rows { + projected = append(projected, row{"t0.a": tr[q.union.table.col("a")]}) + } + out = projected + } + return out +} + +// with extends r with a leaf's row (nil: every column NULL). +func (r row) with(l leaf, tr []Value) row { + nr := make(row, len(r)+len(l.table.Cols)) + for k, v := range r { + nr[k] = v + } + for i, c := range l.table.Cols { + if tr == nil { + nr[l.alias+"."+c.Name] = null + } else { + nr[l.alias+"."+c.Name] = tr[i] + } + } + return nr +} + +// ---- generation ----------------------------------------------------------------------- + +// genSchema draws 2 or 3 tables of 4 columns (id INT PRIMARY KEY, a INT, b INT NOT NULL +// UNIQUE or not, s VARCHAR) with 3 to 6 rows over a small domain (1..3, 'x'..'z', NULL), +// so predicates match several rows and NULLs reach every comparison. +func genSchema(r *rand.Rand, prefix string) *Schema { + s := &Schema{} + n := 2 + r.Intn(2) + for i := 0; i < n; i++ { + t := &Table{Name: fmt.Sprintf("%st%d", prefix, i)} + t.Cols = []Col{{Name: "id", Kind: Int, NotNull: true}, {Name: "a", Kind: Int}, {Name: "b", Kind: Int, NotNull: true}, {Name: "s", Kind: Str}} + t.PK = []string{"id"} + unique := r.Intn(2) == 0 + if unique { + t.Uniques = [][]string{{"b"}} + } + rows := 3 + r.Intn(4) + seenB := map[string]bool{} + for k := 0; k < rows; k++ { + b := intValue(1 + r.Intn(6)) + for unique && seenB[b.S] { + b = intValue(1 + r.Intn(6)) + } + seenB[b.S] = true + t.Rows = append(t.Rows, []Value{intValue(k + 1), genInt(r, true), b, genStr(r, true)}) + } + s.Tables = append(s.Tables, t) + } + // a view over one of the tables, with a conjunct of its own (a fixed column, a + // NOT NULL, an opaque comparison), materialized in the model from the base's rows + if r.Intn(2) == 0 { + base := s.Tables[r.Intn(len(s.Tables))] + v := &Table{Name: prefix + "v0", Cols: base.Cols, view: &derived{base: base}} + for { + // a view body takes no parameter + c := genConj(r, &query{}, []leaf{{alias: "d", table: base}}, s) + if !strings.Contains(c.sql, "$") { + v.view.where = []conj{c} + break + } + } + v.Rows = v.view.rows(s, nil) + s.Tables = append(s.Tables, v) + } + return s +} + +func genInt(r *rand.Rand, nullable bool) Value { + if nullable && r.Intn(4) == 0 { + return null + } + return intValue(1 + r.Intn(3)) +} + +func genStr(r *rand.Rand, nullable bool) Value { + if nullable && r.Intn(4) == 0 { + return null + } + return strValue(string(rune('x' + r.Intn(3)))) +} + +// genQuery draws a statement over the schema. A SELECT: one leaf (a table, the view, a +// derived table or a CTE over a table), or two joined (inner or left), one to three +// WHERE conjuncts, sometimes GROUP BY, UNION ALL or LIMIT 1. Or a single-table UPDATE / +// DELETE under the same WHERE vocabulary, or an INSERT of one row. +func genQuery(r *rand.Rand, m *Schema) *query { + q := &query{kind: "select"} + var bases []*Table + for _, t := range m.Tables { + if t.view == nil { + bases = append(bases, t) + } + } + switch r.Intn(10) { + case 0: + q.kind = "update" + case 1: + q.kind = "delete" + case 2: + q.kind = "insert" + } + if q.kind != "select" { + t := bases[r.Intn(len(bases))] + q.leaves = []leaf{{alias: "t0", table: t}} + if q.kind == "insert" { + id := len(t.Rows) + 1 + r.Intn(3) + for _, c := range t.Cols { + var v Value + switch { + case c.Name == "id": + v = intValue(id) + case c.Kind == Str: + v = genStr(r, !c.NotNull) + default: + v = genInt(r, !c.NotNull) + } + q.set = append(q.set, genAssign(r, q, c, v)) + } + return q + } + if q.kind == "update" { + n := 1 + r.Intn(2) + used := map[string]bool{} + for i := 0; i < n; i++ { + c := t.Cols[1+r.Intn(len(t.Cols)-1)] // not the primary key + if used[c.Name] { + continue + } + used[c.Name] = true + var v Value + if c.Kind == Str { + v = genStr(r, !c.NotNull) + } else { + v = genInt(r, !c.NotNull) + } + q.set = append(q.set, genAssign(r, q, c, v)) + } + } + n := 1 + r.Intn(3) + for i := 0; i < n; i++ { + q.where = append(q.where, genConj(r, q, q.leaves, m)) + } + return q + } + q.leaves = append(q.leaves, genLeaf(r, q, m, "t0", bases)) + if r.Intn(2) == 0 { + l := genLeaf(r, q, m, "t1", bases) + l.outer = r.Intn(3) == 0 + // the ON: an equality between the two, sometimes a further conjunct on the new leaf + l.on = append(l.on, eqCols(q.leaves[0], l, r)) + if r.Intn(3) == 0 { + l.on = append(l.on, genConj(r, q, []leaf{l}, m)) + } + q.leaves = append(q.leaves, l) + } + n := 1 + r.Intn(3) + for i := 0; i < n; i++ { + q.where = append(q.where, genConj(r, q, q.leaves, m)) + } + switch r.Intn(8) { + case 0: + if q.leaves[0].src == nil && q.leaves[0].table.view == nil { + // GROUP BY the primary key, which only a base table leaf carries for the + // functional dependency both dialects accept in the select list + c := q.leaves[0].table.Cols[0] // id + q.group = &c + } + case 1: + u := bases[r.Intn(len(bases))] + q.union = &leaf{alias: "u", table: u} + } + q.limit1 = r.Intn(6) == 0 + return q +} + +// genLeaf draws a relation for alias: a table, the view when the schema has one, a +// derived table or a CTE over a table (each with a conjunct of its own). +func genLeaf(r *rand.Rand, q *query, m *Schema, alias string, bases []*Table) leaf { + switch r.Intn(6) { + case 0, 1: + if v := m.view(); v != nil { + return leaf{alias: alias, table: v} + } + case 2, 3: + base := bases[r.Intn(len(bases))] + d := &derived{base: base, where: []conj{genConj(r, q, []leaf{{alias: "d", table: base}}, m)}} + t := &Table{Name: alias + "_d", Cols: base.Cols, Rows: d.rows(m, q.params)} + return leaf{alias: alias, table: t, src: d, cte: r.Intn(2) == 0} + } + return leaf{alias: alias, table: bases[r.Intn(len(bases))]} +} + +// genAssign is `col = literal` or `col = $n` for a write; an UPDATE's integer column +// sometimes takes `b + 1` (an expression: a Known value the probe cannot check). +func genAssign(r *rand.Rand, q *query, c Col, v Value) assign { + if q.kind == "update" && c.Kind == Int && r.Intn(5) == 0 { + return assign{col: c, sql: "b + 1", value: Value{S: "?"}} + } + if r.Intn(3) == 0 { + q.params = append(q.params, v) + return assign{col: c, sql: fmt.Sprintf("$%d", len(q.params)), value: v} + } + return assign{col: c, sql: literal(c.Kind, v), value: v} +} + +// pick draws a leaf and one of its columns. +func pick(r *rand.Rand, leaves []leaf) (leaf, Col) { + l := leaves[r.Intn(len(leaves))] + return l, l.table.Cols[r.Intn(len(l.table.Cols))] +} + +// eqCols is `b.col = a.col` between two leaves, over columns of one kind. +func eqCols(a, b leaf, r *rand.Rand) conj { + ca := a.table.Cols[r.Intn(len(a.table.Cols))] + var cands []Col + for _, c := range b.table.Cols { + if c.Kind == ca.Kind { + cands = append(cands, c) + } + } + cb := cands[r.Intn(len(cands))] + return conj{sql: b.alias + "." + cb.Name + " = " + a.alias + "." + ca.Name, kind: "pred eq column", + eval: func(rw row, _ *Schema, _ []Value) tri { + return eqValues(rw.get(b.alias, cb.Name), rw.get(a.alias, ca.Name)) + }} +} + +// eqValues is SQL's `=`: unknown when either side is NULL. +func eqValues(a, b Value) tri { + if a.Null || b.Null { + return unknown + } + return bool3(a.S == b.S) +} + +// genConj draws one conjunct over the given leaves (the columns it may read); q supplies +// the parameter list to extend and m the other tables a subquery may use. +func genConj(r *rand.Rand, q *query, leaves []leaf, m *Schema) conj { + l, c := pick(r, leaves) + ref := l.alias + "." + c.Name + lit := func() Value { + if c.Kind == Str { + return genStr(r, false) + } + return genInt(r, false) + } + litSQL := func(v Value) string { return literal(c.Kind, v) } + switch r.Intn(13) { + case 12: // col = : a value known before the statement runs + expr, v := "2 + 1", intValue(3) + if c.Kind == Str { + expr, v = "LOWER('X')", strValue("x") + } + if q.known == nil { + q.known = map[string]Value{} + } + q.known[normText(expr)] = v + return conj{sql: ref + " = " + expr, kind: "pred eq known", eval: func(rw row, _ *Schema, _ []Value) tri { return eqValues(rw.get(l.alias, c.Name), v) }} + case 0, 1: // col = literal + v := lit() + return conj{sql: ref + " = " + litSQL(v), kind: "pred eq const", eval: func(rw row, _ *Schema, _ []Value) tri { return eqValues(rw.get(l.alias, c.Name), v) }} + case 2, 3: // col = $n + q.params = append(q.params, lit()) + n := len(q.params) + return conj{sql: fmt.Sprintf("%s = $%d", ref, n), kind: "pred eq param", eval: func(rw row, _ *Schema, ps []Value) tri { return eqValues(rw.get(l.alias, c.Name), ps[n-1]) }} + case 4: // col = other column of the same leaf (same kind) + var cands []Col + for _, o := range l.table.Cols { + if o.Kind == c.Kind && o.Name != c.Name { + cands = append(cands, o) + } + } + if len(cands) == 0 { + return genConj(r, q, leaves, m) + } + o := cands[r.Intn(len(cands))] + return conj{sql: ref + " = " + l.alias + "." + o.Name, kind: "pred eq column", eval: func(rw row, _ *Schema, _ []Value) tri { + return eqValues(rw.get(l.alias, c.Name), rw.get(l.alias, o.Name)) + }} + case 5: // col IN (a, b[, $n]) + vals := []Value{lit(), lit()} + var parts []string + for _, v := range vals { + parts = append(parts, litSQL(v)) + } + var pn int + if r.Intn(2) == 0 { + q.params = append(q.params, lit()) + pn = len(q.params) + parts = append(parts, fmt.Sprintf("$%d", pn)) + } + return conj{sql: ref + " IN (" + strings.Join(parts, ", ") + ")", kind: "pred in", eval: func(rw row, _ *Schema, ps []Value) tri { + set := vals + if pn > 0 { + set = append(append([]Value(nil), vals...), ps[pn-1]) + } + return inValues(rw.get(l.alias, c.Name), set) + }} + case 6: // col = a OR col = b + v1, v2 := lit(), lit() + return conj{sql: "(" + ref + " = " + litSQL(v1) + " OR " + ref + " = " + litSQL(v2) + ")", kind: "pred in", eval: func(rw row, _ *Schema, _ []Value) tri { + return inValues(rw.get(l.alias, c.Name), []Value{v1, v2}) + }} + case 7: // IS NULL + return conj{sql: ref + " IS NULL", kind: "pred isnull", eval: func(rw row, _ *Schema, _ []Value) tri { return bool3(rw.get(l.alias, c.Name).Null) }} + case 8: // IS NOT NULL + return conj{sql: ref + " IS NOT NULL", kind: "pred isnotnull", eval: func(rw row, _ *Schema, _ []Value) tri { return bool3(!rw.get(l.alias, c.Name).Null) }} + case 9: // col literal: opaque + v := lit() + ops := []string{"<>", "<", ">", "<=", ">="} + op := ops[r.Intn(len(ops))] + return conj{sql: ref + " " + op + " " + litSQL(v), kind: "pred opaque", eval: func(rw row, _ *Schema, _ []Value) tri { return compare(rw.get(l.alias, c.Name), op, v, c.Kind) }} + case 10: // EXISTS (SELECT 1 FROM u WHERE u.x = outer.col [AND u.y = lit]) + return genExists(r, q, l, c, m, false) + case 11: // col = (SELECT MAX(u.x) FROM u): a value known before the statement runs + return genScalar(r, q, l, c, m) + default: // col IN (SELECT u.x FROM u WHERE ...) + return genExists(r, q, l, c, m, true) + } +} + +// genScalar is ` = (SELECT MAX(s0.x) FROM u AS s0)`, an uncorrelated scalar subquery +// (a Known term). u is never the write's own target (MySQL's 1093). +func genScalar(r *rand.Rand, q *query, l leaf, c Col, m *Schema) conj { + u := otherTable(r, q, m) + var cands []Col + for _, o := range u.Cols { + if o.Kind == c.Kind { + cands = append(cands, o) + } + } + x := cands[r.Intn(len(cands))] + ref := l.alias + "." + c.Name + sub := "(SELECT MAX(s0." + x.Name + ") FROM " + u.Name + " AS s0)" + if q.known == nil { + q.known = map[string]Value{} + } + q.known[normText(sub)] = maxOf(u, x) + return conj{sql: ref + " = " + sub, kind: "pred eq known", eval: func(rw row, m *Schema, _ []Value) tri { + return eqValues(rw.get(l.alias, c.Name), maxOf(u, x)) + }} +} + +// maxOf is MAX(col) over the table's rows (NULL when no row has a value). +func maxOf(u *Table, x Col) Value { + best := null + for _, tr := range u.Rows { + v := tr[u.col(x.Name)] + if v.Null { + continue + } + if best.Null || compare(v, ">", best, x.Kind) == yes { + best = v + } + } + return best +} + +// otherTable draws a table for a subquery: any for a SELECT; for a write, one other than +// the target (MySQL refuses a subquery reading the table an UPDATE / DELETE writes, 1093, +// and one reading a view over it, 1443 -- a separate face, out of this probe's scope). +func otherTable(r *rand.Rand, q *query, m *Schema) *Table { + for { + u := m.Tables[r.Intn(len(m.Tables))] + if q.kind == "select" || q.kind == "" || len(q.leaves) == 0 { + return u + } + target := q.leaves[0].table + if u == target || (u.view != nil && u.view.base == target) { + continue + } + return u + } +} + +func inValues(v Value, set []Value) tri { + if v.Null { + return unknown + } + sawNull := false + for _, s := range set { + if s.Null { + sawNull = true + continue + } + if s.S == v.S { + return yes + } + } + if sawNull { + return unknown + } + return no +} + +// compare is SQL's ordering comparison over one kind (integers by value, strings by +// byte order -- the generator's strings are single lower-case letters, which every +// collation orders the same way). +func compare(a Value, op string, b Value, k ColKind) tri { + if a.Null || b.Null { + return unknown + } + var cmp int + if k == Int { + var x, y int + fmt.Sscan(a.S, &x) + fmt.Sscan(b.S, &y) + cmp = x - y + } else { + cmp = strings.Compare(a.S, b.S) + } + switch op { + case "<>": + return bool3(cmp != 0) + case "<": + return bool3(cmp < 0) + case ">": + return bool3(cmp > 0) + case "<=": + return bool3(cmp <= 0) + case ">=": + return bool3(cmp >= 0) + } + return bool3(cmp == 0) +} + +// genExists draws a correlated subquery over another table: `EXISTS (SELECT 1 FROM u AS +// s0 WHERE s0.x = [AND s0.y = lit])`, or the IN form ` IN (SELECT +// s0.x FROM u AS s0 WHERE ...)`. +func genExists(r *rand.Rand, q *query, l leaf, c Col, m *Schema, in bool) conj { + u := otherTable(r, q, m) + var cands []Col + for _, o := range u.Cols { + if o.Kind == c.Kind { + cands = append(cands, o) + } + } + x := cands[r.Intn(len(cands))] + ref := l.alias + "." + c.Name + var extra *conj + if r.Intn(2) == 0 { + y := u.Cols[r.Intn(len(u.Cols))] + var v Value + if y.Kind == Str { + v = genStr(r, false) + } else { + v = genInt(r, false) + } + e := conj{sql: "s0." + y.Name + " = " + literal(y.Kind, v), eval: func(rw row, _ *Schema, _ []Value) tri { return eqValues(rw.get("s0", y.Name), v) }} + extra = &e + } + where := "s0." + x.Name + " = " + ref + if extra != nil { + where += " AND " + extra.sql + } + var sql, kind string + if in { + w := "" + if extra != nil { + w = " WHERE " + extra.sql + } + sql = ref + " IN (SELECT s0." + x.Name + " FROM " + u.Name + " AS s0" + w + ")" + kind = "pred exists (in subquery)" + } else { + sql = "EXISTS (SELECT 1 FROM " + u.Name + " AS s0 WHERE " + where + ")" + kind = "pred exists" + } + return conj{sql: sql, kind: kind, eval: func(rw row, m *Schema, ps []Value) tri { + outer := rw.get(l.alias, c.Name) + result := no + for _, ur := range u.Rows { + sr := row{} + for i, uc := range u.Cols { + sr["s0."+uc.Name] = ur[i] + } + if extra != nil && extra.eval(sr, m, ps) != yes { + continue + } + switch eqValues(sr.get("s0", x.Name), outer) { + case yes: + return yes + case unknown: + if in { + result = unknown // x IN (..., NULL, ...) with no match is NULL + } + } + } + return result + }} +} diff --git a/x/stmtprobe/judge.go b/x/stmtprobe/judge.go new file mode 100644 index 0000000..ccfe970 --- /dev/null +++ b/x/stmtprobe/judge.go @@ -0,0 +1,619 @@ +package stmtprobe + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/kr9ly/sqlshape/v2/x/cardinality" + "github.com/kr9ly/sqlshape/v2/x/facts" +) + +// ---- judging: the facts' claims against the rows the server returned ----------------- + +// Verdict is the judgment of one statement. Kind is "" when every claim held; otherwise +// the class of failure, Detail what was claimed and what the server showed. +type Verdict struct { + Kind string + Detail string + SQL string + Schema string // the DDL and the rows + Params []Value + // Hit are the alphabet entries the statement's facts exercised. + Hit []string + // Unverified counts the claims the judge could not evaluate (an Opaque predicate + // whose text matched no generated conjunct, a Known term): reported, not failed. + Unverified []string +} + +// judge compares f, the facts the analyzer gave for q, with rows, what the server +// returned for it. m is the model the rows came from (for EXISTS witnesses). +func judge(q *query, f *facts.Facts, rows []row, m *Schema) Verdict { + v := Verdict{SQL: q.sql(), Params: q.params} + if f == nil { + v.Kind, v.Detail = "no facts", "the analyzer recorded no facts for the statement" + return v + } + v.Hit = alphabetHits(f) + sc := f.Top + if sc == nil { + v.Kind, v.Detail = "no facts", "the facts have no top scope" + return v + } + aliases := make([]string, len(sc.Leaves)) + for i, l := range sc.Leaves { + aliases[i] = l.Alias + } + col := func(c facts.ColRef, r row) (Value, bool) { + if c.Leaf < 0 || c.Leaf >= len(aliases) { + return Value{}, false + } + val, ok := r[aliases[c.Leaf]+"."+c.Column] + return val, ok + } + fail := func(kind, format string, args ...any) Verdict { + v.Kind, v.Detail = kind, fmt.Sprintf(format, args...) + return v + } + // at most one row: the whole level for a SELECT, the target leaf alone for a write + // (x/obligation judges `single` on an UPDATE / DELETE that way) + if q.kind == "update" || q.kind == "delete" { + if ok, _ := cardinality.TargetSingle(sc, 0); ok && len(rows) > 1 { + return fail("at most one row refuted", "the proof says the target is at most one row; the server matched %d", len(rows)) + } + } else if ok, _ := cardinality.AtMostOne(f); ok && len(rows) > 1 { + return fail("at most one row refuted", "the proof says at most one row; the server returned %d", len(rows)) + } + // present: the row has leaf i's columns (not the all-NULL side of an outer join + // without a match): what Fixed / NotNull / an Edge on that leaf speak about + present := func(i int, r row) bool { + if !nullableLeaf(sc, i) { + return true + } + return restrictedRowPresent(facts.Pred{Restricts: []int{i}}, r, aliases, sc) + } + // predicates: every returned row satisfies each conjunct of the top scope + for _, p := range sc.Preds { + for _, r := range rows { + // an outer join's ON restricts only its nullable side: a row where that side + // is all NULL (no match) is not one the predicate speaks about + if p.Restricts != nil && !restrictedRowPresent(p, r, aliases, sc) { + continue + } + res, why := evalPred(p, r, q, m, col, aliases, sc) + if why != "" { + v.Unverified = append(v.Unverified, why) + break + } + if res != yes { + return fail("predicate refuted", "the facts say every row satisfies %s; the server returned %s (the predicate is %v there)", describePred(p, aliases), r, res) + } + } + } + // fixed columns: one non-NULL value across every returned row + for _, c := range sc.Fixed { + var seen *Value + for _, r := range rows { + val, ok := col(c, r) + if !ok { + v.Unverified = append(v.Unverified, notProjected) + break + } + if !present(c.Leaf, r) { + continue + } + if val.Null { + return fail("fixed column refuted", "the facts say %s.%s is fixed to a known value; the server returned a row with it NULL: %s", aliases[c.Leaf], c.Column, r) + } + if seen == nil { + seen = &val + } else if seen.S != val.S { + return fail("fixed column refuted", "the facts say %s.%s is fixed to one value; the server returned both %s and %s", aliases[c.Leaf], c.Column, seen.S, val.S) + } + } + } + // not null + for _, c := range sc.NotNull { + for _, r := range rows { + if val, ok := col(c, r); ok && present(c.Leaf, r) && val.Null { + return fail("not null refuted", "the facts say %s.%s is not NULL in every row; the server returned %s", aliases[c.Leaf], c.Column, r) + } + } + } + // edges: From fixed implies To fixed -- over the returned rows, To is a function of From + for _, e := range sc.Edges { + seen := map[string]string{} + for _, r := range rows { + from, ok1 := col(e.From, r) + to, ok2 := col(e.To, r) + if !ok1 || !ok2 || from.Null || !present(e.To.Leaf, r) { + continue + } + if prev, ok := seen[from.S]; ok && prev != to.String() { + return fail("edge refuted", "the facts say %s.%s fixed implies %s.%s fixed; the server returned %s = %s with %s.%s both %s and %s", aliases[e.From.Leaf], e.From.Column, aliases[e.To.Leaf], e.To.Column, e.From.Column, from.S, aliases[e.To.Leaf], e.To.Column, prev, to) + } + seen[from.S] = to.String() + } + } + return v +} + +// notProjected is the claim about a column the statement does not return: the other +// leaf's columns under GROUP BY t0.id. +const notProjected = "a column of a leaf the GROUP BY does not project" + +// nullableLeaf: some predicate restricts itself to leaf i alone -- the mark of an outer +// join's nullable side. +func nullableLeaf(sc *facts.Scope, i int) bool { + for _, p := range sc.Preds { + if len(p.Restricts) == 1 && p.Restricts[0] == i { + return true + } + } + return false +} + +// restrictedRowPresent: the leaves p restricts have a row (not the all-NULL side of an +// outer join without a match) in r. +func restrictedRowPresent(p facts.Pred, r row, aliases []string, sc *facts.Scope) bool { + for _, i := range p.Restricts { + if i < 0 || i >= len(aliases) { + continue + } + allNull := true + for k, v := range r { + if strings.HasPrefix(k, aliases[i]+".") && !v.Null { + allNull = false + break + } + } + if allNull { + return false + } + } + return true +} + +// evalPred evaluates one predicate over a row. why is set when it cannot be evaluated. +func evalPred(p facts.Pred, r row, q *query, m *Schema, col func(facts.ColRef, row) (Value, bool), aliases []string, sc *facts.Scope) (tri, string) { + term := func(t facts.Term) (Value, string) { + switch t.Kind { + case facts.Param: + if int(t.Param) < 1 || int(t.Param) > len(q.params) { + return Value{}, fmt.Sprintf("parameter $%d out of range", t.Param) + } + return q.params[t.Param-1], "" + case facts.Const: + return constValue(t.Const), "" + case facts.Column: + v, ok := col(t.Col, r) + if !ok { + return Value{}, notProjected + } + return v, "" + case facts.Known: + if v, ok := q.known[normText(t.Text)]; ok { + return v, "" + } + } + return Value{}, "term of kind " + termKind(t.Kind) + " (" + t.Text + ") is not evaluated" + } + switch p.Op { + case facts.Eq: + a, ok := col(p.Col, r) + if !ok { + return unknown, notProjected + } + b, why := term(p.Term) + if why != "" { + return unknown, why + } + return eqValues(a, b), "" + case facts.In: + a, ok := col(p.Col, r) + if !ok { + return unknown, notProjected + } + var set []Value + for _, t := range p.Terms { + b, why := term(t) + if why != "" { + return unknown, why + } + set = append(set, b) + } + return inValues(a, set), "" + case facts.IsNull: + a, ok := col(p.Col, r) + if !ok { + return unknown, notProjected + } + return bool3(a.Null), "" + case facts.IsNotNull: + a, ok := col(p.Col, r) + if !ok { + return unknown, notProjected + } + return bool3(!a.Null), "" + case facts.Exists: + return existsWitness(p.Sub, r, q, m, col) + case facts.Opaque: + // the generator's own conjunct with the same text, its qualifiers stripped + for _, c := range append(append([]conj(nil), q.where...), onConjs(q)...) { + if bareText(c.sql) == bareText(p.Text) { + return c.eval(r, m, q.params), "" + } + } + return unknown, "opaque predicate " + strconv.Quote(p.Text) + " matches no generated conjunct" + } + return unknown, fmt.Sprintf("predicate op %d is not evaluated", p.Op) +} + +func onConjs(q *query) []conj { + var out []conj + for _, l := range q.leaves { + out = append(out, l.on...) + } + return out +} + +// bareText strips alias qualifiers and spaces from a predicate's text, the way the +// producers reduce column references to their bare names. +func bareText(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] == ' ' { + continue + } + b.WriteByte(s[i]) + } + out := b.String() + for _, a := range []string{"t0.", "t1.", "s0."} { + out = strings.ReplaceAll(out, a, "") + } + return strings.Trim(out, "()") +} + +// existsWitness looks for a row of the sub-scope's leaves satisfying its predicates, with +// Outer terms bound to the enclosing row. +func existsWitness(sub *facts.Scope, outer row, q *query, m *Schema, outerCol func(facts.ColRef, row) (Value, bool)) (tri, string) { + if sub == nil { + return unknown, "EXISTS without a body" + } + var tables []*Table + for _, l := range sub.Leaves { + t := m.table(l.Table) + if t == nil { + return unknown, "EXISTS body leaf " + l.Table + " is not a model table" + } + tables = append(tables, t) + } + aliases := make([]string, len(sub.Leaves)) + for i, l := range sub.Leaves { + aliases[i] = l.Alias + } + innerCol := func(c facts.ColRef, r row) (Value, bool) { + if c.Leaf < 0 || c.Leaf >= len(aliases) { + return Value{}, false + } + v, ok := r[aliases[c.Leaf]+"."+c.Column] + return v, ok + } + term := func(t facts.Term, r row) (Value, string) { + switch t.Kind { + case facts.Param: + if int(t.Param) < 1 || int(t.Param) > len(q.params) { + return Value{}, "parameter out of range" + } + return q.params[t.Param-1], "" + case facts.Const: + return constValue(t.Const), "" + case facts.Column: + v, ok := innerCol(t.Col, r) + if !ok { + return Value{}, "column term not in the body row" + } + return v, "" + case facts.Outer: + v, ok := outerCol(t.Col, outer) + if !ok { + return Value{}, "outer term not in the enclosing row" + } + return v, "" + } + return Value{}, "term of kind " + termKind(t.Kind) + " in an EXISTS body is not evaluated" + } + result := no + var walk func(i int, r row) string + walk = func(i int, r row) string { + if i == len(tables) { + all := yes + for _, p := range sub.Preds { + var res tri + a, ok := innerCol(p.Col, r) + switch p.Op { + case facts.Eq: + if !ok { + return "body column not in the row" + } + b, why := term(p.Term, r) + if why != "" { + return why + } + res = eqValues(a, b) + case facts.In: + if !ok { + return "body column not in the row" + } + var set []Value + for _, t := range p.Terms { + b, why := term(t, r) + if why != "" { + return why + } + set = append(set, b) + } + res = inValues(a, set) + case facts.IsNull: + res = bool3(ok && a.Null) + case facts.IsNotNull: + res = bool3(ok && !a.Null) + default: + return fmt.Sprintf("predicate op %d in an EXISTS body is not evaluated", p.Op) + } + if res == no { + return "" + } + if res == unknown { + all = unknown + } + } + if all == yes { + result = yes + } + return "" + } + for _, tr := range tables[i].Rows { + nr := make(row, len(r)+len(tr)) + for k, v := range r { + nr[k] = v + } + for k, c := range tables[i].Cols { + nr[aliases[i]+"."+c.Name] = tr[k] + } + if why := walk(i+1, nr); why != "" { + return why + } + if result == yes { + return "" + } + } + return "" + } + if why := walk(0, row{}); why != "" { + return unknown, why + } + return result, "" +} + +// constValue reads a Const term's tagged spelling ("i5", "sx", "btrue", "NULL"). +func constValue(c string) Value { + if c == "NULL" || c == "" { + return null + } + return Value{S: c[1:]} +} + +func termKind(k facts.TermKind) string { + switch k { + case facts.Param: + return "param" + case facts.Const: + return "const" + case facts.Column: + return "column" + case facts.Known: + return "known" + case facts.Outer: + return "outer" + case facts.Expr: + return "expr" + } + return fmt.Sprint(k) +} + +func predOp(op facts.PredOp) string { + switch op { + case facts.Eq: + return "eq" + case facts.IsNull: + return "isnull" + case facts.IsNotNull: + return "isnotnull" + case facts.Exists: + return "exists" + case facts.In: + return "in" + case facts.Contains: + return "contains" + case facts.Opaque: + return "opaque" + } + return fmt.Sprint(op) +} + +func describePred(p facts.Pred, aliases []string) string { + name := func(c facts.ColRef) string { + if c.Leaf >= 0 && c.Leaf < len(aliases) { + return aliases[c.Leaf] + "." + c.Column + } + return fmt.Sprintf("leaf%d.%s", c.Leaf, c.Column) + } + t := func(t facts.Term) string { + switch t.Kind { + case facts.Param: + return fmt.Sprintf("$%d", t.Param) + case facts.Const: + return t.Const + case facts.Column: + return name(t.Col) + case facts.Outer: + return "outer " + name(t.Col) + } + return termKind(t.Kind) + "(" + t.Text + ")" + } + switch p.Op { + case facts.Eq: + return name(p.Col) + " = " + t(p.Term) + case facts.In: + var ts []string + for _, x := range p.Terms { + ts = append(ts, t(x)) + } + return name(p.Col) + " IN (" + strings.Join(ts, ", ") + ")" + case facts.IsNull: + return name(p.Col) + " IS NULL" + case facts.IsNotNull: + return name(p.Col) + " IS NOT NULL" + case facts.Exists: + return "EXISTS (...)" + case facts.Opaque: + return "opaque " + strconv.Quote(p.Text) + } + return predOp(p.Op) +} + +// ---- the alphabet: what the facts can say, and what a statement's facts said ---------- + +// Alphabet lists every shape of claim the facts language has, as the probe names them. +// The gate compares it with what the generated statements' facts exercised. +var Alphabet = []string{ + "pred eq param", "pred eq const", "pred eq column", "pred eq known", "pred eq outer", + "pred in", "pred isnull", "pred isnotnull", "pred exists", "pred contains", "pred opaque", + "pred restricted", "pred origin view", "pred origin policy", + "fixed", "edge", "notnull", "scope single", "scope many", "groups", + "leaf table", "leaf view", "leaf derived", "leaf cte", "leaf function", "leaf body", "leaf single", "leaf key partial", "leaf key temporal", + "at most one", "write insert", "write update", "write delete", "value const", "value param", "value known", "kind select", "kind insert", "kind update", "kind delete", "kind call", +} + +// alphabetHits lists the alphabet entries f exercises, at any depth. +func alphabetHits(f *facts.Facts) []string { + hit := map[string]bool{} + switch f.Kind { + case facts.Select: + hit["kind select"] = true + case facts.Insert: + hit["kind insert"] = true + case facts.Update: + hit["kind update"] = true + case facts.Delete: + hit["kind delete"] = true + case facts.Call: + hit["kind call"] = true + } + if ok, _ := cardinality.AtMostOne(f); ok { + hit["at most one"] = true + } + for _, w := range f.Writes { + switch w.Kind { + case facts.Insert: + hit["write insert"] = true + case facts.Update: + hit["write update"] = true + case facts.Delete: + hit["write delete"] = true + } + for _, v := range w.Values { + switch v.Kind { + case facts.Const: + hit["value const"] = true + case facts.Param: + hit["value param"] = true + case facts.Known: + hit["value known"] = true + } + } + } + var walk func(sc *facts.Scope) + walk = func(sc *facts.Scope) { + if sc == nil { + return + } + for _, p := range sc.Preds { + switch p.Op { + case facts.Eq: + hit["pred eq "+termKind(p.Term.Kind)] = true + default: + hit["pred "+predOp(p.Op)] = true + } + if p.Restricts != nil { + hit["pred restricted"] = true + } + switch p.Origin { + case facts.FromView: + hit["pred origin view"] = true + case facts.FromPolicy: + hit["pred origin policy"] = true + } + walk(p.Sub) + } + if len(sc.Fixed) > 0 { + hit["fixed"] = true + } + if len(sc.Edges) > 0 { + hit["edge"] = true + } + if len(sc.NotNull) > 0 { + hit["notnull"] = true + } + if sc.Single { + hit["scope single"] = true + } + if sc.Many != "" { + hit["scope many"] = true + } + if sc.Groups != nil { + hit["groups"] = true + } + for _, l := range sc.Leaves { + switch l.Kind { + case facts.Table: + hit["leaf table"] = true + case facts.View, facts.MatView: + hit["leaf view"] = true + case facts.Derived: + hit["leaf derived"] = true + case facts.CTE: + hit["leaf cte"] = true + case facts.Function: + hit["leaf function"] = true + } + if l.Body != nil { + hit["leaf body"] = true + walk(l.Body) + } + if l.Single { + hit["leaf single"] = true + } + for _, k := range l.Keys { + if len(k.Where) > 0 { + hit["leaf key partial"] = true + } + if k.Temporal { + hit["leaf key temporal"] = true + } + } + } + for _, ch := range sc.Children { + walk(ch) + } + } + walk(f.Top) + var out []string + for k := range hit { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/x/stmtprobe/model.go b/x/stmtprobe/model.go new file mode 100644 index 0000000..c8ba7a1 --- /dev/null +++ b/x/stmtprobe/model.go @@ -0,0 +1,250 @@ +// Package stmtprobe checks a dialect's analyzer against a running server on the claims a +// handwritten test cannot enumerate: it generates a small schema with rows and statements +// over it, asks the analyzer for each statement's verdict, runs the statement, and refutes +// the verdict with what the server did. Two probes share the generator: the facts probe +// (Run) refutes every claim of the statement facts (x/facts) -- a predicate that every row +// was said to satisfy, a column said to be fixed, a level said to be at most one row, the +// value a write stores -- with the rows the server returned; the failure probe +// (RunFailures) refutes the predicted failure modes -- every constraint error the server +// raises must be among the violations predicted for the statement, under the key the +// runtime's Violates would match. It is the oracle behind the One proof, the obligations +// and the expect lines, written once for every dialect; check/postgres and check/mysql +// drive it from their tests. Like pgtest and mysqltest it is sqlshape's own test tooling, +// with no compatibility promise. +package stmtprobe + +import ( + "fmt" + "sort" + "strings" +) + +// Value is one cell as the probe sees it: NULL, or a value spelled as text (an integer +// as its decimal digits, a string as its content), which is how both the model and the +// server's answers compare. +type Value struct { + Null bool + S string +} + +func intValue(i int) Value { return Value{S: fmt.Sprint(i)} } +func strValue(s string) Value { return Value{S: s} } + +var null = Value{Null: true} + +func (v Value) String() string { + if v.Null { + return "NULL" + } + return v.S +} + +// ColKind is a column's type family. +type ColKind byte + +const ( + Int ColKind = iota + 1 + Str +) + +// Col is one column of a model table. +type Col struct { + Name string + Kind ColKind + NotNull bool +} + +// Table is one table of the model, with its rows. +type Table struct { + Name string + Cols []Col + PK []string // the primary key's columns, nil for none + Uniques [][]string // UNIQUE keys over whole columns + Rows [][]Value // parallel to Cols + // view: the table is a view over view.base (CREATE VIEW); Rows are the base's rows + // its conjuncts keep, PK and Uniques are nil + view *derived + // the failure probe's constraints (nil for the facts probe's tables) + uniqueName string // CONSTRAINT UNIQUE for Uniques[0], "" for an unnamed one + check *checkDef // CHECK (col > 0) + fk *fkDef // FOREIGN KEY (col) REFERENCES ref (id) +} + +// checkDef is a CHECK (col > 0) constraint. +type checkDef struct { + col string + name string // "" for the server's own name +} + +// fkDef is a single-column foreign key to another table's id. +type fkDef struct { + col string + ref *Table + name string // "" for the server's own name + onDelete string // "" (the default, RESTRICT / NO ACTION), "CASCADE" or "SET NULL" +} + +func (t *Table) col(name string) int { + for i, c := range t.Cols { + if c.Name == name { + return i + } + } + return -1 +} + +// Schema is the model: tables in creation order. +type Schema struct { + Tables []*Table +} + +// view is the schema's view, nil without one. +func (s *Schema) view() *Table { + for _, t := range s.Tables { + if t.view != nil { + return t + } + } + return nil +} + +func (s *Schema) table(name string) *Table { + for _, t := range s.Tables { + if t.Name == name { + return t + } + } + return nil +} + +// Dialect is what the rendering needs to know about the target database. +type Dialect struct { + // Header is the schema text's first line, the version declaration. + Header string + // IntType / StrType are the column types for the two kinds. + IntType, StrType string + // Positional: the server takes `?` placeholders in order (MySQL) rather than `$n`. + // The analyzer always sees `$n`. + Positional bool +} + +// DDL renders the schema as the analyzer reads it: the header, then CREATE TABLE per +// table (rows are not part of it). +func (s *Schema) DDL(d Dialect) string { + var b strings.Builder + b.WriteString(d.Header) + b.WriteString("\n") + for _, t := range s.Tables { + b.WriteString(t.create(d)) + b.WriteString("\n") + } + return b.String() +} + +func (t *Table) create(d Dialect) string { + if t.view != nil { + return "CREATE VIEW " + t.Name + " AS " + t.view.sql() + ";" + } + var parts []string + for _, c := range t.Cols { + typ := d.IntType + if c.Kind == Str { + typ = d.StrType + } + p := c.Name + " " + typ + if c.NotNull { + p += " NOT NULL" + } + parts = append(parts, p) + } + if t.PK != nil { + parts = append(parts, "PRIMARY KEY ("+strings.Join(t.PK, ", ")+")") + } + for i, u := range t.Uniques { + if i == 0 && t.uniqueName != "" { + parts = append(parts, "CONSTRAINT "+t.uniqueName+" UNIQUE ("+strings.Join(u, ", ")+")") + continue + } + parts = append(parts, "UNIQUE ("+strings.Join(u, ", ")+")") + } + if c := t.check; c != nil { + p := "CHECK (" + c.col + " > 0)" + if c.name != "" { + p = "CONSTRAINT " + c.name + " " + p + } + parts = append(parts, p) + } + if f := t.fk; f != nil { + p := "FOREIGN KEY (" + f.col + ") REFERENCES " + f.ref.Name + " (id)" + if f.name != "" { + p = "CONSTRAINT " + f.name + " " + p + } + if f.onDelete != "" { + p += " ON DELETE " + f.onDelete + } + parts = append(parts, p) + } + return "CREATE TABLE " + t.Name + " (" + strings.Join(parts, ", ") + ");" +} + +// Inserts renders every table's rows as INSERT statements. +func (s *Schema) Inserts() []string { + var out []string + for _, t := range s.Tables { + if t.view != nil { + continue + } + for _, r := range t.Rows { + vals := make([]string, len(r)) + for i, v := range r { + vals[i] = literal(t.Cols[i].Kind, v) + } + out = append(out, "INSERT INTO "+t.Name+" VALUES ("+strings.Join(vals, ", ")+");") + } + } + return out +} + +// Drops renders the DROP TABLE statements, last table first. +func (s *Schema) Drops() []string { + var out []string + for i := len(s.Tables) - 1; i >= 0; i-- { + if s.Tables[i].view != nil { + out = append(out, "DROP VIEW IF EXISTS "+s.Tables[i].Name+";") + continue + } + out = append(out, "DROP TABLE IF EXISTS "+s.Tables[i].Name+";") + } + return out +} + +// literal spells a value of the given kind in SQL. +func literal(k ColKind, v Value) string { + if v.Null { + return "NULL" + } + if k == Str { + return "'" + strings.ReplaceAll(v.S, "'", "''") + "'" + } + return v.S +} + +// row is one produced row of a scope: the values of every leaf's columns, keyed by +// alias.column. +type row map[string]Value + +func (r row) get(alias, col string) Value { return r[alias+"."+col] } + +// keys lists r's keys in order, for a stable rendering. +func (r row) String() string { + var ks []string + for k := range r { + ks = append(ks, k) + } + sort.Strings(ks) + var parts []string + for _, k := range ks { + parts = append(parts, k+"="+r[k].String()) + } + return "{" + strings.Join(parts, " ") + "}" +} diff --git a/x/stmtprobe/run.go b/x/stmtprobe/run.go new file mode 100644 index 0000000..ea5b4ad --- /dev/null +++ b/x/stmtprobe/run.go @@ -0,0 +1,472 @@ +package stmtprobe + +import ( + "context" + "database/sql" + "fmt" + "math/rand" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/kr9ly/sqlshape/v2/x/dialect" + "github.com/kr9ly/sqlshape/v2/x/facts" +) + +// Options drive one run. +type Options struct { + Dialect Dialect + // Load builds the dialect's analyzer for a schema text (dialect.Lookup's Loader). + Load func(schemaSQL string) (dialect.Analyzer, error) + // DB is the server the statements run on; the probe creates and drops its own tables + // there (fp_t). + DB *sql.DB + // Seed and N: the generator's seed and how many statements to judge. + Seed int64 + N int + // KnownUnreached names the alphabet entries this dialect's run is known not to reach + // yet, each with why; an entry reached anyway is reported as stale. + KnownUnreached map[string]string + // Log receives the generator's own failures (a schema or statement one side rejected, + // an evaluator disagreeing with the server), the first few of each kind. + Log func(format string, args ...any) +} + +// Report is a run's outcome. +type Report struct { + Counts map[string]int + Findings []Verdict // distinct by Kind + Detail, minimized + Hit map[string]bool + Unverified map[string]int + Missed []string // alphabet entries neither hit nor known unreached + Stale []string // known-unreached entries that were hit + alphabet []string // the alphabet the report covers (Alphabet, or FailureAlphabet) +} + +// Run generates, judges and reports. +func Run(ctx context.Context, o Options) (*Report, error) { + if o.Log == nil { + o.Log = func(string, ...any) {} + } + r := rand.New(rand.NewSource(o.Seed)) + rep := &Report{Counts: map[string]int{}, Hit: map[string]bool{}, Unverified: map[string]int{}} + seen := map[string]bool{} + logged := map[string]int{} + logf := func(kind, format string, args ...any) { + rep.Counts[kind]++ + logged[kind]++ + if logged[kind] <= 3 { + o.Log(kind+": "+format, args...) + } + } + for i := 0; i < o.N; i++ { + m := genSchema(r, fmt.Sprintf("fp%d_", i)) + q := genQuery(r, m) + an, err := o.Load(m.DDL(o.Dialect)) + if err != nil { + logf("generator (schema)", "%v\n%s", err, m.DDL(o.Dialect)) + continue + } + if p := an.Problems(); len(p) > 0 { + logf("generator (schema)", "%s\n%s", strings.Join(p, "\n"), m.DDL(o.Dialect)) + continue + } + if err := o.apply(ctx, m); err != nil { + return nil, fmt.Errorf("pair %d: creating the tables: %w", i, err) + } + v, class := o.judgeOne(ctx, q, m, an) + for _, h := range v.Hit { + rep.Hit[h] = true + } + for _, u := range v.Unverified { + if rep.Unverified[u] == 0 { + o.Log("unverified: %s\n%s", u, v.SQL) + } + rep.Unverified[u]++ + } + switch { + case class != "": + logf(class, "%s\n%s\n%s", v.Detail, v.SQL, m.DDL(o.Dialect)) + case v.Kind == "": + rep.Counts["pass"]++ + default: + rep.Counts["finding"]++ + // minimize: drop conjuncts and LIMIT while the same kind of failure stands + v = o.shrink(ctx, q, m, an, v) + v.Schema = m.DDL(o.Dialect) + strings.Join(m.Inserts(), "\n") + key := v.Kind + "\n" + v.Detail + if !seen[key] { + seen[key] = true + rep.Findings = append(rep.Findings, v) + } + } + if err := o.drop(ctx, m); err != nil { + return nil, fmt.Errorf("pair %d: dropping the tables: %w", i, err) + } + } + for _, e := range Alphabet { + switch { + case rep.Hit[e]: + if _, known := o.KnownUnreached[e]; known { + rep.Stale = append(rep.Stale, e) + } + default: + if _, known := o.KnownUnreached[e]; !known { + rep.Missed = append(rep.Missed, e) + } + } + } + rep.alphabet = Alphabet + return rep, nil +} + +// judgeOne analyzes and runs q. class names a generator-side failure (both sides reject, +// or the evaluator disagrees with the server), "" when the verdict stands. A write runs +// against freshly created tables: its witness (the rows it will touch) is read first, the +// write executed, and the rows read back for the values the facts say were stored. +func (o Options) judgeOne(ctx context.Context, q *query, m *Schema, an dialect.Analyzer) (Verdict, string) { + sqlText := q.sql() + v := Verdict{SQL: sqlText, Params: q.params} + if q.kind != "select" { + if err := o.apply(ctx, m); err != nil { + v.Detail = err.Error() + return v, "generator (schema)" + } + } + res, aerr := an.Analyze(sqlText) + var rows []row + var serr error + if q.kind == "select" { + rows, serr = o.query(ctx, sqlText, q.params) + } else { + if q.kind != "insert" { + rows, serr = o.query(ctx, q.witness(), q.params) + } + if serr == nil { + serr = o.exec(ctx, sqlText, q.params) + } + } + switch { + case aerr != nil && serr != nil: + v.Detail = fmt.Sprintf("analyzer: %v; server: %v", aerr, serr) + return v, "generator (statement)" + case aerr != nil: + v.Kind, v.Detail = "analyzer rejects what the server accepts", aerr.Error() + return v, "" + case serr != nil && constraintError(serr): + // a write the schema's own constraints refuse (a duplicate key, a NULL in a NOT + // NULL column): the failure modes' concern, not the facts' + v.Detail = serr.Error() + return v, "generator (constraint)" + case serr != nil: + v.Kind, v.Detail = "server rejects what the analyzer accepts", serr.Error() + return v, "" + } + if q.kind != "insert" { + want := q.rows(m) + if !sameRows(want, rows, q.limit1) { + v.Detail = fmt.Sprintf("the generator's evaluation gives %d rows, the server %d:\n model: %v\n server: %v", len(want), len(rows), want, rows) + return v, "generator (evaluator)" + } + } + v = judge(q, res.Facts, rows, m) + if v.Kind != "" || q.kind == "select" || res.Facts == nil { + return v, "" + } + // the write's own claims: the rows it touched now hold the values the facts record + after, err := o.readBack(ctx, q, rows) + if err != nil { + v.Detail = err.Error() + return v, "generator (read back)" + } + switch q.kind { + case "delete": + if len(after) > 0 { + v.Kind, v.Detail = "write refuted", fmt.Sprintf("DELETE left %d of the %d rows the WHERE matched", len(after), len(rows)) + } + case "insert": + if len(after) != 1 { + v.Kind, v.Detail = "write refuted", fmt.Sprintf("INSERT of one row left %d rows with its id", len(after)) + return v, "" + } + fallthrough + case "update": + for _, w := range res.Facts.Writes { + if w.Kind != facts.Insert && w.Kind != facts.Update { + continue + } + for i, name := range w.Assigned { + if i >= len(w.Values) { + break + } + var want Value + switch w.Values[i].Kind { + case facts.Const: + want = constValue(w.Values[i].Const) + case facts.Param: + if int(w.Values[i].Param) < 1 || int(w.Values[i].Param) > len(q.params) { + continue + } + want = q.params[w.Values[i].Param-1] + default: + continue + } + for _, r := range after { + got := r.get("t0", name) + if got.String() != want.String() { + v.Kind, v.Detail = "write refuted", fmt.Sprintf("the facts say the write stores %s into %s; the row read back holds %s: %s", want, name, got, r) + return v, "" + } + } + } + } + } + return v, "" +} + +// constraintError: the server refused a write for a constraint (PostgreSQL's class 23, +// MySQL's 1062 / 1048 / 1364 / 1452 / 1451 / 3819 / 1263 / 1369). +func constraintError(err error) bool { + msg := err.Error() + for _, mark := range []string{"SQLSTATE 23", "Error 1062", "Error 1048", "Error 1364", "Error 1452", "Error 1451", "Error 3819", "Error 1263", "Error 1369"} { + if strings.Contains(msg, mark) { + return true + } + } + return false +} + +// readBack reads the target's rows by the ids of the rows the witness matched (a +// write), or by the inserted id. +func (o Options) readBack(ctx context.Context, q *query, before []row) ([]row, error) { + var ids []string + if q.kind == "insert" { + for _, a := range q.set { + if a.col.Name == "id" { + ids = append(ids, a.value.S) + } + } + } else { + for _, r := range before { + ids = append(ids, r.get("t0", "id").S) + } + } + if len(ids) == 0 { + return nil, nil + } + sel := &query{kind: "select", leaves: q.leaves[:1]} + sel.where = []conj{{sql: "t0.id IN (" + strings.Join(ids, ", ") + ")"}} + return o.query(ctx, sel.sql(), nil) +} + +// exec runs a statement with params bound. +func (o Options) exec(ctx context.Context, sqlText string, params []Value) error { + text, args := o.bind(sqlText, params) + _, err := o.DB.ExecContext(ctx, text, args...) + return err +} + +// bind lists the arguments a statement text references, in order of first appearance, +// and rewrites its placeholders to match: `?` for a positional dialect, $1..$k renumbered +// otherwise (a witness SELECT carries only some of the statement's parameters, and +// PostgreSQL wants the ones it sees numbered without gaps). +func (o Options) bind(sqlText string, params []Value) (string, []any) { + var args []any + index := map[int]int{} + text := paramRef.ReplaceAllStringFunc(sqlText, func(s string) string { + n, _ := strconv.Atoi(s[1:]) + k, seen := index[n] + if !seen { + args = append(args, params[n-1].arg()) + k = len(args) + index[n] = k + } + if o.Dialect.Positional { + if seen { + args = append(args, params[n-1].arg()) // `?` takes one argument per occurrence + } + return "?" + } + return fmt.Sprintf("$%d", k) + }) + return text, args +} + +// shrink drops WHERE conjuncts, ON conjuncts beyond the join equality, and LIMIT 1 one at +// a time while the failure keeps its kind. +func (o Options) shrink(ctx context.Context, q *query, m *Schema, an dialect.Analyzer, v Verdict) Verdict { + try := func(cand *query) bool { + w, class := o.judgeOne(ctx, cand, m, an) + if class == "" && w.Kind == v.Kind { + q, v = cand, w + return true + } + return false + } + for i := 0; i < len(q.where); { + cand := *q + cand.where = append(append([]conj(nil), q.where[:i]...), q.where[i+1:]...) + if !try(&cand) { + i++ + } + } + for li := range q.leaves { + for j := 1; j < len(q.leaves[li].on); { + cand := *q + cand.leaves = append([]leaf(nil), q.leaves...) + cand.leaves[li].on = append(append([]conj(nil), q.leaves[li].on[:j]...), q.leaves[li].on[j+1:]...) + if !try(&cand) { + j++ + } + } + } + if q.limit1 { + cand := *q + cand.limit1 = false + try(&cand) + } + return v +} + +func (o Options) apply(ctx context.Context, m *Schema) error { + for _, s := range m.Drops() { + if _, err := o.DB.ExecContext(ctx, strings.TrimSuffix(s, ";")); err != nil { + return err + } + } + for _, t := range m.Tables { + if _, err := o.DB.ExecContext(ctx, strings.TrimSuffix(t.create(o.Dialect), ";")); err != nil { + return fmt.Errorf("%s: %w", t.create(o.Dialect), err) + } + } + for _, s := range m.Inserts() { + if _, err := o.DB.ExecContext(ctx, strings.TrimSuffix(s, ";")); err != nil { + return fmt.Errorf("%s: %w", s, err) + } + } + return nil +} + +func (o Options) drop(ctx context.Context, m *Schema) error { + for _, s := range m.Drops() { + if _, err := o.DB.ExecContext(ctx, strings.TrimSuffix(s, ";")); err != nil { + return err + } + } + return nil +} + +var paramRef = regexp.MustCompile(`\$(\d+)`) + +// query runs sqlText with params bound, and returns the rows keyed alias.column (the +// select list aliases every column alias__column). +func (o Options) query(ctx context.Context, sqlText string, params []Value) ([]row, error) { + text, args := o.bind(sqlText, params) + rs, err := o.DB.QueryContext(ctx, text, args...) + if err != nil { + return nil, err + } + defer rs.Close() + cols, err := rs.Columns() + if err != nil { + return nil, err + } + var out []row + for rs.Next() { + vals := make([]sql.NullString, len(cols)) + ptrs := make([]any, len(cols)) + for i := range vals { + ptrs[i] = &vals[i] + } + if err := rs.Scan(ptrs...); err != nil { + return nil, err + } + r := row{} + for i, c := range cols { + key := strings.Replace(c, "__", ".", 1) + if vals[i].Valid { + r[key] = Value{S: vals[i].String} + } else { + r[key] = null + } + } + out = append(out, r) + } + return out, rs.Err() +} + +// arg is the value as database/sql binds it. +func (v Value) arg() any { + if v.Null { + return nil + } + if n, err := strconv.Atoi(v.S); err == nil { + return n + } + return v.S +} + +// sameRows compares two row multisets; under LIMIT 1 only the counts are compared (which +// row the server picks is not the model's to say). +func sameRows(want, got []row, limit1 bool) bool { + if limit1 { + return min(len(want), 1) == len(got) + } + if len(want) != len(got) { + return false + } + key := func(rs []row) []string { + out := make([]string, len(rs)) + for i, r := range rs { + out[i] = r.String() + } + sort.Strings(out) + return out + } + a, b := key(want), key(got) + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// String renders the report the way the migrate probe's is written. +func (rep *Report) String(title string) string { + var b strings.Builder + fmt.Fprintf(&b, "# %s\n\n", title) + var keys []string + for k := range rep.Counts { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(&b, "%s: %d\n", k, rep.Counts[k]) + } + for i, v := range rep.Findings { + fmt.Fprintf(&b, "\n## %d. %s\n\n%s\n\nparams: %v\n\n```sql\n%s\n```\n\n### schema\n\n```sql\n%s\n```\n", i+1, v.Kind, v.Detail, v.Params, v.SQL, v.Schema) + } + if len(rep.Unverified) > 0 { + fmt.Fprintf(&b, "\n## unverified claims\n\n") + var us []string + for u := range rep.Unverified { + us = append(us, u) + } + sort.Strings(us) + for _, u := range us { + fmt.Fprintf(&b, "- %d x %s\n", rep.Unverified[u], u) + } + } + fmt.Fprintf(&b, "\n## alphabet coverage\n\n%d entries, %d hit\n\n", len(rep.alphabet), len(rep.Hit)) + for _, e := range rep.alphabet { + switch { + case rep.Hit[e]: + fmt.Fprintf(&b, "- [x] %s\n", e) + default: + fmt.Fprintf(&b, "- [ ] %s\n", e) + } + } + return b.String() +}