diff --git a/.github/ISSUE_TEMPLATE/database-feedback.yml b/.github/ISSUE_TEMPLATE/database-feedback.yml index 33c3ff04..754a9426 100644 --- a/.github/ISSUE_TEMPLATE/database-feedback.yml +++ b/.github/ISSUE_TEMPLATE/database-feedback.yml @@ -32,7 +32,7 @@ body: label: Analysis baseline options: - Fresh `safe-migrate sync` - - Existing Cache V7 + - Existing Cache V8 - '`auto_sync = true`' - '`--no-cache`' validations: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ad94b91..be5e4b64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,7 +105,7 @@ jobs: run: cargo build --locked - name: Exercise CLI runtime - run: cargo test --locked --test cli_tests test_cli_help + run: cargo test --locked --lib internal_tests::cli_tests::test_cli_help -- --exact live-differential: name: PostgreSQL ${{ matrix.postgres }} differential harness @@ -138,8 +138,23 @@ jobs: --health-retries 5 ports: - 5432:5432 + publisher: + image: ${{ matrix.image }} + env: + POSTGRES_DB: safe_migrate + POSTGRES_USER: safe_migrate + POSTGRES_PASSWORD: safe_migrate + options: >- + --health-cmd "pg_isready -U safe_migrate -d safe_migrate" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5433:5432 env: DATABASE_URL: postgres://safe_migrate:safe_migrate@localhost:5432/safe_migrate + PUBLISHER_DATABASE_URL: postgres://safe_migrate:safe_migrate@localhost:5433/safe_migrate + SUBSCRIPTION_DATABASE_URL: postgres://safe_migrate:safe_migrate@publisher:5432/safe_migrate steps: - name: Checkout repository @@ -153,6 +168,36 @@ jobs: - name: Cache dependencies uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - name: Enable logical replication on publisher + shell: bash + run: | + publisher_id='${{ job.services.publisher.id }}' + docker exec "$publisher_id" psql -U safe_migrate -d safe_migrate \ + -v ON_ERROR_STOP=1 -c "ALTER SYSTEM SET wal_level = 'logical'" + docker restart "$publisher_id" + for attempt in {1..30}; do + if docker exec "$publisher_id" \ + pg_isready -U safe_migrate -d safe_migrate; then + exit 0 + fi + sleep 1 + done + docker logs "$publisher_id" + exit 1 + + - name: Wait for PostgreSQL services + shell: bash + run: | + for port in 5432 5433; do + for attempt in {1..30}; do + if pg_isready -h 127.0.0.1 -p "$port" -U safe_migrate -d safe_migrate; then + break + fi + sleep 1 + done + pg_isready -h 127.0.0.1 -p "$port" -U safe_migrate -d safe_migrate + done + - name: Reject an unreachable live database shell: bash env: @@ -178,6 +223,9 @@ jobs: - name: Compare routine and replication state with PostgreSQL run: scripts/live-catalog-differential + - name: Validate a connected logical subscription + run: scripts/live-connected-subscription + - name: Compare simulator state with PostgreSQL shell: bash run: | @@ -260,7 +308,7 @@ jobs: run: | test "$SYNC_STATUS" = "refreshed" test "$BASELINE_SOURCE" = "synced" - test "$CACHE_PATH" = "$HOME/.cache/safe-migrate-action/baselines/action-smoke/baseline-v7.cache" + test "$CACHE_PATH" = "$HOME/.cache/safe-migrate-action/baselines/action-smoke/baseline-v8.cache" test -z "$JSON_REPORT" rm -f -- "$CACHE_PATH" diff --git a/CHANGELOG.md b/CHANGELOG.md index cf3e6aa5..d469b97f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ commits and pull requests. Published binaries, checksums, and generated release notes are available on the [GitHub Releases page](https://github.com/dsecurity49/safe-migrate/releases). +## v0.9.0 — 2026-09-15 + +- Established `safe_migrate::api` as the supported Rust interface for analysis, + configuration, synchronization, and reporting; `_internal` is now private. +- Unified API and CLI reports and evidence, with typed results, categorized + errors, and redacted database/cache-key inputs for embedded callers. +- Added `table-lock` to flag explicit blocking locks, bringing the rule count + to 29. +- Expanded SQL modeling for `TRUNCATE`, storage and column settings, inheritance, + partition detach, `SELECT INTO`, sequence options, and temporary-table commits. +- Added Cache V8 metadata for CHECK definitions, extended statistics, column + inheritance, generated/identity columns, and partition-trigger parentage. +- Improved constraint/index rename and drop propagation, generated CHECK names, + expression preservation, `LIKE` copies, and replica-identity eligibility checks. +- Fixed concurrent-detach state and recursive rename collisions; invalidate + descendant predicates after ancestry changes. Unsupported predicate forms + remain conservative. +- Track PostgreSQL 16+ role-membership grantors and per-grant options for more + accurate `REVOKE`/`CASCADE` analysis. +- Hardened rollback, baseline/version validation, secret cleanup, and report + rendering; reject unsupported PostgreSQL-version assumptions. +- Expanded live catalog comparisons and interrupted-detach coverage, repaired + the CI smoke-test target, and improved live scripts and crate packaging. + ## v0.8.1 — 2026-09-06 - Upgraded Squawk's parser, lexer, syntax tree, and linter to 2.64.0, including diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f54b3a23..86802b2e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,24 @@ # Contributing to safe-migrate +## Start with an issue + +Before submitting a new issue, [search existing issues](https://github.com/dsecurity49/safe-migrate/issues), +including closed ones, for similar reports or proposals. If one already covers +your topic, add any new information there rather than opening a duplicate. + +Otherwise, [open an issue](https://github.com/dsecurity49/safe-migrate/issues/new/choose). +For substantial changes, discuss the approach before starting implementation. +For bug reports, include: + +- minimal SQL; +- expected and actual output; +- safe-migrate version; +- PostgreSQL version or assumed version; +- whether a cache was used; +- relevant configuration, with credentials and other secrets removed. + +## How analysis works + Thanks for contributing. safe-migrate is a Rust PostgreSQL migration analyzer with typed AST extraction, stateful schema simulation, and safety rules. @@ -29,7 +48,7 @@ src/_internal/engine/ configuration, orchestration, and rule dispatch src/_internal/model/ modeled PostgreSQL objects src/_internal/report/ human, JSON, and interactive reporting src/_internal/rules/ safety rule implementations -src/api.rs supported Rust integration façade +src/api.rs, src/api/ supported Rust integration API tests/ integration, state-machine, rule, CLI, and regression tests live_tests/ end-to-end SQL fixtures and frozen database cache docs/ Action guide and CLI/report contract @@ -56,6 +75,10 @@ cargo test architectural_gap cargo test expression_parsing ``` +Implementation tests are registered under the library target (`--lib`), not +individual `--test` targets. The independent public API suite uses +`cargo test --locked --test api_facade`. + End-to-end fixtures: ```bash @@ -182,7 +205,7 @@ The frozen cache under `live_tests/` belongs to the test corpus. Update it only when a fixture requires a changed baseline, and explain the assumption in the pull request. -Cache V7 synchronizes every PostgreSQL routine kind, publications, redacted +Cache V8 synchronizes every PostgreSQL routine kind, publications, redacted subscription metadata, and explicit catalog coverage. Never query or store `pg_subscription.subconninfo`. Changes to the cache model require serialization and inspection regressions, @@ -196,15 +219,3 @@ versions. - Use idiomatic Rust naming and four-space indentation. - Keep one rule concept per file or focused module. - Document non-obvious undo-log and dependency-graph behavior inline. - -## Reporting bugs - -[Open an issue](https://github.com/dsecurity49/safe-migrate/issues/new/choose) -with: - -- minimal SQL; -- expected and actual output; -- safe-migrate version; -- PostgreSQL version or assumed version; -- whether a cache was used; -- relevant configuration. diff --git a/Cargo.lock b/Cargo.lock index 7c22e07e..0a8c7aa2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -225,6 +225,7 @@ dependencies = [ "cipher", "cpufeatures", "rand_core", + "zeroize", ] [[package]] @@ -237,6 +238,7 @@ dependencies = [ "chacha20", "cipher", "poly1305", + "zeroize", ] [[package]] @@ -1020,7 +1022,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "safe-migrate" -version = "0.8.1" +version = "0.9.0" dependencies = [ "anyhow", "assert_cmd", @@ -1042,6 +1044,7 @@ dependencies = [ "terminal_size", "thiserror", "toml", + "zeroize", "zstd", ] @@ -1832,6 +1835,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index dfe6f94b..e9d4f808 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,8 @@ [package] name = "safe-migrate" -version = "0.8.1" +version = "0.9.0" edition = "2024" +autotests = false rust-version = "1.94" description = "Check PostgreSQL migrations against a synchronized database baseline" license = "MIT OR Apache-2.0" @@ -9,7 +10,13 @@ repository = "https://github.com/dsecurity49/safe-migrate" homepage = "https://github.com/dsecurity49/safe-migrate" documentation = "https://docs.rs/safe-migrate" readme = "README.md" -exclude = ["live_tests/.safe-migrate.cache"] +exclude = [ + "live_tests/.safe-migrate.cache", + "/ROADMAP.md", + "/log.txt", + "/heap.c.*", + "/pg_*.h.*", +] keywords = ["postgres", "migration", "linter", "ast", "database"] categories = ["command-line-utilities", "database"] @@ -33,10 +40,15 @@ bincode = { package = "bincode-next", version = "2.1", features = ["serde"] } zstd = "0.13" crossterm = "0.27.0" tempfile = "3.10" -chacha20poly1305 = { version = "0.11", features = ["getrandom"] } +chacha20poly1305 = { version = "0.11", features = ["getrandom", "zeroize"] } +zeroize = "1.8" [dev-dependencies] assert_cmd = "2.0" postgres = "0.19" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" + +[[test]] +name = "api_facade" +path = "tests/api_facade.rs" diff --git a/README.md b/README.md index 24595d80..e8bb81b2 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Prebuilt binaries are available from installer verifies release checksums: ```bash -VERSION='v0.8.1' +VERSION='v0.9.0' curl -fsSL "https://raw.githubusercontent.com/dsecurity49/safe-migrate/${VERSION}/install.sh" | bash -s -- --version "${VERSION}" ``` @@ -50,7 +50,7 @@ Run `safe-migrate cache inspect` to view its provenance and redacted contents. ## What it checks -The 28 built-in rules cover: +The 29 built-in rules cover: - blocking locks, table rewrites, constraints, indexes, partitions, and materialized-view refreshes; @@ -68,7 +68,7 @@ safe-migrate rules --rule require-concurrent-index ## GitHub Actions -Create the `safe-migrate-baseline` GitHub environment, then run: +Create and protect the `safe-migrate-baseline` GitHub environment, then run: ```bash safe-migrate init github-actions --path migrations --configure-secrets @@ -155,6 +155,10 @@ disabled = true Unknown settings and rule IDs are rejected. `safe-migrate rules --json` lists the configuration supported by each rule. +Without a synchronized baseline, the built-in version fallback is deliberately +conservative. Set `assume_pg_version` only when the target is known to be +PostgreSQL 14–18; for example, `assume_pg_version = 170000`. + Suppress a reviewed finding with its primary rule ID: ```sql @@ -178,8 +182,38 @@ Keep a positive `lock_timeout` shorter than a positive `statement_timeout`. ## Rust library -Rust integrations should use the supported `safe_migrate::api` façade. -Documentation is published on [docs.rs](https://docs.rs/safe-migrate). +Rust integrations use `safe_migrate::api`. Load a synchronized baseline when +one is available; otherwise choose explicit conservative analysis. + +```rust,no_run +use safe_migrate::api::{self, Baseline, Config}; +use std::path::Path; + +let config = Config::load_from_file(Path::new("safe-migrate.toml"))?; +let baseline = Baseline::load_optional(Path::new(".safe-migrate.cache"), &config)?; +let outcome = api::analyze( + &config, + "2026-09-05_add_index.sql", + "CREATE INDEX ...", + &baseline, +)?; + +if outcome.should_halt() { + eprintln!("{}", outcome.markdown()); +} +# Ok::<(), Box>(()) +``` + +`load_optional` treats only a missing cache as unavailable; corrupt, +incompatible, or incorrectly encrypted caches remain errors. The API exposes +typed immutable findings, verdicts, evidence, baseline inspection, rule +metadata, and synchronization. Mutable parser, cache, and state-machine +internals are not public. Full API documentation is on +[docs.rs](https://docs.rs/safe-migrate). + +Embedded applications can call `sync_with_secrets` with a validated +`DatabaseUrl` and optional `CacheKey`. This avoids changing process-wide +environment variables; the CLI continues to read secrets from its environment. ## Contributing diff --git a/action.yml b/action.yml index dc2f5f21..061b30cf 100644 --- a/action.yml +++ b/action.yml @@ -12,7 +12,7 @@ inputs: required: false default: "" cache: - description: Explicit trusted Cache V7 path; disables managed GitHub cache restore and save. + description: Explicit trusted Cache V8 path; disables managed GitHub cache restore and save. required: false default: "" config: @@ -135,13 +135,13 @@ runs: cache_dir="${baseline_root}/${INPUT_BASELINE}" rm -rf -- "$cache_dir" mkdir -p -- "$cache_dir" - cache_path="${cache_dir}/baseline-v7.cache" - cache_transport_path="~/.cache/safe-migrate-action/baselines/${INPUT_BASELINE}/baseline-v7.cache" + cache_path="${cache_dir}/baseline-v8.cache" + cache_transport_path="~/.cache/safe-migrate-action/baselines/${INPUT_BASELINE}/baseline-v8.cache" else cache_root="${RUNNER_TEMP}/safe-migrate-action" mkdir -p "$cache_root" cache_dir="$(mktemp -d "${cache_root}/invocation.XXXXXX")" - cache_path="${cache_dir}/baseline-v7.cache" + cache_path="${cache_dir}/baseline-v8.cache" fi printf '%s\n' "cache-path=${cache_path}" >> "$GITHUB_OUTPUT" @@ -158,7 +158,7 @@ runs: else cache_mode=plaintext fi - cache_prefix="safe-migrate-v7-${RUNNER_OS}-${cache_mode}-${INPUT_BASELINE}-" + cache_prefix="safe-migrate-v8-${RUNNER_OS}-${cache_mode}-${INPUT_BASELINE}-" printf '%s\n' "cache-prefix=${cache_prefix}" >> "$GITHUB_OUTPUT" printf '%s\n' \ "cache-primary-key=${cache_prefix}${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ @@ -527,7 +527,10 @@ runs: core.error(`Could not parse safe-migrate JSON report: ${error.message}`); return; } - const sanitize = (s) => (s || '').toString().replace(/::/g, '::\u200B'); + const sanitize = (s) => (s || '').toString() + .replace(/[\u0000-\u001f\u007f-\u009f]/g, character => + `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`) + .replace(/::/g, '::\u200B'); for (const finding of report.violations || []) { if (finding.tier !== 'Tier1' && finding.tier !== 'Tier2') continue; const location = finding.location || {}; diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index 266b2668..f2bd6ef6 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -6,7 +6,7 @@ guarantees. Run them with: ```sh -cargo test --locked --test performance_scenarios -- --ignored --nocapture +cargo test --locked --lib internal_tests::performance_scenarios -- --ignored --nocapture ``` The scenarios validate final state as well as timing, so an apparent speedup @@ -68,7 +68,7 @@ the same aarch64 Android Linux host and optimized profile described above. Run future comparisons with the same command and profile: ```sh -cargo test --release --locked --test performance_scenarios -- --ignored --nocapture --test-threads=1 +cargo test --release --locked --lib internal_tests::performance_scenarios -- --ignored --nocapture --test-threads=1 ``` The allocation scenarios use a process-global counting allocator. Run them @@ -110,7 +110,7 @@ The returned public fields and values remain unchanged; an equivalence test compares incremental capture with a fresh capture after update, insertion, and removal mutations. -Cache V7 decoding streams decompressed bytes through the bounded bincode +Cache V8 decoding streams decompressed bytes through the bounded bincode reader instead of retaining a second, fully decompressed byte vector. This is a structural peak-memory reduction, not an RSS claim: authenticated decryption still completes before decompression, the 256 MiB decoded-size bound remains @@ -143,5 +143,5 @@ The indexed path was about **6.3x faster** while returning the same edge count. Run the isolated comparison with: ```sh -cargo test --locked --jobs 1 --test performance_scenarios large_dependency_graph_lookup_index -- --ignored --nocapture --test-threads=1 +cargo test --locked --jobs 1 --lib internal_tests::performance_scenarios::large_dependency_graph_lookup_index -- --ignored --nocapture --test-threads=1 ``` diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index fc532c52..c3ed3123 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -1,6 +1,6 @@ # CLI and Report Contract -This document defines safe-migrate v0.8.1's CLI, report, cache, and GitHub +This document defines safe-migrate v0.9.0's CLI, report, cache, and GitHub Action behavior. If you are learning safe-migrate, start with the [README](../README.md). This @@ -143,13 +143,15 @@ not production runtime, lock duration, application compatibility, or backfills. No cache means `Tainted`; rules retain their conservative defaults. A stale cache also taints confidence and warns on standard error. -`stale_stats_days` uses the cache timestamp, not file modification time. +`stale_stats_days` uses the cache timestamp, not file modification time. A +missing or future-dated timestamp is stale; inspection reports no calculable +age for either case. PostgreSQL conflicts such as dropping a missing column produce a Tier 1 `chain-conflict`, leave state unchanged, and do not taint confidence by themselves. This applies to both `lint` and `lint-chain`. -Cache V7 supplies typed evidence for: +Cache V8 supplies typed evidence for: - catalog coverage, schema scope, roles, privileges, and session settings; - constraint keys and expressions, generated-column sources, and PostgreSQL's @@ -167,7 +169,8 @@ The principal precision boundaries are: | Area | Contract | | --- | --- | -| Unsupported DDL | Parser-valid but unmodeled semantics are opaque and `Tainted`, never exact no-ops. This includes copied/inherited tables, CTAS lifecycle actions, unsupported role attributes, and unmodeled database, type, view, materialized-view, domain, or aggregate details. | +| Unsupported DDL | Parser-valid but unmodeled semantics are opaque and `Tainted`, never exact no-ops. This includes unsupported copying/inheritance forms, CTAS lifecycle actions, unsupported role attributes, and unmodeled database, type, view, materialized-view, domain, or aggregate details. | +| Partitions | Synchronized predicates retain catalog evidence. Local retained-CHECK synthesis supports simple single-column RANGE/LIST bounds; expression keys, multi-column bounds, and missing ancestor predicates remain conservative. Full local predicate generation is not yet complete. | | Indexes | Synchronized complex-index dependencies support exact cleanup. Locally parsed complex indexes do not claim that precision. CTAS `WITH NO DATA` and expression indexes remain available to safety rules. | | Grants and policies | `ALL TABLES IN SCHEMA` and policy role/expression changes are `Tainted`; their useful modeled effects remain available to security rules. PostgreSQL 17+ `MAINTAIN` is recognized only with a versioned baseline. | | Routines and settings | All synchronized routine kinds are modeled; routine DDL without a typed Squawk extractor is opaque. Unknown `RESET` parameters are opaque, while modeled timeouts/search path and schema-neutral settings remain exact. | @@ -181,7 +184,7 @@ They require positive effective values for statements identified by Squawk's pinned `possibly_slow_stmt` classifier. `lock_timeout` must also be shorter than a positive `statement_timeout`. -Values begin from Cache V7, or unknown without a cache. Ordered `SET`, local +Values begin from Cache V8, or unknown without a cache. Ordered `SET`, local settings, resets, commits, and rollbacks follow PostgreSQL session/local behavior. `SET LOCAL` outside a transaction has no modeled effect. Each rule reports at most once per input file. @@ -198,12 +201,12 @@ These conditions exit `1` instead of producing a clean report: - internal serialization or analysis failure. Sync replaces a cache only after its new payload is complete. An automatic -refresh failure is recorded in JSON and may reuse a readable V7 cache; otherwise +refresh failure is recorded in JSON and may reuse a readable V8 cache; otherwise analysis continues without a baseline and is `Tainted`. Encrypted mode requires `cache_encryption = true` and a valid `SAFE_MIGRATE_CACHE_KEY`. Cache modes cannot be mixed; switching requires a new -`sync`. V7 carries an explicit header, coverage and scope-completion markers, +`sync`. V8 carries an explicit header, coverage and scope-completion markers, role/session provenance, schemas, settings, dependencies, and redacted catalog metadata. It never contains password hashes or subscription connection strings. V1–V6 and unheadered caches are rejected with resync guidance. diff --git a/docs/GITHUB_ACTIONS.md b/docs/GITHUB_ACTIONS.md index 57340ffc..7405820d 100644 --- a/docs/GITHUB_ACTIONS.md +++ b/docs/GITHUB_ACTIONS.md @@ -85,7 +85,7 @@ hour. Adjust it to match your schema-change rate and runner availability. | Merge queue | Never available | Restore only | Offline | A refresh reads the PostgreSQL catalogs in one read-only, repeatable-read -transaction. A PR check decrypts and validates Cache V7, parses the proposed +transaction. A PR check decrypts and validates Cache V8, parses the proposed SQL, and simulates its state changes. Neither path applies the migration. @@ -154,7 +154,7 @@ The baseline contains: It does not contain `DATABASE_URL`, role password hashes, or subscription connection strings. -Cache V7 uses authenticated XChaCha20-Poly1305 encryption. Without the key, a +Cache V8 uses authenticated XChaCha20-Poly1305 encryption. Without the key, a modified cache, forged PR cache, or cache encrypted under another key fails authentication. The decrypted payload is size-bounded, version-checked, and semantically validated before use. @@ -276,7 +276,7 @@ base commit: sparse-checkout: safe-migrate.toml persist-credentials: false -- uses: dsecurity49/safe-migrate@v0.8.1 +- uses: dsecurity49/safe-migrate@v0.9.0 env: SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }} with: diff --git a/live_tests/.safe-migrate.cache b/live_tests/.safe-migrate.cache index 7771d524..1c7f5893 100644 Binary files a/live_tests/.safe-migrate.cache and b/live_tests/.safe-migrate.cache differ diff --git a/live_tests/README.md b/live_tests/README.md index 91b64bba..aa8769e4 100644 --- a/live_tests/README.md +++ b/live_tests/README.md @@ -8,7 +8,7 @@ These SQL fixtures feed three suites: disposable PostgreSQL database for fixtures enabled in `differential_manifest.json`. - `scripts/live-catalog-sync` seeds routines, publications, and a disconnected - subscription, then verifies their Cache V7 representation and connection + subscription, then verifies their Cache V8 representation and connection redaction. In short: `run.sh` checks expected linter findings, `live-differential` compares diff --git a/live_tests/differential_baseline.sql b/live_tests/differential_baseline.sql index eba2defb..41852e35 100644 --- a/live_tests/differential_baseline.sql +++ b/live_tests/differential_baseline.sql @@ -210,6 +210,27 @@ CREATE TABLE sm_core.t_large ( col2 integer, created_at timestamptz ); + +-- Shared source for LIKE cloning of generated/identity columns, CHECKs, +-- indexes, storage settings, and extended statistics. +CREATE TABLE sm_core.catalog_like_source ( + id bigint GENERATED ALWAYS AS IDENTITY + (START WITH 10 INCREMENT BY 2 CACHE 5), + a integer NOT NULL CONSTRAINT catalog_like_source_a_check CHECK (a > 0), + b text, + doubled integer GENERATED ALWAYS AS (a * 2) STORED +); +ALTER TABLE sm_core.catalog_like_source ALTER COLUMN b SET STORAGE MAIN; +ALTER TABLE sm_core.catalog_like_source ALTER COLUMN b SET STATISTICS 250; +CREATE INDEX catalog_like_source_a_idx ON sm_core.catalog_like_source (a); +CREATE STATISTICS sm_core.catalog_like_source_stats (dependencies, ndistinct) + ON a, b FROM sm_core.catalog_like_source; +ALTER STATISTICS sm_core.catalog_like_source_stats SET STATISTICS 250; +CREATE TABLE sm_core.catalog_rules (id integer); +CREATE RULE rule_origin AS ON INSERT TO sm_core.catalog_rules DO ALSO NOTHING; +CREATE RULE rule_disabled AS ON INSERT TO sm_core.catalog_rules DO ALSO NOTHING; +CREATE RULE rule_replica AS ON INSERT TO sm_core.catalog_rules DO ALSO NOTHING; +CREATE RULE rule_always AS ON INSERT TO sm_core.catalog_rules DO ALSO NOTHING; CREATE UNIQUE INDEX t_large_col1_prebuilt_key ON sm_core.t_large (col1); CREATE TABLE sm_core.items ( diff --git a/live_tests/differential_manifest.json b/live_tests/differential_manifest.json index de6de759..8b42fe95 100644 --- a/live_tests/differential_manifest.json +++ b/live_tests/differential_manifest.json @@ -1,2358 +1,2561 @@ { - "rules": [ - { - "rule_dir": "rule_03_drop-schema-cascade", - "enabled": true, - "fixtures": [ - "001_cascade.sql", - "002_cascade_if_exists.sql", - "009_cascade_upper.sql", - "safe_003_create.sql", - "safe_004_rename.sql" - ], - "excluded_fixtures": [ - { - "fixture": "003_multi_cascade.sql", - "reason": "Requires a non-harness staging schema; dropping it adds no normalized state beyond the enabled public-schema case." - }, - { - "fixture": "004_multi_cascade_if_exists.sql", - "reason": "Requires a non-harness staging schema; dropping it adds no normalized state beyond the enabled public-schema case." - }, - { - "fixture": "005_cascade_quoted.sql", - "reason": "Requires a non-harness schema named My Schema and has no relation topology in the canonical baseline." - }, - { - "fixture": "006_cascade_if_exists_quoted.sql", - "reason": "The absent quoted schema produces no normalized state change." - }, - { - "fixture": "007_multi_three_cascade.sql", - "reason": "Requires three non-harness empty schemas whose existence is not represented in normalized simulator state." - }, - { - "fixture": "008_cascade_long_name.sql", - "reason": "Requires a non-harness empty schema whose existence is not represented in normalized simulator state." - }, - { - "fixture": "safe_001_no_cascade.sql", - "reason": "PostgreSQL correctly rejects dropping the populated public schema without CASCADE." - }, - { - "fixture": "safe_002_restrict.sql", - "reason": "PostgreSQL correctly rejects dropping the populated public schema with RESTRICT." - }, - { - "fixture": "safe_005_drop_if_exists_no_cascade.sql", - "reason": "PostgreSQL correctly rejects dropping the populated public schema without CASCADE." - }, - { - "fixture": "safe_006_create_auth.sql", - "reason": "Requires a postgres role, while the differential matrix intentionally runs as its harness-specific safe_migrate role." - }, - { - "fixture": "safe_007_multi_no_cascade.sql", - "reason": "PostgreSQL correctly rejects dropping the populated public schema without CASCADE." - }, - { - "fixture": "safe_008_multi_restrict.sql", - "reason": "PostgreSQL correctly rejects dropping the populated public schema with RESTRICT." - }, - { - "fixture": "safe_009_drop_quoted_no_cascade.sql", - "reason": "Requires a non-harness quoted schema with no normalized topology." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics", - "staging", - "pub" - ], - "scope": [ - "schemas", - "relations", - "indexes", - "foreign_keys", - "partitions" - ], - "required_relations": [ - "public.test_table", - "public.child_table", - "public.parent", - "public.child" - ], - "notes": "Seventh incremental slice. Validates public-schema CASCADE cleanup across relations, indexes, FK edges, triggers, and partition topology." - }, - { - "rule_dir": "rule_01_irreversible-migration", - "enabled": true, - "fixtures": [ - "001_drop_table.sql", - "004_drop_table_if_exists.sql", - "005_drop_cascade.sql", - "safe_001_rename_col.sql", - "safe_002_add_col.sql", - "safe_003_widen_varchar.sql", - "safe_004_widen_int.sql", - "safe_006_set_default.sql", - "safe_007_add_not_null.sql", - "safe_008_rename_table.sql", - "safe_009_add_index.sql", - "002_drop_column.sql", - "006_drop_column_if_exists.sql" - ], - "excluded_fixtures": [ - { - "fixture": "safe_005_create_table.sql", - "reason": "The cumulative canonical baseline contains sm_core.t for rule_18 drop/idempotency cases, so PostgreSQL correctly rejects this unqualified duplicate CREATE TABLE." - }, - { - "fixture": "safe_010_add_column_default.sql", - "reason": "The cumulative canonical baseline contains test_table.status for the rule_06 filtered CTAS fixture; added-column defaults remain covered by rules 07 and 23." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "relations", - "columns", - "indexes", - "foreign_keys" - ], - "fixture_scopes": { - "002_drop_column.sql": [ - "relations", - "columns", - "foreign_keys", - "view_dependencies" - ], - "006_drop_column_if_exists.sql": [ - "relations", - "columns", - "foreign_keys", - "view_dependencies" - ] - }, - "required_relations": [ - "public.test_table", - "public.child_table" - ], - "notes": "Second incremental differential slice. Covers table and column drops, column/table renames, type widening, defaults, nullability, table creation, and index creation." - }, - { - "rule_dir": "rule_04_destructive-general-cascade", - "enabled": true, - "fixtures": [ - "001_drop_view_cascade.sql", - "002_drop_matview_cascade.sql", - "008_drop_view_if_exists_cascade.sql", - "009_drop_matview_if_exists_cascade.sql", - "safe_001_drop_view.sql", - "safe_002_drop_matview.sql", - "safe_008_drop_view_if_exists.sql", - "safe_009_drop_matview_if_exists.sql", - "safe_010_drop_view_restrict.sql", - "007_drop_pub_cascade.sql", - "safe_006_drop_pub.sql", - "safe_011_alter_pub_options.sql" - ], - "excluded_fixtures": [ - { - "fixture": "003_drop_seq_cascade.sql", - "reason": "Cache V7 synchronizes sequences, but this rule does not compare sequence state." - }, - { - "fixture": "004_drop_domain_cascade.sql", - "reason": "Domain state is not yet hydrated from the live baseline into simulator type state." - }, - { - "fixture": "005_drop_func_cascade.sql", - "reason": "Cache V7 synchronizes routine identity and kind, but this rule does not compare routine state." - }, - { - "fixture": "006_drop_proc_cascade.sql", - "reason": "Cache V7 synchronizes routine identity and kind, but this rule does not compare routine state." - }, - { - "fixture": "010_drop_seq_if_exists_cascade.sql", - "reason": "Cache V7 synchronizes sequences, but this rule does not compare sequence state." - }, - { - "fixture": "safe_003_drop_seq.sql", - "reason": "Cache V7 synchronizes sequences, but this rule does not compare sequence state." - }, - { - "fixture": "safe_004_drop_func.sql", - "reason": "Cache V7 synchronizes routine identity and kind, but this rule does not compare routine state." - }, - { - "fixture": "safe_005_drop_proc.sql", - "reason": "Cache V7 synchronizes routine identity and kind, but this rule does not compare routine state." - }, - { - "fixture": "safe_007_drop_domain.sql", - "reason": "Domain state is not yet hydrated from the live baseline into simulator type state." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "relations" - ], - "fixture_scopes": { - "007_drop_pub_cascade.sql": [ - "publications" - ], - "safe_006_drop_pub.sql": [ - "publications" - ], - "safe_011_alter_pub_options.sql": [ - "publications" - ] - }, - "required_relations": [ - "sm_core.myview", - "sm_core.mymat" - ], - "notes": "Eighth incremental slice. Validates CASCADE, RESTRICT, and IF EXISTS drop outcomes for views and materialized views." - }, - { - "rule_dir": "rule_05_destructive-cascade", - "enabled": true, - "fixtures": [ - "001_cascade.sql", - "007_cascade_schema_qualified.sql" - ], - "excluded_fixtures": [ - { - "fixture": "002_cascade_if_exists.sql", - "reason": "Duplicates the participating unqualified CASCADE outcome with IF EXISTS." - }, - { - "fixture": "003_cascade_quoted.sql", - "reason": "Quoted lowercase test_table resolves to the same object and outcome as the participating unqualified fixture." - }, - { - "fixture": "004_cascade_multi.sql", - "reason": "Contains the same single DROP TABLE test_table CASCADE statement as the participating fixture." - }, - { - "fixture": "005_cascade_again.sql", - "reason": "Contains the same single DROP TABLE test_table CASCADE statement as the participating fixture." - }, - { - "fixture": "006_cascade_if_exists_multi.sql", - "reason": "Duplicates the participating unqualified CASCADE outcome with IF EXISTS." - }, - { - "fixture": "008_cascade_upper.sql", - "reason": "Unquoted TEST_TABLE folds to test_table and duplicates the participating unqualified outcome." - }, - { - "fixture": "009_cascade_temp.sql", - "reason": "Contains the same single DROP TABLE test_table CASCADE statement as the participating fixture." - }, - { - "fixture": "010_cascade_long_name.sql", - "reason": "Contains the same single DROP TABLE test_table CASCADE statement as the participating fixture." - }, - { - "fixture": "safe_001_no_cascade.sql", - "reason": "PostgreSQL rejects dropping public.test_table without CASCADE because the enterprise baseline has dependent foreign keys." - }, - { - "fixture": "safe_002_restrict.sql", - "reason": "PostgreSQL rejects dropping public.test_table with RESTRICT because the enterprise baseline has dependent foreign keys." - }, - { - "fixture": "safe_003_alter_table.sql", - "reason": "Add-column state is covered by dedicated participating rules and does not exercise cascade behavior." - }, - { - "fixture": "safe_004_truncate.sql", - "reason": "TRUNCATE changes data rather than normalized schema topology." - }, - { - "fixture": "safe_005_drop_if_exists_no_cascade.sql", - "reason": "PostgreSQL rejects dropping the existing public.test_table without CASCADE despite IF EXISTS." - }, - { - "fixture": "safe_006_drop_quoted_no_cascade.sql", - "reason": "PostgreSQL rejects dropping quoted public.test_table without CASCADE because dependencies exist." - }, - { - "fixture": "safe_007_drop_multi_no_cascade.sql", - "reason": "Requires absent baseline relations t1 and t2." - }, - { - "fixture": "safe_009_insert.sql", - "reason": "PostgreSQL rejects one value for multi-column public.test_table, and data writes are outside normalized schema state." - }, - { - "fixture": "safe_010_delete.sql", - "reason": "DELETE changes data rather than normalized schema topology." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "relations", - "indexes", - "foreign_keys" - ], - "required_relations": [ - "public.test_table", - "public.child_table" - ], - "notes": "Initial incremental live differential slice over the 65+ relation enterprise baseline. Confirms DROP TABLE ... CASCADE removes the target relation, its owned indexes, and same- and cross-schema FK edges without deleting unrelated tables." - }, - { - "rule_dir": "rule_08_type-change-rewrite", - "enabled": true, - "fixtures": [ - "001_lossy_varchar.sql", - "003_lossy_timestamp_to_date.sql", - "004_rewrite_numeric.sql", - "005_lossy_int_to_smallint.sql", - "006_rewrite_text_to_varchar.sql", - "007_lossy_numeric_to_int.sql", - "008_rewrite_bigint_to_int.sql", - "009_lossy_timestamptz_to_date.sql", - "010_rewrite_numeric_to_float.sql", - "011_lossy_numeric.sql", - "012_lossy_timestamptz_to_date.sql", - "013_lossy_varchar.sql", - "014_lossy_int_to_smallint.sql", - "safe_001_widen_varchar.sql", - "safe_002_widen_int.sql", - "safe_003_widen_varchar_limit.sql", - "safe_004_json_to_jsonb.sql", - "safe_005_varchar_to_text.sql", - "safe_006_widen_numeric.sql", - "safe_007_add_column.sql", - "safe_008_alter_default.sql", - "safe_009_drop_default.sql", - "safe_010_rename_column.sql" - ], - "excluded_fixtures": [ - { - "fixture": "002_rewrite_int_to_text.sql", - "reason": "PostgreSQL rejects changing the referenced integer primary key to text while enterprise-baseline integer foreign keys depend on its equality operators." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "relations", - "columns", - "foreign_keys" - ], - "required_relations": [ - "public.test_table", - "public.child_table" - ], - "notes": "Fourth incremental slice. Compares PostgreSQL canonical column types after lossy and non-lossy conversions, plus rename/default mutations." - }, - { - "rule_dir": "rule_07_size-aware-add-column", - "enabled": true, - "fixtures": [ - "003_volatile_gen_random.sql", - "005_volatile_current_timestamp.sql", - "006_volatile_timeofday.sql", - "010_volatile_random_nested.sql", - "safe_001_no_default.sql", - "safe_002_immutable_int.sql", - "safe_003_immutable_text.sql", - "safe_004_immutable_bool.sql", - "safe_005_immutable_json.sql", - "safe_006_nullable.sql", - "safe_007_serial.sql", - "safe_008_immutable_numeric.sql", - "safe_009_immutable_timestamp.sql" - ], - "excluded_fixtures": [ - { - "fixture": "001_volatile_random.sql", - "reason": "PostgreSQL cannot implicitly coerce random() double precision to integer." - }, - { - "fixture": "002_volatile_now.sql", - "reason": "PostgreSQL cannot implicitly coerce random() double precision to integer." - }, - { - "fixture": "004_volatile_clock.sql", - "reason": "PostgreSQL cannot coerce clock_timestamp() to integer." - }, - { - "fixture": "007_volatile_transaction_timestamp.sql", - "reason": "PostgreSQL cannot implicitly coerce random() double precision to integer." - }, - { - "fixture": "008_volatile_statement_timestamp.sql", - "reason": "PostgreSQL cannot implicitly coerce random() double precision to integer." - }, - { - "fixture": "009_volatile_uuid_generate.sql", - "reason": "The local PostgreSQL server does not provide uuid_generate_v4(); enabling uuid-ossp would be an environment-specific side effect." - }, - { - "fixture": "safe_010_alter_set_default.sql", - "reason": "Requires column c in the canonical baseline, which conflicts with enabled add-column fixtures." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "relations", - "columns" - ], - "required_relations": [ - "public.test_table" - ], - "notes": "Tenth incremental slice. Validates added-column type, nullability, and default presence across volatile, immutable, NULL, SERIAL, JSONB, numeric, and timestamp defaults." - }, - { - "rule_dir": "rule_06_create-table-as-select", - "enabled": true, - "fixtures": [ - "001_ctas.sql", - "002_ctas_with_columns.sql", - "004_ctas_no_data.sql", - "005_ctas_if_not_exists.sql", - "006_ctas_unlogged.sql", - "007_ctas_join.sql", - "008_ctas_group_by.sql", - "009_ctas_where.sql", - "010_ctas_subquery.sql" - ], - "excluded_fixtures": [ - { - "fixture": "003_ctas_temp.sql", - "reason": "PostgreSQL creates unqualified temporary relations in a session pg_temp_* schema outside the current schema-scoped projection." - }, - { - "fixture": "safe_001_create.sql", - "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." - }, - { - "fixture": "safe_002_create_if_not_exists.sql", - "reason": "Already represented by rule_18 and produces no CTAS-specific state." - }, - { - "fixture": "safe_003_create_temp.sql", - "reason": "Temporary relations live in a session pg_temp_* schema outside the current projection." - }, - { - "fixture": "safe_004_create_unlogged.sql", - "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." - }, - { - "fixture": "safe_005_select_into.sql", - "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." - }, - { - "fixture": "safe_006_create_with_defaults.sql", - "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." - }, - { - "fixture": "safe_007_create_like.sql", - "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." - }, - { - "fixture": "safe_008_create_inherits.sql", - "reason": "The cumulative canonical baseline contains sm_core.t and inheritance topology is outside this rule's CTAS scope." - }, - { - "fixture": "safe_009_create_partition_of.sql", - "reason": "public.test_table is not partitioned and PostgreSQL correctly rejects this fixture." - }, - { - "fixture": "safe_010_create_with_constraints.sql", - "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "relations" - ], - "required_relations": [ - "public.test_table", - "sm_core.a", - "sm_core.b" - ], - "notes": "Eleventh incremental slice. Validates permanent and unlogged CTAS relation creation across projection, join, aggregate, filter, subquery, IF NOT EXISTS, and WITH NO DATA forms." - }, - { - "rule_dir": "rule_12_blocking-partition-mutation", - "enabled": true, - "fixtures": [ - "001_attach_partition.sql", - "003_attach_default.sql", - "007_attach_schema_qualified.sql" - ], - "excluded_fixtures": [ - { - "fixture": "002_detach_partition.sql", - "reason": "Requires child to start attached, which conflicts with attach fixtures under the per-fixture canonical baseline." - }, - { - "fixture": "004_detach_concurrently.sql", - "reason": "Requires child to start attached and PostgreSQL rejects DETACH CONCURRENTLY in transaction-like execution contexts." - }, - { - "fixture": "005_attach_range.sql", - "reason": "Requires a RANGE parent and date-key-compatible child instead of the enabled LIST baseline." - }, - { - "fixture": "006_attach_hash.sql", - "reason": "Requires a HASH parent and users_0 child instead of the enabled LIST baseline." - }, - { - "fixture": "008_detach_finalize.sql", - "reason": "Requires a prior concurrent detach pending finalization." - }, - { - "fixture": "009_attach_list_string.sql", - "reason": "Requires a text-key LIST parent and region_na child instead of the integer-key LIST baseline." - }, - { - "fixture": "010_attach_range_int.sql", - "reason": "Requires a RANGE parent and logs_old child instead of the enabled LIST baseline." - }, - { - "fixture": "011_detach_weekly.sql", - "reason": "Requires child to start attached, which conflicts with attach fixtures under the per-fixture canonical baseline." - }, - { - "fixture": "012_attach_range_monthly.sql", - "reason": "Requires a RANGE parent and metrics_current child instead of the enabled LIST baseline." - }, - { - "fixture": "safe_001_create_table.sql", - "reason": "The canonical enterprise baseline already contains sm_core.t on the active search path." - }, - { - "fixture": "safe_002_add_column.sql", - "reason": "Add-column state is covered by dedicated participating rules and does not exercise partition topology." - }, - { - "fixture": "safe_003_drop_column.sql", - "reason": "PostgreSQL rejects dropping absent baseline column public.test_table.c." - }, - { - "fixture": "safe_004_rename_column.sql", - "reason": "Rename propagation is covered by dedicated participating rules and does not exercise partition topology." - }, - { - "fixture": "safe_005_set_not_null.sql", - "reason": "Nullability state is covered by dedicated participating rules and does not exercise partition topology." - }, - { - "fixture": "safe_006_create_index.sql", - "reason": "Index topology is covered by dedicated participating rules and does not exercise partition topology." - }, - { - "fixture": "safe_007_truncate.sql", - "reason": "TRUNCATE changes data rather than normalized partition topology." - }, - { - "fixture": "safe_008_insert.sql", - "reason": "PostgreSQL rejects one value for multi-column public.test_table, and data writes are outside normalized schema state." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "relations", - "partitions" - ], - "required_relations": [ - "public.parent", - "public.child" - ], - "notes": "Third incremental slice. Validates LIST/default partition attachment and schema-qualified partition topology." - }, - { - "rule_dir": "rule_10_require-concurrent-index", - "enabled": true, - "transactional": false, - "fixtures": [ - "001_create_index.sql", - "002_drop_index.sql", - "004_create_multi_col.sql", - "005_create_unique.sql", - "006_create_partial.sql", - "007_drop_index_if_exists.sql", - "008_create_index_include.sql", - "010_create_index_lower.sql", - "011_create_index_desc.sql", - "012_create_index_using_gin.sql", - "013_create_index_on_only.sql", - "014_create_index_where_multiple.sql", - "015_create_index_storage_param.sql", - "safe_001_create_concurrently.sql", - "safe_002_drop_concurrently.sql", - "safe_003_create_unique_concurrently.sql" - ], - "excluded_fixtures": [ - { - "fixture": "009_create_index_tablespace.sql", - "reason": "Requires a server-local fastspace tablespace and filesystem location, making it an environment-specific fixture." - }, - { - "fixture": "safe_004_alter_table.sql", - "reason": "Column state is outside this rule's index-focused comparison scope." - }, - { - "fixture": "safe_005_create_table.sql", - "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." - }, - { - "fixture": "safe_006_drop_table.sql", - "reason": "PostgreSQL rejects dropping public.test_table without CASCADE because the enterprise baseline has dependent FKs and a trigger." - }, - { - "fixture": "safe_007_select.sql", - "reason": "Data reads do not change normalized schema state." - }, - { - "fixture": "safe_008_insert.sql", - "reason": "Data writes do not change normalized schema state." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "relations", - "indexes" - ], - "required_relations": [ - "sm_core.t_large", - "public.test_table" - ], - "notes": "Sixth incremental slice. Validates regular, unique, partial, expression, INCLUDE, GIN, ONLY, storage-parameter, and concurrent index outcomes." - }, - { - "rule_dir": "rule_18_missing-idempotency", - "enabled": true, - "fixtures": [ - "001_create_table.sql", - "002_drop_table.sql", - "003_create_index.sql", - "006_drop_view.sql", - "007_drop_matview.sql", - "014_create_view.sql", - "safe_001_create_if_not_exists.sql", - "safe_002_drop_if_exists.sql", - "safe_003_create_index_if_not_exists.sql", - "safe_006_drop_view_if_exists.sql", - "safe_007_drop_matview_if_exists.sql" - ], - "excluded_fixtures": [ - { - "fixture": "004_drop_index.sql", - "reason": "Requires index i in the canonical baseline, which conflicts with enabled create-index fixtures." - }, - { - "fixture": "005_drop_sequence.sql", - "reason": "Cache V7 synchronizes sequences, but this rule does not compare sequence state." - }, - { - "fixture": "008_add_column.sql", - "reason": "Column-presence scope conflicts with create-view fixtures whose projected output columns are not modeled by the simulator." - }, - { - "fixture": "009_drop_column.sql", - "reason": "Requires column c in the canonical baseline, which conflicts with add-column fixtures." - }, - { - "fixture": "010_drop_policy.sql", - "reason": "Policy state is not yet normalized by the differential harness." - }, - { - "fixture": "011_drop_trigger.sql", - "reason": "Trigger state is imported as dependency edges but is not yet normalized by this harness." - }, - { - "fixture": "012_drop_domain.sql", - "reason": "Domain state is not yet hydrated from the live baseline into simulator type state." - }, - { - "fixture": "013_create_sequence.sql", - "reason": "Cache V7 synchronizes sequences, but this rule does not compare sequence state." - }, - { - "fixture": "016_create_schema.sql", - "reason": "Schema existence is not represented in normalized simulator state." - }, - { - "fixture": "017_drop_schema.sql", - "reason": "Schema existence is not represented in normalized simulator state." - }, - { - "fixture": "018_add_column_not_null.sql", - "reason": "Column-presence scope conflicts with create-view fixtures whose projected output columns are not modeled by the simulator." - }, - { - "fixture": "safe_004_drop_index_if_exists.sql", - "reason": "Requires index i in the canonical baseline to produce a state change, which conflicts with enabled create-index fixtures." - }, - { - "fixture": "safe_005_drop_sequence_if_exists.sql", - "reason": "Cache V7 synchronizes sequences, but this rule does not compare sequence state." - }, - { - "fixture": "safe_008_add_column_if_not_exists.sql", - "reason": "Column-presence scope conflicts with create-view fixtures whose projected output columns are not modeled by the simulator." - }, - { - "fixture": "safe_009_drop_column_if_exists.sql", - "reason": "Requires column c in the canonical baseline to produce a state change, which conflicts with add-column fixtures." - }, - { - "fixture": "safe_010_drop_policy_if_exists.sql", - "reason": "Policy state is not yet normalized by the differential harness." - }, - { - "fixture": "safe_011_create_schema_if_not_exists.sql", - "reason": "Schema existence is not represented in normalized simulator state." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "relations", - "indexes" - ], - "required_relations": [ - "sm_core.t", - "sm_core.v", - "sm_core.mv" - ], - "notes": "Fifth incremental slice. Validates idempotent and non-idempotent relation and index create/drop outcomes." - }, - { - "rule_dir": "rule_14_restrictive-policy", - "enabled": true, - "fixtures": [ - "001_restrictive_policy.sql", - "002_restrictive_all.sql", - "003_restrictive_insert.sql", - "004_restrictive_update.sql", - "005_restrictive_delete.sql", - "006_restrictive_select_where.sql", - "007_restrictive_update_check.sql", - "010_restrictive_select_expr.sql", - "011_restrictive_insert_check_expr.sql", - "safe_001_permissive.sql", - "safe_004_permissive_all.sql", - "safe_005_permissive_insert.sql", - "safe_006_permissive_update.sql", - "safe_007_permissive_delete.sql" - ], - "excluded_fixtures": [ - { - "fixture": "008_restrictive_to_role.sql", - "reason": "Requires server role role_admin, which is absent from the local PostgreSQL environment." - }, - { - "fixture": "009_restrictive_multiple_roles.sql", - "reason": "Requires server roles admin and manager, which are absent from the local PostgreSQL environment." - }, - { - "fixture": "012_restrictive_select_tenant.sql", - "reason": "PostgreSQL rejects bigint tenant_id = text current_setting(...) without an explicit cast; changing the enterprise tenant key type would be incorrect." - }, - { - "fixture": "safe_002_create_table.sql", - "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." - }, - { - "fixture": "safe_003_alter_table.sql", - "reason": "This is an unrelated column mutation already covered by rules 07, 08, 23, and 25." - }, - { - "fixture": "safe_008_create_index.sql", - "reason": "This is an unrelated index mutation already covered by rules 01, 10, 18, and 25." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "relations", - "policies" - ], - "required_relations": [ - "public.test_table" - ], - "notes": "Thirteenth incremental slice. Compares policy ownership for restrictive and permissive SELECT, INSERT, UPDATE, DELETE, and ALL policies." - }, - { - "rule_dir": "rule_23_volatile-default", - "enabled": true, - "fixtures": [ - "004_create_table_volatile_gen_random_uuid.sql", - "005_add_col_volatile_timeofday.sql", - "007_create_table_volatile_clock_timestamp.sql", - "008_add_col_volatile_uuid.sql", - "009_set_default_volatile_clock.sql", - "safe_002_add_col_immutable.sql", - "safe_004_add_col_no_default.sql", - "safe_007_set_default_numeric.sql", - "safe_009_add_col_default_text.sql" - ], - "excluded_fixtures": [ - { - "fixture": "001_create_table_volatile.sql", - "reason": "PostgreSQL cannot coerce random() double precision to a timestamp column default." - }, - { - "fixture": "002_add_col_volatile.sql", - "reason": "PostgreSQL cannot implicitly coerce random() double precision to an integer column default." - }, - { - "fixture": "003_set_default_volatile.sql", - "reason": "Requires column c in the canonical baseline, which conflicts with enabled add-column fixtures." - }, - { - "fixture": "006_set_default_volatile_now.sql", - "reason": "PostgreSQL cannot coerce random() double precision to the baseline timestamptz column." - }, - { - "fixture": "safe_001_create_table_immutable.sql", - "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." - }, - { - "fixture": "safe_003_set_default_immutable.sql", - "reason": "Requires column c in the canonical baseline, which conflicts with enabled add-column fixtures." - }, - { - "fixture": "safe_005_create_table_immutable_bool.sql", - "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." - }, - { - "fixture": "safe_006_create_table_serial.sql", - "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." - }, - { - "fixture": "safe_008_create_table_default_null.sql", - "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "relations", - "columns" - ], - "required_relations": [ - "public.test_table", - "sm_core.items" - ], - "notes": "Ninth incremental slice. Compares column type, nullability, and default presence for volatile and immutable default mutations." - }, - { - "rule_dir": "rule_25_schema-drift", - "enabled": true, - "fixtures": [ - "safe_002_create_table.sql", - "safe_003_alter_known_table.sql", - "safe_004_create_view.sql", - "safe_005_create_index.sql", - "safe_008_rename_known_table.sql", - "safe_009_create_seq.sql" - ], - "excluded_fixtures": [ - { - "fixture": "001_drop_unknown_table.sql", - "reason": "PostgreSQL rejects the unknown relation; this is a rule diagnostic case with no resulting live state." - }, - { - "fixture": "002_drop_unknown_view.sql", - "reason": "PostgreSQL rejects the unknown view; this is a rule diagnostic case with no resulting live state." - }, - { - "fixture": "003_drop_unknown_seq.sql", - "reason": "PostgreSQL rejects the unknown sequence and sequence state is not normalized." - }, - { - "fixture": "004_alter_unknown_table.sql", - "reason": "PostgreSQL rejects the unknown relation; this is a rule diagnostic case with no resulting live state." - }, - { - "fixture": "005_drop_unknown_func.sql", - "reason": "PostgreSQL rejects the unknown function and function state is not normalized." - }, - { - "fixture": "006_drop_unknown_index.sql", - "reason": "PostgreSQL rejects the unknown index; this is a rule diagnostic case with no resulting live state." - }, - { - "fixture": "007_drop_unknown_type.sql", - "reason": "PostgreSQL rejects the unknown type and type state is not hydrated." - }, - { - "fixture": "008_rename_unknown_table.sql", - "reason": "PostgreSQL rejects the unknown relation; this is a rule diagnostic case with no resulting live state." - }, - { - "fixture": "009_alter_unknown_table_set_default.sql", - "reason": "PostgreSQL rejects the unknown relation; this is a rule diagnostic case with no resulting live state." - }, - { - "fixture": "safe_001_drop_known_table.sql", - "reason": "PostgreSQL rejects dropping public.test_table without CASCADE because enterprise baseline dependencies exist." - }, - { - "fixture": "safe_006_select.sql", - "reason": "Data reads do not change normalized schema state." - }, - { - "fixture": "safe_007_insert.sql", - "reason": "Data writes do not change normalized schema state." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "relations", - "columns", - "indexes", - "foreign_keys" - ], - "fixture_scopes": { - "safe_004_create_view.sql": [ - "relations" - ], - "safe_009_create_seq.sql": [ - "sequences" - ] - }, - "required_relations": [ - "public.test_table" - ], - "notes": "Twelfth incremental slice. Validates known-object creation, alteration, indexing, and rename propagation while classifying unknown-object fixtures as PostgreSQL rejection cases." - }, - { - "rule_dir": "rule_15_disable-trigger", - "enabled": true, - "fixtures": [ - "0005_disable_trigger.sql", - "0006_disable_trigger.sql", - "0007_disable_trigger.sql", - "0008_disable_trigger.sql", - "001_disable_all.sql", - "002_disable_specific.sql", - "003_enable_all.sql", - "004_enable_specific.sql", - "009_disable_trigger_user.sql", - "010_enable_trigger_audit.sql", - "safe_002_drop_trigger.sql", - "safe_003_add_column.sql", - "safe_005_create_table.sql", - "safe_007_rename_trigger.sql", - "safe_009_create_index.sql", - "safe_010_rename_table.sql" - ], - "excluded_fixtures": [ - { - "fixture": "safe_001_create_trigger.sql", - "reason": "The canonical baseline contains trigger t so drop and table-rename propagation can be validated." - }, - { - "fixture": "safe_004_drop_column.sql", - "reason": "The canonical baseline intentionally omits column c so safe_003 can validate an independent ADD COLUMN." - }, - { - "fixture": "safe_006_drop_table.sql", - "reason": "PostgreSQL correctly rejects DROP TABLE without CASCADE because public.child_table and sm_audit.change_requests depend on public.test_table." - }, - { - "fixture": "safe_008_drop_constraint.sql", - "reason": "The canonical primary-key constraint is test_table_pkey, not the fixture-specific pk_id." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "triggers" - ], - "required_relations": [ - "public.test_table" - ], - "notes": "Thirteenth incremental slice. Compares PostgreSQL trigger identity, owning table, function, enabled mode, table-drop cleanup, and table-rename propagation." - }, - { - "rule_dir": "rule_17_function-volatility-change", - "enabled": true, - "fixtures": [ - "001_to_volatile.sql", - "002_to_stable.sql", - "003_to_immutable.sql", - "004_to_volatile_schema.sql", - "005_to_stable_schema.sql", - "006_to_immutable_schema.sql", - "007_to_volatile_args.sql", - "008_to_stable_args.sql", - "009_to_immutable_args.sql", - "010_to_volatile_more.sql", - "011_to_stable_restrict.sql", - "012_to_immutable_more.sql", - "013_to_volatile_public_args.sql", - "safe_003_rename_func.sql", - "safe_006_set_param.sql", - "safe_007_reset_param.sql", - "safe_009_create_index.sql", - "safe_010_drop_table.sql", - "safe_011_alter_func_rename_args.sql" - ], - "excluded_fixtures": [ - { - "fixture": "safe_001_create_func.sql", - "reason": "The canonical baseline contains sm_core.f() to exercise volatility changes and trigger-function dependencies." - }, - { - "fixture": "safe_002_create_table.sql", - "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." - }, - { - "fixture": "safe_004_set_schema.sql", - "reason": "PostgreSQL correctly rejects moving sm_core.f() to public because public.f() is required by the schema-qualified fixtures." - }, - { - "fixture": "safe_005_owner_to.sql", - "reason": "The local PostgreSQL beta instance has no postgres role; role ownership is outside normalized function state." - }, - { - "fixture": "safe_008_drop_func.sql", - "reason": "PostgreSQL correctly rejects dropping sm_core.f() because trigger public.t depends on it." - }, - { - "fixture": "safe_012_alter_func_set_schema_args.sql", - "reason": "The fixture targets a non-harness schema named audit; the canonical owned schema is sm_audit." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "functions" - ], - "required_relations": [ - "public.test_table", - "sm_core.t" - ], - "notes": "Fourteenth incremental slice. Compares live pg_proc identity and volatility across overloads, schema qualification, option changes, and function renames." - }, - { - "rule_dir": "rule_09_blocking-constraint", - "enabled": true, - "fixtures": [ - "001_check_constraint.sql", - "002_foreign_key.sql", - "003_set_not_null.sql", - "004_unique_constraint.sql", - "005_primary_key.sql", - "008_exclude_constraint.sql", - "009_unique_multi_column.sql", - "010_primary_key_multi_column.sql", - "safe_001_check_not_valid.sql", - "safe_002_drop_not_null.sql", - "safe_003_create_table.sql", - "safe_004_add_column.sql", - "safe_006_rename_column.sql", - "safe_007_create_index.sql", - "safe_008_drop_constraint.sql", - "safe_009_alter_set_default.sql", - "safe_005_drop_column.sql", - "safe_013_unique_using_index.sql", - "safe_011_foreign_key_not_valid.sql", - "safe_012_foreign_key_validate_later.sql" - ], - "excluded_fixtures": [ - { - "fixture": "006_set_storage.sql", - "reason": "Column storage strategy is not synchronized or normalized by the differential harness." - }, - { - "fixture": "007_set_access_method.sql", - "reason": "Table access method identity is not synchronized or normalized by the differential harness." - }, - { - "fixture": "safe_010_drop_index.sql", - "reason": "DROP INDEX IF EXISTS idx is a no-op against the canonical baseline." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "constraints" - ], - "fixture_scopes": { - "002_foreign_key.sql": [ - "constraints", - "foreign_keys" - ], - "003_set_not_null.sql": [ - "relations", - "columns" - ], - "safe_001_check_not_valid.sql": [ - "constraints" - ], - "safe_002_drop_not_null.sql": [ - "relations", - "columns" - ], - "safe_003_create_table.sql": [ - "relations", - "columns", - "constraints" - ], - "safe_004_add_column.sql": [ - "relations", - "columns" - ], - "safe_006_rename_column.sql": [ - "relations", - "columns" - ], - "safe_007_create_index.sql": [ - "indexes" - ], - "safe_008_drop_constraint.sql": [ - "constraints" - ], - "safe_009_alter_set_default.sql": [ - "relations", - "columns" - ], - "safe_011_foreign_key_not_valid.sql": [ - "constraints", - "foreign_keys" - ], - "safe_012_foreign_key_validate_later.sql": [ - "constraints", - "foreign_keys" - ], - "safe_005_drop_column.sql": [ - "relations", - "columns", - "foreign_keys", - "constraints" - ], - "safe_013_unique_using_index.sql": [ - "relations", - "indexes", - "constraints" - ] - }, - "required_relations": [ - "public.test_table", - "sm_core.t", - "sm_core.t_large" - ], - "notes": "Real-world-inspired constraint slice. Covers each advertised constraint kind, NOT VALID/VALIDATE state transitions, and conversion of a prebuilt unique index into a constraint." - }, - { - "rule_dir": "rule_16_broken-compute", - "enabled": true, - "fixtures": [ - "safe_019_drop_func_public.sql", - "safe_003_drop_func_cascade.sql", - "safe_003_alter_table.sql", - "safe_005_drop_table.sql", - "safe_006_create_index.sql", - "safe_013_drop_func_args.sql", - "safe_014_drop_func_variadic.sql", - "safe_015_drop_func_out_param.sql", - "safe_016_drop_func_set_returning.sql", - "safe_017_drop_func_public_args.sql", - "safe_018_drop_func_default_args.sql" - ], - "excluded_fixtures": [ - { - "fixture": "001_drop_func.sql", - "reason": "PostgreSQL correctly rejects dropping sm_core.f() because trigger public.t depends on it." - }, - { - "fixture": "002_drop_func_if_exists.sql", - "reason": "The fixture does not contain IF EXISTS and PostgreSQL correctly rejects the dependent function drop without CASCADE." - }, - { - "fixture": "004_drop_func_restrict.sql", - "reason": "The fixture does not contain RESTRICT and PostgreSQL correctly rejects the dependent function drop without CASCADE." - }, - { - "fixture": "010_drop_func_simple.sql", - "reason": "PostgreSQL correctly rejects dropping sm_core.f() because trigger public.t depends on it." - }, - { - "fixture": "011_drop_func_owner.sql", - "reason": "PostgreSQL correctly rejects dropping sm_core.f() because trigger public.t depends on it." - }, - { - "fixture": "safe_001_create_func.sql", - "reason": "The canonical baseline contains sm_core.f() for trigger dependency and volatility coverage." - }, - { - "fixture": "safe_002_create_table.sql", - "reason": "The cumulative canonical baseline contains sm_core.t." - }, - { - "fixture": "safe_004_create_trigger.sql", - "reason": "The canonical baseline already contains trigger public.t using sm_core.f()." - }, - { - "fixture": "safe_007_drop_index.sql", - "reason": "PostgreSQL rejects dropping absent index sm_core.i without IF EXISTS." - }, - { - "fixture": "safe_008_select.sql", - "reason": "Data reads do not change normalized schema state." - }, - { - "fixture": "safe_009_insert.sql", - "reason": "Data writes do not change normalized schema state." - }, - { - "fixture": "safe_010_update.sql", - "reason": "Data writes do not change normalized schema state." - }, - { - "fixture": "safe_011_delete.sql", - "reason": "Data writes do not change normalized schema state." - }, - { - "fixture": "safe_012_create_sequence.sql", - "reason": "Cache V7 synchronizes sequences, but this rule does not compare sequence state." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "functions" - ], - "fixture_scopes": { - "safe_003_drop_func_cascade.sql": [ - "functions", - "triggers" - ], - "safe_003_alter_table.sql": [ - "relations", - "columns" - ], - "safe_005_drop_table.sql": [ - "relations" - ], - "safe_006_create_index.sql": [ - "indexes" - ] - }, - "required_relations": [ - "public.test_table", - "sm_core.t" - ], - "notes": "Sixteenth incremental slice. Validates function-drop cascade into dependent triggers and overload identity for regular, variadic, OUT-only, set-returning, and default-argument functions." - }, - { - "rule_dir": "rule_20_alter-type-add-value-txn", - "enabled": true, - "fixtures": [ - "001_add_value.sql", - "001_txn.sql", - "004_add_value_if_not_exists.sql", - "005_add_value_if_not_exists_before.sql", - "006_add_value_if_not_exists_after.sql", - "008_add_value_quoted_type.sql", - "009_add_value_status_type.sql", - "safe_001_rename_type.sql", - "safe_002_rename_value.sql", - "safe_004_alter_table.sql", - "safe_006_drop_type.sql", - "safe_008_create_index.sql", - "safe_010_rename_value_search_path.sql", - "safe_011_rename_value_quoted.sql", - "safe_012_create_then_rename_value.sql", - "safe_013_set_schema.sql", - "safe_014_rename_type_dependents.sql" - ], - "excluded_fixtures": [ - { - "fixture": "002_add_value_before.sql", - "reason": "Fixture assumes label d was committed by an earlier migration, but differential fixtures are intentionally rebuilt from baseline independently." - }, - { - "fixture": "003_add_value_after.sql", - "reason": "Fixture assumes label e was committed by an earlier migration, but differential fixtures are intentionally rebuilt from baseline independently." - }, - { - "fixture": "007_add_value_schema_qualified.sql", - "reason": "Fixture targets generic schema my_schema outside the harness-owned sm_* schema boundary." - }, - { - "fixture": "safe_003_create_table.sql", - "reason": "The cumulative canonical baseline already contains sm_core.t." - }, - { - "fixture": "safe_005_create_type.sql", - "reason": "The canonical baseline contains sm_core.my_enum as the prerequisite for ALTER TYPE fixtures." - }, - { - "fixture": "safe_007_select.sql", - "reason": "Data reads do not change normalized schema state." - }, - { - "fixture": "safe_009_alter_schema.sql", - "reason": "Fixture targets generic schema my_schema outside the harness-owned sm_* schema boundary." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "types" - ], - "fixture_scopes": { - "safe_004_alter_table.sql": [ - "relations", - "columns" - ], - "safe_008_create_index.sql": [ - "indexes" - ] - }, - "required_relations": [ - "public.test_table", - "sm_core.t" - ], - "notes": "Seventeenth incremental slice. Hydrates enterprise enum/domain state from PostgreSQL and compares enum existence, ordered labels, positional additions, value renames, search-path and quoted identity, create-then-rename chains, and drops." - }, - { - "rule_dir": "rule_13_partition-strategy-mismatch", - "enabled": true, - "fixtures": [ - "safe_001_attach_list_to_list.sql", - "safe_003_attach_range_to_range.sql", - "safe_004_attach_hash_to_hash.sql", - "safe_005_add_column.sql", - "safe_006_create_index.sql", - "safe_007_rename_table.sql" - ], - "excluded_fixtures": [ - { - "fixture": "001_attach_list_to_range.sql", - "reason": "The shared public.parent is the canonical LIST parent for rule 12; this fixture's filename assumes a conflicting RANGE baseline." - }, - { - "fixture": "002_attach_range_to_list.sql", - "reason": "PostgreSQL rejects a FROM/TO partition bound for a LIST parent, so no resulting state exists to compare." - }, - { - "fixture": "003_attach_hash_to_list.sql", - "reason": "The shared public.parent is the canonical LIST parent for rule 12 and PostgreSQL rejects a hash bound for it." - }, - { - "fixture": "004_attach_range_to_hash.sql", - "reason": "PostgreSQL rejects a FROM/TO partition bound for a HASH parent, so no resulting state exists to compare." - }, - { - "fixture": "005_attach_list_to_hash.sql", - "reason": "PostgreSQL rejects an IN partition bound for a HASH parent, so no resulting state exists to compare." - }, - { - "fixture": "006_attach_hash_to_range.sql", - "reason": "PostgreSQL rejects a hash partition bound for a RANGE parent, so no resulting state exists to compare." - }, - { - "fixture": "007_attach_list_string_to_range.sql", - "reason": "PostgreSQL rejects an IN partition bound for a RANGE parent, so no resulting state exists to compare." - }, - { - "fixture": "008_attach_range_to_list_multi.sql", - "reason": "PostgreSQL rejects a FROM/TO partition bound for a LIST parent, so no resulting state exists to compare." - }, - { - "fixture": "009_attach_hash_to_list_modulus.sql", - "reason": "PostgreSQL rejects a hash partition bound for a LIST parent, so no resulting state exists to compare." - }, - { - "fixture": "010_attach_list_to_hash_multi.sql", - "reason": "PostgreSQL rejects an IN partition bound for a HASH parent, so no resulting state exists to compare." - }, - { - "fixture": "011_attach_hash_to_range_modulus.sql", - "reason": "PostgreSQL rejects a hash partition bound for a RANGE parent, so no resulting state exists to compare." - }, - { - "fixture": "012_attach_range_to_hash_wide.sql", - "reason": "PostgreSQL rejects a FROM/TO partition bound for a HASH parent, so no resulting state exists to compare." - }, - { - "fixture": "safe_002_create_table.sql", - "reason": "The cumulative canonical baseline already contains sm_core.t." - }, - { - "fixture": "safe_008_insert.sql", - "reason": "Data writes do not change normalized schema state." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "relations", - "partitions" - ], - "fixture_scopes": { - "safe_005_add_column.sql": [ - "relations", - "columns" - ], - "safe_006_create_index.sql": [ - "indexes" - ], - "safe_007_rename_table.sql": [ - "relations" - ] - }, - "required_relations": [ - "public.list_parent", - "public.list_child", - "public.range_parent", - "public.range_child", - "public.hash_parent", - "public.hash_child" - ], - "notes": "Eighteenth incremental slice. Compares PostgreSQL-hydrated LIST/RANGE/HASH parent strategies and valid attachment edges; invalid-bound fixtures are documented rather than treated as semantic state." - }, - { - "rule_dir": "rule_24_overbroad-grant", - "enabled": true, - "fixtures": [ - "001_grant_public.sql", - "002_grant_all.sql", - "003_grant_with_option.sql", - "004_grant_all_public.sql", - "005_grant_all_in_schema.sql", - "006_grant_insert_public.sql", - "007_grant_all_in_schema_public.sql", - "008_grant_update_public.sql", - "safe_001_grant_select.sql", - "safe_002_grant_insert.sql", - "safe_003_revoke.sql", - "safe_004_grant_update.sql", - "safe_005_grant_delete.sql" - ], - "excluded_fixtures": [ - { - "fixture": "009_grant_select_with_grant_option.sql", - "reason": "PostgreSQL rejects GRANT OPTION for PUBLIC (SQLSTATE 0LP01); the fixture is not executable against the live oracle." - }, - { - "fixture": "safe_006_grant_select_columns.sql", - "reason": "Column-level ACLs live in pg_attribute.attacl and are intentionally distinct from relation ACL state." - }, - { - "fixture": "safe_007_create_table.sql", - "reason": "The cumulative canonical baseline already contains sm_core.t." - }, - { - "fixture": "safe_008_grant_usage.sql", - "reason": "Schema ACLs are outside the v1 relation-privilege comparison scope." - }, - { - "fixture": "safe_009_grant_execute.sql", - "reason": "Function ACLs are outside the v1 relation-privilege comparison scope." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "privileges" - ], - "required_relations": [ - "public.test_table" - ], - "notes": "Nineteenth incremental slice. Synchronizes explicit non-owner PostgreSQL relation ACLs and compares grants/revokes for named roles and PUBLIC, including ALL TABLES IN SCHEMA expansion." - }, - { - "rule_dir": "rule_11_blocking-mat-view-refresh", - "enabled": true, - "fixtures": [ - "001_refresh.sql", - "002_refresh_with_data.sql", - "003_refresh_no_data.sql", - "004_refresh_analytics.sql", - "005_refresh_schema_qualified.sql", - "006_refresh_other_schema.sql", - "007_refresh_weekly_summary.sql", - "008_refresh_dashboard.sql", - "009_refresh_site_stats.sql", - "010_refresh_monthly.sql", - "011_refresh_user_report.sql", - "012_refresh_inventory.sql", - "safe_003_drop_matview.sql", - "safe_005_alter_table.sql", - "safe_007_create_index.sql" - ], - "excluded_fixtures": [ - { - "fixture": "safe_001_refresh_concurrently.sql", - "reason": "Fixture contains SELECT 1 rather than a materialized-view refresh and changes no normalized state." - }, - { - "fixture": "safe_002_create_matview.sql", - "reason": "The canonical baseline contains sm_core.mymatview with the unique index required by PostgreSQL for concurrent refresh." - }, - { - "fixture": "safe_004_create_table.sql", - "reason": "The cumulative canonical baseline already contains sm_core.t." - }, - { - "fixture": "safe_006_insert.sql", - "reason": "Data writes are outside the normalized schema-state comparison." - }, - { - "fixture": "safe_008_select.sql", - "reason": "Data reads are outside the normalized schema-state comparison." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "relations", - "indexes" - ], - "fixture_scopes": { - "safe_003_drop_matview.sql": [ - "relations", - "indexes" - ], - "safe_005_alter_table.sql": [ - "relations", - "columns" - ], - "safe_007_create_index.sql": [ - "indexes" - ] - }, - "required_relations": [ - "sm_core.mymatview", - "public.test_table" - ], - "notes": "Twentieth incremental slice. Runs legal PostgreSQL concurrent refreshes against a populated materialized view with a qualifying unique index and verifies relation/index topology remains stable." - }, - { - "rule_dir": "rule_26_chain-conflict", - "enabled": true, - "fixtures": [ - "010_conflict.sql", - "011_missing_fk_source_column.sql", - "012_rename_enum_missing_label.sql", - "013_rename_enum_duplicate_label.sql", - "014_rename_value_non_enum.sql", - "015_rename_value_missing_type.sql", - "016_missing_set_role.sql", - "017_unauthorized_set_role.sql", - "018_identifier_truncation_collision.sql", - "004_conflict.sql", - "safe_001_chain.sql", - "safe_001_create_table.sql", - "safe_002_create_index.sql", - "safe_003_add_unique_constraint.sql", - "safe_004_add_not_null.sql", - "safe_005_create_view.sql", - "safe_006_create_seq.sql", - "safe_007_grant_select.sql", - "safe_010_role_transaction_semantics.sql", - "safe_011_session_authorization.sql", - "safe_012_role_search_path_and_owner.sql", - "safe_013_quoted_session_authorization.sql", - "safe_014_session_authorization_rollback.sql", - "safe_015_session_authorization_default.sql", - "safe_016_transitive_set_role_membership.sql", - "safe_017_relation_namespace_resolution.sql", - "safe_018_type_namespace_resolution.sql", - "safe_019_routine_namespace_resolution.sql", - "safe_020_generated_name_truncation.sql", - "safe_021_routine_type_aliases.sql", - "safe_022_role_membership_cascade.sql", - "safe_023_role_options.sql", - "safe_024_logical_replication.sql" - ], - "fixture_transactional": { - "safe_010_role_transaction_semantics.sql": false, - "safe_011_session_authorization.sql": false, - "safe_012_role_search_path_and_owner.sql": false, - "safe_013_quoted_session_authorization.sql": false, - "safe_014_session_authorization_rollback.sql": false, - "safe_015_session_authorization_default.sql": false, - "safe_016_transitive_set_role_membership.sql": false - }, - "fixture_min_pg_version": { - "safe_022_role_membership_cascade.sql": 160000, - "safe_024_logical_replication.sql": 150000, - "safe_023_role_options.sql": 160000 - }, - "expected_live_errors": { - "011_missing_fk_source_column.sql": { - "sqlstate": "42703", - "simulator_rule": "chain-conflict" - }, - "012_rename_enum_missing_label.sql": { - "sqlstate": "22023", - "simulator_rule": "chain-conflict" - }, - "013_rename_enum_duplicate_label.sql": { - "sqlstate": "42710", - "simulator_rule": "chain-conflict" - }, - "014_rename_value_non_enum.sql": { - "sqlstate": "42809", - "simulator_rule": "chain-conflict" - }, - "015_rename_value_missing_type.sql": { - "sqlstate": "42704", - "simulator_rule": "chain-conflict" - }, - "016_missing_set_role.sql": { - "sqlstate": "22023", - "simulator_rule": "chain-conflict" - }, - "017_unauthorized_set_role.sql": { - "sqlstate": "42501", - "simulator_rule": "chain-conflict" - }, - "018_identifier_truncation_collision.sql": { - "sqlstate": "42P07", - "simulator_rule": "chain-conflict" - } - }, - "excluded_fixtures": [ - { - "fixture": "001_chain.sql", - "reason": "PostgreSQL rejects the second incompatible duplicate column definition, leaving no committed resulting state." - }, - { - "fixture": "001_conflict.sql", - "reason": "PostgreSQL rejects the second incompatible duplicate column definition, leaving no committed resulting state." - }, - { - "fixture": "002_chain.sql", - "reason": "PostgreSQL rejects the second incompatible duplicate column definition, leaving no committed resulting state." - }, - { - "fixture": "002_conflict.sql", - "reason": "PostgreSQL rejects the duplicate index name, leaving no committed resulting state." - }, - { - "fixture": "003_chain.sql", - "reason": "PostgreSQL rejects the second incompatible duplicate column definition, leaving no committed resulting state." - }, - { - "fixture": "003_conflict.sql", - "reason": "PostgreSQL rejects the duplicate table definition, leaving no committed resulting state." - }, - { - "fixture": "005_conflict.sql", - "reason": "Fixture requires a baseline column a while column b must remain absent; that topology conflicts with the shared canonical table shape." - }, - { - "fixture": "006_conflict.sql", - "reason": "Fixture requires baseline column c, while participating add-column chains require c to be absent." - }, - { - "fixture": "007_conflict.sql", - "reason": "PostgreSQL rejects the second incompatible duplicate column definition, leaving no committed resulting state." - }, - { - "fixture": "008_conflict.sql", - "reason": "PostgreSQL rejects the second incompatible duplicate column definition, leaving no committed resulting state." - }, - { - "fixture": "009_conflict.sql", - "reason": "Fixture requires baseline column c, while participating add-column chains require c to be absent." - }, - { - "fixture": "safe_008_insert.sql", - "reason": "Data writes are outside the normalized schema-state comparison." - }, - { - "fixture": "safe_009_select.sql", - "reason": "Data reads are outside the normalized schema-state comparison." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics", - "app_user", - "sm_role_quote", - "sm_ns_early", - "sm_ns_late", - "sm_type_early", - "sm_type_late", - "sm_routine_early", - "sm_routine_late" - ], - "scope": [ - "relations", - "columns" - ], - "fixture_scopes": { - "004_conflict.sql": [ - "relations", - "columns" - ], - "010_conflict.sql": [ - "relations", - "columns" - ], - "safe_002_create_index.sql": [ - "indexes" - ], - "safe_003_add_unique_constraint.sql": [ - "constraints" - ], - "safe_005_create_view.sql": [ - "relations", - "view_dependencies" - ], - "safe_006_create_seq.sql": [ - "sequences" - ], - "safe_007_grant_select.sql": [ - "privileges" - ], - "safe_010_role_transaction_semantics.sql": [ - "relations" - ], - "safe_011_session_authorization.sql": [ - "relations" - ], - "safe_012_role_search_path_and_owner.sql": [ - "relations", - "privileges" - ], - "safe_013_quoted_session_authorization.sql": [ - "relations" - ], - "safe_014_session_authorization_rollback.sql": [ - "relations" - ], - "safe_015_session_authorization_default.sql": [ - "relations" - ], - "safe_016_transitive_set_role_membership.sql": [ - "relations", - "roles" - ], - "safe_022_role_membership_cascade.sql": [ - "roles" - ], - "safe_023_role_options.sql": [ - "roles" - ], - "safe_024_logical_replication.sql": [ - "publications", - "subscriptions" - ], - "safe_017_relation_namespace_resolution.sql": [ - "schemas", - "relations", - "columns", - "types" - ], - "safe_018_type_namespace_resolution.sql": [ - "schemas", - "relations", - "columns", - "types" - ], - "safe_019_routine_namespace_resolution.sql": [ - "schemas", - "functions" - ], - "safe_020_generated_name_truncation.sql": [ - "relations", - "columns", - "constraints" - ], - "safe_021_routine_type_aliases.sql": [ - "functions" - ] - }, - "required_relations": [ - "public.test_table" - ], - "required_role_edges": [ - { - "member": "sm_set_member", - "role": "sm_set_bridge", - "kind": "can_set_role_to" - }, - { - "member": "sm_set_bridge", - "role": "sm_set_target", - "kind": "can_set_role_to" - }, - { - "member": "sm_option_member", - "role": "sm_option_parent", - "kind": "can_administer_membership", - "min_pg_version_num": 160000 - }, - { - "member": "sm_option_member", - "role": "sm_option_parent", - "kind": "member_of_without_set", - "min_pg_version_num": 160000 - }, - { - "member": "sm_inherit_member", - "role": "sm_inherit_parent", - "kind": "can_inherit_from", - "min_pg_version_num": 160000 - }, - { - "member": "sm_inherit_member", - "role": "sm_inherit_parent", - "kind": "member_of_without_set", - "min_pg_version_num": 160000 - }, - { - "member": "sm_set_member", - "role": "sm_no_set_target", - "kind": "member_of_without_set", - "min_pg_version_num": 160000 - } - ], - "notes": "Compares valid multi-statement outcomes and expected PostgreSQL rejections. The sourced missing-FK-column case must agree on SQLSTATE 42703 and a chain-conflict finding." - }, - { - "rule_dir": "rule_19_concurrent-in-transaction", - "enabled": true, - "transactional": false, - "fixtures": [ - "safe_001_create_index.sql", - "safe_003_create_concurrently.sql", - "safe_005_create_unique_index.sql", - "safe_007_create_index_where.sql", - "safe_008_create_index_include.sql", - "safe_009_create_index_schema.sql" - ], - "excluded_fixtures": [ - { - "fixture": "001_create_concurrently.sql", - "reason": "PostgreSQL rejects CREATE INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." - }, - { - "fixture": "001_txn.sql", - "reason": "PostgreSQL rejects CREATE INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." - }, - { - "fixture": "002_drop_concurrently.sql", - "reason": "PostgreSQL rejects DROP INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." - }, - { - "fixture": "003_create_concurrently_unique.sql", - "reason": "PostgreSQL rejects CREATE INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." - }, - { - "fixture": "004_drop_concurrently_exists.sql", - "reason": "PostgreSQL rejects DROP INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." - }, - { - "fixture": "005_create_concurrently_schema.sql", - "reason": "PostgreSQL rejects CREATE INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." - }, - { - "fixture": "006_drop_concurrently_schema.sql", - "reason": "PostgreSQL rejects DROP INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." - }, - { - "fixture": "007_create_concurrently_where.sql", - "reason": "PostgreSQL rejects CREATE INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." - }, - { - "fixture": "008_drop_concurrently_cascade.sql", - "reason": "PostgreSQL rejects DROP INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." - }, - { - "fixture": "009_create_concurrently_include.sql", - "reason": "PostgreSQL rejects CREATE INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." - }, - { - "fixture": "010_drop_concurrently_restrict.sql", - "reason": "PostgreSQL rejects DROP INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." - }, - { - "fixture": "011_create_concurrently_multi.sql", - "reason": "PostgreSQL rejects CREATE INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." - }, - { - "fixture": "012_drop_concurrently_public.sql", - "reason": "PostgreSQL rejects DROP INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." - }, - { - "fixture": "013_create_concurrently_covering.sql", - "reason": "PostgreSQL rejects CREATE INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." - }, - { - "fixture": "safe_002_drop_index.sql", - "reason": "Requires a baseline index named i, which conflicts with this rule's participating create-index fixtures." - }, - { - "fixture": "safe_004_drop_concurrently.sql", - "reason": "The absent index makes this IF EXISTS statement a no-op with no differential state." - }, - { - "fixture": "safe_006_drop_index_cascade.sql", - "reason": "Requires a baseline index named i, which conflicts with this rule's participating create-index fixtures." - }, - { - "fixture": "safe_010_drop_index_schema.sql", - "reason": "Requires a baseline index named public.i, which conflicts with this rule's participating create-index fixtures." - }, - { - "fixture": "safe_011_reindex.sql", - "reason": "Requires a baseline index named i, which conflicts with this rule's participating create-index fixtures." - }, - { - "fixture": "safe_012_create_table.sql", - "reason": "The canonical enterprise baseline already contains public.test_table." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "indexes" - ], - "required_relations": [ - "public.test_table" - ], - "notes": "Twenty-second incremental slice. Compares successful index outcomes outside a harness transaction; explicit transaction-error fixtures are nondifferentiable in the successful-state harness and are documented individually." - }, - { - "rule_dir": "rule_21_vacuum-full", - "enabled": true, - "transactional": false, - "fixtures": [ - "001_vacuum_full.sql", - "002_vacuum_full_verbose.sql", - "003_vacuum_full_analyze.sql", - "004_vacuum_full_all.sql", - "006_vacuum_full_verbose_analyze.sql", - "008_vacuum_full_analyze_all.sql", - "009_vacuum_full_parenthesized.sql", - "safe_001_vacuum.sql", - "safe_002_analyze.sql", - "safe_003_vacuum_analyze.sql", - "safe_005_vacuum_verbose.sql", - "safe_006_analyze_all.sql", - "safe_007_vacuum_all.sql", - "safe_008_vacuum_analyze_all.sql" - ], - "excluded_fixtures": [ - { - "fixture": "005_vacuum_full_schema.sql", - "reason": "Requires absent schema and relation my_schema.test_table; adding a duplicate enterprise table solely for maintenance syntax would not increase normalized state coverage." - }, - { - "fixture": "007_vacuum_full_multiple.sql", - "reason": "Requires absent relations t1 and t2; equivalent single-table and whole-database maintenance outcomes are participating." - }, - { - "fixture": "safe_004_create_table.sql", - "reason": "The canonical enterprise baseline already contains sm_core.t on the active search path." - }, - { - "fixture": "safe_009_vacuum_schema.sql", - "reason": "Requires absent schema and relation my_schema.test_table; the public.test_table maintenance control is participating." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "relations", - "columns", - "indexes", - "constraints", - "foreign_keys" - ], - "required_relations": [ - "public.test_table" - ], - "notes": "Twenty-third incremental slice. Runs VACUUM, ANALYZE, and VACUUM FULL outside transactions and verifies that PostgreSQL maintenance preserves enterprise schema topology." - }, - { - "rule_dir": "rule_22_opaque-dynamic-sql", - "enabled": true, - "fixtures": [ - "001_do_block.sql", - "004_set_transaction.sql", - "005_set_constraints.sql", - "006_do_block_language.sql", - "008_set_transaction_read_only.sql", - "010_do_block_with_declare.sql", - "013_invalid_set_role_current_user.sql", - "safe_001_create_table.sql", - "safe_002_alter_table.sql", - "011_select.sql", - "safe_004_create_index.sql", - "013_update.sql", - "014_delete.sql", - "safe_015_comment_on.sql" - ], - "expected_live_errors": { - "013_invalid_set_role_current_user.sql": { - "sqlstate": "42601", - "simulator_rule": "opaque-dynamic-sql" - } - }, - "excluded_fixtures": [ - { - "fixture": "002_execute.sql", - "reason": "PostgreSQL has no top-level EXECUTE string statement; EXECUTE is valid only in procedural or prepared-statement contexts." - }, - { - "fixture": "003_prepare_transaction.sql", - "reason": "Requires an active transaction and server-level prepared-transaction support, and intentionally leaves connection-level state rather than normalized schema state." - }, - { - "fixture": "007_execute_format.sql", - "reason": "PostgreSQL rejects procedural EXECUTE and PL/pgSQL variables at top level." - }, - { - "fixture": "009_set_constraints_named.sql", - "reason": "Requires a constraint named fk_constraint, which is absent from the canonical enterprise baseline." - }, - { - "fixture": "012_insert.sql", - "reason": "PostgreSQL rejects the single unqualified value because public.test_table has multiple non-generated columns." - }, - { - "fixture": "safe_008_drop_table.sql", - "reason": "PostgreSQL rejects dropping public.test_table without CASCADE because the enterprise baseline has cross-table dependencies." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "relations", - "columns", - "indexes", - "constraints", - "foreign_keys", - "view_dependencies" - ], - "fixture_scopes": { - "safe_001_create_table.sql": [ - "relations", - "columns" - ], - "safe_002_alter_table.sql": [ - "relations", - "columns" - ], - "safe_004_create_index.sql": [ - "indexes" - ] - }, - "required_relations": [ - "public.test_table" - ], - "notes": "Twenty-fourth incremental slice. Executes opaque procedural, transaction-control, and currently unmodeled DML statements against PostgreSQL, verifies their schema-state neutrality, compares explicit DDL controls, and covers explicit schema-neutral COMMENT ON statements." - }, - { - "rule_dir": "rule_02_drop-database", - "enabled": true, - "fixtures": [ - "safe_004_drop_table.sql", - "safe_005_select.sql", - "safe_009_update.sql" - ], - "excluded_fixtures": [ - { - "fixture": "001_drop_db.sql", - "reason": "Database mydb is not harness-owned, DROP DATABASE cannot be rollback-isolated, and database existence is outside normalized schema state." - }, - { - "fixture": "002_drop_db_if_exists.sql", - "reason": "Database mydb is not harness-owned; IF EXISTS does not make destructive execution safe when a local database with that name may exist." - }, - { - "fixture": "003_drop_db_force.sql", - "reason": "Database mydb is not harness-owned and FORCE may terminate unrelated local sessions; this connection-level behavior is intentionally nondifferentiable." - }, - { - "fixture": "004_drop_db_force_if_exists.sql", - "reason": "Database mydb is not harness-owned and FORCE may terminate unrelated local sessions; IF EXISTS does not make execution safe." - }, - { - "fixture": "005_drop_db_quoted.sql", - "reason": "Database My-DB is not harness-owned, DROP DATABASE cannot be rollback-isolated, and database existence is outside normalized schema state." - }, - { - "fixture": "006_drop_db_quoted_force.sql", - "reason": "Database My-DB is not harness-owned and FORCE may terminate unrelated local sessions." - }, - { - "fixture": "007_drop_db_multi_word.sql", - "reason": "Database my_database is not harness-owned, DROP DATABASE cannot be rollback-isolated, and database existence is outside normalized schema state." - }, - { - "fixture": "008_drop_db_temp.sql", - "reason": "Database temp_db is not harness-owned, DROP DATABASE cannot be rollback-isolated, and database existence is outside normalized schema state." - }, - { - "fixture": "009_drop_db_mixed_case.sql", - "reason": "PostgreSQL folds MyDataBase to mydatabase, which is not harness-owned; database existence is outside normalized schema state." - }, - { - "fixture": "safe_001_create_db.sql", - "reason": "CREATE DATABASE cannot run inside rollback isolation and would create non-normalized connection-level state using a non-harness-owned name." - }, - { - "fixture": "safe_002_alter_db.sql", - "reason": "Requires non-harness-owned database mydb and mutates connection-level state outside normalized schema comparison." - }, - { - "fixture": "safe_003_create_table.sql", - "reason": "The canonical enterprise baseline already contains sm_core.t on the active search path." - }, - { - "fixture": "safe_006_alter_db_owner.sql", - "reason": "Requires non-harness-owned database mydb and role postgres, which is not the local PostgreSQL role used by this beta environment." - }, - { - "fixture": "safe_007_create_db_options.sql", - "reason": "CREATE DATABASE cannot be rollback-isolated, uses a non-harness-owned name, and requests an environment-specific locale." - }, - { - "fixture": "safe_008_insert.sql", - "reason": "PostgreSQL rejects one value for baseline sm_core.t because it has two non-generated columns." - } - ], - "schemas": [ - "public", - "sm_identity", - "sm_core", - "sm_catalog", - "sm_billing", - "sm_fulfillment", - "sm_audit", - "sm_analytics" - ], - "scope": [ - "relations", - "columns", - "indexes", - "constraints", - "foreign_keys", - "view_dependencies" - ], - "required_relations": [ - "sm_core.t" - ], - "notes": "Twenty-fifth incremental slice. Represents the connection-level rule with safe live controls only; fixed-name database creation, alteration, and deletion are explicitly classified as unsupported nondifferentiable behavior in the schema-state harness." + "rules": [ + { + "rule_dir": "rule_03_drop-schema-cascade", + "enabled": true, + "fixtures": [ + "001_cascade.sql", + "002_cascade_if_exists.sql", + "009_cascade_upper.sql", + "safe_003_create.sql", + "safe_004_rename.sql" + ], + "excluded_fixtures": [ + { + "fixture": "003_multi_cascade.sql", + "reason": "Requires a non-harness staging schema; dropping it adds no normalized state beyond the enabled public-schema case." + }, + { + "fixture": "004_multi_cascade_if_exists.sql", + "reason": "Requires a non-harness staging schema; dropping it adds no normalized state beyond the enabled public-schema case." + }, + { + "fixture": "005_cascade_quoted.sql", + "reason": "Requires a non-harness schema named My Schema and has no relation topology in the canonical baseline." + }, + { + "fixture": "006_cascade_if_exists_quoted.sql", + "reason": "The absent quoted schema produces no normalized state change." + }, + { + "fixture": "007_multi_three_cascade.sql", + "reason": "Requires three non-harness empty schemas whose existence is not represented in normalized simulator state." + }, + { + "fixture": "008_cascade_long_name.sql", + "reason": "Requires a non-harness empty schema whose existence is not represented in normalized simulator state." + }, + { + "fixture": "safe_001_no_cascade.sql", + "reason": "PostgreSQL correctly rejects dropping the populated public schema without CASCADE." + }, + { + "fixture": "safe_002_restrict.sql", + "reason": "PostgreSQL correctly rejects dropping the populated public schema with RESTRICT." + }, + { + "fixture": "safe_005_drop_if_exists_no_cascade.sql", + "reason": "PostgreSQL correctly rejects dropping the populated public schema without CASCADE." + }, + { + "fixture": "safe_006_create_auth.sql", + "reason": "Requires a postgres role, while the differential matrix intentionally runs as its harness-specific safe_migrate role." + }, + { + "fixture": "safe_007_multi_no_cascade.sql", + "reason": "PostgreSQL correctly rejects dropping the populated public schema without CASCADE." + }, + { + "fixture": "safe_008_multi_restrict.sql", + "reason": "PostgreSQL correctly rejects dropping the populated public schema with RESTRICT." + }, + { + "fixture": "safe_009_drop_quoted_no_cascade.sql", + "reason": "Requires a non-harness quoted schema with no normalized topology." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics", + "staging", + "pub" + ], + "scope": [ + "schemas", + "relations", + "indexes", + "foreign_keys", + "partitions" + ], + "required_relations": [ + "public.test_table", + "public.child_table", + "public.parent", + "public.child" + ], + "notes": "Seventh incremental slice. Validates public-schema CASCADE cleanup across relations, indexes, FK edges, triggers, and partition topology." + }, + { + "rule_dir": "rule_01_irreversible-migration", + "enabled": true, + "fixtures": [ + "001_drop_table.sql", + "004_drop_table_if_exists.sql", + "005_drop_cascade.sql", + "safe_001_rename_col.sql", + "safe_002_add_col.sql", + "safe_003_widen_varchar.sql", + "safe_004_widen_int.sql", + "safe_006_set_default.sql", + "safe_007_add_not_null.sql", + "safe_008_rename_table.sql", + "safe_009_add_index.sql", + "002_drop_column.sql", + "006_drop_column_if_exists.sql" + ], + "excluded_fixtures": [ + { + "fixture": "safe_005_create_table.sql", + "reason": "The cumulative canonical baseline contains sm_core.t for rule_18 drop/idempotency cases, so PostgreSQL correctly rejects this unqualified duplicate CREATE TABLE." + }, + { + "fixture": "safe_010_add_column_default.sql", + "reason": "The cumulative canonical baseline contains test_table.status for the rule_06 filtered CTAS fixture; added-column defaults remain covered by rules 07 and 23." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "relations", + "columns", + "indexes", + "foreign_keys" + ], + "fixture_scopes": { + "002_drop_column.sql": [ + "relations", + "columns", + "foreign_keys", + "view_dependencies" + ], + "006_drop_column_if_exists.sql": [ + "relations", + "columns", + "foreign_keys", + "view_dependencies" + ] + }, + "required_relations": [ + "public.test_table", + "public.child_table" + ], + "notes": "Second incremental differential slice. Covers table and column drops, column/table renames, type widening, defaults, nullability, table creation, and index creation." + }, + { + "rule_dir": "rule_04_destructive-general-cascade", + "enabled": true, + "fixtures": [ + "001_drop_view_cascade.sql", + "002_drop_matview_cascade.sql", + "008_drop_view_if_exists_cascade.sql", + "009_drop_matview_if_exists_cascade.sql", + "safe_001_drop_view.sql", + "safe_002_drop_matview.sql", + "safe_008_drop_view_if_exists.sql", + "safe_009_drop_matview_if_exists.sql", + "safe_010_drop_view_restrict.sql", + "007_drop_pub_cascade.sql", + "safe_006_drop_pub.sql", + "safe_011_alter_pub_options.sql" + ], + "excluded_fixtures": [ + { + "fixture": "003_drop_seq_cascade.sql", + "reason": "Cache V7 synchronizes sequences, but this rule does not compare sequence state." + }, + { + "fixture": "004_drop_domain_cascade.sql", + "reason": "Domain state is not yet hydrated from the live baseline into simulator type state." + }, + { + "fixture": "005_drop_func_cascade.sql", + "reason": "Cache V7 synchronizes routine identity and kind, but this rule does not compare routine state." + }, + { + "fixture": "006_drop_proc_cascade.sql", + "reason": "Cache V7 synchronizes routine identity and kind, but this rule does not compare routine state." + }, + { + "fixture": "010_drop_seq_if_exists_cascade.sql", + "reason": "Cache V7 synchronizes sequences, but this rule does not compare sequence state." + }, + { + "fixture": "safe_003_drop_seq.sql", + "reason": "Cache V7 synchronizes sequences, but this rule does not compare sequence state." + }, + { + "fixture": "safe_004_drop_func.sql", + "reason": "Cache V7 synchronizes routine identity and kind, but this rule does not compare routine state." + }, + { + "fixture": "safe_005_drop_proc.sql", + "reason": "Cache V7 synchronizes routine identity and kind, but this rule does not compare routine state." + }, + { + "fixture": "safe_007_drop_domain.sql", + "reason": "Domain state is not yet hydrated from the live baseline into simulator type state." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "relations" + ], + "fixture_scopes": { + "007_drop_pub_cascade.sql": [ + "publications" + ], + "safe_006_drop_pub.sql": [ + "publications" + ], + "safe_011_alter_pub_options.sql": [ + "publications" + ] + }, + "required_relations": [ + "sm_core.myview", + "sm_core.mymat" + ], + "notes": "Eighth incremental slice. Validates CASCADE, RESTRICT, and IF EXISTS drop outcomes for views and materialized views." + }, + { + "rule_dir": "rule_05_destructive-cascade", + "enabled": true, + "fixtures": [ + "001_cascade.sql", + "007_cascade_schema_qualified.sql" + ], + "excluded_fixtures": [ + { + "fixture": "002_cascade_if_exists.sql", + "reason": "Duplicates the participating unqualified CASCADE outcome with IF EXISTS." + }, + { + "fixture": "003_cascade_quoted.sql", + "reason": "Quoted lowercase test_table resolves to the same object and outcome as the participating unqualified fixture." + }, + { + "fixture": "004_cascade_multi.sql", + "reason": "Contains the same single DROP TABLE test_table CASCADE statement as the participating fixture." + }, + { + "fixture": "005_cascade_again.sql", + "reason": "Contains the same single DROP TABLE test_table CASCADE statement as the participating fixture." + }, + { + "fixture": "006_cascade_if_exists_multi.sql", + "reason": "Duplicates the participating unqualified CASCADE outcome with IF EXISTS." + }, + { + "fixture": "008_cascade_upper.sql", + "reason": "Unquoted TEST_TABLE folds to test_table and duplicates the participating unqualified outcome." + }, + { + "fixture": "009_cascade_temp.sql", + "reason": "Contains the same single DROP TABLE test_table CASCADE statement as the participating fixture." + }, + { + "fixture": "010_cascade_long_name.sql", + "reason": "Contains the same single DROP TABLE test_table CASCADE statement as the participating fixture." + }, + { + "fixture": "safe_001_no_cascade.sql", + "reason": "PostgreSQL rejects dropping public.test_table without CASCADE because the enterprise baseline has dependent foreign keys." + }, + { + "fixture": "safe_002_restrict.sql", + "reason": "PostgreSQL rejects dropping public.test_table with RESTRICT because the enterprise baseline has dependent foreign keys." + }, + { + "fixture": "safe_003_alter_table.sql", + "reason": "Add-column state is covered by dedicated participating rules and does not exercise cascade behavior." + }, + { + "fixture": "safe_004_truncate.sql", + "reason": "TRUNCATE changes data rather than normalized schema topology." + }, + { + "fixture": "safe_005_drop_if_exists_no_cascade.sql", + "reason": "PostgreSQL rejects dropping the existing public.test_table without CASCADE despite IF EXISTS." + }, + { + "fixture": "safe_006_drop_quoted_no_cascade.sql", + "reason": "PostgreSQL rejects dropping quoted public.test_table without CASCADE because dependencies exist." + }, + { + "fixture": "safe_007_drop_multi_no_cascade.sql", + "reason": "Requires absent baseline relations t1 and t2." + }, + { + "fixture": "safe_009_insert.sql", + "reason": "PostgreSQL rejects one value for multi-column public.test_table, and data writes are outside normalized schema state." + }, + { + "fixture": "safe_010_delete.sql", + "reason": "DELETE changes data rather than normalized schema topology." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "relations", + "indexes", + "foreign_keys" + ], + "required_relations": [ + "public.test_table", + "public.child_table" + ], + "notes": "Initial incremental live differential slice over the 65+ relation enterprise baseline. Confirms DROP TABLE ... CASCADE removes the target relation, its owned indexes, and same- and cross-schema FK edges without deleting unrelated tables." + }, + { + "rule_dir": "rule_08_type-change-rewrite", + "enabled": true, + "fixtures": [ + "001_lossy_varchar.sql", + "003_lossy_timestamp_to_date.sql", + "004_rewrite_numeric.sql", + "005_lossy_int_to_smallint.sql", + "006_rewrite_text_to_varchar.sql", + "007_lossy_numeric_to_int.sql", + "008_rewrite_bigint_to_int.sql", + "009_lossy_timestamptz_to_date.sql", + "010_rewrite_numeric_to_float.sql", + "011_lossy_numeric.sql", + "012_lossy_timestamptz_to_date.sql", + "013_lossy_varchar.sql", + "014_lossy_int_to_smallint.sql", + "safe_001_widen_varchar.sql", + "safe_002_widen_int.sql", + "safe_003_widen_varchar_limit.sql", + "safe_004_json_to_jsonb.sql", + "safe_005_varchar_to_text.sql", + "safe_006_widen_numeric.sql", + "safe_007_add_column.sql", + "safe_008_alter_default.sql", + "safe_009_drop_default.sql", + "safe_010_rename_column.sql" + ], + "excluded_fixtures": [ + { + "fixture": "002_rewrite_int_to_text.sql", + "reason": "PostgreSQL rejects changing the referenced integer primary key to text while enterprise-baseline integer foreign keys depend on its equality operators." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "relations", + "columns", + "foreign_keys" + ], + "required_relations": [ + "public.test_table", + "public.child_table" + ], + "notes": "Fourth incremental slice. Compares PostgreSQL canonical column types after lossy and non-lossy conversions, plus rename/default mutations." + }, + { + "rule_dir": "rule_07_size-aware-add-column", + "enabled": true, + "fixtures": [ + "003_volatile_gen_random.sql", + "005_volatile_current_timestamp.sql", + "006_volatile_timeofday.sql", + "010_volatile_random_nested.sql", + "safe_001_no_default.sql", + "safe_002_immutable_int.sql", + "safe_003_immutable_text.sql", + "safe_004_immutable_bool.sql", + "safe_005_immutable_json.sql", + "safe_006_nullable.sql", + "safe_007_serial.sql", + "safe_008_immutable_numeric.sql", + "safe_009_immutable_timestamp.sql" + ], + "excluded_fixtures": [ + { + "fixture": "001_volatile_random.sql", + "reason": "PostgreSQL cannot implicitly coerce random() double precision to integer." + }, + { + "fixture": "002_volatile_now.sql", + "reason": "PostgreSQL cannot implicitly coerce random() double precision to integer." + }, + { + "fixture": "004_volatile_clock.sql", + "reason": "PostgreSQL cannot coerce clock_timestamp() to integer." + }, + { + "fixture": "007_volatile_transaction_timestamp.sql", + "reason": "PostgreSQL cannot implicitly coerce random() double precision to integer." + }, + { + "fixture": "008_volatile_statement_timestamp.sql", + "reason": "PostgreSQL cannot implicitly coerce random() double precision to integer." + }, + { + "fixture": "009_volatile_uuid_generate.sql", + "reason": "The local PostgreSQL server does not provide uuid_generate_v4(); enabling uuid-ossp would be an environment-specific side effect." + }, + { + "fixture": "safe_010_alter_set_default.sql", + "reason": "Requires column c in the canonical baseline, which conflicts with enabled add-column fixtures." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "relations", + "columns" + ], + "required_relations": [ + "public.test_table" + ], + "notes": "Tenth incremental slice. Validates added-column type, nullability, and default presence across volatile, immutable, NULL, SERIAL, JSONB, numeric, and timestamp defaults." + }, + { + "rule_dir": "rule_06_create-table-as-select", + "enabled": true, + "fixtures": [ + "001_ctas.sql", + "002_ctas_with_columns.sql", + "004_ctas_no_data.sql", + "005_ctas_if_not_exists.sql", + "006_ctas_unlogged.sql", + "007_ctas_join.sql", + "008_ctas_group_by.sql", + "009_ctas_where.sql", + "010_ctas_subquery.sql", + "005_select_into.sql" + ], + "excluded_fixtures": [ + { + "fixture": "003_ctas_temp.sql", + "reason": "PostgreSQL creates unqualified temporary relations in a session pg_temp_* schema outside the current schema-scoped projection." + }, + { + "fixture": "safe_001_create.sql", + "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." + }, + { + "fixture": "safe_002_create_if_not_exists.sql", + "reason": "Already represented by rule_18 and produces no CTAS-specific state." + }, + { + "fixture": "safe_003_create_temp.sql", + "reason": "Temporary relations live in a session pg_temp_* schema outside the current projection." + }, + { + "fixture": "safe_004_create_unlogged.sql", + "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." + }, + { + "fixture": "safe_006_create_with_defaults.sql", + "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." + }, + { + "fixture": "safe_007_create_like.sql", + "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." + }, + { + "fixture": "safe_008_create_inherits.sql", + "reason": "The cumulative canonical baseline contains sm_core.t and inheritance topology is outside this rule's CTAS scope." + }, + { + "fixture": "safe_009_create_partition_of.sql", + "reason": "public.test_table is not partitioned and PostgreSQL correctly rejects this fixture." + }, + { + "fixture": "safe_010_create_with_constraints.sql", + "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "relations" + ], + "fixture_scopes": { + "005_select_into.sql": [ + "relations", + "columns" + ] + }, + "required_relations": [ + "public.test_table", + "sm_core.a", + "sm_core.b" + ], + "notes": "Eleventh incremental slice. Validates permanent and unlogged CTAS relation creation across projection, join, aggregate, filter, subquery, IF NOT EXISTS, and WITH NO DATA forms." + }, + { + "rule_dir": "rule_12_blocking-partition-mutation", + "enabled": true, + "fixtures": [ + "001_attach_partition.sql", + "003_attach_default.sql", + "007_attach_schema_qualified.sql" + ], + "fixture_min_pg_version": { + "001_attach_partition.sql": 150000, + "003_attach_default.sql": 150000, + "007_attach_schema_qualified.sql": 150000 + }, + "excluded_fixtures": [ + { + "fixture": "002_detach_partition.sql", + "reason": "Requires child to start attached, which conflicts with attach fixtures under the per-fixture canonical baseline." + }, + { + "fixture": "004_detach_concurrently.sql", + "reason": "Requires child to start attached and PostgreSQL rejects DETACH CONCURRENTLY in transaction-like execution contexts." + }, + { + "fixture": "005_attach_range.sql", + "reason": "Requires a RANGE parent and date-key-compatible child instead of the enabled LIST baseline." + }, + { + "fixture": "006_attach_hash.sql", + "reason": "Requires a HASH parent and users_0 child instead of the enabled LIST baseline." + }, + { + "fixture": "008_detach_finalize.sql", + "reason": "Requires a prior concurrent detach pending finalization." + }, + { + "fixture": "009_attach_list_string.sql", + "reason": "Requires a text-key LIST parent and region_na child instead of the integer-key LIST baseline." + }, + { + "fixture": "010_attach_range_int.sql", + "reason": "Requires a RANGE parent and logs_old child instead of the enabled LIST baseline." + }, + { + "fixture": "011_detach_weekly.sql", + "reason": "Requires child to start attached, which conflicts with attach fixtures under the per-fixture canonical baseline." + }, + { + "fixture": "012_attach_range_monthly.sql", + "reason": "Requires a RANGE parent and metrics_current child instead of the enabled LIST baseline." + }, + { + "fixture": "safe_001_create_table.sql", + "reason": "The canonical enterprise baseline already contains sm_core.t on the active search path." + }, + { + "fixture": "safe_002_add_column.sql", + "reason": "Add-column state is covered by dedicated participating rules and does not exercise partition topology." + }, + { + "fixture": "safe_003_drop_column.sql", + "reason": "PostgreSQL rejects dropping absent baseline column public.test_table.c." + }, + { + "fixture": "safe_004_rename_column.sql", + "reason": "Rename propagation is covered by dedicated participating rules and does not exercise partition topology." + }, + { + "fixture": "safe_005_set_not_null.sql", + "reason": "Nullability state is covered by dedicated participating rules and does not exercise partition topology." + }, + { + "fixture": "safe_006_create_index.sql", + "reason": "Index topology is covered by dedicated participating rules and does not exercise partition topology." + }, + { + "fixture": "safe_007_truncate.sql", + "reason": "TRUNCATE changes data rather than normalized partition topology." + }, + { + "fixture": "safe_008_insert.sql", + "reason": "PostgreSQL rejects one value for multi-column public.test_table, and data writes are outside normalized schema state." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "relations", + "partitions" + ], + "required_relations": [ + "public.parent", + "public.child" + ], + "notes": "Third incremental slice. Validates LIST/default partition attachment and schema-qualified partition topology." + }, + { + "rule_dir": "rule_10_require-concurrent-index", + "enabled": true, + "transactional": false, + "fixtures": [ + "001_create_index.sql", + "002_drop_index.sql", + "004_create_multi_col.sql", + "005_create_unique.sql", + "006_create_partial.sql", + "007_drop_index_if_exists.sql", + "008_create_index_include.sql", + "010_create_index_lower.sql", + "011_create_index_desc.sql", + "012_create_index_using_gin.sql", + "013_create_index_on_only.sql", + "014_create_index_where_multiple.sql", + "015_create_index_storage_param.sql", + "safe_001_create_concurrently.sql", + "safe_002_drop_concurrently.sql", + "safe_003_create_unique_concurrently.sql" + ], + "excluded_fixtures": [ + { + "fixture": "009_create_index_tablespace.sql", + "reason": "Requires a server-local fastspace tablespace and filesystem location, making it an environment-specific fixture." + }, + { + "fixture": "safe_004_alter_table.sql", + "reason": "Column state is outside this rule's index-focused comparison scope." + }, + { + "fixture": "safe_005_create_table.sql", + "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." + }, + { + "fixture": "safe_006_drop_table.sql", + "reason": "PostgreSQL rejects dropping public.test_table without CASCADE because the enterprise baseline has dependent FKs and a trigger." + }, + { + "fixture": "safe_007_select.sql", + "reason": "Data reads do not change normalized schema state." + }, + { + "fixture": "safe_008_insert.sql", + "reason": "Data writes do not change normalized schema state." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "relations", + "indexes" + ], + "required_relations": [ + "sm_core.t_large", + "public.test_table" + ], + "notes": "Sixth incremental slice. Validates regular, unique, partial, expression, INCLUDE, GIN, ONLY, storage-parameter, and concurrent index outcomes." + }, + { + "rule_dir": "rule_18_missing-idempotency", + "enabled": true, + "fixtures": [ + "001_create_table.sql", + "002_drop_table.sql", + "003_create_index.sql", + "006_drop_view.sql", + "007_drop_matview.sql", + "014_create_view.sql", + "safe_001_create_if_not_exists.sql", + "safe_002_drop_if_exists.sql", + "safe_003_create_index_if_not_exists.sql", + "safe_006_drop_view_if_exists.sql", + "safe_007_drop_matview_if_exists.sql" + ], + "excluded_fixtures": [ + { + "fixture": "004_drop_index.sql", + "reason": "Requires index i in the canonical baseline, which conflicts with enabled create-index fixtures." + }, + { + "fixture": "005_drop_sequence.sql", + "reason": "Cache V7 synchronizes sequences, but this rule does not compare sequence state." + }, + { + "fixture": "008_add_column.sql", + "reason": "Column-presence scope conflicts with create-view fixtures whose projected output columns are not modeled by the simulator." + }, + { + "fixture": "009_drop_column.sql", + "reason": "Requires column c in the canonical baseline, which conflicts with add-column fixtures." + }, + { + "fixture": "010_drop_policy.sql", + "reason": "Policy state is not yet normalized by the differential harness." + }, + { + "fixture": "011_drop_trigger.sql", + "reason": "Trigger state is imported as dependency edges but is not yet normalized by this harness." + }, + { + "fixture": "012_drop_domain.sql", + "reason": "Domain state is not yet hydrated from the live baseline into simulator type state." + }, + { + "fixture": "013_create_sequence.sql", + "reason": "Cache V7 synchronizes sequences, but this rule does not compare sequence state." + }, + { + "fixture": "016_create_schema.sql", + "reason": "Schema existence is not represented in normalized simulator state." + }, + { + "fixture": "017_drop_schema.sql", + "reason": "Schema existence is not represented in normalized simulator state." + }, + { + "fixture": "018_add_column_not_null.sql", + "reason": "Column-presence scope conflicts with create-view fixtures whose projected output columns are not modeled by the simulator." + }, + { + "fixture": "safe_004_drop_index_if_exists.sql", + "reason": "Requires index i in the canonical baseline to produce a state change, which conflicts with enabled create-index fixtures." + }, + { + "fixture": "safe_005_drop_sequence_if_exists.sql", + "reason": "Cache V7 synchronizes sequences, but this rule does not compare sequence state." + }, + { + "fixture": "safe_008_add_column_if_not_exists.sql", + "reason": "Column-presence scope conflicts with create-view fixtures whose projected output columns are not modeled by the simulator." + }, + { + "fixture": "safe_009_drop_column_if_exists.sql", + "reason": "Requires column c in the canonical baseline to produce a state change, which conflicts with add-column fixtures." + }, + { + "fixture": "safe_010_drop_policy_if_exists.sql", + "reason": "Policy state is not yet normalized by the differential harness." + }, + { + "fixture": "safe_011_create_schema_if_not_exists.sql", + "reason": "Schema existence is not represented in normalized simulator state." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "relations", + "indexes" + ], + "required_relations": [ + "sm_core.t", + "sm_core.v", + "sm_core.mv" + ], + "notes": "Fifth incremental slice. Validates idempotent and non-idempotent relation and index create/drop outcomes." + }, + { + "rule_dir": "rule_14_restrictive-policy", + "enabled": true, + "fixtures": [ + "001_restrictive_policy.sql", + "002_restrictive_all.sql", + "003_restrictive_insert.sql", + "004_restrictive_update.sql", + "005_restrictive_delete.sql", + "006_restrictive_select_where.sql", + "007_restrictive_update_check.sql", + "010_restrictive_select_expr.sql", + "011_restrictive_insert_check_expr.sql", + "safe_001_permissive.sql", + "safe_004_permissive_all.sql", + "safe_005_permissive_insert.sql", + "safe_006_permissive_update.sql", + "safe_007_permissive_delete.sql" + ], + "excluded_fixtures": [ + { + "fixture": "008_restrictive_to_role.sql", + "reason": "Requires server role role_admin, which is absent from the local PostgreSQL environment." + }, + { + "fixture": "009_restrictive_multiple_roles.sql", + "reason": "Requires server roles admin and manager, which are absent from the local PostgreSQL environment." + }, + { + "fixture": "012_restrictive_select_tenant.sql", + "reason": "PostgreSQL rejects bigint tenant_id = text current_setting(...) without an explicit cast; changing the enterprise tenant key type would be incorrect." + }, + { + "fixture": "safe_002_create_table.sql", + "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." + }, + { + "fixture": "safe_003_alter_table.sql", + "reason": "This is an unrelated column mutation already covered by rules 07, 08, 23, and 25." + }, + { + "fixture": "safe_008_create_index.sql", + "reason": "This is an unrelated index mutation already covered by rules 01, 10, 18, and 25." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "relations", + "policies" + ], + "required_relations": [ + "public.test_table" + ], + "notes": "Thirteenth incremental slice. Compares policy ownership for restrictive and permissive SELECT, INSERT, UPDATE, DELETE, and ALL policies." + }, + { + "rule_dir": "rule_23_volatile-default", + "enabled": true, + "fixtures": [ + "004_create_table_volatile_gen_random_uuid.sql", + "005_add_col_volatile_timeofday.sql", + "007_create_table_volatile_clock_timestamp.sql", + "008_add_col_volatile_uuid.sql", + "009_set_default_volatile_clock.sql", + "safe_002_add_col_immutable.sql", + "safe_004_add_col_no_default.sql", + "safe_007_set_default_numeric.sql", + "safe_009_add_col_default_text.sql" + ], + "excluded_fixtures": [ + { + "fixture": "001_create_table_volatile.sql", + "reason": "PostgreSQL cannot coerce random() double precision to a timestamp column default." + }, + { + "fixture": "002_add_col_volatile.sql", + "reason": "PostgreSQL cannot implicitly coerce random() double precision to an integer column default." + }, + { + "fixture": "003_set_default_volatile.sql", + "reason": "Requires column c in the canonical baseline, which conflicts with enabled add-column fixtures." + }, + { + "fixture": "006_set_default_volatile_now.sql", + "reason": "PostgreSQL cannot coerce random() double precision to the baseline timestamptz column." + }, + { + "fixture": "safe_001_create_table_immutable.sql", + "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." + }, + { + "fixture": "safe_003_set_default_immutable.sql", + "reason": "Requires column c in the canonical baseline, which conflicts with enabled add-column fixtures." + }, + { + "fixture": "safe_005_create_table_immutable_bool.sql", + "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." + }, + { + "fixture": "safe_006_create_table_serial.sql", + "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." + }, + { + "fixture": "safe_008_create_table_default_null.sql", + "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "relations", + "columns" + ], + "required_relations": [ + "public.test_table", + "sm_core.items" + ], + "notes": "Ninth incremental slice. Compares column type, nullability, and default presence for volatile and immutable default mutations." + }, + { + "rule_dir": "rule_25_schema-drift", + "enabled": true, + "fixtures": [ + "safe_002_create_table.sql", + "safe_003_alter_known_table.sql", + "safe_004_create_view.sql", + "safe_005_create_index.sql", + "safe_008_rename_known_table.sql", + "safe_009_create_seq.sql" + ], + "excluded_fixtures": [ + { + "fixture": "001_drop_unknown_table.sql", + "reason": "PostgreSQL rejects the unknown relation; this is a rule diagnostic case with no resulting live state." + }, + { + "fixture": "002_drop_unknown_view.sql", + "reason": "PostgreSQL rejects the unknown view; this is a rule diagnostic case with no resulting live state." + }, + { + "fixture": "003_drop_unknown_seq.sql", + "reason": "PostgreSQL rejects the unknown sequence and sequence state is not normalized." + }, + { + "fixture": "004_alter_unknown_table.sql", + "reason": "PostgreSQL rejects the unknown relation; this is a rule diagnostic case with no resulting live state." + }, + { + "fixture": "005_drop_unknown_func.sql", + "reason": "PostgreSQL rejects the unknown function and function state is not normalized." + }, + { + "fixture": "006_drop_unknown_index.sql", + "reason": "PostgreSQL rejects the unknown index; this is a rule diagnostic case with no resulting live state." + }, + { + "fixture": "007_drop_unknown_type.sql", + "reason": "PostgreSQL rejects the unknown type and type state is not hydrated." + }, + { + "fixture": "008_rename_unknown_table.sql", + "reason": "PostgreSQL rejects the unknown relation; this is a rule diagnostic case with no resulting live state." + }, + { + "fixture": "009_alter_unknown_table_set_default.sql", + "reason": "PostgreSQL rejects the unknown relation; this is a rule diagnostic case with no resulting live state." + }, + { + "fixture": "safe_001_drop_known_table.sql", + "reason": "PostgreSQL rejects dropping public.test_table without CASCADE because enterprise baseline dependencies exist." + }, + { + "fixture": "safe_006_select.sql", + "reason": "Data reads do not change normalized schema state." + }, + { + "fixture": "safe_007_insert.sql", + "reason": "Data writes do not change normalized schema state." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "relations", + "columns", + "indexes", + "foreign_keys" + ], + "fixture_scopes": { + "safe_004_create_view.sql": [ + "relations" + ], + "safe_009_create_seq.sql": [ + "sequences" + ] + }, + "required_relations": [ + "public.test_table" + ], + "notes": "Twelfth incremental slice. Validates known-object creation, alteration, indexing, and rename propagation while classifying unknown-object fixtures as PostgreSQL rejection cases." + }, + { + "rule_dir": "rule_15_disable-trigger", + "enabled": true, + "fixtures": [ + "0005_disable_trigger.sql", + "0006_disable_trigger.sql", + "0007_disable_trigger.sql", + "0008_disable_trigger.sql", + "001_disable_all.sql", + "002_disable_specific.sql", + "003_enable_all.sql", + "004_enable_specific.sql", + "009_disable_trigger_user.sql", + "010_enable_trigger_audit.sql", + "safe_002_drop_trigger.sql", + "safe_003_add_column.sql", + "safe_005_create_table.sql", + "safe_007_rename_trigger.sql", + "safe_009_create_index.sql", + "safe_010_rename_table.sql" + ], + "excluded_fixtures": [ + { + "fixture": "safe_001_create_trigger.sql", + "reason": "The canonical baseline contains trigger t so drop and table-rename propagation can be validated." + }, + { + "fixture": "safe_004_drop_column.sql", + "reason": "The canonical baseline intentionally omits column c so safe_003 can validate an independent ADD COLUMN." + }, + { + "fixture": "safe_006_drop_table.sql", + "reason": "PostgreSQL correctly rejects DROP TABLE without CASCADE because public.child_table and sm_audit.change_requests depend on public.test_table." + }, + { + "fixture": "safe_008_drop_constraint.sql", + "reason": "The canonical primary-key constraint is test_table_pkey, not the fixture-specific pk_id." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "triggers" + ], + "required_relations": [ + "public.test_table" + ], + "notes": "Thirteenth incremental slice. Compares PostgreSQL trigger identity, owning table, function, enabled mode, table-drop cleanup, and table-rename propagation." + }, + { + "rule_dir": "rule_17_function-volatility-change", + "enabled": true, + "fixtures": [ + "001_to_volatile.sql", + "002_to_stable.sql", + "003_to_immutable.sql", + "004_to_volatile_schema.sql", + "005_to_stable_schema.sql", + "006_to_immutable_schema.sql", + "007_to_volatile_args.sql", + "008_to_stable_args.sql", + "009_to_immutable_args.sql", + "010_to_volatile_more.sql", + "011_to_stable_restrict.sql", + "012_to_immutable_more.sql", + "013_to_volatile_public_args.sql", + "safe_003_rename_func.sql", + "safe_006_set_param.sql", + "safe_007_reset_param.sql", + "safe_009_create_index.sql", + "safe_010_drop_table.sql", + "safe_011_alter_func_rename_args.sql" + ], + "excluded_fixtures": [ + { + "fixture": "safe_001_create_func.sql", + "reason": "The canonical baseline contains sm_core.f() to exercise volatility changes and trigger-function dependencies." + }, + { + "fixture": "safe_002_create_table.sql", + "reason": "The cumulative canonical baseline contains sm_core.t for rule_18." + }, + { + "fixture": "safe_004_set_schema.sql", + "reason": "PostgreSQL correctly rejects moving sm_core.f() to public because public.f() is required by the schema-qualified fixtures." + }, + { + "fixture": "safe_005_owner_to.sql", + "reason": "The local PostgreSQL beta instance has no postgres role; role ownership is outside normalized function state." + }, + { + "fixture": "safe_008_drop_func.sql", + "reason": "PostgreSQL correctly rejects dropping sm_core.f() because trigger public.t depends on it." + }, + { + "fixture": "safe_012_alter_func_set_schema_args.sql", + "reason": "The fixture targets a non-harness schema named audit; the canonical owned schema is sm_audit." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "functions" + ], + "required_relations": [ + "public.test_table", + "sm_core.t" + ], + "notes": "Fourteenth incremental slice. Compares live pg_proc identity and volatility across overloads, schema qualification, option changes, and function renames." + }, + { + "rule_dir": "rule_09_blocking-constraint", + "enabled": true, + "fixtures": [ + "001_check_constraint.sql", + "002_foreign_key.sql", + "003_set_not_null.sql", + "004_unique_constraint.sql", + "005_primary_key.sql", + "008_exclude_constraint.sql", + "009_unique_multi_column.sql", + "010_primary_key_multi_column.sql", + "safe_001_check_not_valid.sql", + "safe_002_drop_not_null.sql", + "safe_003_create_table.sql", + "safe_004_add_column.sql", + "safe_006_rename_column.sql", + "safe_007_create_index.sql", + "safe_008_drop_constraint.sql", + "safe_009_alter_set_default.sql", + "safe_005_drop_column.sql", + "safe_013_unique_using_index.sql", + "safe_011_foreign_key_not_valid.sql", + "safe_012_foreign_key_validate_later.sql" + ], + "excluded_fixtures": [ + { + "fixture": "006_set_storage.sql", + "reason": "Column storage strategy is not synchronized or normalized by the differential harness." + }, + { + "fixture": "007_set_access_method.sql", + "reason": "Table access method identity is not synchronized or normalized by the differential harness." + }, + { + "fixture": "safe_010_drop_index.sql", + "reason": "DROP INDEX IF EXISTS idx is a no-op against the canonical baseline." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "constraints" + ], + "fixture_scopes": { + "002_foreign_key.sql": [ + "constraints", + "foreign_keys" + ], + "003_set_not_null.sql": [ + "relations", + "columns" + ], + "safe_001_check_not_valid.sql": [ + "constraints" + ], + "safe_002_drop_not_null.sql": [ + "relations", + "columns" + ], + "safe_003_create_table.sql": [ + "relations", + "columns", + "constraints" + ], + "safe_004_add_column.sql": [ + "relations", + "columns" + ], + "safe_006_rename_column.sql": [ + "relations", + "columns" + ], + "safe_007_create_index.sql": [ + "indexes" + ], + "safe_008_drop_constraint.sql": [ + "constraints" + ], + "safe_009_alter_set_default.sql": [ + "relations", + "columns" + ], + "safe_011_foreign_key_not_valid.sql": [ + "constraints", + "foreign_keys" + ], + "safe_012_foreign_key_validate_later.sql": [ + "constraints", + "foreign_keys" + ], + "safe_005_drop_column.sql": [ + "relations", + "columns", + "foreign_keys", + "constraints" + ], + "safe_013_unique_using_index.sql": [ + "relations", + "indexes", + "constraints" + ] + }, + "required_relations": [ + "public.test_table", + "sm_core.t", + "sm_core.t_large" + ], + "notes": "Real-world-inspired constraint slice. Covers each advertised constraint kind, NOT VALID/VALIDATE state transitions, and conversion of a prebuilt unique index into a constraint." + }, + { + "rule_dir": "rule_16_broken-compute", + "enabled": true, + "fixtures": [ + "safe_019_drop_func_public.sql", + "safe_003_drop_func_cascade.sql", + "safe_003_alter_table.sql", + "safe_005_drop_table.sql", + "safe_006_create_index.sql", + "safe_013_drop_func_args.sql", + "safe_014_drop_func_variadic.sql", + "safe_015_drop_func_out_param.sql", + "safe_016_drop_func_set_returning.sql", + "safe_017_drop_func_public_args.sql", + "safe_018_drop_func_default_args.sql" + ], + "excluded_fixtures": [ + { + "fixture": "001_drop_func.sql", + "reason": "PostgreSQL correctly rejects dropping sm_core.f() because trigger public.t depends on it." + }, + { + "fixture": "002_drop_func_if_exists.sql", + "reason": "The fixture does not contain IF EXISTS and PostgreSQL correctly rejects the dependent function drop without CASCADE." + }, + { + "fixture": "004_drop_func_restrict.sql", + "reason": "The fixture does not contain RESTRICT and PostgreSQL correctly rejects the dependent function drop without CASCADE." + }, + { + "fixture": "010_drop_func_simple.sql", + "reason": "PostgreSQL correctly rejects dropping sm_core.f() because trigger public.t depends on it." + }, + { + "fixture": "011_drop_func_owner.sql", + "reason": "PostgreSQL correctly rejects dropping sm_core.f() because trigger public.t depends on it." + }, + { + "fixture": "safe_001_create_func.sql", + "reason": "The canonical baseline contains sm_core.f() for trigger dependency and volatility coverage." + }, + { + "fixture": "safe_002_create_table.sql", + "reason": "The cumulative canonical baseline contains sm_core.t." + }, + { + "fixture": "safe_004_create_trigger.sql", + "reason": "The canonical baseline already contains trigger public.t using sm_core.f()." + }, + { + "fixture": "safe_007_drop_index.sql", + "reason": "PostgreSQL rejects dropping absent index sm_core.i without IF EXISTS." + }, + { + "fixture": "safe_008_select.sql", + "reason": "Data reads do not change normalized schema state." + }, + { + "fixture": "safe_009_insert.sql", + "reason": "Data writes do not change normalized schema state." + }, + { + "fixture": "safe_010_update.sql", + "reason": "Data writes do not change normalized schema state." + }, + { + "fixture": "safe_011_delete.sql", + "reason": "Data writes do not change normalized schema state." + }, + { + "fixture": "safe_012_create_sequence.sql", + "reason": "Cache V7 synchronizes sequences, but this rule does not compare sequence state." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "functions" + ], + "fixture_scopes": { + "safe_003_drop_func_cascade.sql": [ + "functions", + "triggers" + ], + "safe_003_alter_table.sql": [ + "relations", + "columns" + ], + "safe_005_drop_table.sql": [ + "relations" + ], + "safe_006_create_index.sql": [ + "indexes" + ] + }, + "required_relations": [ + "public.test_table", + "sm_core.t" + ], + "notes": "Sixteenth incremental slice. Validates function-drop cascade into dependent triggers and overload identity for regular, variadic, OUT-only, set-returning, and default-argument functions." + }, + { + "rule_dir": "rule_20_alter-type-add-value-txn", + "enabled": true, + "fixtures": [ + "001_add_value.sql", + "001_txn.sql", + "004_add_value_if_not_exists.sql", + "005_add_value_if_not_exists_before.sql", + "006_add_value_if_not_exists_after.sql", + "008_add_value_quoted_type.sql", + "009_add_value_status_type.sql", + "safe_001_rename_type.sql", + "safe_002_rename_value.sql", + "safe_004_alter_table.sql", + "safe_006_drop_type.sql", + "safe_008_create_index.sql", + "safe_010_rename_value_search_path.sql", + "safe_011_rename_value_quoted.sql", + "safe_012_create_then_rename_value.sql", + "safe_013_set_schema.sql", + "safe_014_rename_type_dependents.sql" + ], + "excluded_fixtures": [ + { + "fixture": "002_add_value_before.sql", + "reason": "Fixture assumes label d was committed by an earlier migration, but differential fixtures are intentionally rebuilt from baseline independently." + }, + { + "fixture": "003_add_value_after.sql", + "reason": "Fixture assumes label e was committed by an earlier migration, but differential fixtures are intentionally rebuilt from baseline independently." + }, + { + "fixture": "007_add_value_schema_qualified.sql", + "reason": "Fixture targets generic schema my_schema outside the harness-owned sm_* schema boundary." + }, + { + "fixture": "safe_003_create_table.sql", + "reason": "The cumulative canonical baseline already contains sm_core.t." + }, + { + "fixture": "safe_005_create_type.sql", + "reason": "The canonical baseline contains sm_core.my_enum as the prerequisite for ALTER TYPE fixtures." + }, + { + "fixture": "safe_007_select.sql", + "reason": "Data reads do not change normalized schema state." + }, + { + "fixture": "safe_009_alter_schema.sql", + "reason": "Fixture targets generic schema my_schema outside the harness-owned sm_* schema boundary." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "types" + ], + "fixture_scopes": { + "safe_004_alter_table.sql": [ + "relations", + "columns" + ], + "safe_008_create_index.sql": [ + "indexes" + ] + }, + "required_relations": [ + "public.test_table", + "sm_core.t" + ], + "notes": "Seventeenth incremental slice. Hydrates enterprise enum/domain state from PostgreSQL and compares enum existence, ordered labels, positional additions, value renames, search-path and quoted identity, create-then-rename chains, and drops." + }, + { + "rule_dir": "rule_13_partition-strategy-mismatch", + "enabled": true, + "fixtures": [ + "safe_001_attach_list_to_list.sql", + "safe_003_attach_range_to_range.sql", + "safe_004_attach_hash_to_hash.sql", + "safe_005_add_column.sql", + "safe_006_create_index.sql", + "safe_007_rename_table.sql" + ], + "excluded_fixtures": [ + { + "fixture": "001_attach_list_to_range.sql", + "reason": "The shared public.parent is the canonical LIST parent for rule 12; this fixture's filename assumes a conflicting RANGE baseline." + }, + { + "fixture": "002_attach_range_to_list.sql", + "reason": "PostgreSQL rejects a FROM/TO partition bound for a LIST parent, so no resulting state exists to compare." + }, + { + "fixture": "003_attach_hash_to_list.sql", + "reason": "The shared public.parent is the canonical LIST parent for rule 12 and PostgreSQL rejects a hash bound for it." + }, + { + "fixture": "004_attach_range_to_hash.sql", + "reason": "PostgreSQL rejects a FROM/TO partition bound for a HASH parent, so no resulting state exists to compare." + }, + { + "fixture": "005_attach_list_to_hash.sql", + "reason": "PostgreSQL rejects an IN partition bound for a HASH parent, so no resulting state exists to compare." + }, + { + "fixture": "006_attach_hash_to_range.sql", + "reason": "PostgreSQL rejects a hash partition bound for a RANGE parent, so no resulting state exists to compare." + }, + { + "fixture": "007_attach_list_string_to_range.sql", + "reason": "PostgreSQL rejects an IN partition bound for a RANGE parent, so no resulting state exists to compare." + }, + { + "fixture": "008_attach_range_to_list_multi.sql", + "reason": "PostgreSQL rejects a FROM/TO partition bound for a LIST parent, so no resulting state exists to compare." + }, + { + "fixture": "009_attach_hash_to_list_modulus.sql", + "reason": "PostgreSQL rejects a hash partition bound for a LIST parent, so no resulting state exists to compare." + }, + { + "fixture": "010_attach_list_to_hash_multi.sql", + "reason": "PostgreSQL rejects an IN partition bound for a HASH parent, so no resulting state exists to compare." + }, + { + "fixture": "011_attach_hash_to_range_modulus.sql", + "reason": "PostgreSQL rejects a hash partition bound for a RANGE parent, so no resulting state exists to compare." + }, + { + "fixture": "012_attach_range_to_hash_wide.sql", + "reason": "PostgreSQL rejects a FROM/TO partition bound for a HASH parent, so no resulting state exists to compare." + }, + { + "fixture": "safe_002_create_table.sql", + "reason": "The cumulative canonical baseline already contains sm_core.t." + }, + { + "fixture": "safe_008_insert.sql", + "reason": "Data writes do not change normalized schema state." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "relations", + "partitions" + ], + "fixture_scopes": { + "safe_005_add_column.sql": [ + "relations", + "columns" + ], + "safe_006_create_index.sql": [ + "indexes" + ], + "safe_007_rename_table.sql": [ + "relations" + ] + }, + "required_relations": [ + "public.list_parent", + "public.list_child", + "public.range_parent", + "public.range_child", + "public.hash_parent", + "public.hash_child" + ], + "notes": "Eighteenth incremental slice. Compares PostgreSQL-hydrated LIST/RANGE/HASH parent strategies and valid attachment edges; invalid-bound fixtures are documented rather than treated as semantic state." + }, + { + "rule_dir": "rule_24_overbroad-grant", + "enabled": true, + "fixtures": [ + "001_grant_public.sql", + "002_grant_all.sql", + "003_grant_with_option.sql", + "004_grant_all_public.sql", + "005_grant_all_in_schema.sql", + "006_grant_insert_public.sql", + "007_grant_all_in_schema_public.sql", + "008_grant_update_public.sql", + "safe_001_grant_select.sql", + "safe_002_grant_insert.sql", + "safe_003_revoke.sql", + "safe_004_grant_update.sql", + "safe_005_grant_delete.sql" + ], + "excluded_fixtures": [ + { + "fixture": "009_grant_select_with_grant_option.sql", + "reason": "PostgreSQL rejects GRANT OPTION for PUBLIC (SQLSTATE 0LP01); the fixture is not executable against the live oracle." + }, + { + "fixture": "safe_006_grant_select_columns.sql", + "reason": "Column-level ACLs live in pg_attribute.attacl and are intentionally distinct from relation ACL state." + }, + { + "fixture": "safe_007_create_table.sql", + "reason": "The cumulative canonical baseline already contains sm_core.t." + }, + { + "fixture": "safe_008_grant_usage.sql", + "reason": "Schema ACLs are outside the v1 relation-privilege comparison scope." + }, + { + "fixture": "safe_009_grant_execute.sql", + "reason": "Function ACLs are outside the v1 relation-privilege comparison scope." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "privileges" + ], + "required_relations": [ + "public.test_table" + ], + "notes": "Nineteenth incremental slice. Synchronizes explicit non-owner PostgreSQL relation ACLs and compares grants/revokes for named roles and PUBLIC, including ALL TABLES IN SCHEMA expansion." + }, + { + "rule_dir": "rule_11_blocking-mat-view-refresh", + "enabled": true, + "fixtures": [ + "001_refresh.sql", + "002_refresh_with_data.sql", + "003_refresh_no_data.sql", + "004_refresh_analytics.sql", + "005_refresh_schema_qualified.sql", + "006_refresh_other_schema.sql", + "007_refresh_weekly_summary.sql", + "008_refresh_dashboard.sql", + "009_refresh_site_stats.sql", + "010_refresh_monthly.sql", + "011_refresh_user_report.sql", + "012_refresh_inventory.sql", + "safe_003_drop_matview.sql", + "safe_005_alter_table.sql", + "safe_007_create_index.sql" + ], + "excluded_fixtures": [ + { + "fixture": "safe_001_refresh_concurrently.sql", + "reason": "Fixture contains SELECT 1 rather than a materialized-view refresh and changes no normalized state." + }, + { + "fixture": "safe_002_create_matview.sql", + "reason": "The canonical baseline contains sm_core.mymatview with the unique index required by PostgreSQL for concurrent refresh." + }, + { + "fixture": "safe_004_create_table.sql", + "reason": "The cumulative canonical baseline already contains sm_core.t." + }, + { + "fixture": "safe_006_insert.sql", + "reason": "Data writes are outside the normalized schema-state comparison." + }, + { + "fixture": "safe_008_select.sql", + "reason": "Data reads are outside the normalized schema-state comparison." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "relations", + "indexes" + ], + "fixture_scopes": { + "safe_003_drop_matview.sql": [ + "relations", + "indexes" + ], + "safe_005_alter_table.sql": [ + "relations", + "columns" + ], + "safe_007_create_index.sql": [ + "indexes" + ] + }, + "required_relations": [ + "sm_core.mymatview", + "public.test_table" + ], + "notes": "Twentieth incremental slice. Runs legal PostgreSQL concurrent refreshes against a populated materialized view with a qualifying unique index and verifies relation/index topology remains stable." + }, + { + "rule_dir": "rule_26_chain-conflict", + "enabled": true, + "fixtures": [ + "010_conflict.sql", + "011_missing_fk_source_column.sql", + "012_rename_enum_missing_label.sql", + "013_rename_enum_duplicate_label.sql", + "014_rename_value_non_enum.sql", + "015_rename_value_missing_type.sql", + "016_missing_set_role.sql", + "017_unauthorized_set_role.sql", + "018_identifier_truncation_collision.sql", + "019_concurrent_detach_in_transaction.sql", + "020_invalid_partition_strategy.sql", + "021_concurrent_detach_default_partition.sql", + "004_conflict.sql", + "safe_001_chain.sql", + "safe_001_create_table.sql", + "safe_002_create_index.sql", + "safe_003_add_unique_constraint.sql", + "safe_004_add_not_null.sql", + "safe_005_create_view.sql", + "safe_006_create_seq.sql", + "safe_007_grant_select.sql", + "safe_010_role_transaction_semantics.sql", + "safe_011_session_authorization.sql", + "safe_012_role_search_path_and_owner.sql", + "safe_013_quoted_session_authorization.sql", + "safe_014_session_authorization_rollback.sql", + "safe_015_session_authorization_default.sql", + "safe_016_transitive_set_role_membership.sql", + "safe_017_relation_namespace_resolution.sql", + "safe_018_type_namespace_resolution.sql", + "safe_019_routine_namespace_resolution.sql", + "safe_020_generated_name_truncation.sql", + "safe_021_routine_type_aliases.sql", + "safe_022_role_membership_cascade.sql", + "safe_023_role_options.sql", + "safe_024_logical_replication.sql", + "safe_025_table_catalog_lifecycle.sql", + "safe_026_column_catalog_lifecycle.sql", + "safe_027_like_including_all.sql", + "safe_028_sequence_parameters.sql", + "safe_029_select_into_projection.sql", + "safe_030_lock_and_truncate.sql", + "safe_031_partition_clone_catalog.sql", + "safe_032_inheritance_lifecycle.sql", + "safe_033_typed_table_lifecycle.sql", + "safe_034_generated_expression_change.sql", + "safe_035_metadata_reset.sql", + "safe_036_table_security_reset.sql", + "safe_037_key_index_lifecycle.sql", + "safe_038_type_change_metadata.sql", + "safe_039_rule_enablement.sql", + "safe_040_generated_column_rename.sql", + "safe_041_partition_bounds.sql", + "safe_042_concurrent_hash_detach.sql", + "safe_043_check_constraint_rename.sql", + "safe_044_key_index_rename.sql", + "safe_045_drop_index_settings.sql", + "safe_046_generated_check_names.sql", + "safe_047_concurrent_range_detach_retains_check.sql", + "safe_048_concurrent_list_detach_retains_check.sql", + "safe_049_recursive_column_rename.sql", + "safe_050_autocommit_on_commit_drop.sql", + "safe_051_generated_expression_visible_function.sql" + ], + "fixture_transactional": { + "safe_010_role_transaction_semantics.sql": false, + "safe_042_concurrent_hash_detach.sql": false, + "021_concurrent_detach_default_partition.sql": false, + "safe_011_session_authorization.sql": false, + "safe_012_role_search_path_and_owner.sql": false, + "safe_013_quoted_session_authorization.sql": false, + "safe_014_session_authorization_rollback.sql": false, + "safe_015_session_authorization_default.sql": false, + "safe_016_transitive_set_role_membership.sql": false, + "safe_047_concurrent_range_detach_retains_check.sql": false, + "safe_048_concurrent_list_detach_retains_check.sql": false, + "safe_050_autocommit_on_commit_drop.sql": false + }, + "fixture_autocommit": [ + "safe_042_concurrent_hash_detach.sql", + "021_concurrent_detach_default_partition.sql", + "safe_047_concurrent_range_detach_retains_check.sql", + "safe_048_concurrent_list_detach_retains_check.sql" + ], + "fixture_min_pg_version": { + "safe_034_generated_expression_change.sql": 170000, + "safe_022_role_membership_cascade.sql": 160000, + "safe_024_logical_replication.sql": 150000, + "safe_025_table_catalog_lifecycle.sql": 150000, + "safe_028_sequence_parameters.sql": 150000, + "safe_035_metadata_reset.sql": 160000, + "safe_023_role_options.sql": 160000 + }, + "expected_live_errors": { + "011_missing_fk_source_column.sql": { + "sqlstate": "42703", + "simulator_rule": "chain-conflict" + }, + "012_rename_enum_missing_label.sql": { + "sqlstate": "22023", + "simulator_rule": "chain-conflict" + }, + "013_rename_enum_duplicate_label.sql": { + "sqlstate": "42710", + "simulator_rule": "chain-conflict" + }, + "014_rename_value_non_enum.sql": { + "sqlstate": "42809", + "simulator_rule": "chain-conflict" + }, + "015_rename_value_missing_type.sql": { + "sqlstate": "42704", + "simulator_rule": "chain-conflict" + }, + "016_missing_set_role.sql": { + "sqlstate": "22023", + "simulator_rule": "chain-conflict" + }, + "017_unauthorized_set_role.sql": { + "sqlstate": "42501", + "simulator_rule": "chain-conflict" + }, + "018_identifier_truncation_collision.sql": { + "sqlstate": "42P07", + "simulator_rule": "chain-conflict" + }, + "019_concurrent_detach_in_transaction.sql": { + "sqlstate": "25001", + "simulator_rule": "chain-conflict" + }, + "020_invalid_partition_strategy.sql": { + "sqlstate": "22023", + "simulator_rule": "chain-conflict" + }, + "021_concurrent_detach_default_partition.sql": { + "sqlstate": "55000", + "simulator_rule": "chain-conflict" + } + }, + "excluded_fixtures": [ + { + "fixture": "001_chain.sql", + "reason": "PostgreSQL rejects the second incompatible duplicate column definition, leaving no committed resulting state." + }, + { + "fixture": "001_conflict.sql", + "reason": "PostgreSQL rejects the second incompatible duplicate column definition, leaving no committed resulting state." + }, + { + "fixture": "002_chain.sql", + "reason": "PostgreSQL rejects the second incompatible duplicate column definition, leaving no committed resulting state." + }, + { + "fixture": "002_conflict.sql", + "reason": "PostgreSQL rejects the duplicate index name, leaving no committed resulting state." + }, + { + "fixture": "003_chain.sql", + "reason": "PostgreSQL rejects the second incompatible duplicate column definition, leaving no committed resulting state." + }, + { + "fixture": "003_conflict.sql", + "reason": "PostgreSQL rejects the duplicate table definition, leaving no committed resulting state." + }, + { + "fixture": "005_conflict.sql", + "reason": "Fixture requires a baseline column a while column b must remain absent; that topology conflicts with the shared canonical table shape." + }, + { + "fixture": "006_conflict.sql", + "reason": "Fixture requires baseline column c, while participating add-column chains require c to be absent." + }, + { + "fixture": "007_conflict.sql", + "reason": "PostgreSQL rejects the second incompatible duplicate column definition, leaving no committed resulting state." + }, + { + "fixture": "008_conflict.sql", + "reason": "PostgreSQL rejects the second incompatible duplicate column definition, leaving no committed resulting state." + }, + { + "fixture": "009_conflict.sql", + "reason": "Fixture requires baseline column c, while participating add-column chains require c to be absent." + }, + { + "fixture": "safe_008_insert.sql", + "reason": "Data writes are outside the normalized schema-state comparison." + }, + { + "fixture": "safe_009_select.sql", + "reason": "Data reads are outside the normalized schema-state comparison." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics", + "app_user", + "sm_role_quote", + "sm_ns_early", + "sm_ns_late", + "sm_type_early", + "sm_type_late", + "sm_routine_early", + "sm_routine_late" + ], + "scope": [ + "relations", + "columns" + ], + "fixture_scopes": { + "004_conflict.sql": [ + "relations", + "columns" + ], + "010_conflict.sql": [ + "relations", + "columns" + ], + "safe_002_create_index.sql": [ + "indexes" + ], + "safe_003_add_unique_constraint.sql": [ + "constraints" + ], + "safe_005_create_view.sql": [ + "relations", + "view_dependencies" + ], + "safe_006_create_seq.sql": [ + "sequences" + ], + "safe_007_grant_select.sql": [ + "privileges" + ], + "safe_010_role_transaction_semantics.sql": [ + "relations" + ], + "safe_011_session_authorization.sql": [ + "relations" + ], + "safe_012_role_search_path_and_owner.sql": [ + "relations", + "privileges" + ], + "safe_013_quoted_session_authorization.sql": [ + "relations" + ], + "safe_014_session_authorization_rollback.sql": [ + "relations" + ], + "safe_015_session_authorization_default.sql": [ + "relations" + ], + "safe_016_transitive_set_role_membership.sql": [ + "relations", + "roles" + ], + "safe_022_role_membership_cascade.sql": [ + "roles" + ], + "safe_023_role_options.sql": [ + "roles" + ], + "safe_024_logical_replication.sql": [ + "publications", + "subscriptions" + ], + "safe_025_table_catalog_lifecycle.sql": [ + "relations", + "columns", + "indexes" + ], + "safe_026_column_catalog_lifecycle.sql": [ + "relations", + "columns" + ], + "safe_027_like_including_all.sql": [ + "relations", + "columns", + "indexes", + "constraints", + "sequences", + "extended_statistics" + ], + "safe_028_sequence_parameters.sql": [ + "sequences" + ], + "safe_029_select_into_projection.sql": [ + "relations", + "columns" + ], + "safe_030_lock_and_truncate.sql": [ + "relations", + "columns" + ], + "safe_031_partition_clone_catalog.sql": [ + "relations", + "columns", + "indexes", + "constraints", + "triggers", + "partitions" + ], + "safe_032_inheritance_lifecycle.sql": [ + "relations", + "columns", + "constraints", + "partitions" + ], + "safe_033_typed_table_lifecycle.sql": [ + "relations", + "columns", + "types" + ], + "safe_034_generated_expression_change.sql": [ + "relations", + "columns" + ], + "safe_035_metadata_reset.sql": [ + "relations", + "columns" + ], + "safe_036_table_security_reset.sql": [ + "relations", + "columns" + ], + "safe_037_key_index_lifecycle.sql": [ + "relations", + "columns", + "constraints", + "indexes" + ], + "safe_038_type_change_metadata.sql": [ + "relations", + "columns" + ], + "safe_039_rule_enablement.sql": [ + "relations" + ], + "safe_040_generated_column_rename.sql": [ + "relations", + "columns" + ], + "safe_041_partition_bounds.sql": [ + "relations", + "columns", + "partitions" + ], + "safe_042_concurrent_hash_detach.sql": [ + "relations", + "columns", + "partitions", + "indexes", + "constraints", + "triggers" + ], + "safe_043_check_constraint_rename.sql": [ + "relations", + "columns", + "constraints" + ], + "safe_044_key_index_rename.sql": [ + "relations", + "columns", + "indexes", + "constraints" + ], + "safe_046_generated_check_names.sql": [ + "relations", + "columns", + "constraints" + ], + "safe_045_drop_index_settings.sql": [ + "relations", + "indexes", + "constraints" + ], + "safe_017_relation_namespace_resolution.sql": [ + "schemas", + "relations", + "columns", + "types" + ], + "safe_018_type_namespace_resolution.sql": [ + "schemas", + "relations", + "columns", + "types" + ], + "safe_019_routine_namespace_resolution.sql": [ + "schemas", + "functions" + ], + "safe_020_generated_name_truncation.sql": [ + "relations", + "columns", + "constraints" + ], + "safe_021_routine_type_aliases.sql": [ + "functions" + ], + "safe_047_concurrent_range_detach_retains_check.sql": [ + "relations", + "columns", + "constraints", + "partitions" + ], + "safe_048_concurrent_list_detach_retains_check.sql": [ + "relations", + "columns", + "constraints", + "partitions" + ], + "safe_049_recursive_column_rename.sql": [ + "relations", + "columns", + "indexes", + "constraints", + "partitions" + ], + "safe_050_autocommit_on_commit_drop.sql": [ + "relations", + "columns" + ], + "safe_051_generated_expression_visible_function.sql": [ + "relations", + "columns", + "functions" + ] + }, + "required_relations": [ + "public.test_table" + ], + "required_role_edges": [ + { + "member": "sm_set_member", + "role": "sm_set_bridge", + "kind": "can_set_role_to" + }, + { + "member": "sm_set_bridge", + "role": "sm_set_target", + "kind": "can_set_role_to" + }, + { + "member": "sm_option_member", + "role": "sm_option_parent", + "kind": "can_administer_membership", + "min_pg_version_num": 160000 + }, + { + "member": "sm_option_member", + "role": "sm_option_parent", + "kind": "member_of_without_set", + "min_pg_version_num": 160000 + }, + { + "member": "sm_inherit_member", + "role": "sm_inherit_parent", + "kind": "can_inherit_from", + "min_pg_version_num": 160000 + }, + { + "member": "sm_inherit_member", + "role": "sm_inherit_parent", + "kind": "member_of_without_set", + "min_pg_version_num": 160000 + }, + { + "member": "sm_set_member", + "role": "sm_no_set_target", + "kind": "member_of_without_set", + "min_pg_version_num": 160000 + } + ], + "notes": "Compares valid multi-statement outcomes and expected PostgreSQL rejections. The sourced missing-FK-column case must agree on SQLSTATE 42703 and a chain-conflict finding." + }, + { + "rule_dir": "rule_19_concurrent-in-transaction", + "enabled": true, + "transactional": false, + "fixtures": [ + "safe_001_create_index.sql", + "safe_003_create_concurrently.sql", + "safe_005_create_unique_index.sql", + "safe_007_create_index_where.sql", + "safe_008_create_index_include.sql", + "safe_009_create_index_schema.sql" + ], + "excluded_fixtures": [ + { + "fixture": "001_create_concurrently.sql", + "reason": "PostgreSQL rejects CREATE INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." + }, + { + "fixture": "001_txn.sql", + "reason": "PostgreSQL rejects CREATE INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." + }, + { + "fixture": "002_drop_concurrently.sql", + "reason": "PostgreSQL rejects DROP INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." + }, + { + "fixture": "003_create_concurrently_unique.sql", + "reason": "PostgreSQL rejects CREATE INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." + }, + { + "fixture": "004_drop_concurrently_exists.sql", + "reason": "PostgreSQL rejects DROP INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." + }, + { + "fixture": "005_create_concurrently_schema.sql", + "reason": "PostgreSQL rejects CREATE INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." + }, + { + "fixture": "006_drop_concurrently_schema.sql", + "reason": "PostgreSQL rejects DROP INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." + }, + { + "fixture": "007_create_concurrently_where.sql", + "reason": "PostgreSQL rejects CREATE INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." + }, + { + "fixture": "008_drop_concurrently_cascade.sql", + "reason": "PostgreSQL rejects DROP INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." + }, + { + "fixture": "009_create_concurrently_include.sql", + "reason": "PostgreSQL rejects CREATE INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." + }, + { + "fixture": "010_drop_concurrently_restrict.sql", + "reason": "PostgreSQL rejects DROP INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." + }, + { + "fixture": "011_create_concurrently_multi.sql", + "reason": "PostgreSQL rejects CREATE INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." + }, + { + "fixture": "012_drop_concurrently_public.sql", + "reason": "PostgreSQL rejects DROP INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." + }, + { + "fixture": "013_create_concurrently_covering.sql", + "reason": "PostgreSQL rejects CREATE INDEX CONCURRENTLY inside the fixture's explicit transaction, leaving no successful resulting state." + }, + { + "fixture": "safe_002_drop_index.sql", + "reason": "Requires a baseline index named i, which conflicts with this rule's participating create-index fixtures." + }, + { + "fixture": "safe_004_drop_concurrently.sql", + "reason": "The absent index makes this IF EXISTS statement a no-op with no differential state." + }, + { + "fixture": "safe_006_drop_index_cascade.sql", + "reason": "Requires a baseline index named i, which conflicts with this rule's participating create-index fixtures." + }, + { + "fixture": "safe_010_drop_index_schema.sql", + "reason": "Requires a baseline index named public.i, which conflicts with this rule's participating create-index fixtures." + }, + { + "fixture": "safe_011_reindex.sql", + "reason": "Requires a baseline index named i, which conflicts with this rule's participating create-index fixtures." + }, + { + "fixture": "safe_012_create_table.sql", + "reason": "The canonical enterprise baseline already contains public.test_table." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "indexes" + ], + "required_relations": [ + "public.test_table" + ], + "notes": "Twenty-second incremental slice. Compares successful index outcomes outside a harness transaction; explicit transaction-error fixtures are nondifferentiable in the successful-state harness and are documented individually." + }, + { + "rule_dir": "rule_21_vacuum-full", + "enabled": true, + "transactional": false, + "fixtures": [ + "001_vacuum_full.sql", + "002_vacuum_full_verbose.sql", + "003_vacuum_full_analyze.sql", + "004_vacuum_full_all.sql", + "006_vacuum_full_verbose_analyze.sql", + "008_vacuum_full_analyze_all.sql", + "009_vacuum_full_parenthesized.sql", + "safe_001_vacuum.sql", + "safe_002_analyze.sql", + "safe_003_vacuum_analyze.sql", + "safe_005_vacuum_verbose.sql", + "safe_006_analyze_all.sql", + "safe_007_vacuum_all.sql", + "safe_008_vacuum_analyze_all.sql" + ], + "excluded_fixtures": [ + { + "fixture": "005_vacuum_full_schema.sql", + "reason": "Requires absent schema and relation my_schema.test_table; adding a duplicate enterprise table solely for maintenance syntax would not increase normalized state coverage." + }, + { + "fixture": "007_vacuum_full_multiple.sql", + "reason": "Requires absent relations t1 and t2; equivalent single-table and whole-database maintenance outcomes are participating." + }, + { + "fixture": "safe_004_create_table.sql", + "reason": "The canonical enterprise baseline already contains sm_core.t on the active search path." + }, + { + "fixture": "safe_009_vacuum_schema.sql", + "reason": "Requires absent schema and relation my_schema.test_table; the public.test_table maintenance control is participating." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "relations", + "columns", + "indexes", + "constraints", + "foreign_keys" + ], + "required_relations": [ + "public.test_table" + ], + "notes": "Twenty-third incremental slice. Runs VACUUM, ANALYZE, and VACUUM FULL outside transactions and verifies that PostgreSQL maintenance preserves enterprise schema topology." + }, + { + "rule_dir": "rule_22_opaque-dynamic-sql", + "enabled": true, + "fixtures": [ + "001_do_block.sql", + "004_set_transaction.sql", + "005_set_constraints.sql", + "006_do_block_language.sql", + "008_set_transaction_read_only.sql", + "010_do_block_with_declare.sql", + "013_invalid_set_role_current_user.sql", + "safe_001_create_table.sql", + "safe_002_alter_table.sql", + "011_select.sql", + "safe_004_create_index.sql", + "013_update.sql", + "014_delete.sql", + "safe_015_comment_on.sql" + ], + "expected_live_errors": { + "013_invalid_set_role_current_user.sql": { + "sqlstate": "42601", + "simulator_rule": "opaque-dynamic-sql" + } + }, + "excluded_fixtures": [ + { + "fixture": "002_execute.sql", + "reason": "PostgreSQL has no top-level EXECUTE string statement; EXECUTE is valid only in procedural or prepared-statement contexts." + }, + { + "fixture": "003_prepare_transaction.sql", + "reason": "Requires an active transaction and server-level prepared-transaction support, and intentionally leaves connection-level state rather than normalized schema state." + }, + { + "fixture": "007_execute_format.sql", + "reason": "PostgreSQL rejects procedural EXECUTE and PL/pgSQL variables at top level." + }, + { + "fixture": "009_set_constraints_named.sql", + "reason": "Requires a constraint named fk_constraint, which is absent from the canonical enterprise baseline." + }, + { + "fixture": "012_insert.sql", + "reason": "PostgreSQL rejects the single unqualified value because public.test_table has multiple non-generated columns." + }, + { + "fixture": "safe_008_drop_table.sql", + "reason": "PostgreSQL rejects dropping public.test_table without CASCADE because the enterprise baseline has cross-table dependencies." + } + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "relations", + "columns", + "indexes", + "constraints", + "foreign_keys", + "view_dependencies" + ], + "fixture_scopes": { + "safe_001_create_table.sql": [ + "relations", + "columns" + ], + "safe_002_alter_table.sql": [ + "relations", + "columns" + ], + "safe_004_create_index.sql": [ + "indexes" + ] + }, + "required_relations": [ + "public.test_table" + ], + "notes": "Twenty-fourth incremental slice. Executes opaque procedural, transaction-control, and currently unmodeled DML statements against PostgreSQL, verifies their schema-state neutrality, compares explicit DDL controls, and covers explicit schema-neutral COMMENT ON statements." + }, + { + "rule_dir": "rule_02_drop-database", + "enabled": true, + "fixtures": [ + "safe_004_drop_table.sql", + "safe_005_select.sql", + "safe_009_update.sql" + ], + "excluded_fixtures": [ + { + "fixture": "001_drop_db.sql", + "reason": "Database mydb is not harness-owned, DROP DATABASE cannot be rollback-isolated, and database existence is outside normalized schema state." + }, + { + "fixture": "002_drop_db_if_exists.sql", + "reason": "Database mydb is not harness-owned; IF EXISTS does not make destructive execution safe when a local database with that name may exist." + }, + { + "fixture": "003_drop_db_force.sql", + "reason": "Database mydb is not harness-owned and FORCE may terminate unrelated local sessions; this connection-level behavior is intentionally nondifferentiable." + }, + { + "fixture": "004_drop_db_force_if_exists.sql", + "reason": "Database mydb is not harness-owned and FORCE may terminate unrelated local sessions; IF EXISTS does not make execution safe." + }, + { + "fixture": "005_drop_db_quoted.sql", + "reason": "Database My-DB is not harness-owned, DROP DATABASE cannot be rollback-isolated, and database existence is outside normalized schema state." + }, + { + "fixture": "006_drop_db_quoted_force.sql", + "reason": "Database My-DB is not harness-owned and FORCE may terminate unrelated local sessions." + }, + { + "fixture": "007_drop_db_multi_word.sql", + "reason": "Database my_database is not harness-owned, DROP DATABASE cannot be rollback-isolated, and database existence is outside normalized schema state." + }, + { + "fixture": "008_drop_db_temp.sql", + "reason": "Database temp_db is not harness-owned, DROP DATABASE cannot be rollback-isolated, and database existence is outside normalized schema state." + }, + { + "fixture": "009_drop_db_mixed_case.sql", + "reason": "PostgreSQL folds MyDataBase to mydatabase, which is not harness-owned; database existence is outside normalized schema state." + }, + { + "fixture": "safe_001_create_db.sql", + "reason": "CREATE DATABASE cannot run inside rollback isolation and would create non-normalized connection-level state using a non-harness-owned name." + }, + { + "fixture": "safe_002_alter_db.sql", + "reason": "Requires non-harness-owned database mydb and mutates connection-level state outside normalized schema comparison." + }, + { + "fixture": "safe_003_create_table.sql", + "reason": "The canonical enterprise baseline already contains sm_core.t on the active search path." + }, + { + "fixture": "safe_006_alter_db_owner.sql", + "reason": "Requires non-harness-owned database mydb and role postgres, which is not the local PostgreSQL role used by this beta environment." + }, + { + "fixture": "safe_007_create_db_options.sql", + "reason": "CREATE DATABASE cannot be rollback-isolated, uses a non-harness-owned name, and requests an environment-specific locale." + }, + { + "fixture": "safe_008_insert.sql", + "reason": "PostgreSQL rejects one value for baseline sm_core.t because it has two non-generated columns." } - ] + ], + "schemas": [ + "public", + "sm_identity", + "sm_core", + "sm_catalog", + "sm_billing", + "sm_fulfillment", + "sm_audit", + "sm_analytics" + ], + "scope": [ + "relations", + "columns", + "indexes", + "constraints", + "foreign_keys", + "view_dependencies" + ], + "required_relations": [ + "sm_core.t" + ], + "notes": "Twenty-fifth incremental slice. Represents the connection-level rule with safe live controls only; fixed-name database creation, alteration, and deletion are explicitly classified as unsupported nondifferentiable behavior in the schema-state harness." + } + ] } diff --git a/live_tests/rule_06_create-table-as-select/005_select_into.sql b/live_tests/rule_06_create-table-as-select/005_select_into.sql new file mode 100644 index 00000000..ad8d3fe4 --- /dev/null +++ b/live_tests/rule_06_create-table-as-select/005_select_into.sql @@ -0,0 +1,3 @@ +-- The shared differential baseline owns sm_core.t and uses sm_core first in +-- search_path. Keep this target distinct so this case exercises SELECT INTO. +SELECT * INTO select_into_result FROM test_table; diff --git a/live_tests/rule_06_create-table-as-select/safe_005_select_into.sql b/live_tests/rule_06_create-table-as-select/safe_005_select_into.sql deleted file mode 100644 index 2a283df2..00000000 --- a/live_tests/rule_06_create-table-as-select/safe_005_select_into.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT * INTO t FROM test_table; diff --git a/live_tests/rule_26_chain-conflict/019_concurrent_detach_in_transaction.sql b/live_tests/rule_26_chain-conflict/019_concurrent_detach_in_transaction.sql new file mode 100644 index 00000000..91993d8f --- /dev/null +++ b/live_tests/rule_26_chain-conflict/019_concurrent_detach_in_transaction.sql @@ -0,0 +1,6 @@ +CREATE TABLE sm_core.catalog_detach_parent(id integer) PARTITION BY RANGE(id); +CREATE TABLE sm_core.catalog_detach_child PARTITION OF sm_core.catalog_detach_parent + FOR VALUES FROM (0) TO (10); +BEGIN; +ALTER TABLE sm_core.catalog_detach_parent + DETACH PARTITION sm_core.catalog_detach_child CONCURRENTLY; diff --git a/live_tests/rule_26_chain-conflict/020_invalid_partition_strategy.sql b/live_tests/rule_26_chain-conflict/020_invalid_partition_strategy.sql new file mode 100644 index 00000000..8e785561 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/020_invalid_partition_strategy.sql @@ -0,0 +1,2 @@ +CREATE TABLE sm_core.catalog_invalid_strategy (id integer) + PARTITION BY imaginary (id); diff --git a/live_tests/rule_26_chain-conflict/021_concurrent_detach_default_partition.sql b/live_tests/rule_26_chain-conflict/021_concurrent_detach_default_partition.sql new file mode 100644 index 00000000..270d11cc --- /dev/null +++ b/live_tests/rule_26_chain-conflict/021_concurrent_detach_default_partition.sql @@ -0,0 +1,5 @@ +CREATE TABLE sm_core.catalog_detach_default (id integer) PARTITION BY RANGE (id); +CREATE TABLE sm_core.catalog_detach_child PARTITION OF sm_core.catalog_detach_default + FOR VALUES FROM (0) TO (10); +CREATE TABLE sm_core.catalog_detach_rest PARTITION OF sm_core.catalog_detach_default DEFAULT; +ALTER TABLE sm_core.catalog_detach_default DETACH PARTITION sm_core.catalog_detach_child CONCURRENTLY; diff --git a/live_tests/rule_26_chain-conflict/safe_025_table_catalog_lifecycle.sql b/live_tests/rule_26_chain-conflict/safe_025_table_catalog_lifecycle.sql new file mode 100644 index 00000000..82ad06b4 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_025_table_catalog_lifecycle.sql @@ -0,0 +1,13 @@ +CREATE TABLE sm_core.catalog_table_catalog ( + id bigint NOT NULL, + payload text +); +CREATE UNIQUE INDEX catalog_table_catalog_id_key + ON sm_core.catalog_table_catalog (id); +ALTER TABLE sm_core.catalog_table_catalog CLUSTER ON catalog_table_catalog_id_key; +ALTER TABLE sm_core.catalog_table_catalog REPLICA IDENTITY USING INDEX catalog_table_catalog_id_key; +ALTER TABLE sm_core.catalog_table_catalog ENABLE ROW LEVEL SECURITY; +ALTER TABLE sm_core.catalog_table_catalog FORCE ROW LEVEL SECURITY; +ALTER TABLE sm_core.catalog_table_catalog SET (fillfactor = 80); +ALTER TABLE sm_core.catalog_table_catalog SET UNLOGGED; +ALTER TABLE sm_core.catalog_table_catalog SET ACCESS METHOD heap; diff --git a/live_tests/rule_26_chain-conflict/safe_026_column_catalog_lifecycle.sql b/live_tests/rule_26_chain-conflict/safe_026_column_catalog_lifecycle.sql new file mode 100644 index 00000000..550afa48 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_026_column_catalog_lifecycle.sql @@ -0,0 +1,9 @@ +CREATE TABLE sm_core.catalog_column_catalog ( + base integer NOT NULL, + generated integer GENERATED ALWAYS AS (base * 2) STORED, + payload text +); +ALTER TABLE sm_core.catalog_column_catalog ALTER COLUMN payload SET STORAGE MAIN; +ALTER TABLE sm_core.catalog_column_catalog ALTER COLUMN payload SET COMPRESSION pglz; +ALTER TABLE sm_core.catalog_column_catalog ALTER COLUMN payload SET STATISTICS 321; +ALTER TABLE sm_core.catalog_column_catalog ALTER COLUMN payload SET (n_distinct = 0.25); diff --git a/live_tests/rule_26_chain-conflict/safe_027_like_including_all.sql b/live_tests/rule_26_chain-conflict/safe_027_like_including_all.sql new file mode 100644 index 00000000..18fea8e6 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_027_like_including_all.sql @@ -0,0 +1,2 @@ +CREATE TABLE sm_core.catalog_like_copy + (LIKE sm_core.catalog_like_source INCLUDING ALL); diff --git a/live_tests/rule_26_chain-conflict/safe_028_sequence_parameters.sql b/live_tests/rule_26_chain-conflict/safe_028_sequence_parameters.sql new file mode 100644 index 00000000..c335533a --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_028_sequence_parameters.sql @@ -0,0 +1,9 @@ +CREATE UNLOGGED SEQUENCE sm_core.catalog_sequence + AS integer + INCREMENT BY -3 + MINVALUE -99 + MAXVALUE -3 + START WITH -3 + CACHE 7 + CYCLE; +ALTER SEQUENCE sm_core.catalog_sequence RESTART WITH -12; diff --git a/live_tests/rule_26_chain-conflict/safe_029_select_into_projection.sql b/live_tests/rule_26_chain-conflict/safe_029_select_into_projection.sql new file mode 100644 index 00000000..df30da46 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_029_select_into_projection.sql @@ -0,0 +1,3 @@ +SELECT id, name AS copied_name +INTO UNLOGGED TABLE sm_core.catalog_select_into +FROM sm_core.t; diff --git a/live_tests/rule_26_chain-conflict/safe_030_lock_and_truncate.sql b/live_tests/rule_26_chain-conflict/safe_030_lock_and_truncate.sql new file mode 100644 index 00000000..462145f0 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_030_lock_and_truncate.sql @@ -0,0 +1,3 @@ +CREATE TABLE sm_core.catalog_lock_target (id integer); +LOCK TABLE ONLY sm_core.catalog_lock_target IN SHARE ROW EXCLUSIVE MODE NOWAIT; +TRUNCATE TABLE ONLY sm_core.catalog_lock_target CONTINUE IDENTITY RESTRICT; diff --git a/live_tests/rule_26_chain-conflict/safe_031_partition_clone_catalog.sql b/live_tests/rule_26_chain-conflict/safe_031_partition_clone_catalog.sql new file mode 100644 index 00000000..cf6ebb9e --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_031_partition_clone_catalog.sql @@ -0,0 +1,12 @@ +CREATE TABLE sm_core.catalog_partition_parent ( + id integer NOT NULL, + payload text, + CONSTRAINT catalog_partition_parent_check CHECK (id >= 0), + CONSTRAINT catalog_partition_parent_key UNIQUE (id) +) PARTITION BY RANGE (id); +CREATE TRIGGER catalog_partition_trigger + BEFORE INSERT ON sm_core.catalog_partition_parent + FOR EACH ROW EXECUTE FUNCTION sm_core.f(); +CREATE TABLE sm_core.catalog_partition_child + PARTITION OF sm_core.catalog_partition_parent + FOR VALUES FROM (0) TO (100); diff --git a/live_tests/rule_26_chain-conflict/safe_032_inheritance_lifecycle.sql b/live_tests/rule_26_chain-conflict/safe_032_inheritance_lifecycle.sql new file mode 100644 index 00000000..ddcaecdd --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_032_inheritance_lifecycle.sql @@ -0,0 +1,12 @@ +CREATE TABLE sm_core.catalog_inherit_parent ( + id integer NOT NULL, + payload text, + CONSTRAINT catalog_inherit_check CHECK (id > 0) +); +CREATE TABLE sm_core.catalog_inherit_child ( + extra text +) INHERITS (sm_core.catalog_inherit_parent); +ALTER TABLE sm_core.catalog_inherit_child NO INHERIT sm_core.catalog_inherit_parent; +CREATE TABLE sm_core.catalog_inherit_attached () INHERITS (sm_core.catalog_inherit_parent); +ALTER TABLE sm_core.catalog_inherit_attached NO INHERIT sm_core.catalog_inherit_parent; +ALTER TABLE sm_core.catalog_inherit_attached INHERIT sm_core.catalog_inherit_parent; diff --git a/live_tests/rule_26_chain-conflict/safe_033_typed_table_lifecycle.sql b/live_tests/rule_26_chain-conflict/safe_033_typed_table_lifecycle.sql new file mode 100644 index 00000000..3e5fb473 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_033_typed_table_lifecycle.sql @@ -0,0 +1,7 @@ +CREATE TYPE sm_core.catalog_row_type AS ( + id integer, + payload text +); +CREATE TABLE sm_core.catalog_typed_table OF sm_core.catalog_row_type; +ALTER TABLE sm_core.catalog_typed_table NOT OF; +CREATE TABLE sm_core.catalog_still_typed OF sm_core.catalog_row_type; diff --git a/live_tests/rule_26_chain-conflict/safe_034_generated_expression_change.sql b/live_tests/rule_26_chain-conflict/safe_034_generated_expression_change.sql new file mode 100644 index 00000000..911e8eb8 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_034_generated_expression_change.sql @@ -0,0 +1,6 @@ +CREATE TABLE sm_core.catalog_generated_change ( + base integer, + calculated integer GENERATED ALWAYS AS (base * 2) STORED +); +ALTER TABLE sm_core.catalog_generated_change + ALTER COLUMN calculated SET EXPRESSION AS (base * 3); diff --git a/live_tests/rule_26_chain-conflict/safe_035_metadata_reset.sql b/live_tests/rule_26_chain-conflict/safe_035_metadata_reset.sql new file mode 100644 index 00000000..56c45e54 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_035_metadata_reset.sql @@ -0,0 +1,13 @@ +CREATE TABLE sm_core.catalog_metadata_reset ( + id integer, + payload text +) WITH (fillfactor = 75); +ALTER TABLE sm_core.catalog_metadata_reset RESET (fillfactor); +ALTER TABLE sm_core.catalog_metadata_reset ALTER COLUMN payload SET (n_distinct = 0.5); +ALTER TABLE sm_core.catalog_metadata_reset ALTER COLUMN payload RESET (n_distinct); +ALTER TABLE sm_core.catalog_metadata_reset ALTER COLUMN payload SET STATISTICS 450; +ALTER TABLE sm_core.catalog_metadata_reset ALTER COLUMN payload SET STATISTICS -1; +ALTER TABLE sm_core.catalog_metadata_reset ALTER COLUMN payload SET STORAGE MAIN; +ALTER TABLE sm_core.catalog_metadata_reset ALTER COLUMN payload SET STORAGE DEFAULT; +ALTER TABLE sm_core.catalog_metadata_reset ALTER COLUMN payload SET COMPRESSION pglz; +ALTER TABLE sm_core.catalog_metadata_reset ALTER COLUMN payload SET COMPRESSION default; diff --git a/live_tests/rule_26_chain-conflict/safe_036_table_security_reset.sql b/live_tests/rule_26_chain-conflict/safe_036_table_security_reset.sql new file mode 100644 index 00000000..cb323643 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_036_table_security_reset.sql @@ -0,0 +1,8 @@ +CREATE TABLE sm_core.catalog_security_reset (id integer NOT NULL); +ALTER TABLE sm_core.catalog_security_reset ENABLE ROW LEVEL SECURITY; +ALTER TABLE sm_core.catalog_security_reset FORCE ROW LEVEL SECURITY; +ALTER TABLE sm_core.catalog_security_reset NO FORCE ROW LEVEL SECURITY; +ALTER TABLE sm_core.catalog_security_reset DISABLE ROW LEVEL SECURITY; +ALTER TABLE sm_core.catalog_security_reset REPLICA IDENTITY FULL; +ALTER TABLE sm_core.catalog_security_reset REPLICA IDENTITY NOTHING; +ALTER TABLE sm_core.catalog_security_reset REPLICA IDENTITY DEFAULT; diff --git a/live_tests/rule_26_chain-conflict/safe_037_key_index_lifecycle.sql b/live_tests/rule_26_chain-conflict/safe_037_key_index_lifecycle.sql new file mode 100644 index 00000000..799d30df --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_037_key_index_lifecycle.sql @@ -0,0 +1,5 @@ +CREATE TABLE sm_core.catalog_keys (id integer, value text); +ALTER TABLE sm_core.catalog_keys ADD CONSTRAINT catalog_keys_unique UNIQUE (value); +ALTER TABLE sm_core.catalog_keys DROP CONSTRAINT catalog_keys_unique; +ALTER TABLE sm_core.catalog_keys ADD CONSTRAINT catalog_keys_unique UNIQUE (value); +ALTER TABLE sm_core.catalog_keys ADD CONSTRAINT catalog_keys_primary PRIMARY KEY (id); diff --git a/live_tests/rule_26_chain-conflict/safe_038_type_change_metadata.sql b/live_tests/rule_26_chain-conflict/safe_038_type_change_metadata.sql new file mode 100644 index 00000000..d2f79505 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_038_type_change_metadata.sql @@ -0,0 +1,9 @@ +CREATE TABLE sm_core.catalog_type_change ( + text_value text, + number_value numeric(10, 2), + optional integer DEFAULT NULL +); +ALTER TABLE sm_core.catalog_type_change ALTER COLUMN text_value SET STORAGE MAIN; +ALTER TABLE sm_core.catalog_type_change ALTER COLUMN text_value SET COMPRESSION pglz; +ALTER TABLE sm_core.catalog_type_change ALTER COLUMN text_value TYPE varchar(80); +ALTER TABLE sm_core.catalog_type_change ALTER COLUMN number_value TYPE double precision; diff --git a/live_tests/rule_26_chain-conflict/safe_039_rule_enablement.sql b/live_tests/rule_26_chain-conflict/safe_039_rule_enablement.sql new file mode 100644 index 00000000..1b2d2a91 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_039_rule_enablement.sql @@ -0,0 +1,5 @@ +ALTER TABLE sm_core.catalog_rules DISABLE RULE rule_origin; +ALTER TABLE sm_core.catalog_rules ENABLE RULE rule_origin; +ALTER TABLE sm_core.catalog_rules DISABLE RULE rule_disabled; +ALTER TABLE sm_core.catalog_rules ENABLE REPLICA RULE rule_replica; +ALTER TABLE sm_core.catalog_rules ENABLE ALWAYS RULE rule_always; diff --git a/live_tests/rule_26_chain-conflict/safe_040_generated_column_rename.sql b/live_tests/rule_26_chain-conflict/safe_040_generated_column_rename.sql new file mode 100644 index 00000000..4d6d43d9 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_040_generated_column_rename.sql @@ -0,0 +1,16 @@ +CREATE TABLE sm_core.catalog_generated_rename ( + abs integer, + negative integer GENERATED ALWAYS AS (-abs) STORED, + calculated integer GENERATED ALWAYS AS (abs(abs) + abs) STORED +); +ALTER TABLE sm_core.catalog_generated_rename RENAME COLUMN abs TO "Renamed"; +CREATE FUNCTION sm_core.catalog_absolute(integer) RETURNS integer + LANGUAGE sql IMMUTABLE AS 'SELECT abs($1)'; +CREATE TABLE sm_core.catalog_generated_qualified_rename ( + sm_core integer, + calculated integer GENERATED ALWAYS AS (sm_core.catalog_absolute(sm_core)) STORED +); +ALTER TABLE sm_core.catalog_generated_qualified_rename + RENAME COLUMN sm_core TO "Renamed"; +-- Keep the function schema visible in PostgreSQL's deparsed catalog expression. +SET search_path = pg_catalog, public; diff --git a/live_tests/rule_26_chain-conflict/safe_041_partition_bounds.sql b/live_tests/rule_26_chain-conflict/safe_041_partition_bounds.sql new file mode 100644 index 00000000..6a51a464 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_041_partition_bounds.sql @@ -0,0 +1,7 @@ +CREATE TABLE sm_core.catalog_bounds (id integer) PARTITION /* typed strategy */ BY "RANGE" (id); +CREATE TABLE sm_core.catalog_bound_child PARTITION OF sm_core.catalog_bounds + FOR VALUES FROM (0) TO (10); +CREATE TABLE sm_core.catalog_bound_default PARTITION OF sm_core.catalog_bounds DEFAULT; +ALTER TABLE sm_core.catalog_bounds DETACH PARTITION sm_core.catalog_bound_child; +ALTER TABLE sm_core.catalog_bounds ATTACH PARTITION sm_core.catalog_bound_child + FOR VALUES FROM (10) TO (20); diff --git a/live_tests/rule_26_chain-conflict/safe_042_concurrent_hash_detach.sql b/live_tests/rule_26_chain-conflict/safe_042_concurrent_hash_detach.sql new file mode 100644 index 00000000..d216fbd4 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_042_concurrent_hash_detach.sql @@ -0,0 +1,6 @@ +CREATE TABLE sm_core.catalog_hash_parent (id integer PRIMARY KEY) PARTITION BY HASH (id); +CREATE TRIGGER catalog_hash_trigger BEFORE INSERT ON sm_core.catalog_hash_parent + FOR EACH ROW EXECUTE FUNCTION sm_core.f(); +CREATE TABLE sm_core.catalog_hash_child PARTITION OF sm_core.catalog_hash_parent + FOR VALUES WITH (MODULUS 2, REMAINDER 0); +ALTER TABLE sm_core.catalog_hash_parent DETACH PARTITION sm_core.catalog_hash_child CONCURRENTLY; diff --git a/live_tests/rule_26_chain-conflict/safe_043_check_constraint_rename.sql b/live_tests/rule_26_chain-conflict/safe_043_check_constraint_rename.sql new file mode 100644 index 00000000..21b08d6d --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_043_check_constraint_rename.sql @@ -0,0 +1,10 @@ +CREATE TABLE sm_core.check_rename_target ( + id integer, + payload integer, + CONSTRAINT original_id_check CHECK (id > 0), + CONSTRAINT original_payload_check CHECK (payload > 0) +); +ALTER TABLE sm_core.check_rename_target RENAME CONSTRAINT original_id_check TO renamed_id_check; +ALTER TABLE sm_core.check_rename_target RENAME CONSTRAINT original_payload_check TO renamed_payload_check; +ALTER TABLE sm_core.check_rename_target DROP CONSTRAINT renamed_payload_check; +ALTER TABLE sm_core.check_rename_target DROP COLUMN payload; diff --git a/live_tests/rule_26_chain-conflict/safe_044_key_index_rename.sql b/live_tests/rule_26_chain-conflict/safe_044_key_index_rename.sql new file mode 100644 index 00000000..6cb804db --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_044_key_index_rename.sql @@ -0,0 +1,6 @@ +CREATE TABLE sm_core.rename_key_target (id integer NOT NULL); +ALTER TABLE sm_core.rename_key_target ADD CONSTRAINT original_unique_key UNIQUE (id); +ALTER TABLE sm_core.rename_key_target CLUSTER ON original_unique_key; +ALTER TABLE sm_core.rename_key_target REPLICA IDENTITY USING INDEX original_unique_key; +ALTER TABLE sm_core.rename_key_target RENAME CONSTRAINT original_unique_key TO renamed_unique_key; +ALTER INDEX sm_core.renamed_unique_key RENAME TO final_unique_key; diff --git a/live_tests/rule_26_chain-conflict/safe_045_drop_index_settings.sql b/live_tests/rule_26_chain-conflict/safe_045_drop_index_settings.sql new file mode 100644 index 00000000..2bdb2c72 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_045_drop_index_settings.sql @@ -0,0 +1,10 @@ +CREATE TABLE sm_core.drop_index_settings (id integer NOT NULL); +CREATE UNIQUE INDEX "IdentityIndex" ON sm_core.drop_index_settings (id); +ALTER TABLE sm_core.drop_index_settings CLUSTER ON "IdentityIndex"; +ALTER TABLE sm_core.drop_index_settings REPLICA IDENTITY USING INDEX "IdentityIndex"; +DROP INDEX sm_core."IdentityIndex"; +CREATE TABLE sm_core.drop_key_settings (id integer NOT NULL); +ALTER TABLE sm_core.drop_key_settings ADD CONSTRAINT identity_key UNIQUE (id); +ALTER TABLE sm_core.drop_key_settings CLUSTER ON identity_key; +ALTER TABLE sm_core.drop_key_settings REPLICA IDENTITY USING INDEX identity_key; +ALTER TABLE sm_core.drop_key_settings DROP CONSTRAINT identity_key; diff --git a/live_tests/rule_26_chain-conflict/safe_046_generated_check_names.sql b/live_tests/rule_26_chain-conflict/safe_046_generated_check_names.sql new file mode 100644 index 00000000..6a212ba9 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_046_generated_check_names.sql @@ -0,0 +1,18 @@ +CREATE TABLE sm_core.check_name_holder ( + id integer CONSTRAINT check_name_target_id_check CHECK (id > 0) +); +CREATE TABLE sm_core.check_name_target (id integer CHECK (id > 0)); +ALTER TABLE sm_core.check_name_target + RENAME CONSTRAINT check_name_target_id_check1 TO retained_check; +ALTER TABLE sm_core.check_name_target DROP CONSTRAINT retained_check; +ALTER TABLE sm_core.check_name_target ADD CHECK (id < 100); +ALTER TABLE sm_core.check_name_target + RENAME CONSTRAINT check_name_target_id_check1 TO upper_bound; +CREATE TABLE sm_core.table_check_names ( + id integer, + other integer, + CHECK (id > 0 AND id < 100), + CHECK (id < other) +); +ALTER TABLE sm_core.table_check_names + RENAME CONSTRAINT table_check_names_id_check TO bounded_id; diff --git a/live_tests/rule_26_chain-conflict/safe_047_concurrent_range_detach_retains_check.sql b/live_tests/rule_26_chain-conflict/safe_047_concurrent_range_detach_retains_check.sql new file mode 100644 index 00000000..90f3b68e --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_047_concurrent_range_detach_retains_check.sql @@ -0,0 +1,4 @@ +CREATE TABLE sm_core.retained_range_parent (id integer NOT NULL) PARTITION BY RANGE (id); +CREATE TABLE sm_core.retained_range_child PARTITION OF sm_core.retained_range_parent + FOR VALUES FROM (0) TO (100); +ALTER TABLE sm_core.retained_range_parent DETACH PARTITION sm_core.retained_range_child CONCURRENTLY; \ No newline at end of file diff --git a/live_tests/rule_26_chain-conflict/safe_048_concurrent_list_detach_retains_check.sql b/live_tests/rule_26_chain-conflict/safe_048_concurrent_list_detach_retains_check.sql new file mode 100644 index 00000000..d801c76d --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_048_concurrent_list_detach_retains_check.sql @@ -0,0 +1,4 @@ +CREATE TABLE sm_core.retained_list_parent (id integer NOT NULL) PARTITION BY LIST (id); +CREATE TABLE sm_core.retained_list_child PARTITION OF sm_core.retained_list_parent + FOR VALUES IN (1, 2, 3); +ALTER TABLE sm_core.retained_list_parent DETACH PARTITION sm_core.retained_list_child CONCURRENTLY; \ No newline at end of file diff --git a/live_tests/rule_26_chain-conflict/safe_049_recursive_column_rename.sql b/live_tests/rule_26_chain-conflict/safe_049_recursive_column_rename.sql new file mode 100644 index 00000000..62d96614 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_049_recursive_column_rename.sql @@ -0,0 +1,6 @@ +CREATE TABLE sm_core.rename_parent ( + id integer CONSTRAINT rename_parent_id_check CHECK (id > 0) +); +CREATE TABLE sm_core.rename_child () INHERITS (sm_core.rename_parent); +CREATE INDEX rename_child_id_idx ON sm_core.rename_child (id); +ALTER TABLE sm_core.rename_parent RENAME COLUMN id TO renamed; diff --git a/live_tests/rule_26_chain-conflict/safe_050_autocommit_on_commit_drop.sql b/live_tests/rule_26_chain-conflict/safe_050_autocommit_on_commit_drop.sql new file mode 100644 index 00000000..d70805ef --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_050_autocommit_on_commit_drop.sql @@ -0,0 +1 @@ +CREATE TEMPORARY TABLE sm_autocommit_drop (id integer) ON COMMIT DROP; diff --git a/live_tests/rule_26_chain-conflict/safe_051_generated_expression_visible_function.sql b/live_tests/rule_26_chain-conflict/safe_051_generated_expression_visible_function.sql new file mode 100644 index 00000000..43e6eda1 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_051_generated_expression_visible_function.sql @@ -0,0 +1,7 @@ +CREATE FUNCTION sm_core.generated_visible_double(integer) +RETURNS integer LANGUAGE SQL IMMUTABLE AS 'SELECT $1 * 2'; +SET search_path TO sm_core, public; +CREATE TABLE sm_core.catalog_generated_visible_function ( + base integer, + calculated integer GENERATED ALWAYS AS (sm_core.generated_visible_double(base)) STORED +); diff --git a/scripts/fuzz b/scripts/fuzz index 9a509afc..6f654c4e 100755 --- a/scripts/fuzz +++ b/scripts/fuzz @@ -94,7 +94,9 @@ for migration in "$corpus_dir"/*.sql; do esac ;; 1) - if grep -q 'Failed to parse SQL migration' "$stderr_file"; then + # The CLI exposes parser failures through the stable analysis + # error boundary; retain the legacy wording for older binaries. + if grep -Eq 'Failed to parse SQL migration|analysis failed:.*(expected command|Missing semicolon|Unterminated dollar quoted string)' "$stderr_file"; then migration_name=$(basename "$migration") case "$migration_name" in *_do_block_nested.sql) diff --git a/scripts/live-auto-sync b/scripts/live-auto-sync index cffb8844..95bd6525 100755 --- a/scripts/live-auto-sync +++ b/scripts/live-auto-sync @@ -4,10 +4,20 @@ set -eu repository_root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) cd "$repository_root" +if [ "$#" -gt 0 ]; then + if [ "$#" -eq 1 ] && { [ "$1" = --help ] || [ "$1" = -h ]; }; then + printf '%s\n' "Usage: scripts/live-auto-sync" "Requires disposable local PostgreSQL databases; see CONTRIBUTING.md." + exit 0 + fi + printf '%s\n' "Unexpected arguments; use --help for usage." >&2 + exit 2 +fi + if [ -z "${DATABASE_URL:-}" ]; then printf '%s\n' "DATABASE_URL is required for the live auto-sync contract." >&2 exit 2 fi -cargo test --locked --test live_auto_sync \ - live_auto_sync_refreshes_lint_and_lint_chain -- --ignored --nocapture +cargo test --locked --lib \ + internal_tests::live_auto_sync::live_auto_sync_refreshes_lint_and_lint_chain \ + -- --exact --ignored --nocapture diff --git a/scripts/live-cache-encryption b/scripts/live-cache-encryption index fb5d511b..38a8da41 100755 --- a/scripts/live-cache-encryption +++ b/scripts/live-cache-encryption @@ -4,10 +4,20 @@ set -eu repository_root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) cd "$repository_root" +if [ "$#" -gt 0 ]; then + if [ "$#" -eq 1 ] && { [ "$1" = --help ] || [ "$1" = -h ]; }; then + printf '%s\n' "Usage: scripts/live-cache-encryption" "Requires disposable local PostgreSQL databases; see CONTRIBUTING.md." + exit 0 + fi + printf '%s\n' "Unexpected arguments; use --help for usage." >&2 + exit 2 +fi + if [ -z "${DATABASE_URL:-}" ]; then printf '%s\n' "DATABASE_URL is required for the live cache-encryption contract." >&2 exit 2 fi -cargo test --locked --test live_cache_encryption \ - live_encrypted_cache_round_trip_and_rejection_contract -- --ignored --nocapture +cargo test --locked --lib \ + internal_tests::live_cache_encryption::live_encrypted_cache_round_trip_and_rejection_contract \ + -- --exact --ignored --nocapture diff --git a/scripts/live-catalog-differential b/scripts/live-catalog-differential index bdf105bf..5c6a74aa 100755 --- a/scripts/live-catalog-differential +++ b/scripts/live-catalog-differential @@ -1,11 +1,23 @@ #!/bin/sh set -eu +repository_root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) +cd "$repository_root" + +if [ "$#" -gt 0 ]; then + if [ "$#" -eq 1 ] && { [ "$1" = --help ] || [ "$1" = -h ]; }; then + printf '%s\n' "Usage: scripts/live-catalog-differential" "Requires disposable local PostgreSQL databases; see CONTRIBUTING.md." + exit 0 + fi + printf '%s\n' "Unexpected arguments; use --help for usage." >&2 + exit 2 +fi + if [ -z "${DATABASE_URL:-}" ]; then printf '%s\n' "DATABASE_URL is required for the live catalog differential test." >&2 exit 2 fi -cargo test --locked --test live_catalog_sync \ - live_routine_and_replication_mutations_match_postgresql \ - -- --ignored --nocapture +cargo test --locked --lib \ + internal_tests::live_catalog_sync::live_routine_and_replication_mutations_match_postgresql \ + -- --exact --ignored --nocapture diff --git a/scripts/live-catalog-sync b/scripts/live-catalog-sync index 6d2a7272..16553ef0 100755 --- a/scripts/live-catalog-sync +++ b/scripts/live-catalog-sync @@ -1,11 +1,23 @@ #!/bin/sh set -eu +repository_root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) +cd "$repository_root" + +if [ "$#" -gt 0 ]; then + if [ "$#" -eq 1 ] && { [ "$1" = --help ] || [ "$1" = -h ]; }; then + printf '%s\n' "Usage: scripts/live-catalog-sync" "Requires disposable local PostgreSQL databases; see CONTRIBUTING.md." + exit 0 + fi + printf '%s\n' "Unexpected arguments; use --help for usage." >&2 + exit 2 +fi + if [ -z "${DATABASE_URL:-}" ]; then printf '%s\n' "DATABASE_URL is required for the live catalog sync test." >&2 exit 2 fi -cargo test --locked --test live_catalog_sync \ - live_sync_preserves_routine_and_replication_catalogs_without_connection_secrets \ - -- --ignored --nocapture +cargo test --locked --lib \ + internal_tests::live_catalog_sync::live_sync_preserves_routine_and_replication_catalogs_without_connection_secrets \ + -- --exact --ignored --nocapture diff --git a/scripts/live-connected-subscription b/scripts/live-connected-subscription new file mode 100755 index 00000000..1a19088f --- /dev/null +++ b/scripts/live-connected-subscription @@ -0,0 +1,25 @@ +#!/bin/sh +set -eu + +repository_root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) +cd "$repository_root" + +if [ "$#" -gt 0 ]; then + if [ "$#" -eq 1 ] && { [ "$1" = --help ] || [ "$1" = -h ]; }; then + printf '%s\n' "Usage: scripts/live-connected-subscription" "Requires disposable local PostgreSQL databases; see CONTRIBUTING.md." + exit 0 + fi + printf '%s\n' "Unexpected arguments; use --help for usage." >&2 + exit 2 +fi + +if [ -z "${DATABASE_URL:-}" ] || [ -z "${PUBLISHER_DATABASE_URL:-}" ] || + [ -z "${SUBSCRIPTION_DATABASE_URL:-}" ]; then + printf '%s\n' \ + "DATABASE_URL, PUBLISHER_DATABASE_URL, and SUBSCRIPTION_DATABASE_URL are required." >&2 + exit 2 +fi + +cargo test --locked --lib \ + internal_tests::live_catalog_sync::live_connected_subscription_round_trip_is_redacted_and_exact \ + -- --exact --ignored --nocapture diff --git a/scripts/live-differential b/scripts/live-differential index 9c21557e..a5740b80 100755 --- a/scripts/live-differential +++ b/scripts/live-differential @@ -1,6 +1,9 @@ #!/bin/sh set -eu +repository_root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) +cd "$repository_root" + verbosity=0 rule_filter="" fixture_filter="" @@ -24,7 +27,7 @@ while [ "$#" -gt 0 ]; do ;; --rule) shift - if [ "$#" -eq 0 ]; then + if [ "$#" -eq 0 ] || [ -z "$1" ]; then usage >&2 exit 2 fi @@ -32,7 +35,7 @@ while [ "$#" -gt 0 ]; do ;; --fixture) shift - if [ "$#" -eq 0 ]; then + if [ "$#" -eq 0 ] || [ -z "$1" ]; then usage >&2 exit 2 fi @@ -68,5 +71,6 @@ else unset SAFE_MIGRATE_DIFF_FIXTURE fi -cargo test --locked --test live_differential_harness \ - live_postgres_differential_harness -- --ignored --nocapture +cargo test --locked --lib \ + internal_tests::live_differential_harness::live_postgres_differential_harness \ + -- --exact --ignored --nocapture diff --git a/scripts/test-action-contract b/scripts/test-action-contract index 8922c97c..046d6db9 100755 --- a/scripts/test-action-contract +++ b/scripts/test-action-contract @@ -9,7 +9,7 @@ baseline="$repo_root/scripts/action-baseline" manifest="$repo_root/action.yml" workflow="$repo_root/.github/workflows/ci.yml" -test "$(/bin/sh "$resolver" v0.8.1 "$repo_root/Cargo.toml")" = v0.8.1 +test "$(/bin/sh "$resolver" v0.9.0 "$repo_root/Cargo.toml")" = v0.9.0 test "$(/bin/sh "$resolver" 0123456789abcdef0123456789abcdef01234567 "$repo_root/Cargo.toml")" = source if /bin/sh "$resolver" main "$repo_root/Cargo.toml" >/dev/null 2>&1; then @@ -155,7 +155,7 @@ grep -F 'cache_dir="$(mktemp -d "${cache_root}/invocation.XXXXXX")"' "$manifest" grep -F 'managed_root="${HOME}/.cache/safe-migrate-action"' "$manifest" >/dev/null grep -F 'baseline_root="${managed_root}/baselines"' "$manifest" >/dev/null grep -F 'cache_dir="${baseline_root}/${INPUT_BASELINE}"' "$manifest" >/dev/null -grep -F 'cache_transport_path="~/.cache/safe-migrate-action/baselines/${INPUT_BASELINE}/baseline-v7.cache"' "$manifest" >/dev/null +grep -F 'cache_transport_path="~/.cache/safe-migrate-action/baselines/${INPUT_BASELINE}/baseline-v8.cache"' "$manifest" >/dev/null test "$(grep -Fc 'path: ${{ steps.baseline.outputs.cache-transport-path }}' "$manifest")" -eq 2 if grep -F '../.safe-migrate-action-cache' "$manifest" >/dev/null; then printf '%s\n' "Action cache paths cannot contain '..'" >&2 @@ -171,7 +171,7 @@ fi grep -F 'uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' "$manifest" >/dev/null grep -F 'uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' "$manifest" >/dev/null grep -F "inputs.sync != 'true'" "$manifest" >/dev/null -grep -F 'cache_prefix="safe-migrate-v7-${RUNNER_OS}-${cache_mode}-${INPUT_BASELINE}-"' "$manifest" >/dev/null +grep -F 'cache_prefix="safe-migrate-v8-${RUNNER_OS}-${cache_mode}-${INPUT_BASELINE}-"' "$manifest" >/dev/null grep -F '[[ ! "$SAFE_MIGRATE_CACHE_KEY" =~ ^[0-9A-Fa-f]{64}$ ]]' "$manifest" >/dev/null test "$(grep -Ec '^[[:space:]]+SAFE_MIGRATE_CACHE_KEY: "[0-9A-Fa-f]{64}"$' "$workflow")" -eq 5 if grep -Eq '^[[:space:]]+SAFE_MIGRATE_CACHE_KEY: [0-9A-Fa-f]{64}$' "$workflow"; then @@ -182,6 +182,7 @@ grep -F 'cache-prefix=${cache_prefix}' "$manifest" >/dev/null grep -F 'sync-status=${sync_status}' "$manifest" >/dev/null grep -F 'baseline-source=${baseline_source}' "$manifest" >/dev/null grep -F 'await core.summary.addRaw(markdown).write();' "$manifest" >/dev/null +grep -F '.replace(/[\u0000-\u001f\u007f-\u009f]/g, character =>' "$manifest" >/dev/null grep -F 'core.error(message, properties);' "$manifest" >/dev/null grep -F 'core.warning(message, properties);' "$manifest" >/dev/null grep -F "finding.rule_title || finding.rule_id" "$manifest" >/dev/null diff --git a/src/_internal/analysis/evidence.rs b/src/_internal/analysis/evidence.rs index 210dab4e..22f9c28d 100644 --- a/src/_internal/analysis/evidence.rs +++ b/src/_internal/analysis/evidence.rs @@ -6,7 +6,7 @@ use serde::Serialize; /// value as the compatibility contract rather than matching display text. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] #[serde(rename_all = "snake_case")] -pub enum EvidenceCode { +pub(crate) enum EvidenceCode { BaselineUnavailable, BaselineStale, CatalogCoverageIncomplete, @@ -19,7 +19,7 @@ pub enum EvidenceCode { } impl EvidenceCode { - pub const fn as_str(self) -> &'static str { + pub(crate) const fn as_str(self) -> &'static str { match self { Self::BaselineUnavailable => "baseline_unavailable", Self::BaselineStale => "baseline_stale", @@ -33,7 +33,7 @@ impl EvidenceCode { } } - pub const fn summary(self) -> &'static str { + pub(crate) const fn summary(self) -> &'static str { match self { Self::BaselineUnavailable => "no synchronized baseline was available", Self::BaselineStale => "the synchronized baseline may be stale", @@ -55,21 +55,21 @@ impl EvidenceCode { /// Whether uncertainty affects only one transition or subsequent statements. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] #[serde(rename_all = "snake_case")] -pub enum EvidenceScope { +pub(crate) enum EvidenceScope { Statement, Chain, } /// Safe source context attached by the engine while it evaluates a statement. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] -pub struct EvidenceLocation { +pub(crate) struct EvidenceLocation { pub file: String, pub statement_index: usize, } /// One durable reason why the analyzer had to be conservative. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] -pub struct EvidenceRecord { +pub(crate) struct EvidenceRecord { pub code: EvidenceCode, pub scope: EvidenceScope, pub summary: &'static str, @@ -78,7 +78,7 @@ pub struct EvidenceRecord { } impl EvidenceRecord { - pub fn new(code: EvidenceCode, scope: EvidenceScope) -> Self { + pub(crate) fn new(code: EvidenceCode, scope: EvidenceScope) -> Self { Self { code, scope, @@ -87,7 +87,8 @@ impl EvidenceRecord { } } - pub fn at(mut self, location: EvidenceLocation) -> Self { + #[cfg(test)] + pub(crate) fn at(mut self, location: EvidenceLocation) -> Self { self.location = Some(location); self } @@ -95,21 +96,21 @@ impl EvidenceRecord { /// Ordered, deduplicated evidence carried by analysis state. #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct EvidenceLog { +pub(crate) struct EvidenceLog { records: Vec, } impl EvidenceLog { - pub fn records(&self) -> &[EvidenceRecord] { + pub(crate) fn records(&self) -> &[EvidenceRecord] { &self.records } - pub fn contains(&self, record: &EvidenceRecord) -> bool { + pub(crate) fn contains(&self, record: &EvidenceRecord) -> bool { self.records.contains(record) } /// Returns whether the record was newly inserted. - pub fn insert(&mut self, record: EvidenceRecord) -> bool { + pub(crate) fn insert(&mut self, record: EvidenceRecord) -> bool { if self.contains(&record) { return false; } diff --git a/src/_internal/analysis/expr_ir.rs b/src/_internal/analysis/expr_ir.rs index d40e5042..c6dccf95 100644 --- a/src/_internal/analysis/expr_ir.rs +++ b/src/_internal/analysis/expr_ir.rs @@ -1,7 +1,8 @@ use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum ExprIr { +pub(crate) enum ExprIr { Literal(String), ColumnRef(String), FunctionCall { @@ -19,14 +20,45 @@ pub enum ExprIr { }, Sentinel(String), Omitted, + UnaryOp { + op: String, + expr: Box, + }, } impl ExprIr { + /// Returns referenced unqualified columns when the expression conversion is + /// complete. Callers must retain conservative evidence for `None`. + pub(crate) fn referenced_columns(&self) -> Option> { + fn collect(expression: &ExprIr, columns: &mut BTreeSet) -> Option<()> { + match expression { + ExprIr::Literal(_) => Some(()), + ExprIr::ColumnRef(column) => { + columns.insert(column.clone()); + Some(()) + } + ExprIr::FunctionCall { args, .. } => args + .iter() + .try_for_each(|argument| collect(argument, columns)), + ExprIr::BinaryOp { left, right, .. } => { + collect(left, columns)?; + collect(right, columns) + } + ExprIr::Cast { expr, .. } | ExprIr::UnaryOp { expr, .. } => collect(expr, columns), + ExprIr::Sentinel(_) | ExprIr::Omitted => None, + } + } + + let mut columns = BTreeSet::new(); + collect(self, &mut columns)?; + Some(columns) + } + /// Returns whether conversion lost part of the source expression. The /// expression visitor uses these sentinel literals for syntax it cannot /// represent yet; callers that need dependency proof must not treat an /// empty column list from such an expression as a proven constant. - pub fn contains_opaque(&self) -> bool { + pub(crate) fn contains_opaque(&self) -> bool { const SENTINELS: &[&str] = &[ "", "", @@ -57,11 +89,14 @@ impl ExprIr { is_sentinel(op) || left.contains_opaque() || right.contains_opaque() } Self::Cast { expr, target_type } => is_sentinel(target_type) || expr.contains_opaque(), + Self::UnaryOp { op, expr } => { + !matches!(op.as_str(), "+" | "-" | "NOT") || expr.contains_opaque() + } Self::Omitted => true, } } - pub fn is_volatile(&self) -> bool { + pub(crate) fn is_volatile(&self) -> bool { match self { ExprIr::FunctionCall { name, args } => { const VOLATILE: &[&str] = &[ @@ -94,7 +129,7 @@ impl ExprIr { known_volatile || args.iter().any(ExprIr::is_volatile) } ExprIr::BinaryOp { left, right, .. } => left.is_volatile() || right.is_volatile(), - ExprIr::Cast { expr, .. } => expr.is_volatile(), + ExprIr::Cast { expr, .. } | ExprIr::UnaryOp { expr, .. } => expr.is_volatile(), ExprIr::Sentinel(_) | ExprIr::Literal(_) | ExprIr::ColumnRef(_) | ExprIr::Omitted => { false } diff --git a/src/_internal/analysis/expr_visitor.rs b/src/_internal/analysis/expr_visitor.rs index 60fe599c..3951e73d 100644 --- a/src/_internal/analysis/expr_visitor.rs +++ b/src/_internal/analysis/expr_visitor.rs @@ -1,20 +1,163 @@ use crate::_internal::analysis::expr_ir::ExprIr; use squawk_syntax::ast::{AstNode, Expr}; -pub struct ExprVisitor; +pub(crate) struct ExprVisitor; impl ExprVisitor { - pub fn convert(expr: Expr) -> ExprIr { + pub(crate) fn rename_partition_key_source( + source: &str, + table: &str, + from: &str, + to: &str, + ) -> Option { + use squawk_syntax::ast::{PartitionBy, SourceFile}; + let prefix = "CREATE TABLE __partition_key () "; + let parsed = SourceFile::parse(&format!("{prefix}{source}")); + if !parsed.errors().is_empty() || parsed.tree().stmts().count() != 1 { + return None; + } + let partition = parsed + .tree() + .syntax() + .descendants() + .find_map(PartitionBy::cast)?; + let mut replacements = Vec::new(); + for item in partition.partition_item_list()?.partition_items() { + let expr = item.expr()?; + let range = expr.syntax().text_range(); + let replacement = + Self::rename_column_source(&expr.syntax().text().to_string(), table, from, to)?; + replacements.push(( + usize::from(range.start()).checked_sub(prefix.len())?, + usize::from(range.end()).checked_sub(prefix.len())?, + replacement, + )); + } + let mut result = source.to_string(); + for (start, end, replacement) in replacements.into_iter().rev() { + result.replace_range(start..end, &replacement); + } + Some(result) + } + + pub(crate) fn rename_column_source( + source: &str, + table: &str, + from: &str, + to: &str, + ) -> Option { + use squawk_syntax::ast::{CallExpr, FieldExpr, NameRef, SourceFile}; + let prefix = "SELECT "; + let parsed = SourceFile::parse(&format!("{prefix}{source}")); + if !parsed.errors().is_empty() || parsed.tree().stmts().count() != 1 { + return None; + } + let mut ranges = Vec::new(); + // A statistics expression list is valid SELECT target syntax too, so + // scan every target rather than assuming a single expression. + for name in parsed + .tree() + .syntax() + .descendants() + .filter_map(NameRef::cast) + { + if name.text() != from { + continue; + } + // A qualified callee contains NameRefs too, but none refer to columns. + if name + .syntax() + .ancestors() + .filter_map(CallExpr::cast) + .any(|call| { + call.expr().is_some_and(|callee| { + callee + .syntax() + .text_range() + .contains_range(name.syntax().text_range()) + }) + }) + { + continue; + } + if let Some(parent) = name.syntax().parent() + && let Some(field) = FieldExpr::cast(parent) + { + let qualified_table = field.base().is_some_and( + |base| matches!(base, Expr::NameRef(base) if base.text() == table), + ); + let is_field = field + .field() + .is_some_and(|field| field.syntax() == name.syntax()); + if (is_field && !qualified_table) || (!is_field && qualified_table) { + continue; + } + } + let range = name.syntax().text_range(); + ranges.push(( + usize::from(range.start()).checked_sub(prefix.len())?, + usize::from(range.end()).checked_sub(prefix.len())?, + )); + } + // Preserve the normal PostgreSQL deparse for ordinary identifiers, but + // quote names whose spelling cannot safely be parsed unquoted. + let replacement = if Self::can_render_unquoted_identifier(to) { + to.to_string() + } else { + format!("\"{}\"", to.replace('"', "\"\"")) + }; + let mut result = source.to_string(); + ranges.sort_unstable(); + for (start, end) in ranges.into_iter().rev() { + result.replace_range(start..end, &replacement); + } + Some(result) + } + + fn can_render_unquoted_identifier(identifier: &str) -> bool { + use squawk_syntax::ast::{NameRef, SourceFile, Target}; + + let mut chars = identifier.chars(); + if !matches!(chars.next(), Some('a'..='z' | '_')) + || !chars.all(|character| matches!(character, 'a'..='z' | '0'..='9' | '_' | '$')) + { + return false; + } + let parsed = SourceFile::parse(&format!("SELECT {identifier}")); + parsed.errors().is_empty() + && parsed.tree().stmts().count() == 1 + && parsed + .tree() + .syntax() + .descendants() + .find_map(Target::cast) + .and_then(|target| target.expr()) + .and_then(|expr| NameRef::cast(expr.syntax().clone())) + .is_some_and(|name| name.text() == identifier && !name.is_quoted()) + } + + pub(crate) fn convert(expr: Expr) -> ExprIr { match expr { Expr::Literal(lit) => Self::convert_literal(lit), Expr::NameRef(nr) => Self::convert_name_ref(nr), Expr::CallExpr(ce) => Self::convert_call_expr(ce), Expr::BinExpr(be) => Self::convert_bin_expr(be), Expr::CastExpr(ce) => Self::convert_cast_expr(ce), - Expr::PrefixExpr(pe) => pe - .expr() - .map(Self::convert) - .unwrap_or(ExprIr::Sentinel("".into())), + Expr::PrefixExpr(pe) => { + use squawk_syntax::ast::PrefixOp; + let op = match pe.op() { + Some(PrefixOp::Minus(_)) => "-".into(), + Some(PrefixOp::Plus(_)) => "+".into(), + Some(PrefixOp::Not(_)) => "NOT".into(), + Some(PrefixOp::CustomOp(op)) => op.syntax().text().to_string(), + Some(PrefixOp::OperatorCall(op)) => op.syntax().text().to_string(), + None => return ExprIr::Sentinel("".into()), + }; + ExprIr::UnaryOp { + op, + expr: Box::new(pe.expr().map(Self::convert).unwrap_or(ExprIr::Omitted)), + } + } Expr::ParenExpr(pe) => pe .expr() .map(Self::convert) diff --git a/src/_internal/analysis/facts.rs b/src/_internal/analysis/facts.rs index c5d14b84..1412ca4f 100644 --- a/src/_internal/analysis/facts.rs +++ b/src/_internal/analysis/facts.rs @@ -2,14 +2,49 @@ use crate::_internal::analysis::expr_ir::ExprIr; use crate::_internal::ast::identifiers::{Ident, QualifiedName}; #[derive(Clone, Debug, PartialEq)] -pub enum PersistenceFact { +pub(crate) enum PersistenceFact { Permanent, Temporary, Unlogged, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum OnCommitFact { + PreserveRows, + DeleteRows, + Drop, +} + +/// Properties selected by `CREATE TABLE ... LIKE` after applying its ordered +/// INCLUDING/EXCLUDING clauses. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct LikePropertiesFact { + pub defaults: bool, + pub generated: bool, + pub storage: bool, + pub compression: bool, + pub statistics: bool, + pub constraints: bool, + pub indexes: bool, + pub identity: bool, +} + #[derive(Clone, Debug, PartialEq)] -pub enum PolicyCommand { +pub(crate) struct LikeSourceFact { + pub relation: QualifiedName, + pub properties: LikePropertiesFact, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum ReplicaIdentityFact { + Default, + Full, + Nothing, + UsingIndex(QualifiedName), +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum PolicyCommand { All, Select, Insert, @@ -18,19 +53,19 @@ pub enum PolicyCommand { } #[derive(Clone, Debug, PartialEq)] -pub enum SearchPathTarget { +pub(crate) enum SearchPathTarget { Default, Schemas(Vec), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TimeoutSetting { +pub(crate) enum TimeoutSetting { Lock, Statement, } #[derive(Clone, Debug, PartialEq, Eq)] -pub enum TimeoutSettingValue { +pub(crate) enum TimeoutSettingValue { Default, Milliseconds(u64), Current, @@ -38,7 +73,7 @@ pub enum TimeoutSettingValue { } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ResetSettingTarget { +pub(crate) enum ResetSettingTarget { All, SearchPath, LockTimeout, @@ -46,15 +81,27 @@ pub enum ResetSettingTarget { } #[derive(Clone, Debug, PartialEq)] -pub enum TypeCreationKind { - Enum { variants: Vec }, +pub(crate) enum TypeCreationKind { + Enum { + variants: Vec, + }, + #[expect(dead_code, reason = "reserved for typed Squawk range support")] Range, - Composite, + Composite { + fields: Vec, + }, + #[expect(dead_code, reason = "reserved for typed Squawk base-type support")] Base, } +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub(crate) struct CompositeFieldFact { + pub name: String, + pub data_type: String, +} + #[derive(Clone, Debug, PartialEq)] -pub enum AlterViewAction { +pub(crate) enum AlterViewAction { RenameTo { new_name: Ident, }, @@ -64,10 +111,12 @@ pub enum AlterViewAction { SetSchema { new_schema: String, }, + #[expect(dead_code, reason = "reserved for typed Squawk view-default support")] SetDefault { column: String, default: Option, }, + #[expect(dead_code, reason = "reserved for typed Squawk view-default support")] DropDefault { column: String, }, @@ -75,22 +124,24 @@ pub enum AlterViewAction { from: Ident, to: Ident, }, + #[expect(dead_code, reason = "reserved for typed Squawk view-option support")] SetOptions { options: Vec, }, + #[expect(dead_code, reason = "reserved for typed Squawk view-option support")] ResetOptions { options: Vec, }, } #[derive(Clone, Debug, PartialEq)] -pub enum AlterSchemaActionFact { +pub(crate) enum AlterSchemaActionFact { RenameTo { new_name: Ident }, OwnerTo { new_owner: RoleFact }, } #[derive(Clone, Debug, PartialEq)] -pub enum StatementFact { +pub(crate) enum StatementFact { CreateSchema { name: QualifiedName, if_not_exists: bool, @@ -110,12 +161,20 @@ pub enum StatementFact { if_not_exists: bool, as_select: bool, persistence: PersistenceFact, + on_commit: Option, columns: Vec, foreign_keys: Vec, table_constraints: Vec, partition_by: Option, + partition_strategy: Option, partition_of: Option, - partition_type: Option, + partition_bound: Option, + inherits: Vec, + like_sources: Vec, + of_type: Option, + select_source: Option, + select_outputs: Vec, + select_projection_complete: bool, }, CreateView { name: QualifiedName, @@ -170,6 +229,7 @@ pub enum StatementFact { name: String, table: QualifiedName, function: Option, + row_level: bool, }, DropTrigger { name: String, @@ -183,6 +243,7 @@ pub enum StatementFact { }, AlterTable { name: QualifiedName, + only: bool, actions: Vec, }, AlterIndex { @@ -213,6 +274,8 @@ pub enum StatementFact { name: QualifiedName, if_not_exists: bool, owned_by: Option<(QualifiedName, String)>, + persistence: PersistenceFact, + options: IdentitySequenceOptionsFact, }, AlterSequence { name: QualifiedName, @@ -245,6 +308,16 @@ pub enum StatementFact { concurrently: bool, cascade: bool, }, + Lock { + targets: Vec, + mode: LockModeFact, + nowait: bool, + }, + Truncate { + targets: Vec, + cascade: bool, + restart_identity: bool, + }, SetSearchPath { target: SearchPathTarget, local: bool, @@ -321,8 +394,44 @@ pub enum StatementFact { }, } +/// A relation target whose `ONLY` modifier materially changes inheritance and +/// partition behavior. #[derive(Clone, Debug, PartialEq)] -pub enum AlterSequenceActionFact { +pub(crate) struct RelationTargetFact { + pub name: QualifiedName, + pub only: bool, +} + +/// Lifecycle form used by `ALTER TABLE ... DETACH PARTITION`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DetachPartitionMode { + Immediate, + Concurrently, + Finalize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum LockModeFact { + AccessShare, + RowShare, + RowExclusive, + ShareUpdateExclusive, + Share, + ShareRowExclusive, + Exclusive, + AccessExclusive, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RuleEnableModeFact { + Origin, + Disabled, + Replica, + Always, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum AlterSequenceActionFact { OwnedBy(Option<(QualifiedName, String)>), OwnerTo(RoleFact), RenameTo(Ident), @@ -331,24 +440,24 @@ pub enum AlterSequenceActionFact { } #[derive(Clone, Debug, PartialEq)] -pub enum AlterIndexActionFact { +pub(crate) enum AlterIndexActionFact { RenameTo { new_name: Ident }, } #[derive(Clone, Debug, PartialEq)] -pub struct CreateTypeFact { +pub(crate) struct CreateTypeFact { pub name: QualifiedName, pub kind: TypeCreationKind, } #[derive(Clone, Debug, PartialEq)] -pub struct AlterTypeFact { +pub(crate) struct AlterTypeFact { pub name: QualifiedName, pub actions: Vec, } #[derive(Clone, Debug, PartialEq)] -pub enum AlterTypeActionFact { +pub(crate) enum AlterTypeActionFact { RenameTo { new_name: Ident, }, @@ -367,7 +476,7 @@ pub enum AlterTypeActionFact { } #[derive(Clone, Debug, PartialEq, Default)] -pub enum RoleFact { +pub(crate) enum RoleFact { #[default] Unknown, Named { @@ -380,26 +489,26 @@ pub enum RoleFact { } #[derive(Clone, Debug, PartialEq)] -pub struct CreateRoleFact { +pub(crate) struct CreateRoleFact { pub name: String, pub inherits: bool, pub can_login: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct AlterRoleFact { +pub(crate) struct AlterRoleFact { pub name: RoleFact, pub inherits: Option, } #[derive(Clone, Debug, PartialEq)] -pub struct DropRoleFact { +pub(crate) struct DropRoleFact { pub names: Vec, pub if_exists: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct CreateFunctionFact { +pub(crate) struct CreateFunctionFact { pub name: QualifiedName, pub or_replace: bool, pub params: Vec, @@ -408,7 +517,7 @@ pub struct CreateFunctionFact { } #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct ParamFact { +pub(crate) struct ParamFact { pub mode: ParamModeFact, pub name: Option, pub ty: String, @@ -416,7 +525,7 @@ pub struct ParamFact { } #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum ParamModeFact { +pub(crate) enum ParamModeFact { In, Out, InOut, @@ -424,13 +533,13 @@ pub enum ParamModeFact { } #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum RetTypeFact { +pub(crate) enum RetTypeFact { Table(Vec), Scalar(String), } #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum FuncOptionFact { +pub(crate) enum FuncOptionFact { Language(String), Volatility(VolatilityKind), Security(SecurityKind), @@ -452,32 +561,32 @@ pub enum FuncOptionFact { } #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum VolatilityKind { +pub(crate) enum VolatilityKind { Immutable, Stable, Volatile, } #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum SecurityKind { +pub(crate) enum SecurityKind { Invoker, Definer, } #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum StrictKind { +pub(crate) enum StrictKind { Strict, CalledOnNull, ReturnsNullOnNull, } #[derive(Clone, Debug, PartialEq)] -pub struct AlterFunctionFact { +pub(crate) struct AlterFunctionFact { pub name: QualifiedName, pub params: Vec, pub action: AlterFunctionAction, } #[derive(Clone, Debug, PartialEq)] -pub enum AlterFunctionAction { +pub(crate) enum AlterFunctionAction { Rename { from: String, to: String }, OwnerChange(RoleFact), SchemaChange { new_schema: String }, @@ -487,20 +596,20 @@ pub enum AlterFunctionAction { } #[derive(Clone, Debug, PartialEq)] -pub struct DropFunctionFact { +pub(crate) struct DropFunctionFact { pub signatures: Vec, pub if_exists: bool, pub cascade: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct FunctionSigFact { +pub(crate) struct FunctionSigFact { pub name: QualifiedName, pub params: Vec, } #[derive(Clone, Debug, PartialEq)] -pub struct CreateProcedureFact { +pub(crate) struct CreateProcedureFact { pub name: QualifiedName, pub or_replace: bool, pub params: Vec, @@ -508,55 +617,55 @@ pub struct CreateProcedureFact { } #[derive(Clone, Debug, PartialEq)] -pub struct AlterProcedureFact { +pub(crate) struct AlterProcedureFact { pub name: QualifiedName, pub params: Vec, pub action: AlterFunctionAction, } #[derive(Clone, Debug, PartialEq)] -pub struct DropProcedureFact { +pub(crate) struct DropProcedureFact { pub signatures: Vec, pub if_exists: bool, pub cascade: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct CreateAggregateFact { +pub(crate) struct CreateAggregateFact { pub name: QualifiedName, pub or_replace: bool, pub params: Vec, } #[derive(Clone, Debug, PartialEq)] -pub struct AlterAggregateFact { +pub(crate) struct AlterAggregateFact { pub name: QualifiedName, pub params: Vec, pub action: AlterFunctionAction, } #[derive(Clone, Debug, PartialEq)] -pub struct DropAggregateFact { +pub(crate) struct DropAggregateFact { pub signatures: Vec, pub if_exists: bool, pub cascade: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct CreatePublicationFact { +pub(crate) struct CreatePublicationFact { pub name: String, pub scope: PublicationScope, pub params: Vec, } #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum PublicationScope { +pub(crate) enum PublicationScope { AllTables { except: Vec }, Explicit(Vec), } #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum PublicationObjectFact { +pub(crate) enum PublicationObjectFact { Table { name: QualifiedName, only: bool, @@ -573,19 +682,19 @@ pub enum PublicationObjectFact { } #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum PublicationRowFilter { +pub(crate) enum PublicationRowFilter { Parsed(ExprIr), CatalogSql(String), } #[derive(Clone, Debug, PartialEq)] -pub struct AlterPublicationFact { +pub(crate) struct AlterPublicationFact { pub name: String, pub action: AlterPublicationActionFact, } #[derive(Clone, Debug, PartialEq)] -pub enum AlterPublicationActionFact { +pub(crate) enum AlterPublicationActionFact { AddObjects(Vec), SetObjects(PublicationScope), DropObjects(Vec), @@ -595,14 +704,14 @@ pub enum AlterPublicationActionFact { } #[derive(Clone, Debug, PartialEq)] -pub struct DropPublicationFact { +pub(crate) struct DropPublicationFact { pub names: Vec, pub if_exists: bool, pub cascade: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct CreateSubscriptionFact { +pub(crate) struct CreateSubscriptionFact { pub name: Option, pub connection: ConnectionTarget, pub publications: Vec, @@ -610,7 +719,7 @@ pub struct CreateSubscriptionFact { } #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum ConnectionTarget { +pub(crate) enum ConnectionTarget { Literal(Option), Server(Option), /// A synchronized subscription exists, but its connection string is never @@ -619,20 +728,20 @@ pub enum ConnectionTarget { } #[derive(Clone, Debug, PartialEq)] -pub struct AlterSubscriptionFact { +pub(crate) struct AlterSubscriptionFact { pub name: String, pub action: AlterSubscriptionActionFact, } #[derive(Clone, Debug, PartialEq)] -pub enum SubscriptionPublicationMode { +pub(crate) enum SubscriptionPublicationMode { Set, Add, Drop, } #[derive(Clone, Debug, PartialEq)] -pub enum AlterSubscriptionActionFact { +pub(crate) enum AlterSubscriptionActionFact { SetConnection(ConnectionTarget), Publications { mode: SubscriptionPublicationMode, @@ -651,13 +760,13 @@ pub enum AlterSubscriptionActionFact { } #[derive(Clone, Debug, PartialEq)] -pub struct DropSubscriptionFact { +pub(crate) struct DropSubscriptionFact { pub name: String, pub if_exists: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct GrantFact { +pub(crate) struct GrantFact { pub privileges: PrivilegeSpec, pub target: GrantTarget, pub grantees: Vec, @@ -667,7 +776,7 @@ pub struct GrantFact { } #[derive(Clone, Debug, PartialEq)] -pub struct RevokeFact { +pub(crate) struct RevokeFact { pub grant_option_only: bool, pub role_option: Option, pub privileges: PrivilegeSpec, @@ -681,20 +790,20 @@ pub struct RevokeFact { /// name and value are kept typed at extraction time so state transitions never /// need to infer semantics from raw SQL text. #[derive(Clone, Copy, Debug, PartialEq)] -pub enum RoleMembershipOptionFact { +pub(crate) enum RoleMembershipOptionFact { Admin(bool), Inherit(bool), Set(bool), } #[derive(Clone, Debug, PartialEq)] -pub enum PrivilegeSpec { +pub(crate) enum PrivilegeSpec { All, List(Vec), } #[derive(Clone, Debug, PartialEq)] -pub enum PrivilegeFact { +pub(crate) enum PrivilegeFact { Select, Insert, Update, @@ -714,20 +823,20 @@ pub enum PrivilegeFact { } #[derive(Clone, Debug, PartialEq)] -pub enum GrantTarget { +pub(crate) enum GrantTarget { Tables(Vec), AllTablesInSchema(Vec), Roles(Vec), } #[derive(Clone, Debug, PartialEq)] -pub struct CreateDatabaseFact { +pub(crate) struct CreateDatabaseFact { pub name: String, pub options: Vec, } #[derive(Clone, Debug, PartialEq)] -pub enum DatabaseOptionFact { +pub(crate) enum DatabaseOptionFact { Owner(DatabaseOptionValue), Template(DatabaseOptionValue), Encoding(DatabaseOptionValue), @@ -738,19 +847,19 @@ pub enum DatabaseOptionFact { } #[derive(Clone, Debug, PartialEq)] -pub enum DatabaseOptionValue { +pub(crate) enum DatabaseOptionValue { Default, Literal(Option), } #[derive(Clone, Debug, PartialEq)] -pub struct AlterDatabaseFact { +pub(crate) struct AlterDatabaseFact { pub name: QualifiedName, pub action: AlterDatabaseAction, } #[derive(Clone, Debug, PartialEq)] -pub enum AlterDatabaseAction { +pub(crate) enum AlterDatabaseAction { Rename { to: String }, OwnerChange(RoleFact), TablespaceChange { new_tablespace: String }, @@ -761,26 +870,42 @@ pub enum AlterDatabaseAction { } #[derive(Clone, Debug, PartialEq)] -pub struct DropDatabaseFact { +pub(crate) struct DropDatabaseFact { pub name: QualifiedName, pub if_exists: bool, } #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct AttributeFact { +pub(crate) struct AttributeFact { pub name: String, pub value: String, } #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum ColumnGeneration { +pub(crate) enum ColumnGeneration { Ordinary, Serial, - Identity, + IdentityAlways, + IdentityByDefault, + GeneratedStored, + GeneratedVirtual, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub(crate) struct IdentitySequenceOptionsFact { + pub data_type: Option, + pub start_value: Option, + pub increment: Option, + pub min_value: Option>, + pub max_value: Option>, + pub cache_size: Option, + pub cycle: Option, + pub persistence: Option, + pub sequence_name: Option, } #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct ColumnFact { +pub(crate) struct ColumnFact { pub name: String, pub ty: Option, pub not_null: bool, @@ -790,10 +915,23 @@ pub struct ColumnFact { pub unique_constraint_name: Option, pub default: Option, pub generation: ColumnGeneration, + pub identity_sequence: Option, + pub generated_expr: Option, + #[serde(default)] + pub generated_expr_sql: Option, } #[derive(Clone, Debug, PartialEq)] -pub struct FkFact { +pub(crate) enum SelectOutputFact { + AllColumns, + Column { + source_name: String, + output_name: String, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct FkFact { pub constraint_name: Option, pub references: QualifiedName, pub from_columns: Vec, @@ -801,7 +939,7 @@ pub struct FkFact { } #[derive(Clone, Debug, PartialEq)] -pub enum TableConstraintFact { +pub(crate) enum TableConstraintFact { PrimaryKey { constraint_name: Option, columns: Vec, @@ -812,6 +950,8 @@ pub enum TableConstraintFact { }, Check { constraint_name: Option, + name_hint: Option, + definition: String, columns: Vec, columns_complete: bool, }, @@ -823,7 +963,7 @@ pub enum TableConstraintFact { } #[derive(Clone, Debug, PartialEq)] -pub enum AlterDomainActionFact { +pub(crate) enum AlterDomainActionFact { AddConstraint, DropConstraint, DropDefault, @@ -838,7 +978,7 @@ pub enum AlterDomainActionFact { } #[derive(Clone, Debug, PartialEq)] -pub enum AlterTableActionFact { +pub(crate) enum AlterTableActionFact { AddColumn { name: String, ty: Option, @@ -846,6 +986,9 @@ pub enum AlterTableActionFact { not_null: bool, default: Option, generation: ColumnGeneration, + identity_sequence: Option>, + generated_expr: Option, + generated_expr_sql: Option, }, DropColumn { name: String, @@ -881,6 +1024,7 @@ pub enum AlterTableActionFact { }, AddCheckConstraint { constraint_name: Option, + definition: String, columns: Vec, columns_complete: bool, not_valid: bool, @@ -918,11 +1062,22 @@ pub enum AlterTableActionFact { SetExpression { column: String, expr: ExprIr, + expression_sql: String, }, SetOptions { column: String, attributes: Vec, }, + ResetOptions { + column: String, + names: Vec, + }, + SetTableOptions { + attributes: Vec, + }, + ResetTableOptions { + names: Vec, + }, Inherit { column: String, parent: QualifiedName, @@ -943,23 +1098,45 @@ pub enum AlterTableActionFact { AttachPartition { child: QualifiedName, strategy: Option, + bound: Option, }, DetachPartition { child: QualifiedName, + mode: DetachPartitionMode, }, SetStorage { column: String, + mode: String, + }, + SetCompression { + column: String, + method: Option, + }, + SetStatistics { + column: String, + target: Option, + }, + DropExpression { + column: String, + if_exists: bool, + }, + SetAccessMethod { + access_method: Option, }, - SetAccessMethod, ClusterOn { - index: String, + index: QualifiedName, }, + SetWithoutCluster, InheritTable { parent: QualifiedName, }, NoInheritTable { parent: QualifiedName, }, + OfType { + type_name: QualifiedName, + }, + NotOf, MergePartitions { parent: QualifiedName, }, @@ -976,9 +1153,10 @@ pub enum AlterTableActionFact { new_owner: RoleFact, }, ReplicaIdentity { - option: String, + option: ReplicaIdentityFact, }, ForceRls, + NoForceRls, EnableRls, DisableRls, EnableAlwaysTrigger { @@ -987,4 +1165,8 @@ pub enum AlterTableActionFact { EnableReplicaTrigger { trigger_name: Option, }, + SetRuleMode { + rule_name: Option, + mode: RuleEnableModeFact, + }, } diff --git a/src/_internal/analysis/graph.rs b/src/_internal/analysis/graph.rs index f25a2b72..4af3930f 100644 --- a/src/_internal/analysis/graph.rs +++ b/src/_internal/analysis/graph.rs @@ -3,7 +3,7 @@ use std::cell::OnceCell; use std::collections::{HashMap, HashSet}; #[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum DependencyKind { +pub(crate) enum DependencyKind { ForeignKey { constraint_name: Option, from_columns: Vec, @@ -31,6 +31,7 @@ pub enum DependencyKind { has_predicate: bool, is_concurrent: bool, is_unique: bool, + is_immediate: bool, is_valid: bool, is_ready: bool, is_live: bool, @@ -60,6 +61,9 @@ pub enum DependencyKind { /// ordinary table inheritance. InheritanceOf, PartitionOf, + /// A `DETACH PARTITION CONCURRENTLY` interrupted after its first internal + /// transaction remains attached until `FINALIZE` completes it. + PartitionDetachPending, SequenceOwnedBy { column: String, }, @@ -81,21 +85,21 @@ pub enum DependencyKind { } #[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct ForeignKeyOperatorEvidence { +pub(crate) struct ForeignKeyOperatorEvidence { pub pk_fk: Vec, pub pk_pk: Vec, pub fk_fk: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct DependencyEdge { +pub(crate) struct DependencyEdge { pub dependent: ObjectId, pub referenced: ObjectId, pub kind: DependencyKind, } impl DependencyEdge { - pub fn new(dependent: ObjectId, referenced: ObjectId, kind: DependencyKind) -> Self { + pub(crate) fn new(dependent: ObjectId, referenced: ObjectId, kind: DependencyKind) -> Self { Self { dependent, referenced, @@ -105,7 +109,7 @@ impl DependencyEdge { } #[derive(Debug, Default)] -pub struct DependencyGraph { +pub(crate) struct DependencyGraph { edges: Vec, edge_set: HashSet, indexes: OnceCell, @@ -132,15 +136,15 @@ impl Clone for DependencyGraph { impl DependencyGraph { const CASCADE_INDEX_MIN_EDGES: usize = 1_024; - pub fn new() -> Self { + pub(crate) fn new() -> Self { Self::default() } - pub fn edges(&self) -> &[DependencyEdge] { + pub(crate) fn edges(&self) -> &[DependencyEdge] { &self.edges } - pub fn add_edge(&mut self, edge: DependencyEdge) { + pub(crate) fn add_edge(&mut self, edge: DependencyEdge) { // The dependency graph is a set of typed relationships. Replaying a // hydration row or an idempotent mutation must not multiply cascade // work or make traversal results depend on insertion history. @@ -201,7 +205,7 @@ impl DependencyGraph { /// Confirms that every derived lookup points at the canonical edge list. /// This is intentionally cheap to call from invariant tests, not hot paths. - pub fn indexes_are_valid(&self) -> bool { + pub(crate) fn indexes_are_valid(&self) -> bool { self.indexes() == &Self::build_indexes(&self.edges) } @@ -264,7 +268,7 @@ impl DependencyGraph { .map(|index| &self.edges[*index]) } - pub fn cascade_edges(&self, id: &ObjectId) -> Vec<&DependencyEdge> { + pub(crate) fn cascade_edges(&self, id: &ObjectId) -> Vec<&DependencyEdge> { if self.edges.len() < Self::CASCADE_INDEX_MIN_EDGES { let target = self.resolve_rename(id); return self @@ -278,6 +282,7 @@ impl DependencyGraph { | DependencyKind::ForeignKey { .. } | DependencyKind::InheritanceOf | DependencyKind::PartitionOf + | DependencyKind::PartitionDetachPending ) && self.resolve_rename(&edge.referenced) == target }) .collect(); @@ -291,6 +296,7 @@ impl DependencyGraph { | DependencyKind::ForeignKey { .. } | DependencyKind::InheritanceOf | DependencyKind::PartitionOf + | DependencyKind::PartitionDetachPending ) }) .collect() @@ -301,7 +307,8 @@ impl DependencyGraph { } // Dependency lookups follow the current end of a rename chain. - pub fn is_referenced_by_view(&self, id: &ObjectId) -> Vec<&ObjectId> { + #[cfg(test)] + pub(crate) fn is_referenced_by_view(&self, id: &ObjectId) -> Vec<&ObjectId> { if self.edges.len() >= Self::CASCADE_INDEX_MIN_EDGES { return self .resolved_referenced_edges(id) @@ -320,7 +327,8 @@ impl DependencyGraph { .collect() } - pub fn is_referenced_by_fk(&self, id: &ObjectId) -> Vec<(&ObjectId, u64)> { + #[cfg(test)] + pub(crate) fn is_referenced_by_fk(&self, id: &ObjectId) -> Vec<(&ObjectId, u64)> { if self.edges.len() >= Self::CASCADE_INDEX_MIN_EDGES { return self .resolved_referenced_edges(id) @@ -353,7 +361,8 @@ impl DependencyGraph { .collect() } - pub fn is_referenced_by_index(&self, id: &ObjectId) -> Vec<&ObjectId> { + #[cfg(test)] + pub(crate) fn is_referenced_by_index(&self, id: &ObjectId) -> Vec<&ObjectId> { if self.edges.len() >= Self::CASCADE_INDEX_MIN_EDGES { return self .resolved_referenced_edges(id) @@ -372,11 +381,17 @@ impl DependencyGraph { .collect() } - pub fn partitions_of(&self, id: &ObjectId) -> Vec<&ObjectId> { + #[cfg(test)] + pub(crate) fn partitions_of(&self, id: &ObjectId) -> Vec<&ObjectId> { if self.edges.len() >= Self::CASCADE_INDEX_MIN_EDGES { return self .resolved_referenced_edges(id) - .filter(|edge| matches!(edge.kind, DependencyKind::PartitionOf)) + .filter(|edge| { + matches!( + edge.kind, + DependencyKind::PartitionOf | DependencyKind::PartitionDetachPending + ) + }) .map(|edge| self.resolve_rename(&edge.dependent)) .collect(); } @@ -384,14 +399,16 @@ impl DependencyGraph { self.edges .iter() .filter(|e| { - matches!(e.kind, DependencyKind::PartitionOf) - && (self.resolve_rename(&e.referenced) == target || &e.referenced == id) + matches!( + e.kind, + DependencyKind::PartitionOf | DependencyKind::PartitionDetachPending + ) && (self.resolve_rename(&e.referenced) == target || &e.referenced == id) }) .map(|e| self.resolve_rename(&e.dependent)) .collect() } - pub fn resolve_rename<'a>(&'a self, id: &'a ObjectId) -> &'a ObjectId { + pub(crate) fn resolve_rename<'a>(&'a self, id: &'a ObjectId) -> &'a ObjectId { // A rename back to an earlier name is valid PostgreSQL. The indexed // resolver retains the same cycle fallback while avoiding a full edge // scan for every dependency lookup on large baselines. @@ -399,7 +416,7 @@ impl DependencyGraph { } // Partition ancestry must remain acyclic. - pub fn check_partition_cycle(&self, parent: &ObjectId, child: &ObjectId) -> bool { + pub(crate) fn check_partition_cycle(&self, parent: &ObjectId, child: &ObjectId) -> bool { let resolved_parent = self.resolve_rename(parent); let resolved_child = self.resolve_rename(child); if resolved_parent == resolved_child { @@ -415,8 +432,10 @@ impl DependencyGraph { return true; } let maybe_edge = self.edges.iter().find(|edge| { - matches!(edge.kind, DependencyKind::PartitionOf) - && self.resolve_rename(&edge.dependent) == current_parent + matches!( + edge.kind, + DependencyKind::PartitionOf | DependencyKind::PartitionDetachPending + ) && self.resolve_rename(&edge.dependent) == current_parent }); if let Some(edge) = maybe_edge { let p = self.resolve_rename(&edge.referenced); @@ -431,6 +450,36 @@ impl DependencyGraph { false } + /// Traditional inheritance has the same acyclicity requirement as a + /// partition tree, but must not treat a partition-only edge as ordinary + /// inheritance when callers validate `ALTER TABLE .. INHERIT`. + pub(crate) fn check_inheritance_cycle(&self, parent: &ObjectId, child: &ObjectId) -> bool { + let resolved_parent = self.resolve_rename(parent); + let resolved_child = self.resolve_rename(child); + if resolved_parent == resolved_child { + return true; + } + + let mut pending = vec![resolved_parent]; + let mut visited = HashSet::new(); + while let Some(current) = pending.pop() { + if !visited.insert(current.clone()) { + continue; + } + for edge in self.edges.iter().filter(|edge| { + matches!(edge.kind, DependencyKind::InheritanceOf) + && self.resolve_rename(&edge.dependent) == current + }) { + let ancestor = self.resolve_rename(&edge.referenced); + if ancestor == resolved_child { + return true; + } + pending.push(ancestor); + } + } + false + } + /// Remap every schema-qualified graph endpoint during `ALTER SCHEMA ... /// RENAME`. Synthetic publication nodes remain cluster-scoped, while /// trigger payload identities follow their table/function endpoints. @@ -470,7 +519,7 @@ impl DependencyGraph { /// and publications are represented by a synthetic `public/` ID, so /// a generic endpoint rewrite can otherwise corrupt an unrelated edge when /// two namespaces happen to share a name. - pub fn propagate_relation_rename(&mut self, old_id: &ObjectId, new_id: &ObjectId) { + pub(crate) fn propagate_relation_rename(&mut self, old_id: &ObjectId, new_id: &ObjectId) { for edge in &mut self.edges { match &mut edge.kind { DependencyKind::RenameTo => {} @@ -478,6 +527,7 @@ impl DependencyGraph { | DependencyKind::ViewDependency { .. } | DependencyKind::InheritanceOf | DependencyKind::PartitionOf + | DependencyKind::PartitionDetachPending | DependencyKind::ConstraintDependency { .. } | DependencyKind::ColumnGeneratedFrom { .. } => { if edge.dependent == *old_id { @@ -520,7 +570,7 @@ impl DependencyGraph { /// Propagate an index rename. Indexes are dependent endpoints of their /// `IndexOnRelation` edges; they are not relation references. - pub fn propagate_index_rename(&mut self, old_id: &ObjectId, new_id: &ObjectId) { + pub(crate) fn propagate_index_rename(&mut self, old_id: &ObjectId, new_id: &ObjectId) { for edge in &mut self.edges { if matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) && edge.dependent == *old_id @@ -535,7 +585,7 @@ impl DependencyGraph { /// Propagate a sequence rename through every typed sequence endpoint. /// Ownership uses the sequence as a dependent; a column default uses it /// as a referenced object. Both must follow the canonical identity. - pub fn propagate_sequence_rename(&mut self, old_id: &ObjectId, new_id: &ObjectId) { + pub(crate) fn propagate_sequence_rename(&mut self, old_id: &ObjectId, new_id: &ObjectId) { for edge in &mut self.edges { match &edge.kind { DependencyKind::SequenceOwnedBy { .. } if edge.dependent == *old_id => { @@ -552,7 +602,7 @@ impl DependencyGraph { } /// Propagate a trigger rename through its trigger edge and payload. - pub fn propagate_trigger_rename(&mut self, old_id: &ObjectId, new_id: &ObjectId) { + pub(crate) fn propagate_trigger_rename(&mut self, old_id: &ObjectId, new_id: &ObjectId) { for edge in &mut self.edges { if let DependencyKind::TriggerOnTable { trigger_id, .. } = &mut edge.kind && *trigger_id == *old_id @@ -568,7 +618,7 @@ impl DependencyGraph { } /// Propagate a function rename through trigger dependency payloads. - pub fn propagate_function_rename(&mut self, old_id: &ObjectId, new_id: &ObjectId) { + pub(crate) fn propagate_function_rename(&mut self, old_id: &ObjectId, new_id: &ObjectId) { for edge in &mut self.edges { if let DependencyKind::TriggerOnTable { function_id, .. } = &mut edge.kind && *function_id == *old_id @@ -581,7 +631,7 @@ impl DependencyGraph { } /// Rename a foreign-key constraint payload owned by a relation. - pub fn rename_foreign_key_constraint( + pub(crate) fn rename_constraint( &mut self, table_id: &ObjectId, old_name: &str, @@ -589,13 +639,25 @@ impl DependencyGraph { ) { self.mutate_edges(|edges| { for edge in edges { - if edge.dependent == *table_id - && let DependencyKind::ForeignKey { + if edge.dependent != *table_id { + continue; + } + let name = match &mut edge.kind { + DependencyKind::ForeignKey { constraint_name: Some(name), .. - } = &mut edge.kind - && name == old_name - { + } + | DependencyKind::ConstraintOnRelation { + constraint_name: name, + .. + } + | DependencyKind::ConstraintDependency { + constraint_name: name, + .. + } => name, + _ => continue, + }; + if name == old_name { *name = new_name.to_string(); } } @@ -605,7 +667,7 @@ impl DependencyGraph { /// Rename a table column in all typed dependency payloads that can carry /// column identity. The endpoint direction determines whether the column /// is source-side or referenced-side for foreign keys. - pub fn rename_column_dependencies( + pub(crate) fn rename_column_dependencies( &mut self, table_id: &ObjectId, old_name: &str, @@ -675,7 +737,12 @@ impl DependencyGraph { } /// Rename a column in an index definition attached to a relation. - pub fn rename_index_column(&mut self, table_id: &ObjectId, old_name: &str, new_name: &str) { + pub(crate) fn rename_index_column( + &mut self, + table_id: &ObjectId, + old_name: &str, + new_name: &str, + ) { self.mutate_edges(|edges| { for edge in edges { if edge.referenced != *table_id { @@ -704,7 +771,7 @@ impl DependencyGraph { } /// Rename the owned column recorded on a sequence edge. - pub fn rename_owned_sequence_column( + pub(crate) fn rename_owned_sequence_column( &mut self, sequence_id: &ObjectId, old_name: &str, @@ -723,7 +790,7 @@ impl DependencyGraph { } /// Rename a publication node and its membership payloads. - pub fn rename_publication(&mut self, old_name: &str, new_name: &str) { + pub(crate) fn rename_publication(&mut self, old_name: &str, new_name: &str) { self.mutate_edges(|edges| { for edge in edges { if let DependencyKind::PublicationIncludes { publication_name } = &mut edge.kind @@ -741,20 +808,12 @@ impl DependencyGraph { /// New callers should use the typed helpers above. Keeping this method /// relation-scoped prevents the old all-endpoints behavior from silently /// rewriting sequence, trigger, function, or publication identity data. - pub fn propagate_rename(&mut self, old_id: &ObjectId, new_id: &ObjectId) { + #[cfg(test)] + pub(crate) fn propagate_rename(&mut self, old_id: &ObjectId, new_id: &ObjectId) { self.propagate_relation_rename(old_id, new_id); } - pub fn triggers_on(&self, table_id: &ObjectId) -> Vec<&DependencyEdge> { - self.edges - .iter() - .filter(|e| { - matches!(e.kind, DependencyKind::TriggerOnTable { .. }) && &e.referenced == table_id - }) - .collect() - } - - pub fn triggers_for_function(&self, function_id: &ObjectId) -> Vec<&DependencyEdge> { + pub(crate) fn triggers_for_function(&self, function_id: &ObjectId) -> Vec<&DependencyEdge> { self.edges .iter() .filter(|e| { @@ -1015,6 +1074,7 @@ mod tests { has_predicate: false, is_concurrent: false, is_unique: false, + is_immediate: true, is_valid: true, is_ready: true, is_live: true, @@ -1081,6 +1141,26 @@ mod tests { assert!(graph.indexes_are_valid()); } + #[test] + fn inheritance_cycle_checks_every_parent_and_accepts_diamonds() { + let mut graph = DependencyGraph::new(); + for (child, parent) in [ + ("leaf", "left"), + ("leaf", "right"), + ("left", "root"), + ("right", "root"), + ] { + graph.add_edge(DependencyEdge::new( + id(child), + id(parent), + DependencyKind::InheritanceOf, + )); + } + assert!(graph.check_inheritance_cycle(&id("leaf"), &id("right"))); + assert!(graph.check_inheritance_cycle(&id("leaf"), &id("root"))); + assert!(!graph.check_inheritance_cycle(&id("leaf"), &id("new_child"))); + } + #[test] fn traditional_inheritance_cascades_without_becoming_a_partition() { let parent = id("parent"); diff --git a/src/_internal/analysis/mod.rs b/src/_internal/analysis/mod.rs index 90a05630..0d4b0ceb 100644 --- a/src/_internal/analysis/mod.rs +++ b/src/_internal/analysis/mod.rs @@ -1,11 +1,11 @@ -pub mod evidence; -pub mod expr_ir; +pub(crate) mod evidence; +pub(crate) mod expr_ir; pub(crate) mod expr_visitor; -pub mod facts; -pub mod graph; -pub mod mutations; -pub mod outcome; +pub(crate) mod facts; +pub(crate) mod graph; +pub(crate) mod mutations; +pub(crate) mod outcome; pub(crate) mod resolver; -pub mod settings; -pub mod state; -pub mod transaction; +pub(crate) mod settings; +pub(crate) mod state; +pub(crate) mod transaction; diff --git a/src/_internal/analysis/mutations.rs b/src/_internal/analysis/mutations.rs index 2fe99f59..5d4b66eb 100644 --- a/src/_internal/analysis/mutations.rs +++ b/src/_internal/analysis/mutations.rs @@ -1,19 +1,35 @@ use crate::_internal::analysis::expr_ir::ExprIr; use crate::_internal::analysis::facts::{ - ResetSettingTarget, SearchPathTarget, TableConstraintFact, TimeoutSetting, TimeoutSettingValue, + LockModeFact, ResetSettingTarget, SearchPathTarget, TableConstraintFact, TimeoutSetting, + TimeoutSettingValue, }; use crate::_internal::ast::identifiers::ObjectId; use crate::_internal::model::types::TypeKind; #[derive(Clone, Debug, PartialEq)] -pub enum PersistenceMutation { +pub(crate) enum PersistenceMutation { Permanent, Temporary, Unlogged, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum OnCommitMutation { + PreserveRows, + DeleteRows, + Drop, +} + #[derive(Clone, Debug, PartialEq)] -pub enum Mutation { +pub(crate) enum ReplicaIdentityMutation { + Default, + Full, + Nothing, + UsingIndex(ObjectId), +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Mutation { CreateSchema(CreateSchemaMutation), AlterSchema(AlterSchemaMutation), DropSchema(DropSchemaMutation), @@ -43,6 +59,8 @@ pub enum Mutation { DropView(DropViewMutation), DropMaterializedView(DropMaterializedViewMutation), DropIndex(DropIndex), + LockTable(LockTableMutation), + Truncate(TruncateMutation), ChangeRelationOwner { id: ObjectId, new_owner: crate::_internal::analysis::facts::RoleFact, @@ -100,14 +118,34 @@ pub enum Mutation { } #[derive(Clone, Debug, PartialEq)] -pub struct CreateSchemaMutation { +pub(crate) struct RelationTargetMutation { + pub id: ObjectId, + pub only: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct LockTableMutation { + pub targets: Vec, + pub mode: LockModeFact, + pub nowait: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct TruncateMutation { + pub targets: Vec, + pub cascade: bool, + pub restart_identity: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct CreateSchemaMutation { pub name: String, pub if_not_exists: bool, pub authorization: Option, } #[derive(Clone, Debug, PartialEq)] -pub enum AlterSchemaMutation { +pub(crate) enum AlterSchemaMutation { Rename { old_name: String, new_name: String, @@ -119,14 +157,14 @@ pub enum AlterSchemaMutation { } #[derive(Clone, Debug, PartialEq)] -pub struct DropSchemaMutation { +pub(crate) struct DropSchemaMutation { pub names: Vec, pub if_exists: bool, pub cascade: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct CreatePolicyMutation { +pub(crate) struct CreatePolicyMutation { pub name: String, pub table: ObjectId, pub permissive: bool, @@ -135,75 +173,78 @@ pub struct CreatePolicyMutation { } #[derive(Clone, Debug, PartialEq)] -pub struct DropPolicyMutation { +pub(crate) struct DropPolicyMutation { pub name: String, pub table: ObjectId, pub if_exists: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct CreateTriggerMutation { +pub(crate) struct CreateTriggerMutation { pub name: String, pub table: ObjectId, pub function_id: ObjectId, + pub row_level: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct DropTriggerMutation { +pub(crate) struct DropTriggerMutation { pub name: String, pub table: ObjectId, pub if_exists: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct RenameTriggerMutation { +pub(crate) struct RenameTriggerMutation { pub name: String, pub table: ObjectId, pub new_name: String, } #[derive(Clone, Debug, PartialEq)] -pub struct DropViewMutation { +pub(crate) struct DropViewMutation { pub ids: Vec, pub if_exists: bool, pub cascade: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct DropMaterializedViewMutation { +pub(crate) struct DropMaterializedViewMutation { pub ids: Vec, pub if_exists: bool, pub cascade: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct CreateMaterializedView { +pub(crate) struct CreateMaterializedView { pub id: ObjectId, pub depends_on: Vec, } #[derive(Clone, Debug, PartialEq)] -pub struct RefreshMaterializedViewMutation { +pub(crate) struct RefreshMaterializedViewMutation { pub id: ObjectId, pub concurrently: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct CreateSequenceMutation { +pub(crate) struct CreateSequenceMutation { pub id: ObjectId, pub if_not_exists: bool, pub owned_by: Option<(ObjectId, String)>, + pub persistence: crate::_internal::model::sequence::SequencePersistence, + pub options: IdentitySequenceOptionsMutation, } #[derive(Clone, Debug, PartialEq)] -pub struct AlterSequenceMutation { +pub(crate) struct AlterSequenceMutation { pub id: ObjectId, pub if_exists: bool, pub action: AlterSequenceActionMutation, } #[derive(Clone, Debug, PartialEq)] -pub enum AlterSequenceActionMutation { +pub(crate) enum AlterSequenceActionMutation { OwnedBy(Option<(ObjectId, String)>), OwnerTo(crate::_internal::analysis::facts::RoleFact), RenameTo(ObjectId), @@ -212,52 +253,52 @@ pub enum AlterSequenceActionMutation { } #[derive(Clone, Debug, PartialEq)] -pub struct DropSequenceMutation { +pub(crate) struct DropSequenceMutation { pub ids: Vec, pub if_exists: bool, pub cascade: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct CreateDomainMutation { +pub(crate) struct CreateDomainMutation { pub id: ObjectId, pub base_type: String, } #[derive(Clone, Debug, PartialEq)] -pub struct AlterDomainMutation { +pub(crate) struct AlterDomainMutation { pub id: ObjectId, pub action: Option, } #[derive(Clone, Debug, PartialEq)] -pub struct DropDomainMutation { +pub(crate) struct DropDomainMutation { pub ids: Vec, pub if_exists: bool, pub cascade: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct DropTypeMutation { +pub(crate) struct DropTypeMutation { pub ids: Vec, pub if_exists: bool, pub cascade: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct CreateTypeMutation { +pub(crate) struct CreateTypeMutation { pub id: ObjectId, pub kind: TypeKind, } #[derive(Clone, Debug, PartialEq)] -pub struct AlterTypeMutation { +pub(crate) struct AlterTypeMutation { pub id: ObjectId, pub action: AlterTypeActionMutation, } #[derive(Clone, Debug, PartialEq)] -pub enum AlterTypeActionMutation { +pub(crate) enum AlterTypeActionMutation { AddValue { new_value: String, neighbor: Option, @@ -270,23 +311,39 @@ pub enum AlterTypeActionMutation { } #[derive(Clone, Debug, PartialEq)] -pub struct CreateTable { +pub(crate) struct CreateTable { pub id: ObjectId, pub if_not_exists: bool, pub as_select: bool, + pub as_select_columns_known: bool, pub persistence: PersistenceMutation, + pub on_commit: Option, pub columns: Vec, pub foreign_keys: Vec, pub table_constraints: Vec, pub partition_by: Option, + pub partition_strategy: Option, pub partition_of: Option, - pub partition_type: Option, + pub partition_bound: Option, + pub inherits: Vec, + /// Source tables and the supported column-property selection for `LIKE`. + /// Object-producing options (constraints, indexes, and identity) remain + /// rejected until their complete lifecycles are represented. + pub like_sources: Vec, + pub of_type: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct LikeSourceMutation { + pub relation: ObjectId, + pub properties: crate::_internal::analysis::facts::LikePropertiesFact, } #[derive(Clone, Debug, PartialEq)] -pub struct ColumnMutation { +pub(crate) struct ColumnMutation { pub name: String, pub ty: Option, + pub type_modifier: Option, pub not_null: bool, pub is_primary_key: bool, pub primary_key_constraint_name: Option, @@ -294,10 +351,26 @@ pub struct ColumnMutation { pub unique_constraint_name: Option, pub default: Option, pub generation: crate::_internal::analysis::facts::ColumnGeneration, + pub identity_sequence: Option, + pub generated_expr: Option, + pub generated_expr_sql: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub(crate) struct IdentitySequenceOptionsMutation { + pub data_type: Option, + pub start_value: Option, + pub increment: Option, + pub min_value: Option>, + pub max_value: Option>, + pub cache_size: Option, + pub cycle: Option, + pub persistence: Option, + pub sequence_name: Option, } #[derive(Clone, Debug, PartialEq)] -pub struct FkMutation { +pub(crate) struct FkMutation { pub constraint_name: Option, pub to_table: ObjectId, pub from_columns: Vec, @@ -305,14 +378,14 @@ pub struct FkMutation { } #[derive(Clone, Debug, PartialEq)] -pub struct CreateView { +pub(crate) struct CreateView { pub id: ObjectId, pub or_replace: bool, pub depends_on: Vec, } #[derive(Clone, Debug, PartialEq)] -pub struct CreateIndex { +pub(crate) struct CreateIndex { pub id: ObjectId, pub table: ObjectId, pub if_not_exists: bool, @@ -329,26 +402,27 @@ pub struct CreateIndex { } #[derive(Clone, Debug, PartialEq)] -pub struct AlterTable { +pub(crate) struct AlterTable { pub id: ObjectId, + pub only: bool, pub action: AlterTableActionMutation, } #[derive(Clone, Debug, PartialEq)] -pub struct Rename { +pub(crate) struct Rename { pub old_id: ObjectId, pub new_id: ObjectId, } #[derive(Clone, Debug, PartialEq)] -pub struct DropTable { +pub(crate) struct DropTable { pub ids: Vec, pub if_exists: bool, pub cascade: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct DropIndex { +pub(crate) struct DropIndex { pub ids: Vec, pub if_exists: bool, pub concurrently: bool, @@ -356,35 +430,35 @@ pub struct DropIndex { } #[derive(Clone, Debug, PartialEq)] -pub struct SearchPathChange { +pub(crate) struct SearchPathChange { pub target: SearchPathTarget, pub local: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct TimeoutSettingChange { +pub(crate) struct TimeoutSettingChange { pub setting: TimeoutSetting, pub value: TimeoutSettingValue, pub local: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct SavepointMutation { +pub(crate) struct SavepointMutation { pub name: String, } #[derive(Clone, Debug, PartialEq)] -pub struct ReleaseSavepointMutation { +pub(crate) struct ReleaseSavepointMutation { pub name: String, } #[derive(Clone, Debug, PartialEq)] -pub struct RollbackToSavepointMutation { +pub(crate) struct RollbackToSavepointMutation { pub name: String, } #[derive(Clone, Debug, PartialEq)] -pub struct CreateFunctionMutation { +pub(crate) struct CreateFunctionMutation { pub id: ObjectId, pub or_replace: bool, pub params: Vec, @@ -393,20 +467,20 @@ pub struct CreateFunctionMutation { } #[derive(Clone, Debug, PartialEq)] -pub struct AlterFunctionMutation { +pub(crate) struct AlterFunctionMutation { pub id: ObjectId, pub action: crate::_internal::analysis::facts::AlterFunctionAction, } #[derive(Clone, Debug, PartialEq)] -pub struct DropFunctionMutation { +pub(crate) struct DropFunctionMutation { pub signatures: Vec, pub if_exists: bool, pub cascade: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct CreateProcedureMutation { +pub(crate) struct CreateProcedureMutation { pub id: ObjectId, pub or_replace: bool, pub params: Vec, @@ -414,60 +488,60 @@ pub struct CreateProcedureMutation { } #[derive(Clone, Debug, PartialEq)] -pub struct AlterProcedureMutation { +pub(crate) struct AlterProcedureMutation { pub id: ObjectId, pub action: crate::_internal::analysis::facts::AlterFunctionAction, } #[derive(Clone, Debug, PartialEq)] -pub struct DropProcedureMutation { +pub(crate) struct DropProcedureMutation { pub signatures: Vec, pub if_exists: bool, pub cascade: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct CreateAggregateMutation { +pub(crate) struct CreateAggregateMutation { pub id: ObjectId, pub or_replace: bool, pub params: Vec, } #[derive(Clone, Debug, PartialEq)] -pub struct AlterAggregateMutation { +pub(crate) struct AlterAggregateMutation { pub id: ObjectId, pub action: crate::_internal::analysis::facts::AlterFunctionAction, } #[derive(Clone, Debug, PartialEq)] -pub struct DropAggregateMutation { +pub(crate) struct DropAggregateMutation { pub signatures: Vec, pub if_exists: bool, pub cascade: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct CreatePublicationMutation { +pub(crate) struct CreatePublicationMutation { pub name: String, pub scope: crate::_internal::analysis::facts::PublicationScope, pub params: Vec, } #[derive(Clone, Debug, PartialEq)] -pub struct AlterPublicationMutation { +pub(crate) struct AlterPublicationMutation { pub name: String, pub action: crate::_internal::analysis::facts::AlterPublicationActionFact, } #[derive(Clone, Debug, PartialEq)] -pub struct DropPublicationMutation { +pub(crate) struct DropPublicationMutation { pub names: Vec, pub if_exists: bool, pub cascade: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct CreateSubscriptionMutation { +pub(crate) struct CreateSubscriptionMutation { pub name: Option, pub connection: crate::_internal::analysis::facts::ConnectionTarget, pub publications: Vec, @@ -475,45 +549,45 @@ pub struct CreateSubscriptionMutation { } #[derive(Clone, Debug, PartialEq)] -pub struct AlterSubscriptionMutation { +pub(crate) struct AlterSubscriptionMutation { pub name: String, pub action: crate::_internal::analysis::facts::AlterSubscriptionActionFact, } #[derive(Clone, Debug, PartialEq)] -pub struct DropSubscriptionMutation { +pub(crate) struct DropSubscriptionMutation { pub name: String, pub if_exists: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct CreateRoleMutation { +pub(crate) struct CreateRoleMutation { pub name: String, pub inherits: bool, pub can_login: bool, } #[derive(Clone, Debug, PartialEq)] -pub struct AlterRoleMutation { +pub(crate) struct AlterRoleMutation { pub name: crate::_internal::analysis::facts::RoleFact, pub inherits: Option, } #[derive(Clone, Debug, PartialEq)] -pub struct DropRoleMutation { +pub(crate) struct DropRoleMutation { pub names: Vec, pub if_exists: bool, } #[derive(Clone, Debug, PartialEq)] -pub enum ResolvedGrantTarget { +pub(crate) enum ResolvedGrantTarget { Tables(Vec), AllTablesInSchema(Vec), Roles(Vec), } #[derive(Clone, Debug, PartialEq)] -pub struct GrantMutation { +pub(crate) struct GrantMutation { pub privileges: crate::_internal::analysis::facts::PrivilegeSpec, pub target: ResolvedGrantTarget, pub grantees: Vec, @@ -523,7 +597,7 @@ pub struct GrantMutation { } #[derive(Clone, Debug, PartialEq)] -pub struct RevokeMutation { +pub(crate) struct RevokeMutation { pub grant_option_only: bool, pub role_option: Option, pub privileges: crate::_internal::analysis::facts::PrivilegeSpec, @@ -534,43 +608,53 @@ pub struct RevokeMutation { } #[derive(Clone, Debug, PartialEq)] -pub struct CreateDatabaseMutation { +pub(crate) struct CreateDatabaseMutation { pub name: String, pub options: Vec, } #[derive(Clone, Debug, PartialEq)] -pub struct AlterDatabaseMutation { +pub(crate) struct AlterDatabaseMutation { pub id: ObjectId, pub action: crate::_internal::analysis::facts::AlterDatabaseAction, } #[derive(Clone, Debug, PartialEq)] -pub struct DropDatabaseMutation { +pub(crate) struct DropDatabaseMutation { pub id: ObjectId, pub if_exists: bool, } #[derive(Clone, Debug, PartialEq)] -pub enum OpaqueMutation { +pub(crate) enum OpaqueMutation { /// Squawk accepted the statement but safe-migrate has no typed extractor /// for it. Treating it as a no-op would leave later analysis falsely exact. UnsupportedStatement, DoBlock, Execute, + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "reserved for dynamic SQL extracted from procedural bodies" + ) + )] DynamicSql, PrepareTransaction, SetTransaction, SetConstraints, + #[expect(dead_code, reason = "reserved for opaque resolver collisions")] StateCollision(String), + #[expect(dead_code, reason = "reserved for unresolved typed references")] UnresolvedReference { object_kind: crate::_internal::report::violations::ObjectKind, object_name: String, }, } +#[allow(clippy::large_enum_variant)] #[derive(Clone, Debug, PartialEq)] -pub enum AlterTableActionMutation { +pub(crate) enum AlterTableActionMutation { AddColumn { name: String, ty: Option, @@ -579,6 +663,9 @@ pub enum AlterTableActionMutation { default: Option, depends_on: Option<(ObjectId, String)>, generation: crate::_internal::analysis::facts::ColumnGeneration, + identity_sequence: Option, + generated_expr: Option, + generated_expr_sql: Option, }, DropColumn { name: String, @@ -611,6 +698,7 @@ pub enum AlterTableActionMutation { }, AddCheckConstraint { constraint_name: Option, + definition: String, columns: Vec, columns_complete: bool, not_valid: bool, @@ -645,6 +733,27 @@ pub enum AlterTableActionMutation { column: String, default: Option, }, + SetGeneratedExpression { + column: String, + expr: ExprIr, + expression_sql: String, + }, + SetColumnOptions { + column: String, + attributes: Vec, + }, + ResetColumnOptions { + column: String, + names: Vec, + }, + SetTableOptions { + attributes: Vec, + }, + ResetTableOptions { + names: Vec, + }, + AlterColumnInheritance, + PartitionReshape, ValidateConstraint { constraint_name: String, }, @@ -654,19 +763,70 @@ pub enum AlterTableActionMutation { EnableTrigger { trigger_name: Option, }, + SetTriggerMode { + trigger_name: Option, + mode: crate::_internal::model::trigger::TriggerEnableMode, + }, AttachPartition { child: ObjectId, strategy: Option, + bound: Option, }, DetachPartition { child: ObjectId, + mode: crate::_internal::analysis::facts::DetachPartitionMode, + }, + InheritTable { + parent: ObjectId, + }, + NoInheritTable { + parent: ObjectId, + }, + SetOfType { + type_id: Option, }, SetStorage { column: String, + mode: String, + }, + SetCompression { + column: String, + method: Option, + }, + SetStatistics { + column: String, + target: Option, + }, + DropGeneratedExpression { + column: String, + if_exists: bool, + }, + SetAccessMethod { + access_method: Option, + }, + SetTablespace { + tablespace: String, + }, + SetPersistence { + persistence: crate::_internal::model::relation::Persistence, + }, + SetCluster { + index: Option, + }, + SetRowSecurity { + enabled: bool, + }, + SetForceRowSecurity { + enabled: bool, + }, + SetReplicaIdentity { + option: ReplicaIdentityMutation, + }, + SetRuleMode { + rule_name: Option, + mode: crate::_internal::model::relation::RuleEnableMode, }, - SetAccessMethod, OwnerTo { new_owner: crate::_internal::analysis::facts::RoleFact, }, - Opaque, } diff --git a/src/_internal/analysis/outcome.rs b/src/_internal/analysis/outcome.rs index a00db0fc..536b3775 100644 --- a/src/_internal/analysis/outcome.rs +++ b/src/_internal/analysis/outcome.rs @@ -3,14 +3,18 @@ use crate::_internal::analysis::state::Confidence; /// Immutable result of an analysis run. #[derive(Debug, Clone)] -pub struct AnalysisOutcome { +pub(crate) struct AnalysisOutcome { pub findings: Vec, pub confidence: Confidence, pub evidence: Vec, } impl AnalysisOutcome { - pub fn new(findings: Vec, confidence: Confidence, evidence: Vec) -> Self { + pub(crate) fn new( + findings: Vec, + confidence: Confidence, + evidence: Vec, + ) -> Self { Self { findings, confidence, @@ -22,7 +26,7 @@ impl AnalysisOutcome { /// machine that produced the findings. This is used for invocation facts /// such as a missing or stale baseline, which must taint the verdict but /// must not downgrade the severity of the SQL being analyzed. - pub fn with_evidence(mut self, record: EvidenceRecord) -> Self { + pub(crate) fn with_evidence(mut self, record: EvidenceRecord) -> Self { if !self.evidence.contains(&record) { self.evidence.push(record); self.evidence.sort(); diff --git a/src/_internal/analysis/resolver.rs b/src/_internal/analysis/resolver.rs index 25b505e7..ef6afcf5 100644 --- a/src/_internal/analysis/resolver.rs +++ b/src/_internal/analysis/resolver.rs @@ -13,7 +13,7 @@ mod sequence; mod session; mod types; -pub struct Resolver; +pub(crate) struct Resolver; impl Resolver { fn resolve_creation_name(name: &QualifiedName, state: &AnalysisState) -> ObjectId { @@ -182,7 +182,7 @@ impl Resolver { folded } - pub fn resolve(fact: &StatementFact, state: &AnalysisState) -> Vec { + pub(crate) fn resolve(fact: &StatementFact, state: &AnalysisState) -> Vec { let mut mutations = Vec::new(); match fact { StatementFact::CreateSchema { @@ -212,24 +212,40 @@ impl Resolver { if_not_exists, as_select, persistence, + on_commit, columns, foreign_keys, table_constraints, partition_by, + partition_strategy, partition_of, - partition_type, + partition_bound, + inherits, + like_sources, + of_type, + select_source, + select_outputs, + select_projection_complete, } => { mutations.push(Self::resolve_create_table( name, *if_not_exists, *as_select, persistence, + *on_commit, columns, foreign_keys, table_constraints, partition_by, + partition_strategy, partition_of, - partition_type, + partition_bound, + inherits, + like_sources, + of_type, + select_source, + select_outputs, + *select_projection_complete, state, )); } @@ -328,8 +344,11 @@ impl Resolver { name, table, function, + row_level, } => { - mutations.push(Self::resolve_create_trigger(name, table, function, state)); + mutations.push(Self::resolve_create_trigger( + name, table, function, *row_level, state, + )); } StatementFact::DropTrigger { name, @@ -378,11 +397,15 @@ impl Resolver { name, if_not_exists, owned_by, + persistence, + options, } => { mutations.push(Self::resolve_create_sequence( name, *if_not_exists, owned_by, + persistence, + options, state, )); } @@ -404,8 +427,12 @@ impl Resolver { names, *if_exists, *cascade, state, )); } - StatementFact::AlterTable { name, actions } => { - mutations.extend(Self::resolve_alter_table(name, actions, state)); + StatementFact::AlterTable { + name, + only, + actions, + } => { + mutations.extend(Self::resolve_alter_table(name, *only, actions, state)); } StatementFact::DropTable { names, @@ -444,6 +471,21 @@ impl Resolver { state, )); } + StatementFact::Lock { + targets, + mode, + nowait, + } => mutations.push(Self::resolve_lock(targets, *mode, *nowait, state)), + StatementFact::Truncate { + targets, + cascade, + restart_identity, + } => mutations.push(Self::resolve_truncate( + targets, + *cascade, + *restart_identity, + state, + )), StatementFact::SetSearchPath { target, local } => { mutations.push(Self::resolve_search_path(target, *local)) } diff --git a/src/_internal/analysis/resolver/relation.rs b/src/_internal/analysis/resolver/relation.rs index 584798db..32992b10 100644 --- a/src/_internal/analysis/resolver/relation.rs +++ b/src/_internal/analysis/resolver/relation.rs @@ -1,11 +1,12 @@ use super::Resolver; use crate::_internal::analysis::facts::{ - AlterTableActionFact, ColumnFact, FkFact, PersistenceFact, TableConstraintFact, + AlterTableActionFact, ColumnFact, FkFact, PersistenceFact, SelectOutputFact, + TableConstraintFact, }; use crate::_internal::analysis::mutations::{ AlterTable, AlterTableActionMutation, ColumnMutation, CreateTable, DropIndex, - DropMaterializedViewMutation, DropTable, DropViewMutation, FkMutation, Mutation, - PersistenceMutation, Rename, + DropMaterializedViewMutation, DropTable, DropViewMutation, FkMutation, LikeSourceMutation, + Mutation, PersistenceMutation, Rename, }; use crate::_internal::analysis::state::AnalysisState; use crate::_internal::ast::identifiers::{ObjectId, QualifiedName}; @@ -17,12 +18,20 @@ impl Resolver { if_not_exists: bool, as_select: bool, persistence: &PersistenceFact, + on_commit: Option, columns: &[ColumnFact], foreign_keys: &[FkFact], table_constraints: &[TableConstraintFact], partition_by: &Option, + partition_strategy: &Option, partition_of: &Option, - partition_type: &Option, + partition_bound: &Option, + inherits: &[QualifiedName], + like_sources: &[crate::_internal::analysis::facts::LikeSourceFact], + of_type: &Option, + select_source: &Option, + select_outputs: &[SelectOutputFact], + select_projection_complete: bool, state: &AnalysisState, ) -> Mutation { let persistence = match persistence { @@ -30,11 +39,23 @@ impl Resolver { PersistenceFact::Temporary => PersistenceMutation::Temporary, PersistenceFact::Unlogged => PersistenceMutation::Unlogged, }; - let columns = columns + let on_commit = on_commit.map(|action| match action { + crate::_internal::analysis::facts::OnCommitFact::PreserveRows => { + crate::_internal::analysis::mutations::OnCommitMutation::PreserveRows + } + crate::_internal::analysis::facts::OnCommitFact::DeleteRows => { + crate::_internal::analysis::mutations::OnCommitMutation::DeleteRows + } + crate::_internal::analysis::facts::OnCommitFact::Drop => { + crate::_internal::analysis::mutations::OnCommitMutation::Drop + } + }); + let mut columns: Vec = columns .iter() .map(|column| ColumnMutation { name: column.name.clone(), ty: column.ty.clone(), + type_modifier: None, not_null: column.not_null, is_primary_key: column.is_primary_key, primary_key_constraint_name: column.primary_key_constraint_name.clone(), @@ -42,8 +63,117 @@ impl Resolver { unique_constraint_name: column.unique_constraint_name.clone(), default: column.default.clone(), generation: column.generation, + identity_sequence: column.identity_sequence.as_ref().map(|options| { + crate::_internal::analysis::mutations::IdentitySequenceOptionsMutation { + data_type: options.data_type.clone(), + start_value: options.start_value, + increment: options.increment, + min_value: options.min_value, + max_value: options.max_value, + cache_size: options.cache_size, + cycle: options.cycle, + persistence: options.persistence, + sequence_name: options + .sequence_name + .as_ref() + .map(|name| Self::resolve_creation_name(name, state)), + } + }), + generated_expr: column.generated_expr.clone(), + generated_expr_sql: column.generated_expr_sql.clone(), }) .collect(); + let of_type = of_type + .as_ref() + .map(|name| Self::resolve_type_lookup_name(name, state)); + if let Some(type_id) = &of_type + && let Some(crate::_internal::model::types::TypeOverlay::Present( + crate::_internal::model::types::TypeState { + kind: crate::_internal::model::types::TypeKind::Composite { fields }, + .. + }, + )) = state.local.types.get(type_id) + { + columns.extend(fields.iter().map(|field| ColumnMutation { + name: field.name.clone(), + ty: Some(field.data_type.clone()), + type_modifier: None, + not_null: false, + is_primary_key: false, + primary_key_constraint_name: None, + is_unique: false, + unique_constraint_name: None, + default: None, + generation: crate::_internal::analysis::facts::ColumnGeneration::Ordinary, + identity_sequence: None, + generated_expr: None, + generated_expr_sql: None, + })); + } + let mut as_select_columns_known = !as_select; + if as_select && select_projection_complete { + let source_id = select_source + .as_ref() + .map(|source| Self::resolve_relation_lookup_name(source, state)); + if let Some(source_id) = source_id + && let Some(crate::_internal::model::relation::RelationOverlay::Present(source)) = + state.local.relations.get(&source_id) + { + let mut projected = Vec::new(); + let mut complete = true; + for output in select_outputs { + match output { + SelectOutputFact::AllColumns => { + projected.extend(source.columns.iter().map(|column| ColumnMutation { + name: column.name.clone(), + ty: column.data_type.clone(), + type_modifier: column.type_modifier, + not_null: false, + is_primary_key: false, + primary_key_constraint_name: None, + is_unique: false, + unique_constraint_name: None, + default: None, + generation: + crate::_internal::analysis::facts::ColumnGeneration::Ordinary, + identity_sequence: None, + generated_expr: None, + generated_expr_sql: None, + })); + } + SelectOutputFact::Column { + source_name, + output_name, + } => { + let Some(column) = source.get_column(source_name) else { + complete = false; + break; + }; + projected.push(ColumnMutation { + name: output_name.clone(), + ty: column.data_type.clone(), + type_modifier: column.type_modifier, + not_null: false, + is_primary_key: false, + primary_key_constraint_name: None, + is_unique: false, + unique_constraint_name: None, + default: None, + generation: + crate::_internal::analysis::facts::ColumnGeneration::Ordinary, + identity_sequence: None, + generated_expr: None, + generated_expr_sql: None, + }); + } + } + } + if complete { + columns = projected; + as_select_columns_known = true; + } + } + } let foreign_keys = foreign_keys .iter() .map(|foreign_key| FkMutation { @@ -57,20 +187,36 @@ impl Resolver { id: Self::resolve_creation_name(name, state), if_not_exists, as_select, + as_select_columns_known, persistence, + on_commit, columns, foreign_keys, table_constraints: table_constraints.to_vec(), partition_by: partition_by.clone(), + partition_strategy: partition_strategy.clone(), partition_of: partition_of .as_ref() .map(|parent| Self::resolve_relation_lookup_name(parent, state)), - partition_type: partition_type.clone(), + partition_bound: partition_bound.clone(), + inherits: inherits + .iter() + .map(|parent| Self::resolve_relation_lookup_name(parent, state)) + .collect(), + like_sources: like_sources + .iter() + .map(|source| LikeSourceMutation { + relation: Self::resolve_relation_lookup_name(&source.relation, state), + properties: source.properties, + }) + .collect(), + of_type, }) } pub(super) fn resolve_alter_table( name: &QualifiedName, + only: bool, actions: &[AlterTableActionFact], state: &AnalysisState, ) -> Vec { @@ -85,6 +231,9 @@ impl Resolver { not_null, default, generation, + identity_sequence, + generated_expr, + generated_expr_sql, } => AlterTableActionMutation::AddColumn { name: name.clone(), ty: ty.clone(), @@ -93,6 +242,23 @@ impl Resolver { default: default.clone(), depends_on: None, generation: *generation, + identity_sequence: identity_sequence.as_ref().map(|options| { + crate::_internal::analysis::mutations::IdentitySequenceOptionsMutation { + data_type: options.data_type.clone(), + start_value: options.start_value, + increment: options.increment, + min_value: options.min_value, + max_value: options.max_value, + cache_size: options.cache_size, + cycle: options.cycle, + persistence: options.persistence, + sequence_name: options.sequence_name.as_ref().map(|name| { + Self::resolve_creation_name(name, state) + }), + } + }), + generated_expr: generated_expr.clone(), + generated_expr_sql: generated_expr_sql.clone(), }, AlterTableActionFact::DropColumn { name, @@ -164,11 +330,13 @@ impl Resolver { }, AlterTableActionFact::AddCheckConstraint { constraint_name, + definition, columns, columns_complete, not_valid, } => AlterTableActionMutation::AddCheckConstraint { constraint_name: constraint_name.clone(), + definition: definition.clone(), columns: columns.clone(), columns_complete: *columns_complete, not_valid: *not_valid, @@ -234,23 +402,48 @@ impl Resolver { constraint_name: constraint_name.clone(), } } - AlterTableActionFact::AttachPartition { child, strategy } => { + AlterTableActionFact::AttachPartition { child, strategy, bound } => { AlterTableActionMutation::AttachPartition { child: Self::resolve_relation_lookup_name(child, state), strategy: strategy.clone(), + bound: bound.clone(), } } - AlterTableActionFact::DetachPartition { child } => { + AlterTableActionFact::DetachPartition { child, mode } => { AlterTableActionMutation::DetachPartition { child: Self::resolve_relation_lookup_name(child, state), + mode: *mode, } } - AlterTableActionFact::SetStorage { column } => { + AlterTableActionFact::SetStorage { column, mode } => { AlterTableActionMutation::SetStorage { column: column.clone(), + mode: mode.clone(), + } + } + AlterTableActionFact::SetCompression { column, method } => { + AlterTableActionMutation::SetCompression { + column: column.clone(), + method: method.clone(), + } + } + AlterTableActionFact::SetStatistics { column, target } => { + AlterTableActionMutation::SetStatistics { + column: column.clone(), + target: *target, + } + } + AlterTableActionFact::DropExpression { column, if_exists } => { + AlterTableActionMutation::DropGeneratedExpression { + column: column.clone(), + if_exists: *if_exists, + } + } + AlterTableActionFact::SetAccessMethod { access_method } => { + AlterTableActionMutation::SetAccessMethod { + access_method: access_method.clone(), } } - AlterTableActionFact::SetAccessMethod => AlterTableActionMutation::SetAccessMethod, AlterTableActionFact::DisableTrigger { trigger_name } => { AlterTableActionMutation::DisableTrigger { trigger_name: trigger_name.clone(), @@ -261,25 +454,133 @@ impl Resolver { trigger_name: trigger_name.clone(), } } - AlterTableActionFact::SetExpression { .. } - | AlterTableActionFact::SetOptions { .. } - | AlterTableActionFact::Inherit { .. } - | AlterTableActionFact::NoInherit { .. } - | AlterTableActionFact::ClusterOn { .. } - | AlterTableActionFact::InheritTable { .. } - | AlterTableActionFact::NoInheritTable { .. } - | AlterTableActionFact::MergePartitions { .. } - | AlterTableActionFact::SplitPartition - | AlterTableActionFact::SetTablespace { .. } - | AlterTableActionFact::SetLogged - | AlterTableActionFact::SetUnlogged - | AlterTableActionFact::ReplicaIdentity { .. } - | AlterTableActionFact::ForceRls - | AlterTableActionFact::EnableRls - | AlterTableActionFact::DisableRls - | AlterTableActionFact::EnableAlwaysTrigger { .. } - | AlterTableActionFact::EnableReplicaTrigger { .. } => { - AlterTableActionMutation::Opaque + AlterTableActionFact::EnableAlwaysTrigger { trigger_name } => { + AlterTableActionMutation::SetTriggerMode { + trigger_name: trigger_name.clone(), + mode: crate::_internal::model::trigger::TriggerEnableMode::Always, + } + } + AlterTableActionFact::EnableReplicaTrigger { trigger_name } => { + AlterTableActionMutation::SetTriggerMode { + trigger_name: trigger_name.clone(), + mode: crate::_internal::model::trigger::TriggerEnableMode::Replica, + } + } + AlterTableActionFact::SetRuleMode { rule_name, mode } => { + AlterTableActionMutation::SetRuleMode { + rule_name: rule_name.clone(), + mode: match mode { + crate::_internal::analysis::facts::RuleEnableModeFact::Origin => { + crate::_internal::model::relation::RuleEnableMode::Origin + } + crate::_internal::analysis::facts::RuleEnableModeFact::Disabled => { + crate::_internal::model::relation::RuleEnableMode::Disabled + } + crate::_internal::analysis::facts::RuleEnableModeFact::Replica => { + crate::_internal::model::relation::RuleEnableMode::Replica + } + crate::_internal::analysis::facts::RuleEnableModeFact::Always => { + crate::_internal::model::relation::RuleEnableMode::Always + } + }, + } + } + AlterTableActionFact::SetTablespace { tablespace } => { + AlterTableActionMutation::SetTablespace { + tablespace: tablespace.clone(), + } + } + AlterTableActionFact::SetLogged => AlterTableActionMutation::SetPersistence { + persistence: crate::_internal::model::relation::Persistence::Permanent, + }, + AlterTableActionFact::SetUnlogged => AlterTableActionMutation::SetPersistence { + persistence: crate::_internal::model::relation::Persistence::Unlogged, + }, + AlterTableActionFact::ClusterOn { index } => AlterTableActionMutation::SetCluster { + index: Some(Self::resolve_constraint_index_name(index, &id)), + }, + AlterTableActionFact::SetWithoutCluster => { + AlterTableActionMutation::SetCluster { index: None } + } + AlterTableActionFact::ReplicaIdentity { option } => { + AlterTableActionMutation::SetReplicaIdentity { + option: match option { + crate::_internal::analysis::facts::ReplicaIdentityFact::Default => + crate::_internal::analysis::mutations::ReplicaIdentityMutation::Default, + crate::_internal::analysis::facts::ReplicaIdentityFact::Full => + crate::_internal::analysis::mutations::ReplicaIdentityMutation::Full, + crate::_internal::analysis::facts::ReplicaIdentityFact::Nothing => + crate::_internal::analysis::mutations::ReplicaIdentityMutation::Nothing, + crate::_internal::analysis::facts::ReplicaIdentityFact::UsingIndex(index) => + crate::_internal::analysis::mutations::ReplicaIdentityMutation::UsingIndex( + Self::resolve_constraint_index_name(index, &id), + ), + }, + } + } + AlterTableActionFact::ForceRls => { + AlterTableActionMutation::SetForceRowSecurity { enabled: true } + } + AlterTableActionFact::NoForceRls => { + AlterTableActionMutation::SetForceRowSecurity { enabled: false } + } + AlterTableActionFact::EnableRls => { + AlterTableActionMutation::SetRowSecurity { enabled: true } + } + AlterTableActionFact::DisableRls => { + AlterTableActionMutation::SetRowSecurity { enabled: false } + } + AlterTableActionFact::SetExpression { column, expr, expression_sql } => { + AlterTableActionMutation::SetGeneratedExpression { + column: column.clone(), + expr: expr.clone(), + expression_sql: expression_sql.clone(), + } + } + AlterTableActionFact::InheritTable { parent } => { + AlterTableActionMutation::InheritTable { + parent: Self::resolve_relation_lookup_name(parent, state), + } + } + AlterTableActionFact::NoInheritTable { parent } => { + AlterTableActionMutation::NoInheritTable { + parent: Self::resolve_relation_lookup_name(parent, state), + } + } + AlterTableActionFact::OfType { type_name } => { + AlterTableActionMutation::SetOfType { + type_id: Some(Self::resolve_type_lookup_name(type_name, state)), + } + } + AlterTableActionFact::NotOf => AlterTableActionMutation::SetOfType { type_id: None }, + AlterTableActionFact::SetOptions { column, attributes } => { + AlterTableActionMutation::SetColumnOptions { + column: column.clone(), + attributes: attributes.clone(), + } + } + AlterTableActionFact::ResetOptions { column, names } => { + AlterTableActionMutation::ResetColumnOptions { + column: column.clone(), + names: names.clone(), + } + } + AlterTableActionFact::SetTableOptions { attributes } => { + AlterTableActionMutation::SetTableOptions { + attributes: attributes.clone(), + } + } + AlterTableActionFact::ResetTableOptions { names } => { + AlterTableActionMutation::ResetTableOptions { + names: names.clone(), + } + } + AlterTableActionFact::Inherit { .. } | AlterTableActionFact::NoInherit { .. } => { + AlterTableActionMutation::AlterColumnInheritance + } + AlterTableActionFact::MergePartitions { .. } + | AlterTableActionFact::SplitPartition => { + AlterTableActionMutation::PartitionReshape } AlterTableActionFact::OwnerTo { new_owner } => AlterTableActionMutation::OwnerTo { new_owner: new_owner.clone(), @@ -287,6 +588,7 @@ impl Resolver { }; mutations.push(Mutation::AlterTable(AlterTable { id: id.clone(), + only, action, })); } diff --git a/src/_internal/analysis/resolver/relation_aux.rs b/src/_internal/analysis/resolver/relation_aux.rs index 9b88b0b7..e53f52ad 100644 --- a/src/_internal/analysis/resolver/relation_aux.rs +++ b/src/_internal/analysis/resolver/relation_aux.rs @@ -170,6 +170,7 @@ impl Resolver { name: &str, table: &QualifiedName, function: &Option, + row_level: bool, state: &AnalysisState, ) -> Mutation { let Some(function) = function else { @@ -180,6 +181,7 @@ impl Resolver { name: name.to_string(), table: Self::resolve_relation_lookup_name(table, state), function_id, + row_level, }) } diff --git a/src/_internal/analysis/resolver/sequence.rs b/src/_internal/analysis/resolver/sequence.rs index 71293872..60da9529 100644 --- a/src/_internal/analysis/resolver/sequence.rs +++ b/src/_internal/analysis/resolver/sequence.rs @@ -24,12 +24,36 @@ impl Resolver { name: &QualifiedName, if_not_exists: bool, owned_by: &Option<(QualifiedName, String)>, + persistence: &crate::_internal::analysis::facts::PersistenceFact, + options: &crate::_internal::analysis::facts::IdentitySequenceOptionsFact, state: &AnalysisState, ) -> Mutation { Mutation::CreateSequence(CreateSequenceMutation { id: Self::resolve_creation_name(name, state), if_not_exists, owned_by: Self::resolve_owned_by(owned_by, state), + persistence: match persistence { + crate::_internal::analysis::facts::PersistenceFact::Permanent => { + crate::_internal::model::sequence::SequencePersistence::Permanent + } + crate::_internal::analysis::facts::PersistenceFact::Temporary => { + crate::_internal::model::sequence::SequencePersistence::Temporary + } + crate::_internal::analysis::facts::PersistenceFact::Unlogged => { + crate::_internal::model::sequence::SequencePersistence::Unlogged + } + }, + options: crate::_internal::analysis::mutations::IdentitySequenceOptionsMutation { + data_type: options.data_type.clone(), + start_value: options.start_value, + increment: options.increment, + min_value: options.min_value, + max_value: options.max_value, + cache_size: options.cache_size, + cycle: options.cycle, + persistence: options.persistence, + sequence_name: None, + }, }) } diff --git a/src/_internal/analysis/resolver/session.rs b/src/_internal/analysis/resolver/session.rs index b00d14a5..e1aa05d4 100644 --- a/src/_internal/analysis/resolver/session.rs +++ b/src/_internal/analysis/resolver/session.rs @@ -1,8 +1,11 @@ use super::Resolver; -use crate::_internal::analysis::facts::{SearchPathTarget, TimeoutSetting, TimeoutSettingValue}; +use crate::_internal::analysis::facts::{ + RelationTargetFact, SearchPathTarget, TimeoutSetting, TimeoutSettingValue, +}; use crate::_internal::analysis::mutations::{ - Mutation, ReleaseSavepointMutation, RollbackToSavepointMutation, SavepointMutation, - SearchPathChange, TimeoutSettingChange, + LockTableMutation, Mutation, RelationTargetMutation, ReleaseSavepointMutation, + RollbackToSavepointMutation, SavepointMutation, SearchPathChange, TimeoutSettingChange, + TruncateMutation, }; use crate::_internal::analysis::state::AnalysisState; use crate::_internal::ast::identifiers::QualifiedName; @@ -55,4 +58,42 @@ impl Resolver { is_full, } } + + pub(super) fn resolve_lock( + targets: &[RelationTargetFact], + mode: crate::_internal::analysis::facts::LockModeFact, + nowait: bool, + state: &AnalysisState, + ) -> Mutation { + Mutation::LockTable(LockTableMutation { + targets: targets + .iter() + .map(|target| RelationTargetMutation { + id: Self::resolve_relation_lookup_name(&target.name, state), + only: target.only, + }) + .collect(), + mode, + nowait, + }) + } + + pub(super) fn resolve_truncate( + targets: &[RelationTargetFact], + cascade: bool, + restart_identity: bool, + state: &AnalysisState, + ) -> Mutation { + Mutation::Truncate(TruncateMutation { + targets: targets + .iter() + .map(|target| RelationTargetMutation { + id: Self::resolve_relation_lookup_name(&target.name, state), + only: target.only, + }) + .collect(), + cascade, + restart_identity, + }) + } } diff --git a/src/_internal/analysis/resolver/types.rs b/src/_internal/analysis/resolver/types.rs index 3466f44f..3ec7b900 100644 --- a/src/_internal/analysis/resolver/types.rs +++ b/src/_internal/analysis/resolver/types.rs @@ -17,7 +17,17 @@ impl Resolver { variants: variants.clone(), }, TypeCreationKind::Range => TypeKind::Range, - TypeCreationKind::Composite => TypeKind::Composite, + TypeCreationKind::Composite { fields } => TypeKind::Composite { + fields: fields + .iter() + .map( + |field| crate::_internal::model::types::CompositeFieldState { + name: field.name.clone(), + data_type: field.data_type.clone(), + }, + ) + .collect(), + }, TypeCreationKind::Base => TypeKind::Base, }; Mutation::CreateType(CreateTypeMutation { diff --git a/src/_internal/analysis/settings.rs b/src/_internal/analysis/settings.rs index 24db1796..e3f66137 100644 --- a/src/_internal/analysis/settings.rs +++ b/src/_internal/analysis/settings.rs @@ -1,16 +1,16 @@ /// PostgreSQL stores `lock_timeout` and `statement_timeout` as signed 32-bit /// millisecond GUCs. Values above this limit are rejected by PostgreSQL. -pub const MAX_TIMEOUT_MS: u64 = i32::MAX as u64; +pub(crate) const MAX_TIMEOUT_MS: u64 = i32::MAX as u64; #[derive(Clone, Debug, PartialEq, Eq)] -pub struct ScopedSetting { +pub(crate) struct ScopedSetting { pub default: T, pub session: T, pub effective: T, } impl ScopedSetting { - pub fn new(default: T) -> Self { + pub(crate) fn new(default: T) -> Self { Self { session: default.clone(), effective: default.clone(), @@ -18,14 +18,14 @@ impl ScopedSetting { } } - pub fn reset_effective_to_session(&mut self) { + pub(crate) fn reset_effective_to_session(&mut self) { self.effective = self.session.clone(); } } /// Parse PostgreSQL's documented timeout syntax and normalize it to the /// integer millisecond representation used by its timeout GUCs. -pub fn parse_timeout_ms(raw: &str) -> Result { +pub(crate) fn parse_timeout_ms(raw: &str) -> Result { let value = raw.trim(); if value.is_empty() { return Err("timeout value is empty".to_string()); diff --git a/src/_internal/analysis/state.rs b/src/_internal/analysis/state.rs index 081cee4f..5d9286f0 100644 --- a/src/_internal/analysis/state.rs +++ b/src/_internal/analysis/state.rs @@ -10,7 +10,7 @@ use crate::_internal::db::cache::CatalogCoverage; use crate::_internal::db::cache::DbCache; use crate::_internal::model::constraint::ConstraintState; use crate::_internal::model::function::FunctionOverlay; -pub use crate::_internal::model::relation::RelationOverlay; +pub(crate) use crate::_internal::model::relation::RelationOverlay; use crate::_internal::model::relation::{Persistence, Privilege, RelationKind}; use crate::_internal::model::schema::SchemaOverlay; use crate::_internal::model::sequence::SequenceOverlay; @@ -33,13 +33,13 @@ mod apply_type; mod apply_view_index; #[derive(Debug, Clone, PartialEq, Eq)] -pub enum Confidence { +pub(crate) enum Confidence { Exact, Tainted, } #[derive(Debug, PartialEq, Eq)] -pub enum MutationResult { +pub(crate) enum MutationResult { Applied, Skipped, /// PostgreSQL did not execute this statement because an earlier statement @@ -85,14 +85,14 @@ where } #[derive(Debug, Default, Clone)] -pub struct CascadeResult { +pub(crate) struct CascadeResult { pub dropped_relations: HashSet, pub dropped_indexes: HashSet, pub dropped_constraints: HashSet<(ObjectId, String)>, } #[derive(Clone)] -pub struct LocalState { +pub(crate) struct LocalState { pub schemas: HashMap, pub relations: HashMap, pub types: HashMap, @@ -103,6 +103,9 @@ pub struct LocalState { pub roles: HashMap, pub role_membership_grantors: Vec, pub role_membership_grantors_complete: bool, + /// The cluster's bootstrap superuser, attributed with modern PostgreSQL's + /// implicit superuser grantor. Absent when unknown or unset. + pub bootstrap_superuser: Option, pub triggers: HashMap, pub constraints: HashMap<(ObjectId, String), ConstraintState>, pub graph: DependencyGraph, @@ -148,7 +151,7 @@ pub struct LocalState { } #[derive(Clone, Debug, Default, PartialEq)] -pub struct PreState { +pub(crate) struct PreState { pub relations: HashMap, pub functions: HashMap, pub roles: HashMap, @@ -211,6 +214,7 @@ mod pre_state_tests { has_predicate: false, is_concurrent: false, is_unique: false, + is_immediate: true, is_valid: true, is_ready: true, is_live: true, @@ -238,7 +242,7 @@ mod pre_state_tests { } #[derive(Clone)] -pub struct AnalysisState { +pub(crate) struct AnalysisState { pub pg_version_num: Option, /// Whether the initial cache was loaded from a real cache file. An empty /// cache can be a valid baseline for an empty database, so availability @@ -263,6 +267,7 @@ pub struct AnalysisState { pub scoped_external_relation_dependencies: HashSet, pub scoped_external_type_dependencies: HashSet, pub scoped_external_routine_dependencies: HashSet, + pub scoped_external_index_dependencies: HashSet, pub local: LocalState, } @@ -459,10 +464,30 @@ impl AnalysisState { &self, scope: &crate::_internal::analysis::facts::PublicationScope, ) -> bool { + let schema_has_complete_inheritance = |schema: &str| { + let scope_id = ObjectId::new(schema, ""); + self.baseline_covers_family_object( + &scope_id, + crate::_internal::db::cache::CatalogFamily::Relations, + ) && self.baseline_covers_family_object( + &scope_id, + crate::_internal::db::cache::CatalogFamily::Inheritance, + ) + }; match scope { - // FOR ALL TABLES necessarily depends on the complete catalog and - // future table/inheritance state, neither of which Cache V6 stores. - crate::_internal::analysis::facts::PublicationScope::AllTables { .. } => true, + crate::_internal::analysis::facts::PublicationScope::AllTables { .. } => { + !self.baseline_available + || !matches!( + self.baseline_coverage.schema_scope, + crate::_internal::db::cache::SchemaCoverage::AllNonSystem + ) + || !self + .baseline_coverage + .has(crate::_internal::db::cache::CatalogFamily::Relations) + || !self + .baseline_coverage + .has(crate::_internal::db::cache::CatalogFamily::Inheritance) + } crate::_internal::analysis::facts::PublicationScope::Explicit(objects) => { objects.iter().any(|object| match object { crate::_internal::analysis::facts::PublicationObjectFact::Table { @@ -472,19 +497,21 @@ impl AnalysisState { .. } if !only || *include_partitions => { let id = self.resolve_relation_id(name); - !matches!( + let is_local = matches!( self.local.relations.get(&id), Some(RelationOverlay::Present(relation)) if relation.generation > 0 - ) || self.local.graph.edges().iter().any(|edge| { - matches!(edge.kind, DependencyKind::PartitionOf) - && self.local.graph.resolve_rename(&edge.referenced) - == self.local.graph.resolve_rename(&id) - }) + ); + !is_local && !schema_has_complete_inheritance(&id.schema) } - // Schema-wide and current-schema shorthand scopes also - // include inherited/partitioned descendants. - crate::_internal::analysis::facts::PublicationObjectFact::SchemaTables { .. } - | crate::_internal::analysis::facts::PublicationObjectFact::CurrentSchemaShorthand => true, + crate::_internal::analysis::facts::PublicationObjectFact::SchemaTables { + schema, + .. + } => !schema_has_complete_inheritance(schema), + crate::_internal::analysis::facts::PublicationObjectFact::CurrentSchemaShorthand => self + .local + .search_path + .first() + .is_none_or(|schema| !schema_has_complete_inheritance(schema)), crate::_internal::analysis::facts::PublicationObjectFact::Unknown => false, _ => false, }) @@ -581,12 +608,14 @@ impl AnalysisState { params.push(option.clone()); } - pub fn new(cache: DbCache) -> Self { + #[cfg(test)] + pub(crate) fn new(cache: DbCache) -> Self { Self::with_baseline(cache, true) } /// Construct an authoritative state only from a semantically valid cache. - pub fn try_new(cache: DbCache) -> Result { + #[cfg(test)] + pub(crate) fn try_new(cache: DbCache) -> Result { Ok(Self::new(cache.validated()?)) } @@ -744,11 +773,17 @@ impl AnalysisState { .map(|(id, type_state)| (id.clone(), TypeOverlay::Present(type_state.clone()))) .collect::>(); let type_catalog = types.clone(); + let row_types: HashSet<_> = relations.keys().cloned().collect(); for overlay in relations.values_mut() { if let RelationOverlay::Present(relation) = overlay { for column in &mut relation.columns { column.type_id = column.data_type.as_deref().and_then(|raw| { - Self::resolve_type_reference_from_catalog(raw, &type_catalog, search_path) + Self::resolve_type_reference_from_catalog( + raw, + &type_catalog, + search_path, + |id| row_types.contains(id), + ) }); } } @@ -767,6 +802,7 @@ impl AnalysisState { base_type, &type_catalog, search_path, + |id| row_types.contains(id), ); } } @@ -778,7 +814,7 @@ impl AnalysisState { } } - pub fn with_baseline(cache: DbCache, baseline_available: bool) -> Self { + pub(crate) fn with_baseline(cache: DbCache, baseline_available: bool) -> Self { let baseline_coverage = cache.coverage.clone(); let baseline_boundary_queries_complete = baseline_available && cache.metadata.boundary_queries_complete; @@ -832,7 +868,7 @@ impl AnalysisState { .as_ref() .map(|schemas| schemas.iter().cloned().collect()); let HydratedRelationTypes { - relations, + mut relations, baseline_relations, baseline_fk_dependencies, types, @@ -867,6 +903,11 @@ impl AnalysisState { .iter() .cloned() .collect(); + let scoped_external_index_dependencies = cache + .scoped_external_index_dependencies + .iter() + .cloned() + .collect(); for sequence in cache.sequences.values() { if let Some((table, column)) = &sequence.owned_by { graph.add_edge(DependencyEdge::new( @@ -923,6 +964,7 @@ impl AnalysisState { has_predicate: idx.has_predicate, is_concurrent: false, is_unique: idx.is_unique, + is_immediate: idx.is_immediate, is_valid: idx.is_valid, is_ready: idx.is_ready, is_live: idx.is_live, @@ -982,14 +1024,16 @@ impl AnalysisState { } for inheritance in cache.inheritances { - // Cache validation rejects a detach-in-progress row, so every - // hydrated edge represents a stable direct relationship. Keep - // cross-scope endpoints too: they are evidence that an in-scope - // destructive change may have an omitted dependent. + // A committed first phase of DETACH CONCURRENTLY remains visible + // as an inheritance row with inhdetachpending until FINALIZE. + // Keep cross-scope endpoints too: they are evidence that an + // in-scope destructive change may have an omitted dependent. graph.add_edge(DependencyEdge::new( inheritance.child, inheritance.parent, - if inheritance.is_partition { + if inheritance.detach_pending { + DependencyKind::PartitionDetachPending + } else if inheritance.is_partition { DependencyKind::PartitionOf } else { DependencyKind::InheritanceOf @@ -1048,12 +1092,18 @@ impl AnalysisState { for t in cache.triggers { let trigger_key = Self::trigger_key(&t.table_id, &t.trigger_id.name); + if let Some(RelationOverlay::Present(relation)) = relations.get_mut(&t.table_id) { + relation.triggers.insert(t.trigger_id.name.clone()); + } triggers.insert( trigger_key.clone(), TriggerOverlay::Present(crate::_internal::model::trigger::TriggerState { name: t.trigger_id.name.clone(), id: trigger_key.clone(), table_id: t.table_id.clone(), + function_id: t.function_id.clone(), + row_level: t.row_level, + parent_trigger_id: t.parent_trigger_id.clone(), enabled_mode: t.enabled_mode, generation: 0, }), @@ -1087,6 +1137,7 @@ impl AnalysisState { raw, &type_catalog, &default_search_path, + |id| matches!(relations.get(id), Some(RelationOverlay::Present(_))), ) }) .collect(); @@ -1094,6 +1145,7 @@ impl AnalysisState { &function.return_type, &type_catalog, &default_search_path, + |id| matches!(relations.get(id), Some(RelationOverlay::Present(_))), ); } } @@ -1162,6 +1214,7 @@ impl AnalysisState { scoped_external_relation_dependencies, scoped_external_type_dependencies, scoped_external_routine_dependencies, + scoped_external_index_dependencies, local: LocalState { schemas, relations, @@ -1182,6 +1235,7 @@ impl AnalysisState { .collect(), role_membership_grantors: cache.role_membership_grantors, role_membership_grantors_complete: cache.role_membership_grantors_complete, + bootstrap_superuser: cache.bootstrap_superuser, triggers, constraints, graph, @@ -1225,15 +1279,19 @@ impl AnalysisState { /// Construct state from a cache after validating its cross-record /// invariants, preserving the requested baseline-availability flag. - pub fn try_with_baseline(cache: DbCache, baseline_available: bool) -> Result { + pub(crate) fn try_with_baseline( + cache: DbCache, + baseline_available: bool, + ) -> Result { Ok(Self::with_baseline(cache.validated()?, baseline_available)) } - pub fn get_relation(&self, id: &ObjectId) -> Option<&RelationOverlay> { + #[cfg(test)] + pub(crate) fn get_relation(&self, id: &ObjectId) -> Option<&RelationOverlay> { self.local.relations.get(id) } - pub fn resolve_function_schema( + pub(crate) fn resolve_function_schema( &self, name: &crate::_internal::ast::identifiers::QualifiedName, sig_str: &str, @@ -1254,7 +1312,7 @@ impl AnalysisState { .unwrap_or_else(|| "public".to_string()) } - pub fn resolve_relation_id( + pub(crate) fn resolve_relation_id( &self, name: &crate::_internal::ast::identifiers::QualifiedName, ) -> ObjectId { @@ -1280,7 +1338,7 @@ impl AnalysisState { id } - pub fn relation_is_present(&self, id: &ObjectId) -> bool { + pub(crate) fn relation_is_present(&self, id: &ObjectId) -> bool { matches!( self.local.relations.get(id), Some(RelationOverlay::Present(_)) @@ -1303,7 +1361,7 @@ impl AnalysisState { /// Returns whether a cache-backed absence is authoritative for an object. /// A scoped cache only establishes absence in the schemas it actually /// synchronized. - pub fn baseline_covers_object(&self, id: &ObjectId) -> bool { + pub(crate) fn baseline_covers_object(&self, id: &ObjectId) -> bool { // V7 validation requires these scopes to agree. Keep the legacy // metadata intersection while direct in-process cache construction is // supported, so a caller cannot accidentally turn an explicitly @@ -1381,6 +1439,9 @@ impl AnalysisState { if matches!(family, crate::_internal::db::cache::CatalogFamily::Routines) { return self.scoped_external_routine_dependencies.contains(id); } + if matches!(family, crate::_internal::db::cache::CatalogFamily::Indexes) { + return self.scoped_external_index_dependencies.contains(id); + } true } @@ -1626,7 +1687,7 @@ impl AnalysisState { } } - pub fn baseline_scope_omits_displayed_object<'a>( + pub(crate) fn baseline_scope_omits_displayed_object<'a>( &self, object_name: &'a str, ) -> Option<&'a str> { @@ -1657,16 +1718,20 @@ impl AnalysisState { raw: &str, types: &HashMap, search_path: &[String], + row_type_exists: impl Fn(&ObjectId) -> bool, ) -> Option { let (schema, name) = Self::parse_type_reference(raw)?; if let Some(schema) = schema { let candidate = ObjectId::new(schema, name); - return matches!(types.get(&candidate), Some(TypeOverlay::Present(_))) - .then_some(candidate); + return (matches!(types.get(&candidate), Some(TypeOverlay::Present(_))) + || row_type_exists(&candidate)) + .then_some(candidate); } search_path.iter().find_map(|schema| { let candidate = ObjectId::new(schema, &name); - matches!(types.get(&candidate), Some(TypeOverlay::Present(_))).then_some(candidate) + (matches!(types.get(&candidate), Some(TypeOverlay::Present(_))) + || row_type_exists(&candidate)) + .then_some(candidate) }) } @@ -1725,7 +1790,17 @@ impl AnalysisState { } fn resolve_type_reference(&self, raw: &str) -> Option { - Self::resolve_type_reference_from_catalog(raw, &self.local.types, &self.local.search_path) + Self::resolve_type_reference_from_catalog( + raw, + &self.local.types, + &self.local.search_path, + |id| { + matches!( + self.local.relations.get(id), + Some(RelationOverlay::Present(_)) + ) + }, + ) } fn type_reference_name(id: &ObjectId, qualified: bool) -> String { @@ -1798,14 +1873,69 @@ impl AnalysisState { }) .find(|candidate| { !reserved.contains(candidate) - && !self - .local - .constraints - .contains_key(&(table.clone(), candidate.clone())) + // PostgreSQL's ChooseConstraintName checks the namespace, + // even though explicit constraint names are table-local. + && !self.local.constraints.keys().any(|(owner, name)| { + owner.schema == table.schema && name == candidate + }) }) .expect("constraint suffix space is unbounded") } + pub(super) fn next_generated_relation_name_avoiding( + &self, + schema: &str, + name1: &str, + name2: Option<&str>, + label: &str, + reserved: &HashSet, + ) -> ObjectId { + (0..) + .map(|suffix| { + let label = if suffix == 0 { + label.to_string() + } else { + format!("{label}{suffix}") + }; + ObjectId::new(schema, Self::postgres_object_name(name1, name2, &label)) + }) + .find(|candidate| { + !reserved.contains(candidate) && !self.relation_namespace_is_taken(candidate) + }) + .expect("relation suffix space is unbounded") + } + + pub(super) fn next_generated_statistics_name_avoiding( + &self, + schema: &str, + table: &str, + columns: &[String], + reserved: &HashSet, + ) -> ObjectId { + (0..) + .map(|suffix| { + let label = if suffix == 0 { + "stat".to_string() + } else { + format!("stat{suffix}") + }; + ObjectId::new( + schema, + Self::postgres_object_name(table, Some(&columns.join("_")), &label), + ) + }) + .find(|candidate| { + !reserved.contains(candidate) + && !self.local.relations.values().any(|overlay| match overlay { + RelationOverlay::Present(relation) => { + relation.extended_statistics.contains_key(candidate) + } + RelationOverlay::Dropped => false, + }) + }) + .expect("extended-statistics suffix space is unbounded") + } + fn postgres_object_name(name1: &str, name2: Option<&str>, label: &str) -> String { const MAX_IDENTIFIER_BYTES: usize = 63; @@ -1873,7 +2003,11 @@ impl AnalysisState { } } - pub fn column_was_added_in_transaction(&self, table_id: &ObjectId, column: &str) -> bool { + pub(crate) fn column_was_added_in_transaction( + &self, + table_id: &ObjectId, + column: &str, + ) -> bool { if self.local.transactions.is_empty() { return false; } @@ -1899,7 +2033,8 @@ impl AnalysisState { false } - pub fn capture_pre_state(&self) -> PreState { + #[cfg(test)] + pub(crate) fn capture_pre_state(&self) -> PreState { let mut pre_state = PreState::default(); self.capture_pre_state_into(&mut pre_state); pre_state @@ -1980,7 +2115,7 @@ impl AnalysisState { baseline_foreign_keys.clone_from(&self.baseline_foreign_keys); } - pub fn get_cascade_closure(&self, target_oid: &ObjectId) -> CascadeResult { + pub(crate) fn get_cascade_closure(&self, target_oid: &ObjectId) -> CascadeResult { let mut result = CascadeResult::default(); let mut visited = HashSet::new(); self.walk_cascade(target_oid, &mut visited, &mut result); @@ -2336,6 +2471,121 @@ impl AnalysisState { }) } + /// Identity attributed to a role-membership grantor. Role grants differ + /// from object grants: PostgreSQL 18 routes an implicit grantor through + /// `BOOTSTRAP_SUPERUSERID` when the session user is a superuser, so a + /// superuser session records the *bootstrap* role instead of the session + /// role. An explicit `GRANTED BY` always wins; otherwise a known + /// superuser maps to the cached bootstrap name (falling back to the + /// session identity when that is unknown), and any other role uses + /// itself. `None` means the attribution cannot be resolved statically. + fn role_membership_grantor( + &self, + granted_by: Option<&crate::_internal::analysis::facts::RoleFact>, + ) -> Option { + granted_by + .and_then(|fact| { + self.role_fact_identity(fact) + .map(|(name, _)| ObjectId::new("", name)) + }) + .or_else(|| { + if !self.local.current_role_known { + return None; + } + let current = ObjectId::new("", self.local.current_role.clone()); + let is_superuser = matches!( + self.local.roles.get(¤t), + Some(crate::_internal::model::role::RoleOverlay::Present(role)) + if role.is_superuser + ); + if is_superuser { + self.local + .bootstrap_superuser + .as_ref() + .map(|name| ObjectId::new("", name.clone())) + } else { + Some(current) + } + }) + } + + /// Index of the role-membership record for this exact + /// (member, role, grantor) triple, if present. + fn membership_record_index( + &self, + member: &ObjectId, + role: &ObjectId, + grantor: &ObjectId, + ) -> Option { + self.local + .role_membership_grantors + .iter() + .position(|record| { + &record.member == member && &record.role == role && &record.grantor == grantor + }) + } + + /// Whether the member still holds the ADMIN option on `role` through a + /// record other than `exclude`, mirroring PostgreSQL's + /// `would_still_have_admin_option`. + fn membership_admin_from_other_record( + &self, + member: &ObjectId, + role: &ObjectId, + exclude: Option, + ) -> bool { + self.local + .role_membership_grantors + .iter() + .enumerate() + .any(|(index, record)| { + Some(index) != exclude + && &record.member == member + && &record.role == role + && record.admin + }) + } + + /// Rebuild a member's option-vector projection from its membership + /// records. The effective projection is the union of the per-grantor + /// records, matching both `load_roles` aggregation and the live option + /// vectors the differential harness compares against. No-op when the + /// provenance set is incomplete. + fn reconcile_membership_projection(&mut self, member: &ObjectId) { + if !self.local.role_membership_grantors_complete { + return; + } + let Some(crate::_internal::model::role::RoleOverlay::Present(role)) = + self.local.roles.get_mut(member) + else { + return; + }; + role.member_of.clear(); + role.can_administer_membership.clear(); + role.can_inherit_from.clear(); + role.can_set_role_to.clear(); + for record in &self.local.role_membership_grantors { + if &record.member != member { + continue; + } + if !role.member_of.contains(&record.role) { + role.member_of.push(record.role.clone()); + } + if record.admin && !role.can_administer_membership.contains(&record.role) { + role.can_administer_membership.push(record.role.clone()); + } + if record.inherit && !role.can_inherit_from.contains(&record.role) { + role.can_inherit_from.push(record.role.clone()); + } + if record.set && !role.can_set_role_to.contains(&record.role) { + role.can_set_role_to.push(record.role.clone()); + } + } + // Reuse the role vector ordering the synchronizer produces: empty + // edge lists carry no ordering constraint, and the harness compares + // both live options and sim projections as unordered sets. + } + fn authorize_relation_grant( &self, relation: &crate::_internal::model::relation::RelationState, @@ -2695,7 +2945,7 @@ impl AnalysisState { } } - pub fn apply( + pub(crate) fn apply( &mut self, mutation: &Mutation, precomputed_cascade: Option<&CascadeResult>, @@ -2763,6 +3013,8 @@ impl AnalysisState { Mutation::DropView(drop) => self.apply_drop_view(drop), Mutation::DropMaterializedView(drop) => self.apply_drop_materialized_view(drop), Mutation::DropIndex(drop) => self.apply_drop_index(drop), + Mutation::LockTable(lock) => self.apply_lock_table(lock), + Mutation::Truncate(truncate) => self.apply_truncate(truncate), Mutation::ChangeRelationOwner { id, new_owner } => { self.apply_change_relation_owner(id, new_owner) } @@ -3078,7 +3330,7 @@ impl AnalysisState { } /// Adds conservative-analysis evidence and marks later chain state tainted. - pub fn record_evidence(&mut self, mut record: EvidenceRecord) { + pub(crate) fn record_evidence(&mut self, mut record: EvidenceRecord) { if record.location.is_none() { record.location = self.local.current_evidence_location.clone(); } @@ -3101,11 +3353,11 @@ impl AnalysisState { self.local.current_evidence_location = location; } - pub fn evidence(&self) -> &[EvidenceRecord] { + pub(crate) fn evidence(&self) -> &[EvidenceRecord] { self.local.evidence.records() } - pub fn confidence(&self) -> &Confidence { + pub(crate) fn confidence(&self) -> &Confidence { &self.local.confidence } @@ -3360,6 +3612,11 @@ mod evidence_tests { avg_width: Some(4), default_expr_text: None, type_modifier: None, + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, }) .collect(); relation @@ -3639,6 +3896,7 @@ mod evidence_tests { owner: ObjectId::new("", "postgres"), owned_by: None, kind: crate::_internal::model::sequence::SequenceKind::Standalone, + parameters: Default::default(), generation: 0, }, ); @@ -3684,6 +3942,7 @@ mod evidence_tests { owner: ObjectId::new("", "postgres"), owned_by: None, kind: crate::_internal::model::sequence::SequenceKind::Standalone, + parameters: Default::default(), generation: 0, }, ); @@ -3728,6 +3987,7 @@ mod evidence_tests { let result = state.apply( &Mutation::AlterTable(crate::_internal::analysis::mutations::AlterTable { id: table_id.clone(), + only: false, action: crate::_internal::analysis::mutations::AlterTableActionMutation::DropColumn { name: "id".to_string(), @@ -4196,6 +4456,9 @@ mod evidence_tests { name: "stale_trigger".to_string(), id: trigger_id.clone(), table_id: ObjectId::new("old_schema", "table"), + function_id: ObjectId::new("other_schema", "fn()"), + row_level: true, + parent_trigger_id: None, enabled_mode: crate::_internal::model::trigger::TriggerEnableMode::Origin, generation: 0, }), @@ -4237,6 +4500,7 @@ mod evidence_tests { name: "parent_pkey".to_string(), kind: crate::_internal::model::constraint::ConstraintKind::PrimaryKey, validated: true, + definition: None, backing_index: None, }); cache diff --git a/src/_internal/analysis/state/apply_misc.rs b/src/_internal/analysis/state/apply_misc.rs index fc6bdb9f..d27d19cd 100644 --- a/src/_internal/analysis/state/apply_misc.rs +++ b/src/_internal/analysis/state/apply_misc.rs @@ -1,11 +1,47 @@ use super::{AnalysisState, MutationResult}; use crate::_internal::analysis::evidence::{EvidenceCode, EvidenceScope}; use crate::_internal::analysis::mutations::{ - AlterDatabaseMutation, CreateDatabaseMutation, DropDatabaseMutation, + AlterDatabaseMutation, CreateDatabaseMutation, DropDatabaseMutation, LockTableMutation, + TruncateMutation, }; use crate::_internal::ast::identifiers::ObjectId; +use crate::_internal::model::relation::RelationKind; impl AnalysisState { + pub(super) fn apply_lock_table(&mut self, lock: &LockTableMutation) -> MutationResult { + for target in &lock.targets { + if let Err(result) = self.ensure_relation_target( + &target.id, + |kind| *kind == RelationKind::Table, + format!("locked relation '{}' does not exist", target.id), + format!("locked relation '{}' is not a table", target.id), + ) { + return result; + } + } + // Locks are transaction-scoped runtime state. Validating each target + // is exact; retaining them in the schema snapshot would incorrectly + // make a lock survive COMMIT or ROLLBACK. + MutationResult::Applied + } + + pub(super) fn apply_truncate(&mut self, truncate: &TruncateMutation) -> MutationResult { + for target in &truncate.targets { + if let Err(result) = self.ensure_relation_target( + &target.id, + |kind| *kind == RelationKind::Table, + format!("truncated relation '{}' does not exist", target.id), + format!("truncated relation '{}' is not a table", target.id), + ) { + return result; + } + } + // Row contents and sequence counters are runtime data rather than + // schema catalog state. The operation remains fully typed for rules; + // no invented relation or sequence metadata is written here. + MutationResult::Applied + } + pub(super) fn apply_check_timeouts(&mut self) -> MutationResult { MutationResult::Applied } diff --git a/src/_internal/analysis/state/apply_policy_trigger.rs b/src/_internal/analysis/state/apply_policy_trigger.rs index 20d5453c..b49853f3 100644 --- a/src/_internal/analysis/state/apply_policy_trigger.rs +++ b/src/_internal/analysis/state/apply_policy_trigger.rs @@ -190,6 +190,9 @@ impl AnalysisState { name: create_trigger.name.clone(), id: trigger_id.clone(), table_id: create_trigger.table.clone(), + function_id: create_trigger.function_id.clone(), + row_level: create_trigger.row_level, + parent_trigger_id: None, enabled_mode: TriggerEnableMode::Origin, generation, }), diff --git a/src/_internal/analysis/state/apply_relation.rs b/src/_internal/analysis/state/apply_relation.rs index 58ded861..04cd4afb 100644 --- a/src/_internal/analysis/state/apply_relation.rs +++ b/src/_internal/analysis/state/apply_relation.rs @@ -8,653 +8,1997 @@ use crate::_internal::analysis::mutations::{ use crate::_internal::ast::identifiers::ObjectId; use crate::_internal::model::constraint::{ConstraintKind, ConstraintState}; use crate::_internal::model::relation::{ColumnAction, RelationKind, RelationState}; -use crate::_internal::model::sequence::{SequenceKind, SequenceOverlay, SequenceState}; -use crate::_internal::model::trigger::TriggerOverlay; +use crate::_internal::model::sequence::{ + SequenceKind, SequenceOverlay, SequenceParameters, SequencePersistence, SequenceState, +}; +use crate::_internal::model::trigger::{TriggerOverlay, TriggerState}; use std::collections::HashSet; type RelationLookup = ObjectLookup; impl AnalysisState { - fn relation_or_index_lookup(&self, id: &ObjectId) -> RelationLookup { - if self.relation_is_present(id) || self.index_is_present(id) { - RelationLookup::Present - } else if matches!(self.local.relations.get(id), Some(RelationOverlay::Dropped)) { - RelationLookup::Tombstone - } else if self.baseline_covers_family_object( - id, - crate::_internal::db::cache::CatalogFamily::Relations, - ) || self - .baseline_covers_family_object(id, crate::_internal::db::cache::CatalogFamily::Indexes) - { - RelationLookup::AuthoritativelyAbsent - } else { - RelationLookup::Unknown + fn inherited_descendants(&self, root: &ObjectId) -> Vec { + let mut pending = vec![root.clone()]; + let mut visited = HashSet::from([root.clone()]); + let mut descendants = Vec::new(); + while let Some(parent) = pending.pop() { + for edge in self.local.graph.edges() { + if edge.referenced != parent + || !matches!( + edge.kind, + DependencyKind::InheritanceOf | DependencyKind::PartitionOf + ) + || !visited.insert(edge.dependent.clone()) + { + continue; + } + pending.push(edge.dependent.clone()); + descendants.push(edge.dependent.clone()); + } } + descendants } - pub(super) fn apply_drop_table( - &mut self, - drop_table: &DropTable, - precomputed_cascade: Option<&CascadeResult>, - ) -> MutationResult { - if drop_table.ids.is_empty() { - return MutationResult::Skipped; + fn rename_relation_column_metadata(&mut self, table: &ObjectId, from: &str, to: &str) { + self.snapshot_relation(table); + if let Some(RelationOverlay::Present(relation)) = self.local.relations.get_mut(table) { + relation.apply_column_action(&ColumnAction::Rename { + from: from.to_string(), + to: to.to_string(), + }); } - - let renames: Vec = self + let constraints = self + .local + .constraints + .iter() + .filter(|((owner, _), constraint)| { + owner == table && constraint.kind == ConstraintKind::Check + }) + .filter_map(|((_, name), constraint)| { + constraint + .definition + .as_deref() + .map(|source| (name.clone(), source.to_string())) + }) + .collect::>(); + for (name, source) in constraints { + self.snapshot_constraint(table, &name); + if let Some(constraint) = self.local.constraints.get_mut(&(table.clone(), name)) { + constraint.definition = + crate::_internal::analysis::expr_visitor::ExprVisitor::rename_column_source( + &source, + &table.name, + from, + to, + ); + } + } + self.snapshot_graph_full(); + self.local.graph.rename_column_dependencies(table, from, to); + self.local.graph.rename_index_column(table, from, to); + let sequences = self .local .graph .edges() .iter() - .filter(|e| matches!(e.kind, DependencyKind::RenameTo)) - .cloned() - .collect(); - let resolve = |id: &ObjectId| -> ObjectId { - let mut current = id; - let mut visited = HashSet::new(); - loop { - if !visited.insert(current.clone()) { - return id.clone(); - } - match renames.iter().find(|r| &r.dependent == current) { - Some(edge) => current = &edge.referenced, - None => return current.clone(), - } + .filter(|edge| { + matches!(&edge.kind, DependencyKind::SequenceOwnedBy { column } if column == from) + }) + .map(|edge| (edge.dependent.clone(), edge.referenced == *table)) + .filter_map(|(sequence, owned)| owned.then_some(sequence)) + .collect::>(); + for sequence in sequences { + self.snapshot_sequence(&sequence); + if let Some(SequenceOverlay::Present(state)) = self.local.sequences.get_mut(&sequence) + && let Some((_, column)) = &mut state.owned_by + { + *column = to.to_string(); } + self.local + .graph + .rename_owned_sequence_column(&sequence, from, to); + } + } + fn partition_attachment_is_compatible( + &self, + parent_id: &ObjectId, + child_id: &ObjectId, + ) -> Result { + let Some(RelationOverlay::Present(parent)) = self.local.relations.get(parent_id) else { + return Ok(false); }; - - let display_names = drop_table - .ids - .iter() - .map(ToString::to_string) - .collect::>() - .join(", "); - let mut present_targets = Vec::new(); - let mut unknown_target = false; - for id in &drop_table.ids { - match self.relation_lookup(id, |kind| *kind == RelationKind::Table) { - RelationLookup::Present => present_targets.push(id.clone()), - RelationLookup::WrongKind => { - return MutationResult::Conflict { - reason: format!("'{}' is not a table", id), - }; - } - RelationLookup::AuthoritativelyAbsent if drop_table.if_exists => {} - RelationLookup::AuthoritativelyAbsent => { - return MutationResult::Conflict { - reason: format!("table '{}' does not exist", id), - }; - } - RelationLookup::Tombstone if drop_table.if_exists => {} - RelationLookup::Tombstone => { - return MutationResult::Conflict { - reason: format!("table '{}' does not exist", id), - }; - } - RelationLookup::Unknown => { - self.taint(EvidenceCode::UnknownObjectState, EvidenceScope::Chain); - unknown_target = true; - if !drop_table.if_exists { - return MutationResult::Skipped; + let Some(RelationOverlay::Present(child)) = self.local.relations.get(child_id) else { + return Ok(false); + }; + if parent.columns.len() != child.columns.len() { + return Err(format!( + "partition '{}' must have exactly the same columns as parent '{}'", + child_id, parent_id + )); + } + for (parent_column, child_column) in parent.columns.iter().zip(&child.columns) { + if parent_column.name != child_column.name { + return Err(format!( + "partition column '{}' does not match parent column '{}' in position", + child_column.name, parent_column.name + )); + } + let type_matches = match (&parent_column.type_id, &child_column.type_id) { + (Some(parent_type), Some(child_type)) => parent_type == child_type, + _ => match (&parent_column.data_type, &child_column.data_type) { + (Some(parent_type), Some(child_type)) => { + parent_type.trim().eq_ignore_ascii_case(child_type.trim()) } - } + _ => return Ok(false), + }, + }; + if !type_matches || parent_column.type_modifier != child_column.type_modifier { + return Err(format!( + "partition column '{}.{}' does not have the same type as parent", + child_id, child_column.name + )); + } + if !parent_column.is_nullable && child_column.is_nullable { + return Err(format!( + "partition column '{}.{}' must be NOT NULL like its parent", + child_id, child_column.name + )); + } + if parent + .generated_columns + .get(&parent_column.name) + .map(|value| &value.kind) + != child + .generated_columns + .get(&child_column.name) + .map(|value| &value.kind) + { + return Err(format!( + "partition column '{}.{}' has incompatible generated-column state", + child_id, child_column.name + )); } } - present_targets.sort_unstable_by_key(ToString::to_string); - present_targets.dedup(); - // `IF EXISTS` suppresses an absent-object error; it does not prove an - // object outside a scoped baseline is absent. PostgreSQL can therefore - // drop an unmodeled target (and its dependencies) in the same atomic - // statement. Do not apply known siblings with an incomplete target - // list. - if unknown_target { - return MutationResult::Skipped; - } - if present_targets.is_empty() { - return MutationResult::Skipped; - } - - // A synchronized relation row proves that the table exists, but it - // cannot prove a DROP result when the dependency catalog family was - // omitted. Treat both RESTRICT and CASCADE conservatively rather - // than letting a partial graph look complete merely because it has - // some cached edges. - if present_targets - .iter() - .any(|id| self.baseline_relation_is_known(id)) - && !self.baseline_has_coverage(crate::_internal::db::cache::CatalogFamily::Dependencies) - { - self.taint( - EvidenceCode::CatalogCoverageIncomplete, - EvidenceScope::Chain, - ); - return MutationResult::Skipped; - } - - // Relation-owned dependency loaders currently expand selected - // foreign-key boundaries, but do not establish that every possible - // cross-schema default, generated expression, policy, or extension - // dependency was loaded. Keep a scoped baseline DROP TABLE - // conservative until that object-class coverage is explicit. - if present_targets.iter().any(|id| { - self.baseline_scoped_family_object( - id, - crate::_internal::db::cache::CatalogFamily::Relations, - ) + let child_checks: std::collections::HashMap<&str, &ConstraintState> = self + .local + .constraints + .values() + .filter(|constraint| { + constraint.table_id == *child_id && constraint.kind == ConstraintKind::Check + }) + .map(|constraint| (constraint.name.as_str(), constraint)) + .collect(); + for parent_check in self.local.constraints.values().filter(|constraint| { + constraint.table_id == *parent_id && constraint.kind == ConstraintKind::Check }) { - self.taint( - EvidenceCode::CatalogCoverageIncomplete, - EvidenceScope::Chain, - ); - return MutationResult::Skipped; + let Some(child_check) = child_checks.get(parent_check.name.as_str()) else { + return Err(format!( + "partition '{}' is missing parent CHECK constraint '{}'", + child_id, parent_check.name + )); + }; + let (Some(parent_definition), Some(child_definition)) = + (&parent_check.definition, &child_check.definition) + else { + return Ok(false); + }; + if Self::normalized_constraint_expression(parent_definition) + != Self::normalized_constraint_expression(child_definition) + { + return Err(format!( + "partition CHECK constraint '{}' does not match parent '{}'", + parent_check.name, parent_id + )); + } } - let roots: HashSet = present_targets.iter().map(&resolve).collect(); - let mut dropped_relations = roots.clone(); - let mut dropped_indexes = HashSet::new(); - let mut dropped_constraints = HashSet::new(); - - if drop_table.cascade { - let local_closure; - let closure = match precomputed_cascade { - Some(c) => c, - None => { - local_closure = self.cascade_for_relations(&present_targets); - &local_closure - } + for parent_constraint in self.local.constraints.values().filter(|constraint| { + constraint.table_id == *parent_id + && matches!( + constraint.kind, + ConstraintKind::PrimaryKey | ConstraintKind::Unique + ) + }) { + let Some(child_constraint) = self + .local + .constraints + .get(&(child_id.clone(), parent_constraint.name.clone())) + else { + continue; }; - let closure_touches_baseline = closure - .dropped_relations - .iter() - .any(|id| self.baseline_relation_is_known(id) && !roots.contains(id)) - || closure - .dropped_constraints - .iter() - .any(|constraint| self.baseline_foreign_keys.contains(constraint)); - if closure_touches_baseline - && !self - .baseline_has_coverage(crate::_internal::db::cache::CatalogFamily::Dependencies) - { - self.taint( - EvidenceCode::CatalogCoverageIncomplete, - EvidenceScope::Chain, - ); - return MutationResult::Skipped; + if child_constraint.kind != parent_constraint.kind { + return Err(format!( + "partition constraint '{}' conflicts with parent constraint kind", + parent_constraint.name + )); } - if closure - .dropped_relations - .iter() - .any(|id| !self.relation_is_present(id) && !self.baseline_relation_is_known(id)) - { - // A scoped cache may retain a dependency edge to a relation - // whose catalog row was omitted. CASCADE removes it in - // PostgreSQL, but its unmodeled metadata makes the result - // incomplete rather than exact. - self.taint(EvidenceCode::UnknownObjectState, EvidenceScope::Chain); + let columns_for = |table: &ObjectId| { + self.local + .graph + .edges() + .iter() + .find_map(|edge| match &edge.kind { + DependencyKind::ConstraintOnRelation { + constraint_name, + columns, + .. + } if edge.dependent == *table + && edge.referenced == *table + && constraint_name == &parent_constraint.name => + { + Some(columns.clone()) + } + _ => None, + }) + }; + match (columns_for(parent_id), columns_for(child_id)) { + (Some(parent_columns), Some(child_columns)) if parent_columns == child_columns => {} + (Some(_), Some(_)) => { + return Err(format!( + "partition constraint '{}' has different key columns from parent", + parent_constraint.name + )); + } + _ => return Ok(false), } - dropped_relations = closure.dropped_relations.clone(); - dropped_indexes = closure.dropped_indexes.clone(); - dropped_constraints = closure.dropped_constraints.clone(); + } - for dropped_rel_id in &closure.dropped_relations { - self.snapshot_relation(dropped_rel_id); - self.local - .relations - .insert(dropped_rel_id.clone(), RelationOverlay::Dropped); + for parent_trigger in self.partition_row_trigger_plans(parent_id) { + let child_trigger_id = Self::trigger_key(child_id, &parent_trigger.name); + if matches!( + self.local.triggers.get(&child_trigger_id), + Some(TriggerOverlay::Present(_)) + ) { + return Err(format!( + "trigger '{}' on partition '{}' conflicts with parent trigger", + parent_trigger.name, child_id + )); } + } + Ok(true) + } - self.snapshot_graph_full(); - self.local.graph.retain_edges(|e| match &e.kind { - DependencyKind::IndexOnRelation { .. } => { - !closure.dropped_indexes.contains(&resolve(&e.dependent)) + fn partition_row_trigger_plans(&self, parent: &ObjectId) -> Vec { + self.local + .triggers + .values() + .filter_map(|overlay| match overlay { + TriggerOverlay::Present(trigger) + if trigger.table_id == *parent && trigger.row_level => + { + Some(trigger.clone()) } - DependencyKind::ForeignKey { - constraint_name, .. - } => { - let from_dropped = closure.dropped_relations.contains(&resolve(&e.dependent)); - let to_dropped = closure.dropped_relations.contains(&resolve(&e.referenced)); - let constraint_explicitly_dropped = if let Some(cname) = constraint_name { - closure - .dropped_constraints - .contains(&(resolve(&e.dependent), cname.clone())) - } else { - false - }; - !(from_dropped || to_dropped || constraint_explicitly_dropped) - } - DependencyKind::ViewDependency { .. } => { - !closure.dropped_relations.contains(&resolve(&e.dependent)) - } - DependencyKind::SequenceOwnedBy { .. } => { - !closure.dropped_relations.contains(&resolve(&e.referenced)) - } - _ => true, - }); - } else { - let has_view_deps = self.local.graph.edges().iter().any(|e| { - self.dependency_edge_is_current(e) - && matches!(e.kind, DependencyKind::ViewDependency { .. }) - && roots.contains(&resolve(&e.referenced)) - && !roots.contains(&resolve(&e.dependent)) - }); - let has_fk_deps = self.local.graph.edges().iter().any(|e| { - self.dependency_edge_is_current(e) - && matches!(e.kind, DependencyKind::ForeignKey { .. }) - && roots.contains(&resolve(&e.referenced)) - && !roots.contains(&resolve(&e.dependent)) - }); - let has_inheritance_deps = self.local.graph.edges().iter().any(|e| { - matches!( - e.kind, - DependencyKind::InheritanceOf | DependencyKind::PartitionOf - ) && roots.contains(&resolve(&e.referenced)) - && !roots.contains(&resolve(&e.dependent)) - }); + _ => None, + }) + .collect() + } - if has_view_deps || has_fk_deps || has_inheritance_deps { - let relation_word = if present_targets.len() == 1 { - "relation" - } else { - "relations" - }; - let dependent_verb = if present_targets.len() == 1 { - "has" - } else { - "have" - }; + fn clone_row_triggers_to_partition( + &mut self, + parent: &ObjectId, + child: &ObjectId, + ) -> MutationResult { + let plans = self.partition_row_trigger_plans(parent); + for parent_trigger in &plans { + let clone_id = Self::trigger_key(child, &parent_trigger.name); + if matches!( + self.local.triggers.get(&clone_id), + Some(TriggerOverlay::Present(_)) + ) { return MutationResult::Conflict { reason: format!( - "{relation_word} '{}' still {dependent_verb} dependent objects; use CASCADE", - display_names, + "trigger '{}' on partition '{}' conflicts with parent trigger", + parent_trigger.name, child ), }; } + } - for id in &roots { - self.snapshot_relation(id); - self.local - .relations - .insert(id.clone(), RelationOverlay::Dropped); + for parent_trigger in plans { + self.snapshot_generation_counter(); + self.local.generation_counter += 1; + let generation = self.local.generation_counter; + let clone_id = Self::trigger_key(child, &parent_trigger.name); + self.snapshot_trigger(&clone_id); + self.local.triggers.insert( + clone_id.clone(), + TriggerOverlay::Present(TriggerState { + name: parent_trigger.name.clone(), + id: clone_id.clone(), + table_id: child.clone(), + function_id: parent_trigger.function_id.clone(), + row_level: true, + parent_trigger_id: Some(parent_trigger.id.clone()), + enabled_mode: parent_trigger.enabled_mode, + generation, + }), + ); + self.snapshot_relation(child); + if let Some(RelationOverlay::Present(relation)) = self.local.relations.get_mut(child) { + relation.triggers.insert(parent_trigger.name); } - self.snapshot_graph_full(); - self.local.graph.retain_edges(|e| { - if roots.contains(&resolve(&e.dependent)) { - return !matches!( - e.kind, - DependencyKind::ForeignKey { .. } - | DependencyKind::ColumnGeneratedFrom { .. } - | DependencyKind::ColumnDefaultOnSequence { .. } - ); - } - if roots.contains(&resolve(&e.referenced)) { - return !matches!( - e.kind, - DependencyKind::IndexOnRelation { .. } - | DependencyKind::SequenceOwnedBy { .. } - | DependencyKind::ColumnGeneratedFrom { .. } - ); - } - true - }); - } - - let owned_sequences_to_drop: Vec = - self.local - .sequences - .iter() - .filter_map(|(id, overlay)| match overlay { - SequenceOverlay::Present(sequence) - if sequence.owned_by.as_ref().is_some_and(|(table, _)| { - dropped_relations.contains(&resolve(table)) - }) => - { - Some(id.clone()) - } - _ => None, - }) - .collect(); - for sequence_id in owned_sequences_to_drop { - self.snapshot_sequence(&sequence_id); - self.local - .sequences - .insert(sequence_id, SequenceOverlay::Dropped); + self.local.graph.add_edge(DependencyEdge::new( + clone_id.clone(), + child.clone(), + DependencyKind::TriggerOnTable { + trigger_id: clone_id, + function_id: parent_trigger.function_id, + trigger_generation: generation, + }, + )); } + MutationResult::Applied + } - self.remove_dropped_constraints(&dropped_relations, &dropped_constraints); - - let triggers_to_drop: Vec = self + fn remove_partition_trigger_clones(&mut self, parent: &ObjectId, child: &ObjectId) { + let parent_trigger_ids: HashSet = self + .local + .triggers + .values() + .filter_map(|overlay| match overlay { + TriggerOverlay::Present(trigger) if trigger.table_id == *parent => { + Some(trigger.id.clone()) + } + _ => None, + }) + .collect(); + let clones: Vec<(ObjectId, String)> = self .local .triggers .iter() - .filter_map(|(id, overlay)| { - let TriggerOverlay::Present(trigger) = overlay else { - return None; - }; - let graph_matches = self.local.graph.edges().iter().any(|edge| { - matches!(edge.kind, DependencyKind::TriggerOnTable { .. }) - && edge.dependent == *id - && dropped_relations.contains(&resolve(&edge.referenced)) - }); - (dropped_relations.contains(&resolve(&trigger.table_id)) || graph_matches) - .then(|| id.clone()) + .filter_map(|(id, overlay)| match overlay { + TriggerOverlay::Present(trigger) + if trigger.table_id == *child + && trigger + .parent_trigger_id + .as_ref() + .is_some_and(|id| parent_trigger_ids.contains(id)) => + { + Some((id.clone(), trigger.name.clone())) + } + _ => None, }) .collect(); - for trigger_id in triggers_to_drop { - self.snapshot_trigger(&trigger_id); + if clones.is_empty() { + return; + } + self.snapshot_relation(child); + self.snapshot_graph_full(); + for (id, name) in clones { + self.snapshot_trigger(&id); self.local .triggers - .insert(trigger_id, TriggerOverlay::Dropped); + .insert(id.clone(), TriggerOverlay::Dropped); + if let Some(RelationOverlay::Present(relation)) = self.local.relations.get_mut(child) { + relation.triggers.remove(&name); + } + self.local.graph.retain_edges(|edge| edge.dependent != id); } + } - // PostgreSQL drops triggers only after the table drop succeeds. - self.snapshot_graph_full(); - self.local.graph.retain_edges(|e| { - !(matches!(e.kind, DependencyKind::TriggerOnTable { .. }) - && dropped_relations.contains(&resolve(&e.referenced))) - }); - - // A successful relation drop removes every modeled edge that touches - // the dropped relation (or a cascaded index). Keep this final sweep - // broad so newly added edge kinds cannot leak stale topology through - // a table-drop path. - self.snapshot_graph_full(); - self.local.graph.retain_edges(|edge| { - let dependent = resolve(&edge.dependent); - let referenced = resolve(&edge.referenced); - !dropped_relations.contains(&dependent) - && !dropped_relations.contains(&referenced) - && !dropped_indexes.contains(&dependent) - }); + fn partition_indexes_equivalent(left: &DependencyKind, right: &DependencyKind) -> bool { + match (left, right) { + ( + DependencyKind::IndexOnRelation { + using_method: left_method, + key_columns: left_keys, + included_columns: left_included, + dependency_columns: left_dependencies, + dependency_columns_known: left_dependencies_known, + has_expression_keys: left_expressions, + has_predicate: left_predicate, + is_unique: left_unique, + is_valid: left_valid, + is_ready: left_ready, + is_live: left_live, + has_default_sort_order: left_sort, + has_default_opclasses: left_opclasses, + has_default_collations: left_collations, + eligibility_known: left_eligibility, + .. + }, + DependencyKind::IndexOnRelation { + using_method: right_method, + key_columns: right_keys, + included_columns: right_included, + dependency_columns: right_dependencies, + dependency_columns_known: right_dependencies_known, + has_expression_keys: right_expressions, + has_predicate: right_predicate, + is_unique: right_unique, + is_valid: right_valid, + is_ready: right_ready, + is_live: right_live, + has_default_sort_order: right_sort, + has_default_opclasses: right_opclasses, + has_default_collations: right_collations, + eligibility_known: right_eligibility, + .. + }, + ) => { + *left_valid + && *right_valid + && *left_ready + && *right_ready + && *left_live + && *right_live + && left_method == right_method + && left_keys == right_keys + && left_included == right_included + && left_dependencies == right_dependencies + && left_dependencies_known == right_dependencies_known + && left_expressions == right_expressions + && left_predicate == right_predicate + && left_unique == right_unique + && left_sort == right_sort + && left_opclasses == right_opclasses + && left_collations == right_collations + && left_eligibility == right_eligibility + } + _ => false, + } + } - let publication_updates: Vec<(String, Vec<_>)> = self + fn ensure_partition_indexes_and_constraints(&mut self, parent: &ObjectId, child: &ObjectId) { + let parent_indexes: Vec = self .local - .publications + .graph + .edges() .iter() - .filter_map(|(name, overlay)| { - let crate::_internal::model::replication::PublicationOverlay::Present(publication) = - overlay - else { - return None; - }; - let crate::_internal::analysis::facts::PublicationScope::Explicit(objects) = - &publication.scope - else { - return None; - }; - let retained = objects - .iter() - .filter(|object| { - let crate::_internal::analysis::facts::PublicationObjectFact::Table { - name, - .. - } = object - else { - return true; - }; - !dropped_relations.contains(&resolve(&self.resolve_relation_id(name))) - }) - .cloned() - .collect::>(); - (retained.len() != objects.len()).then(|| (name.clone(), retained)) + .filter(|edge| { + edge.referenced == *parent + && matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) }) + .cloned() + .collect(); + let child_indexes: Vec = self + .local + .graph + .edges() + .iter() + .filter(|edge| { + edge.referenced == *child + && matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) + }) + .cloned() + .collect(); + let parent_constraints: Vec = self + .local + .constraints + .values() + .filter(|constraint| constraint.table_id == *parent) + .cloned() .collect(); - for (publication_name, retained) in publication_updates { - self.snapshot_publication(&publication_name); - if let Some(crate::_internal::model::replication::PublicationOverlay::Present( - publication, - )) = self.local.publications.get_mut(&publication_name) - && let crate::_internal::analysis::facts::PublicationScope::Explicit(objects) = - &mut publication.scope - { - *objects = retained; - } - } - self.snapshot_graph_full(); - self.local.graph.retain_edges(|edge| { - !matches!(edge.kind, DependencyKind::PublicationIncludes { .. }) - || !dropped_relations.contains(&resolve(&edge.dependent)) - }); - MutationResult::Applied - } + for parent_index in parent_indexes { + let child_index = child_indexes + .iter() + .find(|candidate| { + Self::partition_indexes_equivalent(&parent_index.kind, &candidate.kind) + }) + .map(|edge| edge.dependent.clone()) + .unwrap_or_else(|| { + let columns = match &parent_index.kind { + DependencyKind::IndexOnRelation { key_columns, .. } => { + (!key_columns.is_empty()).then(|| key_columns.join("_")) + } + _ => None, + }; + let id = self.next_generated_relation_name_avoiding( + &child.schema, + &child.name, + columns.as_deref(), + "idx", + &HashSet::new(), + ); + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + id.clone(), + child.clone(), + parent_index.kind.clone(), + )); + id + }); - /// PostgreSQL records every NOT NULL column (whether declared inline, - /// introduced by a PRIMARY KEY, or set later) as a `pg_constraint` row of - /// `contype = 'n'` on PG18+. The normalized catalog represents it as a - /// `ConstraintKind::NotNull` entry with its single column on a - /// `ConstraintOnRelation` edge, mirroring the live sync so the differential - /// harness and destructive-transition dependency checks agree with - /// PostgreSQL. - fn register_not_null_constraint(&mut self, table: &ObjectId, column: &str) { - // PostgreSQL only materializes NOT NULL columns as `pg_constraint` - // rows (`contype = 'n'`) on PG18+. On PG17 and earlier a NOT NULL - // column is tracked purely by its nullability attribute, so recording - // it as a distinct constraint would add normalized state that the live - // database never lists. Column nullability is still carried by the - // column itself; only this separate constraint representation is - // suppressed. - if self.effective_pg_version_num(0) < 180_000 { - return; - } - if self.not_null_constraint_for_column(table, column).is_some() { - return; + let Some(parent_constraint) = parent_constraints.iter().find(|constraint| { + constraint.backing_index.as_ref() == Some(&parent_index.dependent) + && matches!( + constraint.kind, + ConstraintKind::PrimaryKey | ConstraintKind::Unique + ) + }) else { + continue; + }; + if self + .local + .constraints + .contains_key(&(child.clone(), parent_constraint.name.clone())) + { + continue; + } + let columns = self + .local + .graph + .edges() + .iter() + .find_map(|edge| match &edge.kind { + DependencyKind::ConstraintOnRelation { + constraint_name, + columns, + .. + } if edge.dependent == *parent + && edge.referenced == *parent + && constraint_name == &parent_constraint.name => + { + Some(columns.clone()) + } + _ => None, + }); + self.snapshot_constraint(child, &parent_constraint.name); + self.local.constraints.insert( + (child.clone(), parent_constraint.name.clone()), + ConstraintState { + table_id: child.clone(), + name: parent_constraint.name.clone(), + kind: parent_constraint.kind, + validated: true, + definition: None, + backing_index: Some(child_index), + }, + ); + if let Some(columns) = columns { + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + child.clone(), + child.clone(), + DependencyKind::ConstraintOnRelation { + constraint_name: parent_constraint.name.clone(), + columns, + is_primary: parent_constraint.kind == ConstraintKind::PrimaryKey, + }, + )); + } else { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + } } - let name = self.next_generated_constraint_name_avoiding( - table, - &table.name, - Some(column), - "not_null", - &HashSet::new(), - ); - self.snapshot_constraint(table, &name); - self.local.constraints.insert( - (table.clone(), name.clone()), - ConstraintState { - table_id: table.clone(), - name: name.clone(), - kind: ConstraintKind::NotNull, - validated: true, - backing_index: None, - }, - ); - self.snapshot_graph(); - self.local.graph.add_edge(DependencyEdge::new( - table.clone(), - table.clone(), - DependencyKind::ConstraintOnRelation { - constraint_name: name, - columns: vec![column.to_string()], - is_primary: false, - }, - )); } - fn drop_not_null_constraint(&mut self, table: &ObjectId, column: &str) { - let Some((table_id, name)) = self.not_null_constraint_for_column(table, column) else { - return; + fn default_column_sequence_parameters( + data_type: Option<&str>, + persistence: &crate::_internal::model::relation::Persistence, + ) -> SequenceParameters { + let data_type = match data_type + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("smallint" | "smallserial" | "serial2") => "smallint", + Some("integer" | "int" | "int4" | "serial" | "serial4") => "integer", + _ => "bigint", }; - self.snapshot_constraint(&table_id, &name); - self.local - .constraints - .remove(&(table_id.clone(), name.clone())); - self.snapshot_graph(); - let resolution_graph = self.local.graph.clone(); - let column_name = column.to_string(); - self.local.graph.retain_edges(|edge| { - !matches!( - &edge.kind, - DependencyKind::ConstraintOnRelation { - constraint_name, - columns, - is_primary: false, - .. - } if resolution_graph.resolve_rename(&edge.dependent) == &table_id - && constraint_name == &name - && columns.len() == 1 - && columns[0] == column_name - ) - }); + let max_value = match data_type { + "smallint" => i16::MAX as i64, + "integer" => i32::MAX as i64, + _ => i64::MAX, + }; + SequenceParameters { + data_type: data_type.to_string(), + max_value, + persistence: match persistence { + crate::_internal::model::relation::Persistence::Permanent => { + SequencePersistence::Permanent + } + crate::_internal::model::relation::Persistence::Temporary => { + SequencePersistence::Temporary + } + crate::_internal::model::relation::Persistence::Unlogged => { + SequencePersistence::Unlogged + } + }, + ..SequenceParameters::default() + } } - /// Resolve the `ConstraintKind::NotNull` constraint (if any) that guards a - /// single column on `table`, returning its identity. Not-null constraints - /// carry exactly one column on a non-primary `ConstraintOnRelation` edge. - fn not_null_constraint_for_column( - &self, - table: &ObjectId, - column: &str, - ) -> Option<(ObjectId, String)> { - let resolution_graph = self.local.graph.clone(); - self.local.graph.edges().iter().find_map(|edge| { - let dependent = resolution_graph.resolve_rename(&edge.dependent); - if dependent != table { - return None; - } - if let DependencyKind::ConstraintOnRelation { - constraint_name, - columns, - is_primary: false, - .. - } = &edge.kind - && columns.len() == 1 - && columns[0] == column - { - Some((dependent.clone(), constraint_name.clone())) - } else { - None - } - }) + fn constraint_index_dependency(columns: Vec, is_unique: bool) -> DependencyKind { + DependencyKind::IndexOnRelation { + using_method: Some("btree".to_string()), + key_columns: columns.clone(), + included_columns: Vec::new(), + dependency_columns: columns, + dependency_columns_known: true, + has_expression_keys: false, + has_predicate: false, + is_concurrent: false, + is_unique, + is_immediate: true, + is_valid: true, + is_ready: true, + is_live: true, + has_default_sort_order: true, + has_default_opclasses: true, + has_default_collations: true, + eligibility_known: true, + } } - pub(super) fn apply_create_table(&mut self, create: &CreateTable) -> MutationResult { - if let Err(result) = self.ensure_schema_target(&create.id.schema) { - return result; - } - if create.if_not_exists && self.relation_namespace_is_taken(&create.id) { - return MutationResult::Skipped; + pub(super) fn apply_identity_sequence_options( + mut parameters: SequenceParameters, + options: &crate::_internal::analysis::mutations::IdentitySequenceOptionsMutation, + ) -> Option { + if let Some(increment) = options.increment { + parameters.increment = increment; } - if self.relation_namespace_is_taken(&create.id) { - return MutationResult::Conflict { - reason: format!("relation '{}' already exists", create.id), + if let Some(data_type) = options.data_type.as_deref() { + let normalized = data_type.trim().to_ascii_lowercase(); + let (minimum, maximum) = match normalized.as_str() { + "smallint" | "int2" => (i16::MIN as i64, i16::MAX as i64), + "integer" | "int" | "int4" => (i32::MIN as i64, i32::MAX as i64), + "bigint" | "int8" => (i64::MIN, i64::MAX), + _ => return None, }; - } - - let mut column_names = HashSet::new(); - for column in &create.columns { - if !column_names.insert(column.name.clone()) { - return MutationResult::Conflict { - reason: format!("column '{}' specified more than once", column.name), - }; + parameters.data_type = match normalized.as_str() { + "smallint" | "int2" => "smallint", + "integer" | "int" | "int4" => "integer", + _ => "bigint", } - } - let primary_declarations = create - .columns - .iter() - .filter(|column| column.is_primary_key) - .count() - + create - .table_constraints - .iter() - .filter(|constraint| matches!(constraint, TableConstraintFact::PrimaryKey { .. })) - .count(); - if primary_declarations > 1 { - return MutationResult::Conflict { - reason: "multiple primary keys for table are not allowed".to_string(), + .to_string(); + parameters.min_value = if parameters.increment > 0 { 1 } else { minimum }; + parameters.max_value = if parameters.increment > 0 { + maximum + } else { + -1 }; } - for constraint in &create.table_constraints { - let columns = match constraint { - TableConstraintFact::PrimaryKey { columns, .. } - | TableConstraintFact::Unique { columns, .. } => columns, - TableConstraintFact::Check { .. } | TableConstraintFact::Exclude { .. } => { - continue; + let type_bounds = match parameters.data_type.as_str() { + "smallint" => (i16::MIN as i64, i16::MAX as i64), + "integer" => (i32::MIN as i64, i32::MAX as i64), + "bigint" => (i64::MIN, i64::MAX), + _ => return None, + }; + parameters.min_value = match options.min_value { + Some(Some(value)) => value, + Some(None) => { + if parameters.increment > 0 { + 1 + } else { + type_bounds.0 } - }; - if columns.is_empty() { - return MutationResult::Conflict { - reason: "key constraint must name at least one column".to_string(), - }; } - let mut key_columns = HashSet::new(); - for column in columns { - if !key_columns.insert(column) { - return MutationResult::Conflict { - reason: format!( - "column '{}' appears more than once in a key constraint", - column - ), - }; - } - if !column_names.contains(column) { - return MutationResult::Conflict { - reason: format!( - "constraint references column '{}' which does not exist on relation '{}'", - column, create.id - ), - }; + None => parameters.min_value, + }; + parameters.max_value = match options.max_value { + Some(Some(value)) => value, + Some(None) => { + if parameters.increment > 0 { + type_bounds.1 + } else { + -1 } } + None => parameters.max_value, + }; + if let Some(start) = options.start_value { + parameters.start_value = start; + } else if options.increment.is_some() && options.increment.unwrap_or(1) < 0 { + parameters.start_value = parameters.max_value; } - - if let Some(parent_id) = &create.partition_of - && let Err(result) = self.ensure_relation_target( - parent_id, - |kind| *kind == RelationKind::Table, - format!("partition parent relation '{}' does not exist", parent_id), - format!("partition parent '{}' is not a table", parent_id), - ) - { - return result; + if let Some(cache_size) = options.cache_size { + parameters.cache_size = cache_size; } - if let Some(parent_id) = &create.partition_of { - let Some(RelationOverlay::Present(parent)) = self.local.relations.get(parent_id) else { - self.taint(EvidenceCode::UnknownObjectState, EvidenceScope::Chain); - return MutationResult::Skipped; + if let Some(cycle) = options.cycle { + parameters.cycle = cycle; + } + if let Some(logged) = options.persistence { + parameters.persistence = if logged { + SequencePersistence::Permanent + } else { + SequencePersistence::Unlogged }; - if parent.partition_type.is_none() { - return MutationResult::Conflict { - reason: format!("partition parent '{}' is not partitioned", parent_id), - }; - } } - let mut effective_fk_target_columns = Vec::with_capacity(create.foreign_keys.len()); - for fk in &create.foreign_keys { - if fk.from_columns.is_empty() { - return MutationResult::Conflict { - reason: format!( - "foreign key on relation '{}' has no source columns", - create.id - ), - }; - } - if !fk.to_columns.is_empty() && fk.from_columns.len() != fk.to_columns.len() { - return MutationResult::Conflict { - reason: format!( - "foreign key on '{}' has {} source columns but {} referenced columns", - create.id, - fk.from_columns.len(), - fk.to_columns.len() - ), - }; - } - let mut source_columns = HashSet::new(); - if let Some(column) = fk - .from_columns - .iter() - .find(|column| !source_columns.insert(column.as_str())) - { - return MutationResult::Conflict { - reason: format!( - "foreign key on '{}' repeats source column '{}'", - create.id, column - ), - }; + (parameters.increment != 0 + && parameters.cache_size > 0 + && parameters.min_value < parameters.max_value + && parameters.start_value >= parameters.min_value + && parameters.start_value <= parameters.max_value) + .then_some(parameters) + } + + fn normalized_constraint_expression(definition: &str) -> String { + let mut offset = 0usize; + let mut normalized = String::new(); + for token in squawk_lexer::tokenize(definition) { + let end = offset + token.len as usize; + let text = &definition[offset..end]; + offset = end; + if matches!( + token.kind, + squawk_lexer::TokenKind::Whitespace + | squawk_lexer::TokenKind::LineComment + | squawk_lexer::TokenKind::BlockComment { .. } + | squawk_lexer::TokenKind::Eof + ) { + continue; } - let mut target_columns = HashSet::new(); - if let Some(column) = fk - .to_columns + let text = if matches!(token.kind, squawk_lexer::TokenKind::Ident) { + text.to_ascii_lowercase() + } else { + text.to_string() + }; + use std::fmt::Write; + let _ = write!(normalized, "{:?}:{}:{};", token.kind, text.len(), text); + } + normalized + } + + fn relation_or_index_lookup(&self, id: &ObjectId) -> RelationLookup { + if self.relation_is_present(id) || self.index_is_present(id) { + RelationLookup::Present + } else if matches!(self.local.relations.get(id), Some(RelationOverlay::Dropped)) { + RelationLookup::Tombstone + } else if self.baseline_covers_family_object( + id, + crate::_internal::db::cache::CatalogFamily::Relations, + ) || self + .baseline_covers_family_object(id, crate::_internal::db::cache::CatalogFamily::Indexes) + { + RelationLookup::AuthoritativelyAbsent + } else { + RelationLookup::Unknown + } + } + + pub(super) fn apply_drop_table( + &mut self, + drop_table: &DropTable, + precomputed_cascade: Option<&CascadeResult>, + ) -> MutationResult { + if drop_table.ids.is_empty() { + return MutationResult::Skipped; + } + + let renames: Vec = self + .local + .graph + .edges() + .iter() + .filter(|e| matches!(e.kind, DependencyKind::RenameTo)) + .cloned() + .collect(); + let resolve = |id: &ObjectId| -> ObjectId { + let mut current = id; + let mut visited = HashSet::new(); + loop { + if !visited.insert(current.clone()) { + return id.clone(); + } + match renames.iter().find(|r| &r.dependent == current) { + Some(edge) => current = &edge.referenced, + None => return current.clone(), + } + } + }; + + let display_names = drop_table + .ids + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + let mut present_targets = Vec::new(); + let mut unknown_target = false; + for id in &drop_table.ids { + match self.relation_lookup(id, |kind| *kind == RelationKind::Table) { + RelationLookup::Present => present_targets.push(id.clone()), + RelationLookup::WrongKind => { + return MutationResult::Conflict { + reason: format!("'{}' is not a table", id), + }; + } + RelationLookup::AuthoritativelyAbsent if drop_table.if_exists => {} + RelationLookup::AuthoritativelyAbsent => { + return MutationResult::Conflict { + reason: format!("table '{}' does not exist", id), + }; + } + RelationLookup::Tombstone if drop_table.if_exists => {} + RelationLookup::Tombstone => { + return MutationResult::Conflict { + reason: format!("table '{}' does not exist", id), + }; + } + RelationLookup::Unknown => { + self.taint(EvidenceCode::UnknownObjectState, EvidenceScope::Chain); + unknown_target = true; + if !drop_table.if_exists { + return MutationResult::Skipped; + } + } + } + } + + present_targets.sort_unstable_by_key(ToString::to_string); + present_targets.dedup(); + // `IF EXISTS` suppresses an absent-object error; it does not prove an + // object outside a scoped baseline is absent. PostgreSQL can therefore + // drop an unmodeled target (and its dependencies) in the same atomic + // statement. Do not apply known siblings with an incomplete target + // list. + if unknown_target { + return MutationResult::Skipped; + } + if present_targets.is_empty() { + return MutationResult::Skipped; + } + + // A synchronized relation row proves that the table exists, but it + // cannot prove a DROP result when the dependency catalog family was + // omitted. Treat both RESTRICT and CASCADE conservatively rather + // than letting a partial graph look complete merely because it has + // some cached edges. + if present_targets + .iter() + .any(|id| self.baseline_relation_is_known(id)) + && !self.baseline_has_coverage(crate::_internal::db::cache::CatalogFamily::Dependencies) + { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + return MutationResult::Skipped; + } + + // Relation-owned dependency loaders currently expand selected + // foreign-key boundaries, but do not establish that every possible + // cross-schema default, generated expression, policy, or extension + // dependency was loaded. Keep a scoped baseline DROP TABLE + // conservative until that object-class coverage is explicit. + if present_targets.iter().any(|id| { + self.baseline_scoped_family_object( + id, + crate::_internal::db::cache::CatalogFamily::Relations, + ) + }) { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + return MutationResult::Skipped; + } + + let roots: HashSet = present_targets.iter().map(&resolve).collect(); + let mut dropped_relations = roots.clone(); + let mut dropped_indexes = HashSet::new(); + let mut dropped_constraints = HashSet::new(); + + if drop_table.cascade { + let local_closure; + let closure = match precomputed_cascade { + Some(c) => c, + None => { + local_closure = self.cascade_for_relations(&present_targets); + &local_closure + } + }; + let closure_touches_baseline = closure + .dropped_relations + .iter() + .any(|id| self.baseline_relation_is_known(id) && !roots.contains(id)) + || closure + .dropped_constraints + .iter() + .any(|constraint| self.baseline_foreign_keys.contains(constraint)); + if closure_touches_baseline + && !self + .baseline_has_coverage(crate::_internal::db::cache::CatalogFamily::Dependencies) + { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + return MutationResult::Skipped; + } + if closure + .dropped_relations + .iter() + .any(|id| !self.relation_is_present(id) && !self.baseline_relation_is_known(id)) + { + // A scoped cache may retain a dependency edge to a relation + // whose catalog row was omitted. CASCADE removes it in + // PostgreSQL, but its unmodeled metadata makes the result + // incomplete rather than exact. + self.taint(EvidenceCode::UnknownObjectState, EvidenceScope::Chain); + } + dropped_relations = closure.dropped_relations.clone(); + dropped_indexes = closure.dropped_indexes.clone(); + dropped_constraints = closure.dropped_constraints.clone(); + + for dropped_rel_id in &closure.dropped_relations { + self.snapshot_relation(dropped_rel_id); + self.local + .relations + .insert(dropped_rel_id.clone(), RelationOverlay::Dropped); + } + + self.snapshot_graph_full(); + self.local.graph.retain_edges(|e| match &e.kind { + DependencyKind::IndexOnRelation { .. } => { + !closure.dropped_indexes.contains(&resolve(&e.dependent)) + } + DependencyKind::ForeignKey { + constraint_name, .. + } => { + let from_dropped = closure.dropped_relations.contains(&resolve(&e.dependent)); + let to_dropped = closure.dropped_relations.contains(&resolve(&e.referenced)); + let constraint_explicitly_dropped = if let Some(cname) = constraint_name { + closure + .dropped_constraints + .contains(&(resolve(&e.dependent), cname.clone())) + } else { + false + }; + !(from_dropped || to_dropped || constraint_explicitly_dropped) + } + DependencyKind::ViewDependency { .. } => { + !closure.dropped_relations.contains(&resolve(&e.dependent)) + } + DependencyKind::SequenceOwnedBy { .. } => { + !closure.dropped_relations.contains(&resolve(&e.referenced)) + } + _ => true, + }); + } else { + let has_view_deps = self.local.graph.edges().iter().any(|e| { + self.dependency_edge_is_current(e) + && matches!(e.kind, DependencyKind::ViewDependency { .. }) + && roots.contains(&resolve(&e.referenced)) + && !roots.contains(&resolve(&e.dependent)) + }); + let has_fk_deps = self.local.graph.edges().iter().any(|e| { + self.dependency_edge_is_current(e) + && matches!(e.kind, DependencyKind::ForeignKey { .. }) + && roots.contains(&resolve(&e.referenced)) + && !roots.contains(&resolve(&e.dependent)) + }); + let has_inheritance_deps = self.local.graph.edges().iter().any(|e| { + matches!( + e.kind, + DependencyKind::InheritanceOf + | DependencyKind::PartitionOf + | DependencyKind::PartitionDetachPending + ) && roots.contains(&resolve(&e.referenced)) + && !roots.contains(&resolve(&e.dependent)) + }); + + if has_view_deps || has_fk_deps || has_inheritance_deps { + let relation_word = if present_targets.len() == 1 { + "relation" + } else { + "relations" + }; + let dependent_verb = if present_targets.len() == 1 { + "has" + } else { + "have" + }; + return MutationResult::Conflict { + reason: format!( + "{relation_word} '{}' still {dependent_verb} dependent objects; use CASCADE", + display_names, + ), + }; + } + + for id in &roots { + self.snapshot_relation(id); + self.local + .relations + .insert(id.clone(), RelationOverlay::Dropped); + } + + self.snapshot_graph_full(); + self.local.graph.retain_edges(|e| { + if roots.contains(&resolve(&e.dependent)) { + return !matches!( + e.kind, + DependencyKind::ForeignKey { .. } + | DependencyKind::ColumnGeneratedFrom { .. } + | DependencyKind::ColumnDefaultOnSequence { .. } + ); + } + if roots.contains(&resolve(&e.referenced)) { + return !matches!( + e.kind, + DependencyKind::IndexOnRelation { .. } + | DependencyKind::SequenceOwnedBy { .. } + | DependencyKind::ColumnGeneratedFrom { .. } + ); + } + true + }); + } + + let owned_sequences_to_drop: Vec = + self.local + .sequences + .iter() + .filter_map(|(id, overlay)| match overlay { + SequenceOverlay::Present(sequence) + if sequence.owned_by.as_ref().is_some_and(|(table, _)| { + dropped_relations.contains(&resolve(table)) + }) => + { + Some(id.clone()) + } + _ => None, + }) + .collect(); + for sequence_id in owned_sequences_to_drop { + self.snapshot_sequence(&sequence_id); + self.local + .sequences + .insert(sequence_id, SequenceOverlay::Dropped); + } + + self.remove_dropped_constraints(&dropped_relations, &dropped_constraints); + + let triggers_to_drop: Vec = self + .local + .triggers + .iter() + .filter_map(|(id, overlay)| { + let TriggerOverlay::Present(trigger) = overlay else { + return None; + }; + let graph_matches = self.local.graph.edges().iter().any(|edge| { + matches!(edge.kind, DependencyKind::TriggerOnTable { .. }) + && edge.dependent == *id + && dropped_relations.contains(&resolve(&edge.referenced)) + }); + (dropped_relations.contains(&resolve(&trigger.table_id)) || graph_matches) + .then(|| id.clone()) + }) + .collect(); + for trigger_id in triggers_to_drop { + self.snapshot_trigger(&trigger_id); + self.local + .triggers + .insert(trigger_id, TriggerOverlay::Dropped); + } + + // PostgreSQL drops triggers only after the table drop succeeds. + self.snapshot_graph_full(); + self.local.graph.retain_edges(|e| { + !(matches!(e.kind, DependencyKind::TriggerOnTable { .. }) + && dropped_relations.contains(&resolve(&e.referenced))) + }); + + // A successful relation drop removes every modeled edge that touches + // the dropped relation (or a cascaded index). Keep this final sweep + // broad so newly added edge kinds cannot leak stale topology through + // a table-drop path. + self.snapshot_graph_full(); + self.local.graph.retain_edges(|edge| { + let dependent = resolve(&edge.dependent); + let referenced = resolve(&edge.referenced); + !dropped_relations.contains(&dependent) + && !dropped_relations.contains(&referenced) + && !dropped_indexes.contains(&dependent) + }); + + let publication_updates: Vec<(String, Vec<_>)> = self + .local + .publications + .iter() + .filter_map(|(name, overlay)| { + let crate::_internal::model::replication::PublicationOverlay::Present(publication) = + overlay + else { + return None; + }; + let crate::_internal::analysis::facts::PublicationScope::Explicit(objects) = + &publication.scope + else { + return None; + }; + let retained = objects + .iter() + .filter(|object| { + let crate::_internal::analysis::facts::PublicationObjectFact::Table { + name, + .. + } = object + else { + return true; + }; + !dropped_relations.contains(&resolve(&self.resolve_relation_id(name))) + }) + .cloned() + .collect::>(); + (retained.len() != objects.len()).then(|| (name.clone(), retained)) + }) + .collect(); + for (publication_name, retained) in publication_updates { + self.snapshot_publication(&publication_name); + if let Some(crate::_internal::model::replication::PublicationOverlay::Present( + publication, + )) = self.local.publications.get_mut(&publication_name) + && let crate::_internal::analysis::facts::PublicationScope::Explicit(objects) = + &mut publication.scope + { + *objects = retained; + } + } + self.snapshot_graph_full(); + self.local.graph.retain_edges(|edge| { + !matches!(edge.kind, DependencyKind::PublicationIncludes { .. }) + || !dropped_relations.contains(&resolve(&edge.dependent)) + }); + + MutationResult::Applied + } + + /// PostgreSQL records every NOT NULL column (whether declared inline, + /// introduced by a PRIMARY KEY, or set later) as a `pg_constraint` row of + /// `contype = 'n'` on PG18+. The normalized catalog represents it as a + /// `ConstraintKind::NotNull` entry with its single column on a + /// `ConstraintOnRelation` edge, mirroring the live sync so the differential + /// harness and destructive-transition dependency checks agree with + /// PostgreSQL. + fn register_not_null_constraint(&mut self, table: &ObjectId, column: &str) { + // PostgreSQL only materializes NOT NULL columns as `pg_constraint` + // rows (`contype = 'n'`) on PG18+. On PG17 and earlier a NOT NULL + // column is tracked purely by its nullability attribute, so recording + // it as a distinct constraint would add normalized state that the live + // database never lists. Column nullability is still carried by the + // column itself; only this separate constraint representation is + // suppressed. + if self.effective_pg_version_num(0) < 180_000 { + return; + } + if self.not_null_constraint_for_column(table, column).is_some() { + return; + } + let name = self.next_generated_constraint_name_avoiding( + table, + &table.name, + Some(column), + "not_null", + &HashSet::new(), + ); + self.snapshot_constraint(table, &name); + self.local.constraints.insert( + (table.clone(), name.clone()), + ConstraintState { + table_id: table.clone(), + name: name.clone(), + kind: ConstraintKind::NotNull, + validated: true, + definition: None, + backing_index: None, + }, + ); + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + table.clone(), + table.clone(), + DependencyKind::ConstraintOnRelation { + constraint_name: name, + columns: vec![column.to_string()], + is_primary: false, + }, + )); + } + + fn drop_not_null_constraint(&mut self, table: &ObjectId, column: &str) { + let Some((table_id, name)) = self.not_null_constraint_for_column(table, column) else { + return; + }; + self.snapshot_constraint(&table_id, &name); + self.local + .constraints + .remove(&(table_id.clone(), name.clone())); + self.snapshot_graph_full(); + let resolution_graph = self.local.graph.clone(); + let column_name = column.to_string(); + self.local.graph.retain_edges(|edge| { + !matches!( + &edge.kind, + DependencyKind::ConstraintOnRelation { + constraint_name, + columns, + is_primary: false, + .. + } if resolution_graph.resolve_rename(&edge.dependent) == &table_id + && constraint_name == &name + && columns.len() == 1 + && columns[0] == column_name + ) + }); + } + + /// Resolve the `ConstraintKind::NotNull` constraint (if any) that guards a + /// single column on `table`, returning its identity. Not-null constraints + /// carry exactly one column on a non-primary `ConstraintOnRelation` edge. + fn not_null_constraint_for_column( + &self, + table: &ObjectId, + column: &str, + ) -> Option<(ObjectId, String)> { + let resolution_graph = self.local.graph.clone(); + self.local.graph.edges().iter().find_map(|edge| { + let dependent = resolution_graph.resolve_rename(&edge.dependent); + if dependent != table { + return None; + } + if let DependencyKind::ConstraintOnRelation { + constraint_name, + columns, + is_primary: false, + .. + } = &edge.kind + && columns.len() == 1 + && columns[0] == column + { + Some((dependent.clone(), constraint_name.clone())) + } else { + None + } + }) + } + + pub(super) fn apply_create_table(&mut self, create: &CreateTable) -> MutationResult { + if let Some(strategy) = &create.partition_strategy + && !["range", "list", "hash"] + .iter() + .any(|valid| strategy.eq_ignore_ascii_case(valid)) + { + return MutationResult::Conflict { + reason: format!("unrecognized partitioning strategy '{strategy}'"), + }; + } + if let Err(result) = self.ensure_schema_target(&create.id.schema) { + return result; + } + if create.if_not_exists && self.relation_namespace_is_taken(&create.id) { + return MutationResult::Skipped; + } + if self.relation_namespace_is_taken(&create.id) { + return MutationResult::Conflict { + reason: format!("relation '{}' already exists", create.id), + }; + } + if let Some(type_id) = &create.of_type { + match self.local.types.get(type_id) { + Some(crate::_internal::model::types::TypeOverlay::Present( + crate::_internal::model::types::TypeState { + kind: crate::_internal::model::types::TypeKind::Composite { .. }, + .. + }, + )) => {} + Some(crate::_internal::model::types::TypeOverlay::Present(_)) => { + return MutationResult::Conflict { + reason: format!("type '{}' is not composite", type_id), + }; + } + Some(crate::_internal::model::types::TypeOverlay::Dropped) => { + return MutationResult::Conflict { + reason: format!("composite type '{}' does not exist", type_id), + }; + } + None => { + self.taint(EvidenceCode::UnknownObjectState, EvidenceScope::Chain); + return MutationResult::Skipped; + } + } + } + + let mut inherited_columns = Vec::new(); + let mut inherited_names = HashSet::new(); + let mut inherited_generated_columns = std::collections::HashMap::new(); + let mut inherited_check_constraints: std::collections::HashMap< + String, + (Vec, String), + > = std::collections::HashMap::new(); + let mut like_dependency_edges = Vec::new(); + let mut like_identity_columns = Vec::new(); + let mut like_generated_columns = Vec::new(); + let mut like_check_constraints = Vec::new(); + let mut like_indexes = Vec::new(); + let mut like_extended_statistics = Vec::new(); + let mut reserved_statistics_ids = HashSet::new(); + let mut partition_foreign_keys = Vec::new(); + for parent_id in &create.inherits { + if let Err(result) = self.ensure_relation_target( + parent_id, + |kind| *kind == RelationKind::Table, + format!("inheritance parent relation '{}' does not exist", parent_id), + format!("inheritance parent '{}' is not a table", parent_id), + ) { + return result; + } + if self + .local + .graph + .check_inheritance_cycle(parent_id, &create.id) + { + return MutationResult::Conflict { + reason: format!( + "inheriting '{}' into '{}' would create an inheritance cycle", + parent_id, create.id + ), + }; + } + let Some(RelationOverlay::Present(parent)) = self.local.relations.get(parent_id) else { + self.taint(EvidenceCode::UnknownObjectState, EvidenceScope::Chain); + return MutationResult::Skipped; + }; + for column in &parent.columns { + if !inherited_names.insert(column.name.clone()) { + let existing = inherited_columns + .iter_mut() + .find(|candidate: &&mut crate::_internal::model::column::Column| { + candidate.name == column.name + }) + .expect("an inherited name always has a column"); + let compatible_type = match ( + existing.type_id.as_ref(), + column.type_id.as_ref(), + existing.data_type.as_deref(), + column.data_type.as_deref(), + ) { + (Some(left), Some(right), _, _) => left == right, + (_, _, Some(left), Some(right)) => { + left.trim().eq_ignore_ascii_case(right.trim()) + } + _ => false, + }; + if !compatible_type + || (existing.default.is_some() + && column.default.is_some() + && existing.default != column.default) + { + return MutationResult::Conflict { + reason: format!( + "inherited column '{}' has incompatible parent definitions", + column.name + ), + }; + } + existing.is_nullable &= column.is_nullable; + if existing.default.is_none() { + existing.default = column.default.clone(); + existing.default_expr_text = column.default_expr_text.clone(); + } + continue; + } + inherited_columns.push(column.clone()); + } + for (column, generated) in &parent.generated_columns { + if let Some(existing) = inherited_generated_columns.get(column) + && existing != generated + { + return MutationResult::Conflict { + reason: format!( + "inherited generated column '{}' has incompatible parent definitions", + column + ), + }; + } + inherited_generated_columns.insert(column.clone(), generated.clone()); + } + for constraint in self.local.constraints.values().filter(|constraint| { + constraint.table_id == *parent_id + && matches!(constraint.kind, ConstraintKind::Check) + }) { + let Some(definition) = constraint.definition.as_deref() else { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + return MutationResult::Skipped; + }; + let Some(columns) = + self.local + .graph + .edges() + .iter() + .find_map(|edge| match &edge.kind { + DependencyKind::ConstraintDependency { + constraint_name, + columns, + } if edge.dependent == *parent_id + && edge.referenced == *parent_id + && constraint_name == &constraint.name => + { + Some(columns.clone()) + } + _ => None, + }) + else { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + return MutationResult::Skipped; + }; + if let Some((_, existing)) = inherited_check_constraints.get(&constraint.name) + && Self::normalized_constraint_expression(existing) + != Self::normalized_constraint_expression(definition) + { + return MutationResult::Conflict { + reason: format!( + "inherited CHECK constraint '{}' has incompatible parent definitions", + constraint.name + ), + }; + } + inherited_check_constraints + .insert(constraint.name.clone(), (columns, definition.to_string())); + } + } + like_check_constraints.extend(inherited_check_constraints.iter().map( + |(name, (columns, definition))| (name.clone(), columns.clone(), definition.clone()), + )); + + // An unadorned LIKE copies only names, types, and NOT NULL markers. + // Each selected property is copied independently, matching PostgreSQL's + // `INCLUDING` contract without inventing unrelated catalog objects. + for like_source in &create.like_sources { + let source_id = &like_source.relation; + if !self.relation_is_present(source_id) + && let Some(crate::_internal::model::types::TypeOverlay::Present( + crate::_internal::model::types::TypeState { + kind: crate::_internal::model::types::TypeKind::Composite { fields }, + .. + }, + )) = self.local.types.get(source_id) + { + for field in fields { + if !inherited_names.insert(field.name.clone()) { + return MutationResult::Conflict { + reason: format!( + "column '{}' is copied from more than one source", + field.name + ), + }; + } + inherited_columns.push( + crate::_internal::model::column::Column::migration_created( + field.name.clone(), + Some(field.data_type.clone()), + true, + None, + ), + ); + } + continue; + } + if let Err(result) = self.ensure_relation_target( + source_id, + |kind| { + matches!( + kind, + RelationKind::Table | RelationKind::View | RelationKind::MaterializedView + ) + }, + format!("LIKE source relation '{}' does not exist", source_id), + format!( + "LIKE source '{}' cannot provide a table row type", + source_id + ), + ) { + return result; + } + let Some(RelationOverlay::Present(source)) = self.local.relations.get(source_id) else { + self.taint(EvidenceCode::UnknownObjectState, EvidenceScope::Chain); + return MutationResult::Skipped; + }; + let source = source.clone(); + if like_source.properties.statistics { + for statistics in source.extended_statistics.values() { + let id = self.next_generated_statistics_name_avoiding( + &create.id.schema, + &create.id.name, + &statistics.columns, + &reserved_statistics_ids, + ); + reserved_statistics_ids.insert(id.clone()); + let mut cloned = statistics.clone(); + cloned.id = id; + // LIKE copies the statistics definition, while PostgreSQL + // initializes the new object's target independently. + cloned.target = None; + like_extended_statistics.push(cloned); + } + } + if like_source.properties.generated || like_source.properties.defaults { + like_dependency_edges.extend(self.local.graph.edges().iter().filter_map(|edge| { + if edge.dependent != *source_id { + return None; + } + match &edge.kind { + DependencyKind::ColumnGeneratedFrom { .. } + if like_source.properties.generated => + { + Some(DependencyEdge::new( + create.id.clone(), + create.id.clone(), + edge.kind.clone(), + )) + } + DependencyKind::ColumnDefaultOnSequence { .. } + if like_source.properties.defaults => + { + Some(DependencyEdge::new( + create.id.clone(), + edge.referenced.clone(), + edge.kind.clone(), + )) + } + _ => None, + } + })); + } + if like_source.properties.identity { + for (column, generation) in &source.identity_columns { + let Some(parameters) = self.local.sequences.values().find_map(|overlay| { + let SequenceOverlay::Present(sequence) = overlay else { + return None; + }; + (sequence.kind == SequenceKind::Identity + && sequence.owned_by.as_ref() + == Some(&(source_id.clone(), column.clone()))) + .then(|| sequence.parameters.clone()) + }) else { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + return MutationResult::Skipped; + }; + like_identity_columns.push((column.clone(), *generation, parameters)); + } + } + if like_source.properties.generated { + like_generated_columns.extend( + source + .generated_columns + .iter() + .map(|(column, state)| (column.clone(), state.clone())), + ); + } + if like_source.properties.constraints { + let source_checks: Vec = self + .local + .constraints + .values() + .filter(|constraint| { + constraint.table_id == *source_id + && matches!(constraint.kind, ConstraintKind::Check) + }) + .cloned() + .collect(); + for constraint in source_checks { + let Some(columns) = self.local.graph.edges().iter().find_map(|edge| { + if edge.dependent != *source_id || edge.referenced != *source_id { + return None; + } + match &edge.kind { + DependencyKind::ConstraintDependency { + constraint_name, + columns, + } if constraint_name == &constraint.name => Some(columns.clone()), + _ => None, + } + }) else { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + return MutationResult::Skipped; + }; + let Some(definition) = constraint.definition.clone() else { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + return MutationResult::Skipped; + }; + like_check_constraints.push((constraint.name.clone(), columns, definition)); + } + } + if like_source.properties.indexes { + let source_constraints: Vec = self + .local + .constraints + .values() + .filter(|constraint| constraint.table_id == *source_id) + .cloned() + .collect(); + let mut copied_constraints = HashSet::new(); + for edge in self.local.graph.edges().iter().filter(|edge| { + edge.referenced == *source_id + && matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) + }) { + let constraint = source_constraints + .iter() + .find(|constraint| { + constraint.backing_index.as_ref() == Some(&edge.dependent) + }) + .and_then(|constraint| { + let columns = self.local.graph.edges().iter().find_map(|key_edge| { + if key_edge.dependent != *source_id + || key_edge.referenced != *source_id + { + return None; + } + match &key_edge.kind { + DependencyKind::ConstraintOnRelation { + constraint_name, + columns, + .. + } + | DependencyKind::ConstraintDependency { + constraint_name, + columns, + } if constraint_name == &constraint.name => { + Some(columns.clone()) + } + _ => None, + } + })?; + copied_constraints.insert(constraint.name.clone()); + Some((constraint.kind, columns)) + }); + like_indexes.push((edge.kind.clone(), constraint)); + } + for constraint in source_constraints.into_iter().filter(|constraint| { + matches!( + constraint.kind, + ConstraintKind::PrimaryKey + | ConstraintKind::Unique + | ConstraintKind::Exclusion + ) && !copied_constraints.contains(&constraint.name) + }) { + let Some((columns, is_key)) = + self.local.graph.edges().iter().find_map(|edge| { + if edge.dependent != *source_id || edge.referenced != *source_id { + return None; + } + match &edge.kind { + DependencyKind::ConstraintOnRelation { + constraint_name, + columns, + .. + } if constraint_name == &constraint.name => { + Some((columns.clone(), true)) + } + DependencyKind::ConstraintDependency { + constraint_name, + columns, + } if constraint_name == &constraint.name => { + Some((columns.clone(), false)) + } + _ => None, + } + }) + else { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + return MutationResult::Skipped; + }; + let is_unique = matches!( + constraint.kind, + ConstraintKind::PrimaryKey | ConstraintKind::Unique + ); + like_indexes.push(( + DependencyKind::IndexOnRelation { + using_method: is_key.then(|| "btree".to_string()), + key_columns: columns.clone(), + included_columns: Vec::new(), + dependency_columns: columns.clone(), + dependency_columns_known: true, + has_expression_keys: !is_key, + has_predicate: false, + is_concurrent: false, + is_unique, + is_immediate: true, + is_valid: true, + is_ready: true, + is_live: true, + has_default_sort_order: is_key, + has_default_opclasses: is_key, + has_default_collations: is_key, + eligibility_known: true, + }, + Some((constraint.kind, columns)), + )); + } + } + for source_column in &source.columns { + if !inherited_names.insert(source_column.name.clone()) { + return MutationResult::Conflict { + reason: format!( + "column '{}' is copied from more than one source relation", + source_column.name + ), + }; + } + let mut column = source_column.clone(); + if !like_source.properties.defaults { + column.default = None; + column.default_expr_text = None; + } + if !like_source.properties.storage { + column.storage = None; + } + if !like_source.properties.compression { + column.compression = None; + } + column.statistics_target = None; + column.options.clear(); + if !like_source.properties.generated { + column.generated = Some(false); + } + inherited_columns.push(column); + } + } + + if let Some(parent_id) = &create.partition_of { + if let Err(result) = self.ensure_relation_target( + parent_id, + |kind| *kind == RelationKind::Table, + format!("partition parent relation '{}' does not exist", parent_id), + format!("partition parent '{}' is not a table", parent_id), + ) { + return result; + } + let Some(RelationOverlay::Present(parent)) = self.local.relations.get(parent_id) else { + unreachable!("partition parent presence was checked above") + }; + if parent.partition_type.is_none() { + return MutationResult::Conflict { + reason: format!("partition parent '{}' is not partitioned", parent_id), + }; + } + for column in &parent.columns { + if !inherited_names.insert(column.name.clone()) { + return MutationResult::Conflict { + reason: format!( + "partition column '{}' conflicts with another table source", + column.name + ), + }; + } + inherited_columns.push(column.clone()); + } + inherited_generated_columns.extend(parent.generated_columns.clone()); + + let parent_constraints: Vec = self + .local + .constraints + .values() + .filter(|constraint| constraint.table_id == *parent_id) + .cloned() + .collect(); + for constraint in parent_constraints + .iter() + .filter(|constraint| matches!(constraint.kind, ConstraintKind::Check)) + { + let Some(definition) = constraint.definition.clone() else { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + return MutationResult::Skipped; + }; + let Some(columns) = + self.local + .graph + .edges() + .iter() + .find_map(|edge| match &edge.kind { + DependencyKind::ConstraintDependency { + constraint_name, + columns, + } if edge.dependent == *parent_id + && edge.referenced == *parent_id + && constraint_name == &constraint.name => + { + Some(columns.clone()) + } + _ => None, + }) + else { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + return MutationResult::Skipped; + }; + like_check_constraints.push((constraint.name.clone(), columns, definition)); + } + + for edge in self.local.graph.edges().iter().filter(|edge| { + edge.referenced == *parent_id + && matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) + }) { + let constraint = parent_constraints + .iter() + .find(|constraint| constraint.backing_index.as_ref() == Some(&edge.dependent)) + .and_then(|constraint| { + let columns = + self.local + .graph + .edges() + .iter() + .find_map(|key_edge| match &key_edge.kind { + DependencyKind::ConstraintOnRelation { + constraint_name, + columns, + .. + } + | DependencyKind::ConstraintDependency { + constraint_name, + columns, + } if key_edge.dependent == *parent_id + && key_edge.referenced == *parent_id + && constraint_name == &constraint.name => + { + Some(columns.clone()) + } + _ => None, + })?; + Some((constraint.kind, columns)) + }); + like_indexes.push((edge.kind.clone(), constraint)); + } + + partition_foreign_keys.extend(self.local.graph.edges().iter().filter_map(|edge| { + let DependencyKind::ForeignKey { + constraint_name: Some(name), + .. + } = &edge.kind + else { + return None; + }; + (edge.dependent == *parent_id).then(|| (name.clone(), edge.clone())) + })); + } + + let mut column_names = inherited_names; + for column in &create.columns { + if !column_names.insert(column.name.clone()) { + let inherited = inherited_columns + .iter() + .find(|candidate| candidate.name == column.name) + .expect("an inherited name always has a column"); + let compatible = match (inherited.data_type.as_deref(), column.ty.as_deref()) { + (Some(left), Some(right)) => left.trim().eq_ignore_ascii_case(right.trim()), + _ => false, + }; + if !compatible { + return MutationResult::Conflict { + reason: format!( + "column '{}' conflicts with an inherited column type", + column.name + ), + }; + } + } + } + for column in &create.columns { + let Some(expr) = &column.generated_expr else { + continue; + }; + let Some(references) = expr.referenced_columns() else { + self.taint(EvidenceCode::UnsupportedSemantics, EvidenceScope::Statement); + return MutationResult::Skipped; + }; + if let Some(reference) = references + .iter() + .find(|reference| *reference == &column.name || !column_names.contains(*reference)) + { + return MutationResult::Conflict { + reason: format!( + "generated expression for '{}.{}' references invalid column '{}'", + create.id, column.name, reference + ), + }; + } + } + let primary_declarations = create + .columns + .iter() + .filter(|column| column.is_primary_key) + .count() + + create + .table_constraints + .iter() + .filter(|constraint| matches!(constraint, TableConstraintFact::PrimaryKey { .. })) + .count(); + if primary_declarations > 1 { + return MutationResult::Conflict { + reason: "multiple primary keys for table are not allowed".to_string(), + }; + } + for constraint in &create.table_constraints { + let columns = match constraint { + TableConstraintFact::PrimaryKey { columns, .. } + | TableConstraintFact::Unique { columns, .. } => columns, + TableConstraintFact::Check { .. } | TableConstraintFact::Exclude { .. } => { + continue; + } + }; + if columns.is_empty() { + return MutationResult::Conflict { + reason: "key constraint must name at least one column".to_string(), + }; + } + let mut key_columns = HashSet::new(); + for column in columns { + if !key_columns.insert(column) { + return MutationResult::Conflict { + reason: format!( + "column '{}' appears more than once in a key constraint", + column + ), + }; + } + if !column_names.contains(column) { + return MutationResult::Conflict { + reason: format!( + "constraint references column '{}' which does not exist on relation '{}'", + column, create.id + ), + }; + } + } + } + + let mut effective_fk_target_columns = Vec::with_capacity(create.foreign_keys.len()); + for fk in &create.foreign_keys { + if fk.from_columns.is_empty() { + return MutationResult::Conflict { + reason: format!( + "foreign key on relation '{}' has no source columns", + create.id + ), + }; + } + if !fk.to_columns.is_empty() && fk.from_columns.len() != fk.to_columns.len() { + return MutationResult::Conflict { + reason: format!( + "foreign key on '{}' has {} source columns but {} referenced columns", + create.id, + fk.from_columns.len(), + fk.to_columns.len() + ), + }; + } + let mut source_columns = HashSet::new(); + if let Some(column) = fk + .from_columns + .iter() + .find(|column| !source_columns.insert(column.as_str())) + { + return MutationResult::Conflict { + reason: format!( + "foreign key on '{}' repeats source column '{}'", + create.id, column + ), + }; + } + let mut target_columns = HashSet::new(); + if let Some(column) = fk + .to_columns .iter() .find(|column| !target_columns.insert(column.as_str())) { @@ -899,6 +2243,18 @@ impl AnalysisState { } } + let resolved_persistence = match create.persistence { + PersistenceMutation::Permanent => { + crate::_internal::model::relation::Persistence::Permanent + } + PersistenceMutation::Temporary => { + crate::_internal::model::relation::Persistence::Temporary + } + PersistenceMutation::Unlogged => { + crate::_internal::model::relation::Persistence::Unlogged + } + }; + // PostgreSQL chooses all implicit sequence names before the // table becomes visible. Reserve them up front so a collision // or malformed statement cannot leave partial local state. @@ -909,23 +2265,88 @@ impl AnalysisState { crate::_internal::analysis::facts::ColumnGeneration::Serial => { Some(SequenceKind::SerialLike) } - crate::_internal::analysis::facts::ColumnGeneration::Identity => { + crate::_internal::analysis::facts::ColumnGeneration::IdentityAlways + | crate::_internal::analysis::facts::ColumnGeneration::IdentityByDefault => { Some(SequenceKind::Identity) } - crate::_internal::analysis::facts::ColumnGeneration::Ordinary => None, + crate::_internal::analysis::facts::ColumnGeneration::Ordinary + | crate::_internal::analysis::facts::ColumnGeneration::GeneratedStored + | crate::_internal::analysis::facts::ColumnGeneration::GeneratedVirtual => None, }; if let Some(kind) = kind { - let sequence_id = - self.next_implicit_sequence_id(&create.id, &column.name, &reserved_sequences); + let sequence_id = column + .identity_sequence + .as_ref() + .and_then(|options| options.sequence_name.clone()) + .unwrap_or_else(|| { + self.next_implicit_sequence_id( + &create.id, + &column.name, + &reserved_sequences, + ) + }); + if self.relation_namespace_is_taken(&sequence_id) + || reserved_sequences.contains(&sequence_id) + { + return MutationResult::Conflict { + reason: format!("relation '{}' already exists", sequence_id), + }; + } + let default_parameters = Self::default_column_sequence_parameters( + column.ty.as_deref(), + &resolved_persistence, + ); + let parameters = match column.identity_sequence.as_ref() { + Some(options) => { + let Some(parameters) = + Self::apply_identity_sequence_options(default_parameters, options) + else { + return MutationResult::Conflict { + reason: format!( + "identity sequence options for '{}.{}' are invalid", + create.id, column.name + ), + }; + }; + parameters + } + None => default_parameters, + }; reserved_sequences.insert(sequence_id.clone()); - implicit_sequences.push((sequence_id, column.name.clone(), kind)); + implicit_sequences.push((sequence_id, column.name.clone(), kind, parameters)); } } + for (column, _, parameters) in &like_identity_columns { + let sequence_id = + self.next_implicit_sequence_id(&create.id, column, &reserved_sequences); + reserved_sequences.insert(sequence_id.clone()); + implicit_sequences.push(( + sequence_id, + column.clone(), + SequenceKind::Identity, + parameters.clone(), + )); + } // Resolve every constraint name before mutating the relation. PostgreSQL // rejects duplicate names atomically, while a state map would otherwise // silently overwrite the earlier inline constraint. let mut reserved_constraint_names = HashSet::new(); + let mut reserved_index_ids = HashSet::new(); + for (name, _, _) in &like_check_constraints { + if !reserved_constraint_names.insert(name.clone()) { + return MutationResult::Conflict { + reason: format!("constraint '{}' is copied more than once", name), + }; + } + } + for (name, _) in &partition_foreign_keys { + if !reserved_constraint_names.insert(name.clone()) { + return MutationResult::Conflict { + reason: format!("constraint '{}' is inherited more than once", name), + }; + } + } let primary_key_name = create .columns .iter() @@ -945,13 +2366,14 @@ impl AnalysisState { }); let primary_key_constraint_name = primary_key_name.map(|explicit_name| { explicit_name.unwrap_or_else(|| { - self.next_generated_constraint_name_avoiding( - &create.id, + self.next_generated_relation_name_avoiding( + &create.id.schema, &create.id.name, None, "pkey", - &reserved_constraint_names, + &reserved_index_ids, ) + .name }) }); if let Some(name) = &primary_key_constraint_name @@ -961,6 +2383,15 @@ impl AnalysisState { reason: format!("constraint '{}' is specified more than once", name), }; } + if let Some(name) = &primary_key_constraint_name { + let index_id = ObjectId::new(&create.id.schema, name); + if self.relation_namespace_is_taken(&index_id) { + return MutationResult::Conflict { + reason: format!("relation '{}' already exists", index_id), + }; + } + reserved_index_ids.insert(index_id); + } let unique_constraints = create .columns @@ -987,19 +2418,27 @@ impl AnalysisState { let mut unique_constraint_names = Vec::with_capacity(unique_constraints.len()); for (explicit_name, columns) in &unique_constraints { let name = explicit_name.clone().unwrap_or_else(|| { - self.next_generated_constraint_name_avoiding( - &create.id, + self.next_generated_relation_name_avoiding( + &create.id.schema, &create.id.name, Some(&columns.join("_")), "key", - &reserved_constraint_names, + &reserved_index_ids, ) + .name }); if !reserved_constraint_names.insert(name.clone()) { return MutationResult::Conflict { reason: format!("constraint '{}' is specified more than once", name), }; } + let index_id = ObjectId::new(&create.id.schema, &name); + if self.relation_namespace_is_taken(&index_id) { + return MutationResult::Conflict { + reason: format!("relation '{}' already exists", index_id), + }; + } + reserved_index_ids.insert(index_id); unique_constraint_names.push((name, columns.clone())); } @@ -1024,46 +2463,132 @@ impl AnalysisState { let mut inline_constraint_names = Vec::new(); for constraint in &create.table_constraints { - let (kind, explicit_name, label, columns, columns_complete) = match constraint { - TableConstraintFact::Check { - constraint_name, - columns, - columns_complete, - } => ( - ConstraintKind::Check, - constraint_name, - "check", - columns, - columns_complete, - ), - TableConstraintFact::Exclude { - constraint_name, - columns, - columns_complete, - } => ( - ConstraintKind::Exclusion, - constraint_name, - "excl", - columns, - columns_complete, - ), - _ => continue, - }; + let (kind, explicit_name, name_hint, label, definition, columns, columns_complete) = + match constraint { + TableConstraintFact::Check { + constraint_name, + name_hint, + definition, + columns, + columns_complete, + } => ( + ConstraintKind::Check, + constraint_name, + name_hint.as_deref(), + "check", + Some(definition.as_str()), + columns, + columns_complete, + ), + TableConstraintFact::Exclude { + constraint_name, + columns, + columns_complete, + } => ( + ConstraintKind::Exclusion, + constraint_name, + None, + "excl", + None, + columns, + columns_complete, + ), + _ => continue, + }; let name = explicit_name.clone().unwrap_or_else(|| { - self.next_generated_constraint_name_avoiding( - &create.id, - &create.id.name, - None, - label, - &reserved_constraint_names, - ) + if matches!(&kind, ConstraintKind::Exclusion) { + self.next_generated_relation_name_avoiding( + &create.id.schema, + &create.id.name, + name_hint, + label, + &reserved_index_ids, + ) + .name + } else { + self.next_generated_constraint_name_avoiding( + &create.id, + &create.id.name, + name_hint, + label, + &reserved_constraint_names, + ) + } }); if !reserved_constraint_names.insert(name.clone()) { + if matches!(&kind, ConstraintKind::Check) + && inherited_check_constraints.get(&name).is_some_and( + |(_, inherited_definition)| { + definition.is_some_and(|definition| { + Self::normalized_constraint_expression(definition) + == Self::normalized_constraint_expression(inherited_definition) + }) + }, + ) + { + continue; + } return MutationResult::Conflict { reason: format!("constraint '{}' is specified more than once", name), }; } - inline_constraint_names.push((kind, name, columns.clone(), *columns_complete)); + let backing_index = if matches!(&kind, ConstraintKind::Exclusion) { + let index_id = ObjectId::new(&create.id.schema, &name); + if self.relation_namespace_is_taken(&index_id) { + return MutationResult::Conflict { + reason: format!("relation '{}' already exists", index_id), + }; + } + reserved_index_ids.insert(index_id.clone()); + Some(index_id) + } else { + None + }; + inline_constraint_names.push(( + kind, + name, + definition.map(str::to_string), + columns.clone(), + *columns_complete, + backing_index, + )); + } + + let mut like_index_plans = Vec::new(); + for (index_kind, constraint) in like_indexes { + let (name2, label) = match &constraint { + Some((ConstraintKind::PrimaryKey, _)) => (None, "pkey"), + Some((ConstraintKind::Unique, columns)) => (Some(columns.join("_")), "key"), + Some((ConstraintKind::Exclusion, columns)) => (Some(columns.join("_")), "excl"), + Some(_) => unreachable!("LIKE INDEXES clones only index-backed constraints"), + None => { + let columns = match &index_kind { + DependencyKind::IndexOnRelation { key_columns, .. } => { + key_columns.join("_") + } + _ => unreachable!("LIKE index plan must contain an index edge"), + }; + ((!columns.is_empty()).then_some(columns), "idx") + } + }; + let index_id = loop { + let candidate = self.next_generated_relation_name_avoiding( + &create.id.schema, + &create.id.name, + name2.as_deref(), + label, + &reserved_index_ids, + ); + if constraint.is_none() || !reserved_constraint_names.contains(&candidate.name) { + break candidate; + } + reserved_index_ids.insert(candidate); + }; + reserved_index_ids.insert(index_id.clone()); + if constraint.is_some() { + reserved_constraint_names.insert(index_id.name.clone()); + } + like_index_plans.push((index_id, index_kind, constraint)); } self.snapshot_relation(&create.id); @@ -1072,18 +2597,6 @@ impl AnalysisState { self.local.generation_counter += 1; let generation = self.local.generation_counter; - let resolved_persistence = match create.persistence { - PersistenceMutation::Permanent => { - crate::_internal::model::relation::Persistence::Permanent - } - PersistenceMutation::Temporary => { - crate::_internal::model::relation::Persistence::Temporary - } - PersistenceMutation::Unlogged => { - crate::_internal::model::relation::Persistence::Unlogged - } - }; - let mut rel_state = RelationState::new( create.id.clone(), ObjectId::new("", &self.local.current_role), @@ -1093,8 +2606,33 @@ impl AnalysisState { resolved_persistence, self.local.transactions.len(), ); + rel_state.on_commit = create.on_commit.map(|action| match action { + crate::_internal::analysis::mutations::OnCommitMutation::PreserveRows => { + crate::_internal::model::relation::OnCommitAction::PreserveRows + } + crate::_internal::analysis::mutations::OnCommitMutation::DeleteRows => { + crate::_internal::model::relation::OnCommitAction::DeleteRows + } + crate::_internal::analysis::mutations::OnCommitMutation::Drop => { + crate::_internal::model::relation::OnCommitAction::Drop + } + }); + rel_state.of_type = create.of_type.clone(); + rel_state.columns = inherited_columns; + rel_state.identity_columns.extend( + like_identity_columns + .iter() + .map(|(column, generation, _)| (column.clone(), *generation)), + ); + rel_state.generated_columns = inherited_generated_columns; + rel_state.generated_columns.extend(like_generated_columns); + rel_state.extended_statistics.extend( + like_extended_statistics + .into_iter() + .map(|statistics| (statistics.id.clone(), statistics)), + ); - if create.as_select { + if create.as_select && !create.as_select_columns_known { // CTAS derives its columns from a query that is intentionally not // represented in the current fact model. Keep the relation // identity for the destructive-operation rule, but make later @@ -1102,25 +2640,12 @@ impl AnalysisState { self.taint(EvidenceCode::UnsupportedSemantics, EvidenceScope::Chain); } - // Store partition strategy information - rel_state.partition_type = create - .partition_by - .as_ref() - .and_then(|partition_by| partition_by.split_whitespace().nth(2)) - .and_then(|strategy| strategy.split('(').next()) - .map(str::to_uppercase) - .or_else(|| { - create.partition_of.as_ref().and_then(|parent_id| { - self.local.relations.get(parent_id).and_then(|r| { - if let RelationOverlay::Present(rel) = r { - rel.partition_type.clone() - } else { - None - } - }) - }) - }); + rel_state.partition_type = create.partition_strategy.as_deref().map(str::to_uppercase); rel_state.partition_by = create.partition_by.clone(); + rel_state.partition_bound = create + .partition_bound + .as_deref() + .map(canonical_partition_bound); let pk_columns: HashSet<&str> = create .table_constraints @@ -1147,6 +2672,17 @@ impl AnalysisState { not_null: col.not_null || is_pk, default: col.default.clone(), }); + if let Some(column) = rel_state + .columns + .iter_mut() + .find(|column| column.name == col.name) + { + column.is_nullable &= !(col.not_null || is_pk); + if col.default.is_some() { + column.default = RelationState::normalize_column_default(&col.default); + column.default_expr_text = None; + } + } if let Some(column) = rel_state .columns .iter_mut() @@ -1156,10 +2692,82 @@ impl AnalysisState { .data_type .as_deref() .and_then(|raw| self.resolve_type_reference(raw)); + column.type_modifier = col.type_modifier; + if matches!( + col.generation, + crate::_internal::analysis::facts::ColumnGeneration::GeneratedStored + | crate::_internal::analysis::facts::ColumnGeneration::GeneratedVirtual + ) { + column.generated = Some(true); + } } + if matches!( + col.generation, + crate::_internal::analysis::facts::ColumnGeneration::GeneratedStored + | crate::_internal::analysis::facts::ColumnGeneration::GeneratedVirtual + ) { + rel_state.generated_columns.insert( + col.name.clone(), + crate::_internal::model::relation::GeneratedColumnState { + kind: match col.generation { + crate::_internal::analysis::facts::ColumnGeneration::GeneratedStored => { + crate::_internal::model::relation::GeneratedColumnKind::Stored + } + crate::_internal::analysis::facts::ColumnGeneration::GeneratedVirtual => { + crate::_internal::model::relation::GeneratedColumnKind::Virtual + } + _ => unreachable!("generated kind checked above"), + }, + expression: col.generated_expr_sql.clone(), + }, + ); + } + if matches!( + col.generation, + crate::_internal::analysis::facts::ColumnGeneration::IdentityAlways + | crate::_internal::analysis::facts::ColumnGeneration::IdentityByDefault + ) { + rel_state.identity_columns.insert( + col.name.clone(), + match col.generation { + crate::_internal::analysis::facts::ColumnGeneration::IdentityAlways => { + crate::_internal::model::relation::IdentityGeneration::Always + } + crate::_internal::analysis::facts::ColumnGeneration::IdentityByDefault => { + crate::_internal::model::relation::IdentityGeneration::ByDefault + } + _ => unreachable!("identity generation checked above"), + }, + ); + } + } + + for column in &rel_state.columns { + let parent_count = create + .inherits + .iter() + .chain(create.partition_of.iter()) + .filter(|parent| { + matches!(self.local.relations.get(*parent), + Some(RelationOverlay::Present(relation)) if relation.has_column(&column.name)) + }) + .count() as u32; + let is_local = create.partition_of.is_none() + && (parent_count == 0 + || create + .columns + .iter() + .any(|declared| declared.name == column.name)); + rel_state.column_inheritance.insert( + column.name.clone(), + crate::_internal::model::relation::ColumnInheritance { + parent_count, + is_local, + }, + ); } - for (sequence_id, column_name, _) in &implicit_sequences { + for (sequence_id, column_name, _, _) in &implicit_sequences { if let Some(column) = rel_state .columns .iter_mut() @@ -1178,11 +2786,106 @@ impl AnalysisState { .relations .insert(create.id.clone(), RelationOverlay::Present(rel_state)); + if !like_dependency_edges.is_empty() { + self.snapshot_graph(); + for edge in like_dependency_edges { + self.local.graph.add_edge(edge); + } + } + + for column in &create.columns { + let Some(expr) = &column.generated_expr else { + continue; + }; + let Some(references) = expr.referenced_columns() else { + self.taint(EvidenceCode::UnsupportedSemantics, EvidenceScope::Statement); + continue; + }; + self.snapshot_graph(); + for depends_on_column in references { + self.local.graph.add_edge(DependencyEdge::new( + create.id.clone(), + create.id.clone(), + DependencyKind::ColumnGeneratedFrom { + column: column.name.clone(), + depends_on_column, + }, + )); + } + } + for column in ¬_null_columns { self.register_not_null_constraint(&create.id, column); } - for (sequence_id, column_name, kind) in implicit_sequences { + for (name, columns, definition) in like_check_constraints { + self.snapshot_constraint(&create.id, &name); + self.local.constraints.insert( + (create.id.clone(), name.clone()), + ConstraintState { + table_id: create.id.clone(), + name: name.clone(), + kind: ConstraintKind::Check, + validated: true, + definition: Some(definition), + backing_index: None, + }, + ); + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + create.id.clone(), + create.id.clone(), + DependencyKind::ConstraintDependency { + constraint_name: name, + columns, + }, + )); + } + + for (index_id, index_kind, constraint) in like_index_plans { + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + index_id.clone(), + create.id.clone(), + index_kind, + )); + if let Some((kind, columns)) = constraint { + let constraint_name = index_id.name.clone(); + self.snapshot_constraint(&create.id, &constraint_name); + self.local.constraints.insert( + (create.id.clone(), constraint_name.clone()), + ConstraintState { + table_id: create.id.clone(), + name: constraint_name.clone(), + kind, + validated: true, + definition: None, + backing_index: Some(index_id), + }, + ); + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + create.id.clone(), + create.id.clone(), + match kind { + ConstraintKind::PrimaryKey | ConstraintKind::Unique => { + DependencyKind::ConstraintOnRelation { + constraint_name, + columns, + is_primary: matches!(kind, ConstraintKind::PrimaryKey), + } + } + ConstraintKind::Exclusion => DependencyKind::ConstraintDependency { + constraint_name, + columns, + }, + _ => unreachable!("LIKE INDEXES clones only index-backed constraints"), + }, + )); + } + } + + for (sequence_id, column_name, kind, parameters) in implicit_sequences { self.snapshot_sequence(&sequence_id); self.snapshot_generation_counter(); self.local.generation_counter += 1; @@ -1193,6 +2896,7 @@ impl AnalysisState { owner: ObjectId::new("", &self.local.current_role), owned_by: Some((create.id.clone(), column_name.clone())), kind, + parameters, generation: self.local.generation_counter, }), ); @@ -1207,6 +2911,7 @@ impl AnalysisState { } if let Some(name) = primary_key_constraint_name.clone() { + let index_id = ObjectId::new(&create.id.schema, &name); self.snapshot_constraint(&create.id, &name); self.local.constraints.insert( (create.id.clone(), name.clone()), @@ -1215,12 +2920,35 @@ impl AnalysisState { name: name.clone(), kind: ConstraintKind::PrimaryKey, validated: true, - backing_index: None, + definition: None, + backing_index: Some(index_id.clone()), }, ); + let columns = create + .table_constraints + .iter() + .find_map(|constraint| match constraint { + TableConstraintFact::PrimaryKey { columns, .. } => Some(columns.clone()), + _ => None, + }) + .unwrap_or_else(|| { + create + .columns + .iter() + .filter(|column| column.is_primary_key) + .map(|column| column.name.clone()) + .collect() + }); + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + index_id, + create.id.clone(), + Self::constraint_index_dependency(columns, true), + )); } for (name, columns) in unique_constraint_names { + let index_id = ObjectId::new(&create.id.schema, &name); self.snapshot_constraint(&create.id, &name); self.local.constraints.insert( (create.id.clone(), name.clone()), @@ -1229,10 +2957,16 @@ impl AnalysisState { name: name.clone(), kind: ConstraintKind::Unique, validated: true, - backing_index: None, + definition: None, + backing_index: Some(index_id.clone()), }, ); self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + index_id, + create.id.clone(), + Self::constraint_index_dependency(columns.clone(), true), + )); self.local.graph.add_edge(DependencyEdge::new( create.id.clone(), create.id.clone(), @@ -1252,6 +2986,14 @@ impl AnalysisState { DependencyKind::PartitionOf, )); } + for parent_id in &create.inherits { + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + create.id.clone(), + parent_id.clone(), + DependencyKind::InheritanceOf, + )); + } if !create.foreign_keys.is_empty() { self.snapshot_graph(); @@ -1271,6 +3013,7 @@ impl AnalysisState { name: constraint_name.clone(), kind: ConstraintKind::ForeignKey, validated: true, + definition: None, backing_index: None, }, ); @@ -1286,7 +3029,44 @@ impl AnalysisState { }, )); } - for (kind, name, columns, columns_complete) in inline_constraint_names { + for (constraint_name, edge) in partition_foreign_keys { + let DependencyKind::ForeignKey { + from_columns, + to_columns, + operator_evidence, + .. + } = edge.kind + else { + unreachable!("partition foreign-key plans contain only foreign keys") + }; + self.snapshot_constraint(&create.id, &constraint_name); + self.local.constraints.insert( + (create.id.clone(), constraint_name.clone()), + ConstraintState { + table_id: create.id.clone(), + name: constraint_name.clone(), + kind: ConstraintKind::ForeignKey, + validated: true, + definition: None, + backing_index: None, + }, + ); + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + create.id.clone(), + edge.referenced, + DependencyKind::ForeignKey { + constraint_name: Some(constraint_name), + from_columns, + to_columns, + operator_evidence, + from_generation: generation, + }, + )); + } + for (kind, name, definition, columns, columns_complete, backing_index) in + inline_constraint_names + { self.snapshot_constraint(&create.id, &name); self.local.constraints.insert( (create.id.clone(), name.clone()), @@ -1295,9 +3075,36 @@ impl AnalysisState { name: name.clone(), kind, validated: true, - backing_index: None, + definition, + backing_index: backing_index.clone(), }, ); + if let Some(index_id) = backing_index { + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + index_id, + create.id.clone(), + DependencyKind::IndexOnRelation { + using_method: None, + key_columns: columns.clone(), + included_columns: Vec::new(), + dependency_columns: columns.clone(), + dependency_columns_known: columns_complete, + has_expression_keys: true, + has_predicate: false, + is_concurrent: false, + is_unique: false, + is_immediate: true, + is_valid: true, + is_ready: true, + is_live: true, + has_default_sort_order: false, + has_default_opclasses: false, + has_default_collations: false, + eligibility_known: false, + }, + )); + } if columns_complete { self.snapshot_graph(); self.local.graph.add_edge(DependencyEdge::new( @@ -1342,6 +3149,44 @@ impl AnalysisState { }, )); } + if let Some(parent) = &create.partition_of { + let generated_partition_constraint = + self.local + .relations + .get(&create.id) + .and_then(|overlay| match overlay { + RelationOverlay::Present(relation) => Some(relation), + RelationOverlay::Dropped => None, + }) + .and_then(|relation| { + let bound = relation.partition_bound.as_deref()?; + if bound.eq_ignore_ascii_case("DEFAULT") { + self.synthesize_default_partition_constraint(parent) + } else { + let strategy = self.local.relations.get(parent).and_then( + |overlay| match overlay { + RelationOverlay::Present(parent) => { + parent.partition_type.as_deref() + } + RelationOverlay::Dropped => None, + }, + )?; + let keys = self.partition_key_columns(parent)?; + self.synthesize_partition_check(strategy, bound, &keys, relation) + } + }); + if generated_partition_constraint.is_some() + && let Some(RelationOverlay::Present(relation)) = + self.local.relations.get_mut(&create.id) + { + relation.partition_constraint = generated_partition_constraint; + } + let result = self.clone_row_triggers_to_partition(parent, &create.id); + debug_assert!(matches!(result, MutationResult::Applied)); + if !matches!(result, MutationResult::Applied) { + return result; + } + } MutationResult::Applied } @@ -1383,6 +3228,7 @@ impl AnalysisState { has_predicate, is_concurrent, is_unique, + is_immediate, is_valid, is_ready, is_live, @@ -1412,6 +3258,7 @@ impl AnalysisState { has_predicate, is_concurrent, is_unique, + is_immediate, is_valid, is_ready, is_live, @@ -1424,6 +3271,105 @@ impl AnalysisState { } pub(super) fn apply_alter_table(&mut self, alter: &AlterTable) -> MutationResult { + let recursive_rename_descendants = if !alter.only + && matches!(alter.action, AlterTableActionMutation::RenameColumn { .. }) + { + self.inherited_descendants(&alter.id) + } else { + Vec::new() + }; + if let AlterTableActionMutation::RenameColumn { from, to } = &alter.action { + for descendant in &recursive_rename_descendants { + let Some(RelationOverlay::Present(relation)) = self.local.relations.get(descendant) + else { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + return MutationResult::Skipped; + }; + if relation.has_column(to) { + return MutationResult::Conflict { + reason: format!( + "column '{}' already exists on relation '{}'", + to, descendant + ), + }; + } + if relation.generated_columns.values().any(|generated| { + generated.expression.as_deref().is_none_or(|source| { + crate::_internal::analysis::expr_visitor::ExprVisitor::rename_column_source( + source, + &descendant.name, + from, + to, + ) + .is_none() + }) + }) { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + return MutationResult::Skipped; + } + let Some(provenance) = relation.column_inheritance.get(from) else { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + return MutationResult::Skipped; + }; + let expected_parents = self + .local + .graph + .edges() + .iter() + .filter(|edge| { + edge.dependent == *descendant + && matches!( + edge.kind, + DependencyKind::InheritanceOf | DependencyKind::PartitionOf + ) + && (edge.referenced == alter.id + || recursive_rename_descendants.contains(&edge.referenced)) + }) + .count() as u32; + if provenance.parent_count > expected_parents { + return MutationResult::Conflict { + reason: format!("cannot rename inherited column '{}'", from), + }; + } + } + } + if alter.only + && matches!(alter.action, AlterTableActionMutation::RenameColumn { .. }) + && self.local.graph.edges().iter().any(|edge| { + matches!( + edge.kind, + DependencyKind::InheritanceOf + | DependencyKind::PartitionOf + | DependencyKind::PartitionDetachPending + ) && self.local.graph.resolve_rename(&edge.referenced) + == self.local.graph.resolve_rename(&alter.id) + }) + { + return MutationResult::Conflict { + reason: "inherited columns must be renamed in child tables too".into(), + }; + } + let concurrent_detach = matches!( + alter.action, + AlterTableActionMutation::DetachPartition { + mode: crate::_internal::analysis::facts::DetachPartitionMode::Concurrently, + .. + } + ); + if concurrent_detach && self.in_transaction() { + return MutationResult::Conflict { + reason: "DETACH PARTITION CONCURRENTLY cannot run inside a transaction".into(), + }; + } match self.relation_lookup(&alter.id, |kind| *kind == RelationKind::Table) { ObjectLookup::Present => {} ObjectLookup::WrongKind => { @@ -1494,6 +3440,27 @@ impl AnalysisState { }; let relation_columns_known = !relation.columns.is_empty() || relation.estimated_rows.is_some(); + if let AlterTableActionMutation::SetRuleMode { rule_name, mode } = &alter.action { + let Some(rule_name) = rule_name else { + self.taint(EvidenceCode::UnsupportedSemantics, EvidenceScope::Statement); + return MutationResult::Skipped; + }; + if !relation.rules.contains_key(rule_name) { + return MutationResult::Conflict { + reason: format!( + "rule '{}' does not exist on relation '{}'", + rule_name, alter.id + ), + }; + } + self.snapshot_relation(&alter.id); + let Some(RelationOverlay::Present(relation)) = self.local.relations.get_mut(&alter.id) + else { + unreachable!("relation presence was checked before rule mutation") + }; + relation.rules.insert(rule_name.clone(), *mode); + return MutationResult::Applied; + } // Adding a column does not need to enumerate existing columns when the // baseline is incomplete; the new column is still represented in the // post-statement state. Other column-targeting actions remain @@ -1507,6 +3474,13 @@ impl AnalysisState { | AlterTableActionMutation::DropNotNull { .. } | AlterTableActionMutation::SetType { .. } | AlterTableActionMutation::SetDefault { .. } + | AlterTableActionMutation::SetStorage { .. } + | AlterTableActionMutation::SetCompression { .. } + | AlterTableActionMutation::SetStatistics { .. } + | AlterTableActionMutation::SetGeneratedExpression { .. } + | AlterTableActionMutation::DropGeneratedExpression { .. } + | AlterTableActionMutation::SetColumnOptions { .. } + | AlterTableActionMutation::ResetColumnOptions { .. } ) { self.taint( @@ -1566,23 +3540,95 @@ impl AnalysisState { if relation.has_column(to) { return MutationResult::Conflict { reason: format!( - "column '{}' already exists on relation '{}'", - to, alter.id + "column '{}' already exists on relation '{}'", + to, alter.id + ), + }; + } + if relation.generated_columns.values().any(|generated| { + generated.expression.as_deref().is_none_or(|source| { + crate::_internal::analysis::expr_visitor::ExprVisitor::rename_column_source( + source, + &alter.id.name, + from, + to, + ) + .is_none() + }) + }) { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + return MutationResult::Skipped; + } + } + AlterTableActionMutation::SetNotNull { column } + | AlterTableActionMutation::DropNotNull { column } + | AlterTableActionMutation::SetType { column, .. } + | AlterTableActionMutation::SetDefault { column, .. } + | AlterTableActionMutation::SetStorage { column, .. } + | AlterTableActionMutation::SetCompression { column, .. } + | AlterTableActionMutation::SetStatistics { column, .. } + | AlterTableActionMutation::SetGeneratedExpression { column, .. } + | AlterTableActionMutation::DropGeneratedExpression { column, .. } + | AlterTableActionMutation::SetColumnOptions { column, .. } + | AlterTableActionMutation::ResetColumnOptions { column, .. } + if !relation.has_column(column) => + { + return MutationResult::Conflict { + reason: format!( + "column '{}' does not exist on relation '{}'", + column, alter.id + ), + }; + } + AlterTableActionMutation::SetCompression { + method: Some(method), + .. + } if !method.eq_ignore_ascii_case("pglz") => { + // lz4 availability is a PostgreSQL build capability, not a + // catalog fact carried by V8. Do not claim an exact result. + self.taint(EvidenceCode::UnsupportedSemantics, EvidenceScope::Statement); + return MutationResult::Skipped; + } + AlterTableActionMutation::SetGeneratedExpression { column, expr, .. } => { + if !relation.generated_columns.contains_key(column) { + return MutationResult::Conflict { + reason: format!( + "column '{}.{}' is not a generated column", + alter.id, column + ), + }; + } + let Some(references) = expr.referenced_columns() else { + self.taint(EvidenceCode::UnsupportedSemantics, EvidenceScope::Statement); + return MutationResult::Skipped; + }; + if let Some(reference) = references + .iter() + .find(|reference| *reference == column || !relation.has_column(reference)) + { + return MutationResult::Conflict { + reason: format!( + "generated expression for '{}.{}' references invalid column '{}'", + alter.id, column, reference ), }; } } - AlterTableActionMutation::SetNotNull { column } - | AlterTableActionMutation::DropNotNull { column } - | AlterTableActionMutation::SetType { column, .. } - | AlterTableActionMutation::SetDefault { column, .. } - if !relation.has_column(column) => + AlterTableActionMutation::DropGeneratedExpression { column, if_exists } + if !relation.generated_columns.contains_key(column) => { - return MutationResult::Conflict { - reason: format!( - "column '{}' does not exist on relation '{}'", - column, alter.id - ), + return if *if_exists { + MutationResult::Skipped + } else { + MutationResult::Conflict { + reason: format!( + "column '{}.{}' has no generated expression", + alter.id, column + ), + } }; } _ => {} @@ -1663,6 +3709,17 @@ impl AnalysisState { ), }; } + let constraint = &self.local.constraints[&(alter.id.clone(), old_name.clone())]; + if matches!( + constraint.kind, + ConstraintKind::PrimaryKey | ConstraintKind::Unique | ConstraintKind::Exclusion + ) && let Some(index) = &constraint.backing_index + { + return self.apply_rename_relation(&Rename { + old_id: index.clone(), + new_id: ObjectId::new(index.schema.clone(), new_name.clone()), + }); + } } AlterTableActionMutation::AddForeignKey { constraint_name, @@ -1692,13 +3749,16 @@ impl AnalysisState { } } AlterTableActionMutation::AddCheckConstraint { - constraint_name, .. + constraint_name, + columns, + columns_complete, + .. } => { let name = constraint_name.clone().unwrap_or_else(|| { self.next_generated_constraint_name_avoiding( &alter.id, &alter.id.name, - None, + (*columns_complete && columns.len() == 1).then(|| columns[0].as_str()), "check", &HashSet::new(), ) @@ -1823,6 +3883,19 @@ impl AnalysisState { ), }; } + if using_index.is_none() + && self.relation_namespace_object_is_present(&ObjectId::new( + &alter.id.schema, + &name, + )) + { + return MutationResult::Conflict { + reason: format!( + "constraint index '{}.{}' already exists", + alter.id.schema, name + ), + }; + } } AlterTableActionMutation::AlterConstraint { name, .. } => { let Some(name) = name else { @@ -1884,7 +3957,10 @@ impl AnalysisState { }; } let existing_parent = self.local.graph.edges().iter().find_map(|edge| { - (matches!(edge.kind, DependencyKind::PartitionOf) && edge.dependent == *child) + (matches!( + edge.kind, + DependencyKind::PartitionOf | DependencyKind::PartitionDetachPending + ) && edge.dependent == *child) .then_some(edge.referenced.clone()) }); if let Some(existing_parent) = existing_parent { @@ -1895,8 +3971,58 @@ impl AnalysisState { ), }; } + match self.partition_attachment_is_compatible(&alter.id, child) { + Ok(true) => {} + Ok(false) => { + // Missing catalog detail should lower confidence, not + // erase the attachment from the transition state. The + // taint keeps downstream findings conservative while + // the mutation retains the edge and bound. + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + } + Err(reason) => return MutationResult::Conflict { reason }, + } } - AlterTableActionMutation::DetachPartition { child } => { + AlterTableActionMutation::DetachPartition { child, mode } => { + if matches!( + mode, + crate::_internal::analysis::facts::DetachPartitionMode::Concurrently + ) { + for edge in self + .local + .graph + .edges() + .iter() + .filter(|edge| edge.referenced == alter.id) + { + if matches!(edge.kind, DependencyKind::PartitionDetachPending) { + return MutationResult::Conflict { + reason: format!( + "parent '{}' already has a partition pending detach", + alter.id + ), + }; + } + if matches!(edge.kind, DependencyKind::PartitionOf) + && let Some(RelationOverlay::Present(partition)) = + self.local.relations.get(&edge.dependent) + && partition + .partition_bound + .as_deref() + .is_some_and(|bound| bound.trim().eq_ignore_ascii_case("DEFAULT")) + { + return MutationResult::Conflict { + reason: format!( + "cannot detach concurrently from '{}' while it has a default partition", + alter.id + ), + }; + } + } + } if let Err(result) = self.ensure_relation_target( child, |kind| *kind == RelationKind::Table, @@ -1905,32 +4031,313 @@ impl AnalysisState { ) { return result; } + let expected_kind = match mode { + crate::_internal::analysis::facts::DetachPartitionMode::Finalize => { + DependencyKind::PartitionDetachPending + } + crate::_internal::analysis::facts::DetachPartitionMode::Immediate + | crate::_internal::analysis::facts::DetachPartitionMode::Concurrently => { + DependencyKind::PartitionOf + } + }; if !self.local.graph.edges().iter().any(|edge| { - matches!(edge.kind, DependencyKind::PartitionOf) + edge.kind == expected_kind && edge.dependent == *child && edge.referenced == alter.id }) { + let action = match mode { + crate::_internal::analysis::facts::DetachPartitionMode::Finalize => { + "has no pending concurrent detach from" + } + _ => "is not attached to", + }; + return MutationResult::Conflict { + reason: format!("partition '{}' {} parent '{}'", child, action, alter.id), + }; + } + } + AlterTableActionMutation::InheritTable { parent } + | AlterTableActionMutation::NoInheritTable { parent } => { + if let Err(result) = self.ensure_relation_target( + parent, + |kind| *kind == RelationKind::Table, + format!("inheritance parent relation '{}' does not exist", parent), + format!("inheritance parent '{}' is not a table", parent), + ) { + return result; + } + let has_edge = self.local.graph.edges().iter().any(|edge| { + matches!(edge.kind, DependencyKind::InheritanceOf) + && edge.dependent == alter.id + && edge.referenced == *parent + }); + match &alter.action { + AlterTableActionMutation::InheritTable { .. } if has_edge => { + return MutationResult::Conflict { + reason: format!( + "relation '{}' already inherits from '{}'", + alter.id, parent + ), + }; + } + AlterTableActionMutation::NoInheritTable { .. } if !has_edge => { + return MutationResult::Conflict { + reason: format!( + "relation '{}' does not inherit from '{}'", + alter.id, parent + ), + }; + } + AlterTableActionMutation::InheritTable { .. } + if self.local.graph.check_inheritance_cycle(parent, &alter.id) => + { + return MutationResult::Conflict { + reason: format!( + "inheriting '{}' into '{}' would create an inheritance cycle", + parent, alter.id + ), + }; + } + AlterTableActionMutation::InheritTable { .. } => { + let Some(RelationOverlay::Present(parent_relation)) = + self.local.relations.get(parent) + else { + unreachable!("inheritance parent presence was checked above") + }; + let Some(RelationOverlay::Present(child_relation)) = + self.local.relations.get(&alter.id) + else { + unreachable!("alter target presence was checked above") + }; + for parent_column in &parent_relation.columns { + let Some(child_column) = child_relation.get_column(&parent_column.name) + else { + return MutationResult::Conflict { + reason: format!( + "relation '{}' lacks inherited column '{}'", + alter.id, parent_column.name + ), + }; + }; + let compatible_type = match ( + parent_column.type_id.as_ref(), + child_column.type_id.as_ref(), + parent_column.data_type.as_deref(), + child_column.data_type.as_deref(), + ) { + (Some(left), Some(right), _, _) => left == right, + (_, _, Some(left), Some(right)) => { + left.trim().eq_ignore_ascii_case(right.trim()) + } + _ => false, + }; + if !compatible_type + || (!parent_column.is_nullable && child_column.is_nullable) + || parent_relation.generated_columns.get(&parent_column.name) + != child_relation.generated_columns.get(&parent_column.name) + { + return MutationResult::Conflict { + reason: format!( + "column '{}.{}' is incompatible with inheritance parent '{}'", + alter.id, parent_column.name, parent + ), + }; + } + } + for parent_constraint in + self.local.constraints.values().filter(|constraint| { + constraint.table_id == *parent + && matches!(constraint.kind, ConstraintKind::Check) + }) + { + let Some(child_constraint) = self + .local + .constraints + .get(&(alter.id.clone(), parent_constraint.name.clone())) + else { + return MutationResult::Conflict { + reason: format!( + "relation '{}' lacks inherited CHECK constraint '{}'", + alter.id, parent_constraint.name + ), + }; + }; + let definitions_match = parent_constraint + .definition + .as_deref() + .zip(child_constraint.definition.as_deref()) + .is_some_and(|(parent_definition, child_definition)| { + Self::normalized_constraint_expression(parent_definition) + == Self::normalized_constraint_expression(child_definition) + }); + if !matches!(child_constraint.kind, ConstraintKind::Check) + || !definitions_match + { + return MutationResult::Conflict { + reason: format!( + "CHECK constraint '{}' is incompatible with inheritance parent '{}'", + parent_constraint.name, parent + ), + }; + } + } + } + _ => {} + } + } + AlterTableActionMutation::SetCluster { index: Some(index) } => { + let owned = self.local.graph.edges().iter().any(|edge| { + matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) + && edge.dependent == *index + && edge.referenced == alter.id + }); + if !owned { return MutationResult::Conflict { reason: format!( - "partition '{}' is not attached to parent '{}'", - child, alter.id + "index '{}' does not belong to relation '{}'", + index, alter.id ), }; } } - // These are fully typed, but their physical storage details are - // intentionally outside the schema state. Preserve the modeled - // relation while recording that the post-statement physical - // state is not represented; returning Applied without evidence - // would falsely claim an exact transition. - AlterTableActionMutation::SetStorage { .. } - | AlterTableActionMutation::SetAccessMethod => { - self.taint(EvidenceCode::UnsupportedSemantics, EvidenceScope::Statement); - return MutationResult::Applied; + AlterTableActionMutation::SetReplicaIdentity { + option: + crate::_internal::analysis::mutations::ReplicaIdentityMutation::UsingIndex(index), + } => { + let Some(edge) = self.local.graph.edges().iter().find(|edge| { + edge.dependent == *index + && edge.referenced == alter.id + && matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) + }) else { + return MutationResult::Conflict { + reason: format!( + "replica identity index '{}' does not belong to relation '{}'", + index, alter.id + ), + }; + }; + let DependencyKind::IndexOnRelation { + key_columns, + dependency_columns_known, + has_expression_keys, + has_predicate, + is_unique, + is_immediate, + is_valid, + is_ready, + is_live, + eligibility_known, + .. + } = &edge.kind + else { + unreachable!("index edge was checked above"); + }; + if !*eligibility_known || !*dependency_columns_known { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + return MutationResult::Skipped; + } + let all_keys_not_null = self + .local + .relations + .get(&alter.id) + .and_then(|overlay| match overlay { + RelationOverlay::Present(relation) => { + Some(key_columns.iter().all(|column| { + relation + .columns + .iter() + .find(|candidate| candidate.name == *column) + .is_some_and(|column| !column.is_nullable) + })) + } + RelationOverlay::Dropped => None, + }) + .unwrap_or(false); + if !*is_unique + || *has_predicate + || *has_expression_keys + || !*is_valid + || !*is_immediate + || !*is_ready + || !*is_live + || !all_keys_not_null + { + return MutationResult::Conflict { + reason: format!( + "index '{}' is not eligible for replica identity on relation '{}'", + index, alter.id + ), + }; + } } - AlterTableActionMutation::Opaque => { - self.taint(EvidenceCode::UnsupportedSemantics, EvidenceScope::Chain); - return MutationResult::Applied; + AlterTableActionMutation::SetOfType { + type_id: Some(type_id), + } => { + if self + .local + .relations + .get(&alter.id) + .and_then(|overlay| match overlay { + RelationOverlay::Present(relation) => relation.of_type.as_ref(), + RelationOverlay::Dropped => None, + }) + .is_some() + { + return MutationResult::Conflict { + reason: format!("relation '{}' is already a typed table", alter.id), + }; + } + let Some(crate::_internal::model::types::TypeOverlay::Present( + crate::_internal::model::types::TypeState { + kind: crate::_internal::model::types::TypeKind::Composite { fields }, + .. + }, + )) = self.local.types.get(type_id) + else { + return MutationResult::Conflict { + reason: format!("type '{}' is not an existing composite type", type_id), + }; + }; + let Some(RelationOverlay::Present(relation)) = self.local.relations.get(&alter.id) + else { + self.taint(EvidenceCode::UnknownObjectState, EvidenceScope::Chain); + return MutationResult::Skipped; + }; + let matches_layout = relation.columns.len() == fields.len() + && relation.columns.iter().zip(fields).all(|(column, field)| { + column.name == field.name + && column.data_type.as_deref().is_some_and(|data_type| { + data_type + .trim() + .eq_ignore_ascii_case(field.data_type.trim()) + }) + }); + if !matches_layout { + return MutationResult::Conflict { + reason: format!( + "relation '{}' does not match composite type '{}' column layout", + alter.id, type_id + ), + }; + } + } + AlterTableActionMutation::SetOfType { type_id: None } => { + let typed = self + .local + .relations + .get(&alter.id) + .and_then(|overlay| match overlay { + RelationOverlay::Present(relation) => relation.of_type.as_ref(), + RelationOverlay::Dropped => None, + }); + if typed.is_none() { + return MutationResult::Conflict { + reason: format!("relation '{}' is not a typed table", alter.id), + }; + } } _ => {} } @@ -1944,10 +4351,15 @@ impl AnalysisState { trigger_name.as_deref(), crate::_internal::model::trigger::TriggerEnableMode::Origin, )), + AlterTableActionMutation::SetTriggerMode { trigger_name, mode } => { + Some((trigger_name.as_deref(), *mode)) + } _ => None, }; if let Some((trigger_name, enabled_mode)) = trigger_mode { - let all = trigger_name.is_none_or(|name| name.eq_ignore_ascii_case("all")); + let all = trigger_name.is_none_or(|name| { + name.eq_ignore_ascii_case("all") || name.eq_ignore_ascii_case("user") + }); let trigger_ids: Vec = self .local .triggers @@ -2173,22 +4585,42 @@ impl AnalysisState { let implicit_add = match &alter.action { AlterTableActionMutation::AddColumn { - name, generation, .. + name, + generation, + identity_sequence, + .. } => match generation { crate::_internal::analysis::facts::ColumnGeneration::Serial => Some(( self.next_implicit_sequence_id(&alter.id, name, &HashSet::new()), name.clone(), SequenceKind::SerialLike, + None, )), - crate::_internal::analysis::facts::ColumnGeneration::Identity => Some(( - self.next_implicit_sequence_id(&alter.id, name, &HashSet::new()), + crate::_internal::analysis::facts::ColumnGeneration::IdentityAlways + | crate::_internal::analysis::facts::ColumnGeneration::IdentityByDefault => Some(( + identity_sequence + .as_ref() + .and_then(|options| options.sequence_name.clone()) + .unwrap_or_else(|| { + self.next_implicit_sequence_id(&alter.id, name, &HashSet::new()) + }), name.clone(), SequenceKind::Identity, + identity_sequence.clone(), )), - crate::_internal::analysis::facts::ColumnGeneration::Ordinary => None, + crate::_internal::analysis::facts::ColumnGeneration::Ordinary + | crate::_internal::analysis::facts::ColumnGeneration::GeneratedStored + | crate::_internal::analysis::facts::ColumnGeneration::GeneratedVirtual => None, }, _ => None, }; + if let Some((sequence_id, _, _, _)) = &implicit_add + && self.relation_namespace_is_taken(sequence_id) + { + return MutationResult::Conflict { + reason: format!("relation '{}' already exists", sequence_id), + }; + } let owned_sequences_for_column: Vec = match &alter.action { AlterTableActionMutation::DropColumn { name, .. } | AlterTableActionMutation::RenameColumn { from: name, .. } => self @@ -2309,6 +4741,7 @@ impl AnalysisState { let mut drop_column_constraints: HashSet<(ObjectId, String)> = HashSet::new(); let mut drop_column_indexes: HashSet = HashSet::new(); + let mut drop_column_statistics: HashSet = HashSet::new(); let mut cascade_generated_columns: HashSet = HashSet::new(); let mut cascade_view_roots: HashSet = HashSet::new(); if let AlterTableActionMutation::DropColumn { name, cascade, .. } = &alter.action { @@ -2329,6 +4762,16 @@ impl AnalysisState { } let mut unknown_dependency = false; let mut known_dependency = false; + if let Some(RelationOverlay::Present(relation)) = self.local.relations.get(&alter.id) { + for statistics in relation.extended_statistics.values() { + if statistics.columns.iter().any(|column| column == name) { + known_dependency = true; + if *cascade { + drop_column_statistics.insert(statistics.id.clone()); + } + } + } + } for edge in self.local.graph.edges() { if !self.dependency_edge_is_current(edge) { continue; @@ -2670,1274 +5113,2655 @@ impl AnalysisState { not_null, default, depends_on, - generation: _, + generation, + identity_sequence: _, + generated_expr, + generated_expr_sql, + } => { + if let Some(existing_col) = rel.columns.iter().find(|c| c.name == *name) { + if *if_not_exists { + return MutationResult::Skipped; + } + return MutationResult::Conflict { + reason: format!( + "column '{}' already exists with type {}; this statement adds it again with type {}", + name, + existing_col.data_type.as_deref().unwrap_or("unknown"), + ty.as_deref().unwrap_or("unknown") + ), + }; + } + rel.apply_column_action(&ColumnAction::Add { + name: name.clone(), + data_type: ty.clone(), + not_null: *not_null, + default: default.clone(), + }); + if *not_null { + deferred_not_null.push((name.clone(), true)); + } + if let Some(column) = rel.columns.iter_mut().find(|column| column.name == *name) + { + column.type_id = action_type_id.clone(); + if matches!( + generation, + crate::_internal::analysis::facts::ColumnGeneration::GeneratedStored + | crate::_internal::analysis::facts::ColumnGeneration::GeneratedVirtual + ) { + column.generated = Some(true); + } + } + match generation { + crate::_internal::analysis::facts::ColumnGeneration::GeneratedStored + | crate::_internal::analysis::facts::ColumnGeneration::GeneratedVirtual => { + rel.generated_columns.insert( + name.clone(), + crate::_internal::model::relation::GeneratedColumnState { + kind: match generation { + crate::_internal::analysis::facts::ColumnGeneration::GeneratedStored => crate::_internal::model::relation::GeneratedColumnKind::Stored, + crate::_internal::analysis::facts::ColumnGeneration::GeneratedVirtual => crate::_internal::model::relation::GeneratedColumnKind::Virtual, + _ => unreachable!("generated kind checked above"), + }, + expression: generated_expr_sql.clone(), + }, + ); + } + crate::_internal::analysis::facts::ColumnGeneration::IdentityAlways => { + rel.identity_columns.insert( + name.clone(), + crate::_internal::model::relation::IdentityGeneration::Always, + ); + } + crate::_internal::analysis::facts::ColumnGeneration::IdentityByDefault => { + rel.identity_columns.insert( + name.clone(), + crate::_internal::model::relation::IdentityGeneration::ByDefault, + ); + } + _ => {} + } + + if let Some((sequence_id, column_name, _, _)) = &implicit_add + && column_name == name + && let Some(column) = + rel.columns.iter_mut().find(|column| column.name == *name) + { + column.default = Some(Self::sequence_nextval_default(sequence_id)); + column.default_expr_text = Some(format!( + "nextval('{}.{}'::regclass)", + sequence_id.schema, sequence_id.name + )); + column.is_nullable = false; + } + + if let Some((source_table, source_col)) = depends_on { + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + alter.id.clone(), + source_table.clone(), + DependencyKind::ColumnGeneratedFrom { + column: name.clone(), + depends_on_column: source_col.clone(), + }, + )); + } + if let Some(expr) = generated_expr + && let Some(references) = expr.referenced_columns() + { + self.snapshot_graph(); + for depends_on_column in references { + self.local.graph.add_edge(DependencyEdge::new( + alter.id.clone(), + alter.id.clone(), + DependencyKind::ColumnGeneratedFrom { + column: name.clone(), + depends_on_column, + }, + )); + } + } + } + AlterTableActionMutation::DropColumn { + name, if_exists, .. } => { - if let Some(existing_col) = rel.columns.iter().find(|c| c.name == *name) { - if *if_not_exists { + if !rel.has_column(name) { + if *if_exists { + // Column doesn't exist and IF EXISTS was specified: no-op return MutationResult::Skipped; } return MutationResult::Conflict { reason: format!( - "column '{}' already exists with type {}; this statement adds it again with type {}", - name, - existing_col.data_type.as_deref().unwrap_or("unknown"), - ty.as_deref().unwrap_or("unknown") + "column '{}' does not exist on relation '{}'", + name, alter.id ), }; } - rel.apply_column_action(&ColumnAction::Add { - name: name.clone(), + rel.apply_column_action(&ColumnAction::Drop { name: name.clone() }); + if !drop_column_statistics.is_empty() { + rel.extended_statistics + .retain(|id, _| !drop_column_statistics.contains(id)); + } + for generated_column in &cascade_generated_columns { + if rel.has_column(generated_column) { + rel.apply_column_action(&ColumnAction::Drop { + name: generated_column.clone(), + }); + } + } + } + AlterTableActionMutation::RenameColumn { from, to } => { + rel.apply_column_action(&ColumnAction::Rename { + from: from.clone(), + to: to.clone(), + }); + } + AlterTableActionMutation::SetNotNull { column } => { + rel.apply_column_action(&ColumnAction::SetNotNull { + name: column.clone(), + }); + deferred_not_null.push((column.clone(), true)); + } + AlterTableActionMutation::DropNotNull { column } => { + rel.apply_column_action(&ColumnAction::DropNotNull { + name: column.clone(), + }); + deferred_not_null.push((column.clone(), false)); + } + AlterTableActionMutation::SetType { column, ty, .. } => { + rel.apply_column_action(&ColumnAction::SetType { + name: column.clone(), data_type: ty.clone(), - not_null: *not_null, + }); + if let Some(column) = rel.columns.iter_mut().find(|entry| entry.name == *column) + { + column.type_id = action_type_id.clone(); + } + } + AlterTableActionMutation::SetDefault { column, default } => { + rel.apply_column_action(&ColumnAction::SetDefault { + name: column.clone(), default: default.clone(), }); - if *not_null { - deferred_not_null.push((name.clone(), true)); + } + AlterTableActionMutation::SetStorage { column, mode } => { + rel.apply_column_action(&ColumnAction::SetStorage { + name: column.clone(), + mode: mode.clone(), + }); + } + AlterTableActionMutation::SetCompression { column, method } => { + rel.apply_column_action(&ColumnAction::SetCompression { + name: column.clone(), + method: method.clone(), + }); + } + AlterTableActionMutation::SetStatistics { column, target } => { + rel.apply_column_action(&ColumnAction::SetStatistics { + name: column.clone(), + target: *target, + }); + } + AlterTableActionMutation::SetColumnOptions { column, attributes } => { + rel.apply_column_action(&ColumnAction::SetOptions { + name: column.clone(), + options: attributes + .iter() + .map(|attribute| (attribute.name.clone(), attribute.value.clone())) + .collect(), + }); + } + AlterTableActionMutation::ResetColumnOptions { column, names } => { + rel.apply_column_action(&ColumnAction::ResetOptions { + name: column.clone(), + names: names.clone(), + }); + } + AlterTableActionMutation::DropGeneratedExpression { column, .. } => { + if let Some(entry) = rel.columns.iter_mut().find(|entry| entry.name == *column) + { + entry.generated = Some(false); + } + rel.generated_columns.remove(column); + } + AlterTableActionMutation::AddForeignKey { + constraint_name, + to_table, + from_columns, + to_columns, + not_valid, + } => { + let constraint_name = constraint_name.clone().unwrap_or_else(|| { + self.next_generated_constraint_name_avoiding( + &alter.id, + &alter.id.name, + Some(&from_columns.join("_")), + "fkey", + &HashSet::new(), + ) + }); + self.snapshot_constraint(&alter.id, &constraint_name); + self.local.constraints.insert( + (alter.id.clone(), constraint_name.clone()), + ConstraintState { + table_id: alter.id.clone(), + name: constraint_name.clone(), + kind: ConstraintKind::ForeignKey, + validated: !not_valid, + definition: None, + backing_index: None, + }, + ); + if *not_valid { + self.snapshot_pending_validation(); + self.local + .pending_validation + .insert((alter.id.clone(), constraint_name.clone())); + } + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + alter.id.clone(), + to_table.clone(), + DependencyKind::ForeignKey { + constraint_name: Some(constraint_name), + from_columns: from_columns.clone(), + to_columns: effective_fk_target_columns + .clone() + .unwrap_or_else(|| to_columns.clone()), + operator_evidence: None, + from_generation: generation, + }, + )); + } + AlterTableActionMutation::DropConstraint { name, .. } => { + self.snapshot_constraint(&alter.id, name); + let removed_constraint = self + .local + .constraints + .remove(&(alter.id.clone(), name.clone())); + if let Some(index) = removed_constraint + .as_ref() + .filter(|constraint| { + matches!( + constraint.kind, + ConstraintKind::PrimaryKey + | ConstraintKind::Unique + | ConstraintKind::Exclusion + ) + }) + .and_then(|constraint| constraint.backing_index.as_ref()) + { + self.snapshot_relation(&alter.id); + if let Some(RelationOverlay::Present(relation)) = + self.local.relations.get_mut(&alter.id) + { + relation.clear_index_settings(&index.name); + } + self.snapshot_graph_full(); + self.local.graph.retain_edges(|edge| { + !(edge.dependent == *index + && matches!(edge.kind, DependencyKind::IndexOnRelation { .. })) + }); + } + if let Some(ref c) = removed_constraint + && c.kind == crate::_internal::model::constraint::ConstraintKind::NotNull + { + // A not-null constraint guards exactly one column; PG18 + // allows it to be dropped by name like any other + // constraint, which releases the column's nullability. + let resolution_graph = self.local.graph.clone(); + let guarded_column: Option = + self.local.graph.edges().iter().find_map(|edge| { + if resolution_graph.resolve_rename(&edge.dependent) != &alter.id { + return None; + } + if let DependencyKind::ConstraintOnRelation { + constraint_name, + columns, + is_primary: false, + .. + } = &edge.kind + && constraint_name == name + && columns.len() == 1 + { + Some(columns[0].clone()) + } else { + None + } + }); + if let Some(column) = guarded_column { + self.snapshot_relation(&alter.id); + if let Some(RelationOverlay::Present(rel)) = + self.local.relations.get_mut(&alter.id) + { + if let Some(col) = rel.columns.iter_mut().find(|c| c.name == column) + { + col.is_nullable = true; + } + } + } + } + if self + .local + .pending_validation + .contains(&(alter.id.clone(), name.clone())) + { + self.snapshot_pending_validation(); + self.local + .pending_validation + .remove(&(alter.id.clone(), name.clone())); + } + if self + .baseline_foreign_keys + .contains(&(alter.id.clone(), name.clone())) + { + self.snapshot_baseline_foreign_keys(); + self.baseline_foreign_keys + .remove(&(alter.id.clone(), name.clone())); + } + self.snapshot_graph_full(); + let resolution_graph = self.local.graph.clone(); + self.local.graph.retain_edges(|e| { + let dependent = resolution_graph.resolve_rename(&e.dependent); + match &e.kind { + DependencyKind::ForeignKey { + constraint_name, .. + } => { + !(dependent == &alter.id && constraint_name.as_ref() == Some(name)) + } + DependencyKind::ConstraintOnRelation { + constraint_name, .. + } => !(dependent == &alter.id && constraint_name == name), + DependencyKind::ConstraintDependency { + constraint_name, .. + } => !(dependent == &alter.id && constraint_name == name), + _ => true, + } + }); + } + AlterTableActionMutation::RenameConstraint { old_name, new_name } => { + self.snapshot_constraint(&alter.id, old_name); + self.snapshot_constraint(&alter.id, new_name); + if let Some(mut constraint) = self + .local + .constraints + .remove(&(alter.id.clone(), old_name.clone())) + { + constraint.name = new_name.clone(); + self.local + .constraints + .insert((alter.id.clone(), new_name.clone()), constraint); } - if let Some(column) = rel.columns.iter_mut().find(|column| column.name == *name) + if self + .local + .pending_validation + .contains(&(alter.id.clone(), old_name.clone())) { - column.type_id = action_type_id.clone(); + self.snapshot_pending_validation(); + self.local + .pending_validation + .remove(&(alter.id.clone(), old_name.clone())); + self.local + .pending_validation + .insert((alter.id.clone(), new_name.clone())); } - - if let Some((sequence_id, column_name, _)) = &implicit_add - && column_name == name - && let Some(column) = - rel.columns.iter_mut().find(|column| column.name == *name) + if self + .baseline_foreign_keys + .contains(&(alter.id.clone(), old_name.clone())) { - column.default = Some(Self::sequence_nextval_default(sequence_id)); - column.default_expr_text = Some(format!( - "nextval('{}.{}'::regclass)", - sequence_id.schema, sequence_id.name - )); - column.is_nullable = false; + self.snapshot_baseline_foreign_keys(); + self.baseline_foreign_keys + .remove(&(alter.id.clone(), old_name.clone())); + self.baseline_foreign_keys + .insert((alter.id.clone(), new_name.clone())); } - - if let Some((source_table, source_col)) = depends_on { + self.snapshot_graph_full(); + self.local + .graph + .rename_constraint(&alter.id, old_name, new_name); + } + AlterTableActionMutation::AddCheckConstraint { + constraint_name, + definition, + columns, + columns_complete, + not_valid, + } => { + let constraint_name = constraint_name.clone().unwrap_or_else(|| { + self.next_generated_constraint_name_avoiding( + &alter.id, + &alter.id.name, + (*columns_complete && columns.len() == 1).then(|| columns[0].as_str()), + "check", + &HashSet::new(), + ) + }); + self.snapshot_constraint(&alter.id, &constraint_name); + self.local.constraints.insert( + (alter.id.clone(), constraint_name.clone()), + ConstraintState { + table_id: alter.id.clone(), + name: constraint_name.clone(), + kind: ConstraintKind::Check, + validated: !not_valid, + definition: Some(definition.clone()), + backing_index: None, + }, + ); + if *not_valid { + self.snapshot_pending_validation(); + self.local + .pending_validation + .insert((alter.id.clone(), constraint_name.clone())); + } + if !relation_columns_known || !columns_complete { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + } else { self.snapshot_graph(); self.local.graph.add_edge(DependencyEdge::new( alter.id.clone(), - source_table.clone(), - DependencyKind::ColumnGeneratedFrom { - column: name.clone(), - depends_on_column: source_col.clone(), + alter.id.clone(), + DependencyKind::ConstraintDependency { + constraint_name, + columns: columns.clone(), }, )); } } - AlterTableActionMutation::DropColumn { - name, if_exists, .. + AlterTableActionMutation::AddUniqueConstraint { + constraint_name, + columns, + using_index, } => { - if !rel.has_column(name) { - if *if_exists { - // Column doesn't exist and IF EXISTS was specified: no-op - return MutationResult::Skipped; - } - return MutationResult::Conflict { - reason: format!( - "column '{}' does not exist on relation '{}'", - name, alter.id - ), - }; + let constraint_name = constraint_name + .clone() + .or_else(|| using_index.as_ref().map(|index| index.name.clone())) + .unwrap_or_else(|| { + self.next_generated_constraint_name_avoiding( + &alter.id, + &alter.id.name, + None, + "key", + &HashSet::new(), + ) + }); + let backing_index = Some(ObjectId::new(&alter.id.schema, &constraint_name)); + if let Some(index) = using_index { + self.adopt_index_for_constraint(index, &alter.id, &constraint_name); + } else { + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + ObjectId::new(&alter.id.schema, &constraint_name), + alter.id.clone(), + Self::constraint_index_dependency(columns.clone(), true), + )); } - rel.apply_column_action(&ColumnAction::Drop { name: name.clone() }); - for generated_column in &cascade_generated_columns { - if rel.has_column(generated_column) { - rel.apply_column_action(&ColumnAction::Drop { - name: generated_column.clone(), - }); - } + self.snapshot_constraint(&alter.id, &constraint_name); + self.local.constraints.insert( + (alter.id.clone(), constraint_name.clone()), + ConstraintState { + table_id: alter.id.clone(), + name: constraint_name.clone(), + kind: ConstraintKind::Unique, + validated: true, + definition: None, + backing_index, + }, + ); + if columns.is_empty() || !relation_columns_known { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + } else { + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + alter.id.clone(), + alter.id.clone(), + DependencyKind::ConstraintOnRelation { + constraint_name, + columns: columns.clone(), + is_primary: false, + }, + )); } } - AlterTableActionMutation::RenameColumn { from, to } => { - rel.apply_column_action(&ColumnAction::Rename { - from: from.clone(), - to: to.clone(), - }); - } - AlterTableActionMutation::SetNotNull { column } => { - rel.apply_column_action(&ColumnAction::SetNotNull { - name: column.clone(), - }); - deferred_not_null.push((column.clone(), true)); - } - AlterTableActionMutation::DropNotNull { column } => { - rel.apply_column_action(&ColumnAction::DropNotNull { - name: column.clone(), - }); - deferred_not_null.push((column.clone(), false)); - } - AlterTableActionMutation::SetType { column, ty, .. } => { - rel.apply_column_action(&ColumnAction::SetType { - name: column.clone(), - data_type: ty.clone(), - }); - if let Some(column) = rel.columns.iter_mut().find(|entry| entry.name == *column) - { - column.type_id = action_type_id.clone(); + AlterTableActionMutation::AddPrimaryKeyConstraint { + constraint_name, + columns, + using_index, + } => { + let constraint_name = constraint_name + .clone() + .or_else(|| using_index.as_ref().map(|index| index.name.clone())) + .unwrap_or_else(|| { + self.next_generated_constraint_name_avoiding( + &alter.id, + &alter.id.name, + None, + "pkey", + &HashSet::new(), + ) + }); + let backing_index = Some(ObjectId::new(&alter.id.schema, &constraint_name)); + if let Some(index) = using_index { + self.adopt_index_for_constraint(index, &alter.id, &constraint_name); + } else { + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + ObjectId::new(&alter.id.schema, &constraint_name), + alter.id.clone(), + Self::constraint_index_dependency(columns.clone(), true), + )); + } + self.snapshot_constraint(&alter.id, &constraint_name); + self.local.constraints.insert( + (alter.id.clone(), constraint_name.clone()), + ConstraintState { + table_id: alter.id.clone(), + name: constraint_name.clone(), + kind: ConstraintKind::PrimaryKey, + validated: true, + definition: None, + backing_index, + }, + ); + if columns.is_empty() || !relation_columns_known { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + } else { + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + alter.id.clone(), + alter.id.clone(), + DependencyKind::ConstraintOnRelation { + constraint_name, + columns: columns.clone(), + is_primary: true, + }, + )); + for column in columns.iter() { + if let Some(RelationOverlay::Present(relation)) = + self.local.relations.get_mut(&alter.id) + { + relation.apply_column_action(&ColumnAction::SetNotNull { + name: column.clone(), + }); + } + self.register_not_null_constraint(&alter.id, column); + } } } - AlterTableActionMutation::SetDefault { column, default } => { - rel.apply_column_action(&ColumnAction::SetDefault { - name: column.clone(), - default: default.clone(), - }); - } - AlterTableActionMutation::AddForeignKey { + AlterTableActionMutation::AddExcludeConstraint { constraint_name, - to_table, - from_columns, - to_columns, - not_valid, + columns, + columns_complete, } => { let constraint_name = constraint_name.clone().unwrap_or_else(|| { self.next_generated_constraint_name_avoiding( &alter.id, &alter.id.name, - Some(&from_columns.join("_")), - "fkey", + None, + "excl", &HashSet::new(), ) }); + let backing_index = ObjectId::new(&alter.id.schema, &constraint_name); + if self.relation_namespace_is_taken(&backing_index) { + return MutationResult::Conflict { + reason: format!("relation '{}' already exists", backing_index), + }; + } self.snapshot_constraint(&alter.id, &constraint_name); self.local.constraints.insert( (alter.id.clone(), constraint_name.clone()), ConstraintState { table_id: alter.id.clone(), name: constraint_name.clone(), - kind: ConstraintKind::ForeignKey, - validated: !not_valid, - backing_index: None, + kind: ConstraintKind::Exclusion, + validated: true, + definition: None, + backing_index: Some(backing_index.clone()), }, ); - if *not_valid { - self.snapshot_pending_validation(); - self.local - .pending_validation - .insert((alter.id.clone(), constraint_name.clone())); + if !relation_columns_known || !columns_complete { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + } else { + self.snapshot_graph_full(); + self.local.graph.add_edge(DependencyEdge::new( + backing_index, + alter.id.clone(), + DependencyKind::IndexOnRelation { + using_method: None, + key_columns: columns.clone(), + included_columns: Vec::new(), + dependency_columns: columns.clone(), + dependency_columns_known: true, + has_expression_keys: true, + has_predicate: false, + is_concurrent: false, + is_unique: false, + is_immediate: true, + is_valid: true, + is_ready: true, + is_live: true, + has_default_sort_order: false, + has_default_opclasses: false, + has_default_collations: false, + eligibility_known: false, + }, + )); + self.local.graph.add_edge(DependencyEdge::new( + alter.id.clone(), + alter.id.clone(), + DependencyKind::ConstraintDependency { + constraint_name, + columns: columns.clone(), + }, + )); } - self.snapshot_graph(); - self.local.graph.add_edge(DependencyEdge::new( - alter.id.clone(), - to_table.clone(), - DependencyKind::ForeignKey { - constraint_name: Some(constraint_name), - from_columns: from_columns.clone(), - to_columns: effective_fk_target_columns - .clone() - .unwrap_or_else(|| to_columns.clone()), - operator_evidence: None, - from_generation: generation, - }, - )); } - AlterTableActionMutation::DropConstraint { name, .. } => { - self.snapshot_constraint(&alter.id, name); - let removed_constraint = self + AlterTableActionMutation::ValidateConstraint { constraint_name } => { + self.snapshot_constraint(&alter.id, constraint_name); + if let Some(constraint) = self .local .constraints - .remove(&(alter.id.clone(), name.clone())); - if let Some(ref c) = removed_constraint - && c.kind == crate::_internal::model::constraint::ConstraintKind::NotNull + .get_mut(&(alter.id.clone(), constraint_name.clone())) { - // A not-null constraint guards exactly one column; PG18 - // allows it to be dropped by name like any other - // constraint, which releases the column's nullability. - let resolution_graph = self.local.graph.clone(); - let guarded_column: Option = - self.local.graph.edges().iter().find_map(|edge| { - if resolution_graph.resolve_rename(&edge.dependent) != &alter.id { - return None; - } - if let DependencyKind::ConstraintOnRelation { - constraint_name, - columns, - is_primary: false, - .. - } = &edge.kind - && constraint_name == name - && columns.len() == 1 - { - Some(columns[0].clone()) - } else { - None - } - }); - if let Some(column) = guarded_column { - self.snapshot_relation(&alter.id); - if let Some(RelationOverlay::Present(rel)) = - self.local.relations.get_mut(&alter.id) - { - if let Some(col) = rel.columns.iter_mut().find(|c| c.name == column) - { - col.is_nullable = true; - } - } - } + constraint.validated = true; } if self .local .pending_validation - .contains(&(alter.id.clone(), name.clone())) + .contains(&(alter.id.clone(), constraint_name.clone())) { self.snapshot_pending_validation(); self.local .pending_validation - .remove(&(alter.id.clone(), name.clone())); + .remove(&(alter.id.clone(), constraint_name.clone())); } - if self - .baseline_foreign_keys - .contains(&(alter.id.clone(), name.clone())) - { - self.snapshot_baseline_foreign_keys(); - self.baseline_foreign_keys - .remove(&(alter.id.clone(), name.clone())); + } + AlterTableActionMutation::AttachPartition { child, .. } => { + // Validation above rejects cyclic attachments before state mutation. + if self.local.graph.check_partition_cycle(&alter.id, child) { + return MutationResult::Conflict { + reason: format!( + "attaching partition '{}' to '{}' would create a partition cycle", + child, alter.id + ), + }; + } else { + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + child.clone(), + alter.id.clone(), + DependencyKind::PartitionOf, + )); } - self.snapshot_graph(); - let resolution_graph = self.local.graph.clone(); - self.local.graph.retain_edges(|e| { - let dependent = resolution_graph.resolve_rename(&e.dependent); - match &e.kind { - DependencyKind::ForeignKey { - constraint_name, .. - } => { - !(dependent == &alter.id && constraint_name.as_ref() == Some(name)) + } + AlterTableActionMutation::DetachPartition { child, mode } => { + self.snapshot_graph_full(); + match mode { + crate::_internal::analysis::facts::DetachPartitionMode::Immediate + | crate::_internal::analysis::facts::DetachPartitionMode::Finalize => { + let kind = match mode { + crate::_internal::analysis::facts::DetachPartitionMode::Immediate => { + DependencyKind::PartitionOf + } + crate::_internal::analysis::facts::DetachPartitionMode::Finalize => { + DependencyKind::PartitionDetachPending + } + crate::_internal::analysis::facts::DetachPartitionMode::Concurrently => { + unreachable!("concurrent detach is handled separately") + } + }; + self.local.graph.retain_edges(|edge| { + !(edge.kind == kind + && edge.dependent == *child + && edge.referenced == alter.id) + }); + } + crate::_internal::analysis::facts::DetachPartitionMode::Concurrently => { + self.local.graph.retain_edges(|edge| { + !(matches!(edge.kind, DependencyKind::PartitionOf) + && edge.dependent == *child + && edge.referenced == alter.id) + }); + // PostgreSQL retains a CHECK duplicating the + // partition predicate on concurrent detach + // (DetachAddConstraintIfNeeded). Reproduce its + // exact deparse; otherwise stay conservative. + match self.retained_check_for_detached_partition(&alter.id, child) { + RetainedCheckSynthesis::NoCheck => {} + RetainedCheckSynthesis::CantResolve => { + self.taint( + EvidenceCode::UnsupportedSemantics, + EvidenceScope::Chain, + ); + } + RetainedCheckSynthesis::Definition(definition) => { + self.register_retained_partition_check(child, definition); + } } - DependencyKind::ConstraintOnRelation { - constraint_name, .. - } => !(dependent == &alter.id && constraint_name == name), - DependencyKind::ConstraintDependency { - constraint_name, .. - } => !(dependent == &alter.id && constraint_name == name), - _ => true, } + } + } + AlterTableActionMutation::InheritTable { parent } => { + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + alter.id.clone(), + parent.clone(), + DependencyKind::InheritanceOf, + )); + } + AlterTableActionMutation::NoInheritTable { parent } => { + self.snapshot_graph_full(); + self.local.graph.retain_edges(|edge| { + !(edge.dependent == alter.id + && edge.referenced == *parent + && matches!(edge.kind, DependencyKind::InheritanceOf)) }); } - AlterTableActionMutation::RenameConstraint { old_name, new_name } => { - self.snapshot_constraint(&alter.id, old_name); - self.snapshot_constraint(&alter.id, new_name); - if let Some(mut constraint) = self - .local - .constraints - .remove(&(alter.id.clone(), old_name.clone())) - { - constraint.name = new_name.clone(); - self.local - .constraints - .insert((alter.id.clone(), new_name.clone()), constraint); - } - if self - .local - .pending_validation - .contains(&(alter.id.clone(), old_name.clone())) - { - self.snapshot_pending_validation(); - self.local - .pending_validation - .remove(&(alter.id.clone(), old_name.clone())); - self.local - .pending_validation - .insert((alter.id.clone(), new_name.clone())); + AlterTableActionMutation::SetTablespace { tablespace } => { + rel.tablespace = Some(tablespace.clone()); + } + AlterTableActionMutation::SetAccessMethod { access_method } => { + rel.access_method = access_method.clone(); + } + AlterTableActionMutation::SetPersistence { persistence } => { + rel.persistence = persistence.clone(); + } + AlterTableActionMutation::SetCluster { index } => { + rel.cluster_index = index.as_ref().map(|index| index.name.clone()); + } + AlterTableActionMutation::SetRowSecurity { enabled } => { + rel.row_security = Some(*enabled); + } + AlterTableActionMutation::SetForceRowSecurity { enabled } => { + rel.force_row_security = Some(*enabled); + } + AlterTableActionMutation::SetReplicaIdentity { option } => { + rel.replica_identity = Some(match option { + crate::_internal::analysis::mutations::ReplicaIdentityMutation::Default => + "DEFAULT".to_string(), + crate::_internal::analysis::mutations::ReplicaIdentityMutation::Full => + "FULL".to_string(), + crate::_internal::analysis::mutations::ReplicaIdentityMutation::Nothing => + "NOTHING".to_string(), + crate::_internal::analysis::mutations::ReplicaIdentityMutation::UsingIndex(index) => + format!("USING INDEX {}", index.name), + }); + } + AlterTableActionMutation::SetOfType { type_id } => { + rel.of_type = type_id.clone(); + } + AlterTableActionMutation::SetTableOptions { attributes } => { + for attribute in attributes { + rel.table_options + .insert(attribute.name.clone(), attribute.value.clone()); } - if self - .baseline_foreign_keys - .contains(&(alter.id.clone(), old_name.clone())) - { - self.snapshot_baseline_foreign_keys(); - self.baseline_foreign_keys - .remove(&(alter.id.clone(), old_name.clone())); - self.baseline_foreign_keys - .insert((alter.id.clone(), new_name.clone())); + } + AlterTableActionMutation::ResetTableOptions { names } => { + for name in names { + rel.table_options.remove(name); } - self.snapshot_graph_full(); - self.local - .graph - .rename_foreign_key_constraint(&alter.id, old_name, new_name); } - AlterTableActionMutation::AddCheckConstraint { - constraint_name, - columns, - columns_complete, - not_valid, + AlterTableActionMutation::SetGeneratedExpression { + column, + expression_sql, + .. } => { - let constraint_name = constraint_name.clone().unwrap_or_else(|| { - self.next_generated_constraint_name_avoiding( - &alter.id, - &alter.id.name, - None, - "check", - &HashSet::new(), + if let Some(generated) = rel.generated_columns.get_mut(column) { + generated.expression = Some(expression_sql.clone()); + } + } + AlterTableActionMutation::AlterColumnInheritance + | AlterTableActionMutation::PartitionReshape => { + // These forms have a typed parse but change physical or + // inheritance metadata that RelationState cannot yet + // represent. Never leave subsequent statements exact. + self.taint(EvidenceCode::UnsupportedSemantics, EvidenceScope::Chain); + } + _ => {} + } + } + for (column, register) in deferred_not_null { + if register { + self.register_not_null_constraint(&alter.id, &column); + } else { + self.drop_not_null_constraint(&alter.id, &column); + } + } + match &alter.action { + AlterTableActionMutation::InheritTable { parent } + | AlterTableActionMutation::NoInheritTable { parent } => { + let columns = match self.local.relations.get(parent) { + Some(RelationOverlay::Present(parent)) => parent + .columns + .iter() + .map(|column| column.name.clone()) + .collect::>(), + _ => Vec::new(), + }; + let adding = matches!(alter.action, AlterTableActionMutation::InheritTable { .. }); + let mut incomplete = false; + self.snapshot_relation(&alter.id); + if let Some(RelationOverlay::Present(relation)) = + self.local.relations.get_mut(&alter.id) + { + for column in columns { + let Some(provenance) = relation.column_inheritance.get_mut(&column) else { + incomplete = true; + continue; + }; + let count = if adding { + provenance.parent_count.checked_add(1) + } else { + provenance.parent_count.checked_sub(1) + }; + if let Some(count) = count { + provenance.parent_count = count; + if count == 0 { + provenance.is_local = true; + } + } else { + relation.column_inheritance.remove(&column); + incomplete = true; + } + } + } + if incomplete { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + } + } + AlterTableActionMutation::AttachPartition { child, bound, .. } => { + self.invalidate_descendant_partition_predicates(child); + let canonical_bound = bound.as_deref().map(canonical_partition_bound); + let generated_partition_constraint = self + .local + .relations + .get(child) + .and_then(|overlay| match overlay { + RelationOverlay::Present(relation) => Some(relation), + RelationOverlay::Dropped => None, + }) + .and_then(|relation| { + let parent_strategy = self.local.relations.get(&alter.id).and_then( + |overlay| match overlay { + RelationOverlay::Present(parent) => { + parent.partition_type.as_deref() + } + RelationOverlay::Dropped => None, + }, + )?; + let keys = self.partition_key_columns(&alter.id)?; + if canonical_bound + .as_deref() + .is_some_and(|value| value.eq_ignore_ascii_case("DEFAULT")) + { + return self.synthesize_default_partition_constraint(&alter.id); + } + self.synthesize_partition_check( + parent_strategy, + canonical_bound.as_deref()?, + &keys, + relation, ) }); - self.snapshot_constraint(&alter.id, &constraint_name); - self.local.constraints.insert( - (alter.id.clone(), constraint_name.clone()), - ConstraintState { - table_id: alter.id.clone(), - name: constraint_name.clone(), - kind: ConstraintKind::Check, - validated: !not_valid, - backing_index: None, - }, - ); - if *not_valid { - self.snapshot_pending_validation(); - self.local - .pending_validation - .insert((alter.id.clone(), constraint_name.clone())); - } - if !relation_columns_known || !columns_complete { - self.taint( - EvidenceCode::CatalogCoverageIncomplete, - EvidenceScope::Chain, - ); - } else { - self.snapshot_graph(); - self.local.graph.add_edge(DependencyEdge::new( - alter.id.clone(), - alter.id.clone(), - DependencyKind::ConstraintDependency { - constraint_name, - columns: columns.clone(), + self.snapshot_relation(child); + if let Some(RelationOverlay::Present(relation)) = + self.local.relations.get_mut(child) + { + relation.partition_bound = canonical_bound; + relation.partition_constraint = generated_partition_constraint; + for column in &relation.columns { + relation.column_inheritance.insert( + column.name.clone(), + crate::_internal::model::relation::ColumnInheritance { + parent_count: 1, + is_local: false, }, - )); + ); } } - AlterTableActionMutation::AddUniqueConstraint { - constraint_name, - columns, - using_index, - } => { - let constraint_name = constraint_name - .clone() - .or_else(|| using_index.as_ref().map(|index| index.name.clone())) - .unwrap_or_else(|| { - self.next_generated_constraint_name_avoiding( - &alter.id, - &alter.id.name, - None, - "key", - &HashSet::new(), - ) - }); - let backing_index = using_index - .as_ref() - .map(|index| ObjectId::new(index.schema.clone(), constraint_name.clone())); - if let Some(index) = using_index { - self.adopt_index_for_constraint(index, &alter.id, &constraint_name); - } - self.snapshot_constraint(&alter.id, &constraint_name); - self.local.constraints.insert( - (alter.id.clone(), constraint_name.clone()), - ConstraintState { - table_id: alter.id.clone(), - name: constraint_name.clone(), - kind: ConstraintKind::Unique, - validated: true, - backing_index, - }, - ); - if columns.is_empty() || !relation_columns_known { - self.taint( - EvidenceCode::CatalogCoverageIncomplete, - EvidenceScope::Chain, - ); - } else { - self.snapshot_graph(); - self.local.graph.add_edge(DependencyEdge::new( - alter.id.clone(), - alter.id.clone(), - DependencyKind::ConstraintOnRelation { - constraint_name, - columns: columns.clone(), - is_primary: false, + self.refresh_default_partition_constraints(&alter.id); + self.ensure_partition_indexes_and_constraints(&alter.id, child); + let result = self.clone_row_triggers_to_partition(&alter.id, child); + debug_assert!(matches!(result, MutationResult::Applied)); + if !matches!(result, MutationResult::Applied) { + return result; + } + } + AlterTableActionMutation::DetachPartition { child, .. } => { + // The retained CHECK for a concurrent detach is synthesized in + // the apply arm; this block only resets partition metadata. + self.invalidate_descendant_partition_predicates(child); + self.snapshot_relation(child); + if let Some(RelationOverlay::Present(relation)) = + self.local.relations.get_mut(child) + { + relation.partition_bound = None; + relation.partition_constraint = None; + for column in &relation.columns { + relation.column_inheritance.insert( + column.name.clone(), + crate::_internal::model::relation::ColumnInheritance { + parent_count: 0, + is_local: true, }, - )); + ); } } - AlterTableActionMutation::AddPrimaryKeyConstraint { - constraint_name, - columns, - using_index, - } => { - let constraint_name = constraint_name - .clone() - .or_else(|| using_index.as_ref().map(|index| index.name.clone())) - .unwrap_or_else(|| { - self.next_generated_constraint_name_avoiding( - &alter.id, - &alter.id.name, - None, - "pkey", - &HashSet::new(), - ) - }); - let backing_index = using_index - .as_ref() - .map(|index| ObjectId::new(index.schema.clone(), constraint_name.clone())); - if let Some(index) = using_index { - self.adopt_index_for_constraint(index, &alter.id, &constraint_name); - } - self.snapshot_constraint(&alter.id, &constraint_name); - self.local.constraints.insert( - (alter.id.clone(), constraint_name.clone()), - ConstraintState { - table_id: alter.id.clone(), - name: constraint_name.clone(), - kind: ConstraintKind::PrimaryKey, - validated: true, - backing_index, + self.remove_partition_trigger_clones(&alter.id, child); + } + _ => {} + } + if let AlterTableActionMutation::SetDefault { column, default } = &alter.action { + self.snapshot_graph_full(); + self.local.graph.retain_edges(|edge| { + !matches!( + &edge.kind, + DependencyKind::ColumnDefaultOnSequence { column: edge_column } + if edge.dependent == alter.id && edge_column == column + ) + }); + if let Some(default) = default { + let matching_sequences = self + .local + .sequences + .iter() + .filter_map(|(id, overlay)| { + matches!(overlay, SequenceOverlay::Present(_)).then_some(id.clone()) + }) + .filter(|id| Self::expression_references_sequence(default, id)) + .collect::>(); + match matching_sequences.as_slice() { + [sequence] => self.local.graph.add_edge(DependencyEdge::new( + alter.id.clone(), + sequence.clone(), + DependencyKind::ColumnDefaultOnSequence { + column: column.clone(), }, - ); - if columns.is_empty() || !relation_columns_known { - self.taint( - EvidenceCode::CatalogCoverageIncomplete, - EvidenceScope::Chain, - ); - } else { - self.snapshot_graph(); - self.local.graph.add_edge(DependencyEdge::new( - alter.id.clone(), - alter.id.clone(), - DependencyKind::ConstraintOnRelation { - constraint_name, - columns: columns.clone(), - is_primary: true, + )), + [] if Self::expression_contains_nextval(default) => self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ), + _ => self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ), + } + } + } + match &alter.action { + AlterTableActionMutation::SetGeneratedExpression { column, expr, .. } => { + let references = expr + .referenced_columns() + .expect("generated expression was validated before state mutation"); + self.snapshot_graph_full(); + self.local.graph.retain_edges(|edge| { + !matches!( + &edge.kind, + DependencyKind::ColumnGeneratedFrom { column: generated_column, .. } + if edge.dependent == alter.id + && edge.referenced == alter.id + && generated_column == column + ) + }); + for source_column in references { + self.local.graph.add_edge(DependencyEdge::new( + alter.id.clone(), + alter.id.clone(), + DependencyKind::ColumnGeneratedFrom { + column: column.clone(), + depends_on_column: source_column, + }, + )); + } + } + AlterTableActionMutation::DropGeneratedExpression { column, .. } => { + self.snapshot_graph_full(); + self.local.graph.retain_edges(|edge| { + !matches!( + &edge.kind, + DependencyKind::ColumnGeneratedFrom { column: generated_column, .. } + if edge.dependent == alter.id + && edge.referenced == alter.id + && generated_column == column + ) + }); + } + _ => {} + } + if let AlterTableActionMutation::RenameColumn { from, to } = &alter.action { + self.rename_relation_column_metadata(&alter.id, from, to); + for descendant in recursive_rename_descendants { + self.rename_relation_column_metadata(&descendant, from, to); + } + + // Publication column lists are catalog identities, not merely + // display text. PostgreSQL follows a renamed column in an + // explicit publication list, so keep the modeled scope aligned. + let publication_updates: Vec<(String, Vec)> = self + .local + .publications + .iter() + .filter_map(|(name, overlay)| { + let crate::_internal::model::replication::PublicationOverlay::Present(publication) = + overlay + else { + return None; + }; + let indexes = match &publication.scope { + crate::_internal::analysis::facts::PublicationScope::Explicit(objects) => objects + .iter() + .enumerate() + .filter_map(|(index, object)| { + let crate::_internal::analysis::facts::PublicationObjectFact::Table { + name: table_name, + columns: Some(columns), + .. + } = object + else { + return None; + }; + (self.resolve_relation_id(table_name) == alter.id + && columns.iter().any(|column| column == from)) + .then_some(index) + }) + .collect::>(), + _ => Vec::new(), + }; + (!indexes.is_empty()).then(|| (name.clone(), indexes)) + }) + .collect(); + for (publication_name, object_indexes) in publication_updates { + self.snapshot_publication(&publication_name); + if let Some(crate::_internal::model::replication::PublicationOverlay::Present( + publication, + )) = self.local.publications.get_mut(&publication_name) + && let crate::_internal::analysis::facts::PublicationScope::Explicit(objects) = + &mut publication.scope + { + for index in object_indexes { + if let Some( + crate::_internal::analysis::facts::PublicationObjectFact::Table { + columns: Some(columns), + .. }, - )); - for column in columns.iter() { - self.register_not_null_constraint(&alter.id, column); + ) = objects.get_mut(index) + { + for column in columns { + if column == from { + *column = to.clone(); + } + } } } } - AlterTableActionMutation::AddExcludeConstraint { - constraint_name, - columns, - columns_complete, - } => { - let constraint_name = constraint_name.clone().unwrap_or_else(|| { - self.next_generated_constraint_name_avoiding( - &alter.id, - &alter.id.name, - None, - "excl", - &HashSet::new(), - ) - }); - self.snapshot_constraint(&alter.id, &constraint_name); - self.local.constraints.insert( - (alter.id.clone(), constraint_name.clone()), - ConstraintState { - table_id: alter.id.clone(), - name: constraint_name.clone(), - kind: ConstraintKind::Exclusion, - validated: true, - backing_index: None, - }, - ); - if !relation_columns_known || !columns_complete { - self.taint( - EvidenceCode::CatalogCoverageIncomplete, - EvidenceScope::Chain, - ); - } else { - self.snapshot_graph(); - self.local.graph.add_edge(DependencyEdge::new( - alter.id.clone(), - alter.id.clone(), - DependencyKind::ConstraintDependency { - constraint_name, - columns: columns.clone(), - }, - )); + } + } + if let Some((sequence_id, column_name, kind, identity_options)) = implicit_add { + let default_parameters = self + .local + .relations + .get(&alter.id) + .and_then(|overlay| match overlay { + RelationOverlay::Present(table) => { + Some(Self::default_column_sequence_parameters( + table + .get_column(&column_name) + .and_then(|column| column.data_type.as_deref()), + &table.persistence, + )) } + RelationOverlay::Dropped => None, + }) + .unwrap_or_default(); + let parameters = match identity_options.as_ref() { + Some(options) => { + let Some(parameters) = + Self::apply_identity_sequence_options(default_parameters, options) + else { + return MutationResult::Conflict { + reason: format!( + "identity sequence options for '{}.{}' are invalid", + alter.id, column_name + ), + }; + }; + parameters } - AlterTableActionMutation::ValidateConstraint { constraint_name } => { - self.snapshot_constraint(&alter.id, constraint_name); - if let Some(constraint) = self + None => default_parameters, + }; + self.snapshot_sequence(&sequence_id); + self.snapshot_generation_counter(); + self.local.generation_counter += 1; + self.local.sequences.insert( + sequence_id.clone(), + SequenceOverlay::Present(SequenceState { + id: sequence_id.clone(), + owner: self .local - .constraints - .get_mut(&(alter.id.clone(), constraint_name.clone())) - { - constraint.validated = true; + .relations + .get(&alter.id) + .and_then(|overlay| match overlay { + RelationOverlay::Present(table) => Some(table.owner.clone()), + RelationOverlay::Dropped => None, + }) + .unwrap_or_else(|| ObjectId::new("", &self.local.current_role)), + owned_by: Some((alter.id.clone(), column_name.clone())), + kind, + parameters, + generation: self.local.generation_counter, + }), + ); + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + sequence_id, + alter.id.clone(), + DependencyKind::SequenceOwnedBy { + column: column_name, + }, + )); + } + if matches!(alter.action, AlterTableActionMutation::DropColumn { .. }) + && !drop_column_constraints.is_empty() + { + self.remove_dropped_constraints(&HashSet::new(), &drop_column_constraints); + self.snapshot_graph_full(); + let resolution_graph = self.local.graph.clone(); + self.local.graph.retain_edges(|edge| { + let dependent = resolution_graph.resolve_rename(&edge.dependent); + match &edge.kind { + DependencyKind::ForeignKey { + constraint_name: Some(name), + .. + } => !drop_column_constraints.contains(&(dependent.clone(), name.clone())), + DependencyKind::ConstraintOnRelation { + constraint_name: name, + .. + } => !drop_column_constraints.contains(&(dependent.clone(), name.clone())), + DependencyKind::ConstraintDependency { + constraint_name: name, + .. + } => !drop_column_constraints.contains(&(dependent.clone(), name.clone())), + _ => { + // The preflight above has already rejected unknown + // column-bearing edges; this arm keeps unrelated + // topology intact. + true } - if self - .local - .pending_validation - .contains(&(alter.id.clone(), constraint_name.clone())) + } + }); + } + if !cascade_view_roots.is_empty() { + let views = cascade_view_roots.into_iter().collect::>(); + // Preflight established a column-level dependency and CASCADE; + // this applies the recursive view/index closure PostgreSQL drops. + let _ = self.apply_drop_relation_family(&views, true, "view"); + } + if let AlterTableActionMutation::DropColumn { name, .. } = &alter.action { + let resolved_table = self.local.graph.resolve_rename(&alter.id).clone(); + let resolution_graph = self.local.graph.clone(); + if self.local.graph.edges().iter().any(|edge| { + resolution_graph.resolve_rename(&edge.dependent) == &resolved_table + && matches!( + &edge.kind, + DependencyKind::ColumnGeneratedFrom { column, .. } + | DependencyKind::ColumnDefaultOnSequence { column } + if column == name || cascade_generated_columns.contains(column) + ) + }) { + self.snapshot_graph_full(); + self.local.graph.retain_edges(|edge| { + !(resolution_graph.resolve_rename(&edge.dependent) == &resolved_table + && matches!( + &edge.kind, + DependencyKind::ColumnGeneratedFrom { column, .. } + | DependencyKind::ColumnDefaultOnSequence { column } + if column == name || cascade_generated_columns.contains(column) + )) + }); + } + } + if matches!(alter.action, AlterTableActionMutation::DropColumn { .. }) + && !drop_column_indexes.is_empty() + { + self.snapshot_graph_full(); + self.local.graph.retain_edges(|edge| { + !(matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) + && drop_column_indexes.contains(&edge.dependent)) + }); + } + match &alter.action { + AlterTableActionMutation::DropColumn { name, .. } => { + self.drop_not_null_constraint(&alter.id, name); + for sequence_id in owned_sequences_for_column { + self.snapshot_sequence(&sequence_id); + self.local + .sequences + .insert(sequence_id.clone(), SequenceOverlay::Dropped); + self.snapshot_graph_full(); + self.local.graph.retain_edges(|edge| { + !(matches!(edge.kind, DependencyKind::SequenceOwnedBy { .. }) + && edge.dependent == sequence_id) + }); + } + } + AlterTableActionMutation::RenameColumn { from, to } => { + self.snapshot_graph_full(); + let resolved_table = self.local.graph.resolve_rename(&alter.id).clone(); + self.local + .graph + .rename_index_column(&resolved_table, from, to); + for sequence_id in owned_sequences_for_column { + self.snapshot_sequence(&sequence_id); + if let Some(SequenceOverlay::Present(sequence)) = + self.local.sequences.get_mut(&sequence_id) + && let Some((_, column)) = &mut sequence.owned_by { - self.snapshot_pending_validation(); - self.local - .pending_validation - .remove(&(alter.id.clone(), constraint_name.clone())); + *column = to.clone(); } + self.snapshot_graph_full(); + self.local + .graph + .rename_owned_sequence_column(&sequence_id, from, to); } - AlterTableActionMutation::AttachPartition { child, .. } => { - // Validation above rejects cyclic attachments before state mutation. - if self.local.graph.check_partition_cycle(&alter.id, child) { - return MutationResult::Conflict { - reason: format!( - "attaching partition '{}' to '{}' would create a partition cycle", - child, alter.id - ), - }; + } + _ => {} + } + MutationResult::Applied + } + + /// Return the key definitions that can be proved for a relation. + /// `None` means a key exists but its columns (or index eligibility) are + /// not represented by the current cache/model; callers must taint rather + /// than invent a matching foreign-key target in that case. + fn unique_keys_for_relation(&self, id: &ObjectId) -> Option, bool)>> { + let resolved = self.local.graph.resolve_rename(id); + if self.baseline_relation_is_known(resolved) + && self + .local + .relations + .get(resolved) + .is_some_and(|overlay| { + matches!(overlay, RelationOverlay::Present(relation) if relation.columns.is_empty()) + }) + { + return None; + } + let mut keys = Vec::new(); + let mut unknown = false; + for edge in self.local.graph.edges() { + if edge.dependent != *resolved { + continue; + } + match &edge.kind { + DependencyKind::ConstraintOnRelation { + columns, + is_primary, + .. + } => { + if columns.is_empty() { + unknown = true; } else { - self.snapshot_graph(); - self.local.graph.add_edge(DependencyEdge::new( - child.clone(), - alter.id.clone(), - DependencyKind::PartitionOf, - )); + keys.push((columns.clone(), *is_primary)); } } - AlterTableActionMutation::DetachPartition { child } => { - self.snapshot_graph(); - self.local.graph.retain_edges(|e| { - !(matches!(e.kind, DependencyKind::PartitionOf) - && e.dependent == *child - && e.referenced == alter.id) - }); - } + DependencyKind::IndexOnRelation { + is_unique: true, .. + } => unknown = true, _ => {} } } - for (column, register) in deferred_not_null { - if register { - self.register_not_null_constraint(&alter.id, &column); - } else { - self.drop_not_null_constraint(&alter.id, &column); - } + if !keys.is_empty() { + return Some(keys); } - if let AlterTableActionMutation::SetDefault { column, default } = &alter.action { - self.snapshot_graph_full(); - self.local.graph.retain_edges(|edge| { - !matches!( - &edge.kind, - DependencyKind::ColumnDefaultOnSequence { column: edge_column } - if edge.dependent == alter.id && edge_column == column - ) - }); - if let Some(default) = default { - let matching_sequences = self - .local - .sequences - .iter() - .filter_map(|(id, overlay)| { - matches!(overlay, SequenceOverlay::Present(_)).then_some(id.clone()) - }) - .filter(|id| Self::expression_references_sequence(default, id)) - .collect::>(); - match matching_sequences.as_slice() { - [sequence] => self.local.graph.add_edge(DependencyEdge::new( - alter.id.clone(), - sequence.clone(), - DependencyKind::ColumnDefaultOnSequence { - column: column.clone(), - }, - )), - [] if Self::expression_contains_nextval(default) => self.taint( - EvidenceCode::CatalogCoverageIncomplete, - EvidenceScope::Chain, - ), - _ => self.taint( - EvidenceCode::CatalogCoverageIncomplete, - EvidenceScope::Chain, - ), + if unknown + || self + .local + .constraints + .iter() + .any(|((table, _), constraint)| { + table == resolved + && matches!( + constraint.kind, + ConstraintKind::PrimaryKey | ConstraintKind::Unique + ) + }) + { + None + } else { + Some(Vec::new()) + } + } + + pub(super) fn apply_rename_relation(&mut self, rename: &Rename) -> MutationResult { + let renames_relation = self.relation_is_present(&rename.old_id); + let renames_index = self.index_is_present(&rename.old_id); + if renames_index && rename.old_id.name != rename.new_id.name { + for constraint in self.local.constraints.values().filter(|constraint| { + constraint.backing_index.as_ref() == Some(&rename.old_id) + && matches!( + constraint.kind, + ConstraintKind::PrimaryKey + | ConstraintKind::Unique + | ConstraintKind::Exclusion + ) + }) { + if constraint.name != rename.new_id.name + && self + .local + .constraints + .contains_key(&(constraint.table_id.clone(), rename.new_id.name.clone())) + { + return MutationResult::Conflict { + reason: format!( + "constraint '{}' already exists on relation '{}'", + rename.new_id.name, constraint.table_id + ), + }; } } } - if let AlterTableActionMutation::RenameColumn { from, to } = &alter.action { - self.snapshot_graph_full(); - self.local - .graph - .rename_column_dependencies(&alter.id, from, to); + match self.relation_or_index_lookup(&rename.old_id) { + RelationLookup::Present => {} + _ if self.baseline_covers_family_object( + &rename.old_id, + crate::_internal::db::cache::CatalogFamily::Relations, + ) || self.baseline_covers_family_object( + &rename.old_id, + crate::_internal::db::cache::CatalogFamily::Indexes, + ) => + { + return MutationResult::Conflict { + reason: format!("relation '{}' does not exist", rename.old_id), + }; + } + RelationLookup::Tombstone + | RelationLookup::AuthoritativelyAbsent + | RelationLookup::Unknown => { + self.taint(EvidenceCode::UnknownObjectState, EvidenceScope::Chain); + return MutationResult::Skipped; + } + RelationLookup::WrongKind => { + unreachable!("relation renames accept every modeled relation kind") + } + } + if rename.old_id != rename.new_id && self.relation_namespace_is_taken(&rename.new_id) { + return MutationResult::Conflict { + reason: format!("relation '{}' already exists", rename.new_id), + }; + } + if rename.old_id.schema != rename.new_id.schema + && !self.schema_is_present(&rename.new_id.schema) + { + if self.schema_absence_is_authoritative(&rename.new_id.schema) { + return MutationResult::Conflict { + reason: format!("schema '{}' does not exist", rename.new_id.schema), + }; + } + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + return MutationResult::Skipped; + } - // Publication column lists are catalog identities, not merely - // display text. PostgreSQL follows a renamed column in an - // explicit publication list, so keep the modeled scope aligned. - let publication_updates: Vec<(String, Vec)> = self - .local - .publications + let schema_move = rename.old_id.schema != rename.new_id.schema; + let associated_sequence_moves: Vec<(ObjectId, ObjectId)> = if schema_move { + self.local + .sequences .iter() - .filter_map(|(name, overlay)| { - let crate::_internal::model::replication::PublicationOverlay::Present(publication) = - overlay - else { + .filter_map(|(id, overlay)| { + let SequenceOverlay::Present(sequence) = overlay else { return None; }; - let indexes = match &publication.scope { - crate::_internal::analysis::facts::PublicationScope::Explicit(objects) => objects - .iter() - .enumerate() - .filter_map(|(index, object)| { - let crate::_internal::analysis::facts::PublicationObjectFact::Table { - name: table_name, - columns: Some(columns), - .. - } = object - else { - return None; - }; - (self.resolve_relation_id(table_name) == alter.id - && columns.iter().any(|column| column == from)) - .then_some(index) - }) - .collect::>(), - _ => Vec::new(), - }; - (!indexes.is_empty()).then(|| (name.clone(), indexes)) + sequence + .owned_by + .as_ref() + .is_some_and(|(table, _)| table == &rename.old_id) + .then(|| { + ( + id.clone(), + ObjectId::new(rename.new_id.schema.clone(), id.name.clone()), + ) + }) }) - .collect(); - for (publication_name, object_indexes) in publication_updates { - self.snapshot_publication(&publication_name); - if let Some(crate::_internal::model::replication::PublicationOverlay::Present( - publication, - )) = self.local.publications.get_mut(&publication_name) - && let crate::_internal::analysis::facts::PublicationScope::Explicit(objects) = - &mut publication.scope + .collect() + } else { + Vec::new() + }; + let associated_index_moves: Vec<(ObjectId, ObjectId)> = if schema_move { + self.local + .graph + .edges() + .iter() + .filter(|edge| { + matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) + && edge.referenced == rename.old_id + }) + .map(|edge| { + ( + edge.dependent.clone(), + ObjectId::new(rename.new_id.schema.clone(), edge.dependent.name.clone()), + ) + }) + .collect() + } else { + Vec::new() + }; + for (old_id, new_id) in associated_sequence_moves + .iter() + .chain(&associated_index_moves) + { + if old_id != new_id && self.relation_namespace_is_taken(new_id) { + return MutationResult::Conflict { + reason: format!("associated object '{}' already exists", new_id), + }; + } + } + + let publication_scope_updates: Vec<(String, Vec)> = self + .local + .publications + .iter() + .filter_map(|(publication_name, overlay)| { + let crate::_internal::model::replication::PublicationOverlay::Present(publication) = + overlay + else { + return None; + }; + let crate::_internal::analysis::facts::PublicationScope::Explicit(objects) = + &publication.scope + else { + return None; + }; + let indexes = objects + .iter() + .enumerate() + .filter_map(|(index, object)| { + let crate::_internal::analysis::facts::PublicationObjectFact::Table { + name, + .. + } = object + else { + return None; + }; + (self.resolve_relation_id(name) == rename.old_id).then_some(index) + }) + .collect::>(); + (!indexes.is_empty()).then(|| (publication_name.clone(), indexes)) + }) + .collect(); + + self.snapshot_namespace(); + if let Some(RelationOverlay::Present(mut state)) = + self.local.relations.remove(&rename.old_id) + { + state.id = rename.new_id.clone(); + self.local + .relations + .insert(rename.new_id.clone(), RelationOverlay::Present(state)); + } + let owned_sequence_ids: Vec = self + .local + .sequences + .iter() + .filter_map(|(id, overlay)| match overlay { + SequenceOverlay::Present(sequence) + if sequence + .owned_by + .as_ref() + .is_some_and(|(table, _)| table == &rename.old_id) => { - for index in object_indexes { - if let Some( - crate::_internal::analysis::facts::PublicationObjectFact::Table { - columns: Some(columns), - .. - }, - ) = objects.get_mut(index) - { - for column in columns { - if column == from { - *column = to.clone(); - } - } - } - } + Some(id.clone()) } + _ => None, + }) + .collect(); + for sequence_id in owned_sequence_ids { + self.snapshot_sequence(&sequence_id); + if let Some(SequenceOverlay::Present(sequence)) = + self.local.sequences.get_mut(&sequence_id) + && let Some((table, _)) = &mut sequence.owned_by + { + *table = rename.new_id.clone(); } } - if let Some((sequence_id, column_name, kind)) = implicit_add { - self.snapshot_sequence(&sequence_id); - self.snapshot_generation_counter(); - self.local.generation_counter += 1; - self.local.sequences.insert( - sequence_id.clone(), - SequenceOverlay::Present(SequenceState { - id: sequence_id.clone(), - owner: self - .local - .relations - .get(&alter.id) - .and_then(|overlay| match overlay { - RelationOverlay::Present(table) => Some(table.owner.clone()), - RelationOverlay::Dropped => None, - }) - .unwrap_or_else(|| ObjectId::new("", &self.local.current_role)), - owned_by: Some((alter.id.clone(), column_name.clone())), - kind, - generation: self.local.generation_counter, - }), - ); - self.snapshot_graph(); + for (old_sequence_id, new_sequence_id) in &associated_sequence_moves { + self.snapshot_sequence(old_sequence_id); + self.snapshot_sequence(new_sequence_id); + let Some(SequenceOverlay::Present(mut sequence)) = + self.local.sequences.remove(old_sequence_id) + else { + continue; + }; + sequence.id = new_sequence_id.clone(); + if let Some((table, _)) = &mut sequence.owned_by { + *table = rename.new_id.clone(); + } + self.local + .sequences + .insert(new_sequence_id.clone(), SequenceOverlay::Present(sequence)); + self.local + .graph + .propagate_sequence_rename(old_sequence_id, new_sequence_id); self.local.graph.add_edge(DependencyEdge::new( - sequence_id, - alter.id.clone(), - DependencyKind::SequenceOwnedBy { - column: column_name, - }, + old_sequence_id.clone(), + new_sequence_id.clone(), + DependencyKind::RenameTo, )); } - if matches!(alter.action, AlterTableActionMutation::DropColumn { .. }) - && !drop_column_constraints.is_empty() - { - self.remove_dropped_constraints(&HashSet::new(), &drop_column_constraints); - self.snapshot_graph_full(); - let resolution_graph = self.local.graph.clone(); - self.local.graph.retain_edges(|edge| { - let dependent = resolution_graph.resolve_rename(&edge.dependent); - match &edge.kind { - DependencyKind::ForeignKey { - constraint_name: Some(name), - .. - } => !drop_column_constraints.contains(&(dependent.clone(), name.clone())), - DependencyKind::ConstraintOnRelation { - constraint_name: name, - .. - } => !drop_column_constraints.contains(&(dependent.clone(), name.clone())), - DependencyKind::ConstraintDependency { - constraint_name: name, - .. - } => !drop_column_constraints.contains(&(dependent.clone(), name.clone())), - _ => { - // The preflight above has already rejected unknown - // column-bearing edges; this arm keeps unrelated - // topology intact. - true - } - } - }); - } - if !cascade_view_roots.is_empty() { - let views = cascade_view_roots.into_iter().collect::>(); - // Preflight established a column-level dependency and CASCADE; - // this applies the recursive view/index closure PostgreSQL drops. - let _ = self.apply_drop_relation_family(&views, true, "view"); + for (old_index_id, new_index_id) in &associated_index_moves { + self.local + .graph + .propagate_index_rename(old_index_id, new_index_id); + self.rename_index_catalog_references(old_index_id, new_index_id); + self.local.graph.add_edge(DependencyEdge::new( + old_index_id.clone(), + new_index_id.clone(), + DependencyKind::RenameTo, + )); } - if let AlterTableActionMutation::DropColumn { name, .. } = &alter.action { - let resolved_table = self.local.graph.resolve_rename(&alter.id).clone(); - let resolution_graph = self.local.graph.clone(); - if self.local.graph.edges().iter().any(|edge| { - resolution_graph.resolve_rename(&edge.dependent) == &resolved_table - && matches!( - &edge.kind, - DependencyKind::ColumnGeneratedFrom { column, .. } - | DependencyKind::ColumnDefaultOnSequence { column } - if column == name || cascade_generated_columns.contains(column) - ) - }) { - self.snapshot_graph_full(); - self.local.graph.retain_edges(|edge| { - !(resolution_graph.resolve_rename(&edge.dependent) == &resolved_table - && matches!( - &edge.kind, - DependencyKind::ColumnGeneratedFrom { column, .. } - | DependencyKind::ColumnDefaultOnSequence { column } - if column == name || cascade_generated_columns.contains(column) - )) - }); - } + let triggers_to_move: Vec<(ObjectId, crate::_internal::model::trigger::TriggerState)> = + self.local + .triggers + .iter() + .filter_map(|(id, overlay)| match overlay { + TriggerOverlay::Present(trigger) if trigger.table_id == rename.old_id => { + Some((id.clone(), trigger.clone())) + } + _ => None, + }) + .collect(); + for (old_trigger_id, mut trigger) in triggers_to_move { + let new_trigger_id = Self::trigger_key(&rename.new_id, &trigger.name); + self.local.triggers.remove(&old_trigger_id); + trigger.id = new_trigger_id.clone(); + trigger.table_id = rename.new_id.clone(); + self.local + .triggers + .insert(new_trigger_id.clone(), TriggerOverlay::Present(trigger)); + self.local + .graph + .propagate_trigger_rename(&old_trigger_id, &new_trigger_id); + self.local.graph.add_edge(DependencyEdge::new( + old_trigger_id, + new_trigger_id, + DependencyKind::RenameTo, + )); } - if matches!(alter.action, AlterTableActionMutation::DropColumn { .. }) - && !drop_column_indexes.is_empty() - { - self.snapshot_graph_full(); - self.local.graph.retain_edges(|edge| { - !(matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) - && drop_column_indexes.contains(&edge.dependent)) - }); + let constraints_to_move: Vec<(String, ConstraintState)> = self + .local + .constraints + .iter() + .filter(|((table_id, _), _)| table_id == &rename.old_id) + .map(|((_, name), constraint)| (name.clone(), constraint.clone())) + .collect(); + for (name, mut constraint) in constraints_to_move { + self.snapshot_constraint(&rename.old_id, &name); + self.snapshot_constraint(&rename.new_id, &name); + self.local + .constraints + .remove(&(rename.old_id.clone(), name.clone())); + constraint.table_id = rename.new_id.clone(); + self.local + .constraints + .insert((rename.new_id.clone(), name), constraint); } - match &alter.action { - AlterTableActionMutation::DropColumn { name, .. } => { - self.drop_not_null_constraint(&alter.id, name); - for sequence_id in owned_sequences_for_column { - self.snapshot_sequence(&sequence_id); - self.local - .sequences - .insert(sequence_id.clone(), SequenceOverlay::Dropped); - self.snapshot_graph_full(); - self.local.graph.retain_edges(|edge| { - !(matches!(edge.kind, DependencyKind::SequenceOwnedBy { .. }) - && edge.dependent == sequence_id) - }); - } - } - AlterTableActionMutation::RenameColumn { from, to } => { - self.snapshot_graph_full(); - let resolved_table = self.local.graph.resolve_rename(&alter.id).clone(); - self.local - .graph - .rename_index_column(&resolved_table, from, to); - for sequence_id in owned_sequences_for_column { - self.snapshot_sequence(&sequence_id); - if let Some(SequenceOverlay::Present(sequence)) = - self.local.sequences.get_mut(&sequence_id) - && let Some((_, column)) = &mut sequence.owned_by - { - *column = to.clone(); + + for (publication_name, object_indexes) in publication_scope_updates { + self.snapshot_publication(&publication_name); + if let Some(crate::_internal::model::replication::PublicationOverlay::Present( + publication, + )) = self.local.publications.get_mut(&publication_name) + && let crate::_internal::analysis::facts::PublicationScope::Explicit(objects) = + &mut publication.scope + { + for index in object_indexes { + let Some(crate::_internal::analysis::facts::PublicationObjectFact::Table { + name, + .. + }) = objects.get_mut(index) + else { + continue; + }; + let name_quoted = name.name.quoted; + let schema_quoted = name.schema.as_ref().is_some_and(|schema| schema.quoted); + name.name = crate::_internal::ast::identifiers::Ident::new( + rename.new_id.name.clone(), + name_quoted, + ); + if name.schema.is_some() || rename.old_id.schema != rename.new_id.schema { + name.schema = Some(crate::_internal::ast::identifiers::Ident::new( + rename.new_id.schema.clone(), + schema_quoted, + )); } - self.snapshot_graph_full(); - self.local - .graph - .rename_owned_sequence_column(&sequence_id, from, to); } } - _ => {} } - MutationResult::Applied - } - - /// Return the key definitions that can be proved for a relation. - /// `None` means a key exists but its columns (or index eligibility) are - /// not represented by the current cache/model; callers must taint rather - /// than invent a matching foreign-key target in that case. - fn unique_keys_for_relation(&self, id: &ObjectId) -> Option, bool)>> { - let resolved = self.local.graph.resolve_rename(id); - if self.baseline_relation_is_known(resolved) - && self - .local - .relations - .get(resolved) - .is_some_and(|overlay| { - matches!(overlay, RelationOverlay::Present(relation) if relation.columns.is_empty()) - }) - { - return None; + self.local.pending_validation = std::mem::take(&mut self.local.pending_validation) + .into_iter() + .map(|(table, name)| { + if table == rename.old_id { + (rename.new_id.clone(), name) + } else { + (table, name) + } + }) + .collect(); + self.local.graph.add_edge(DependencyEdge::new( + rename.old_id.clone(), + rename.new_id.clone(), + DependencyKind::RenameTo, + )); + if renames_relation { + self.local + .graph + .propagate_relation_rename(&rename.old_id, &rename.new_id); } - let mut keys = Vec::new(); - let mut unknown = false; - for edge in self.local.graph.edges() { - if edge.dependent != *resolved { - continue; + if renames_index { + self.local + .graph + .propagate_index_rename(&rename.old_id, &rename.new_id); + self.rename_index_catalog_references(&rename.old_id, &rename.new_id); + } + + if renames_relation { + if self.baseline_relations.remove(&rename.old_id) { + self.baseline_relations.insert(rename.new_id.clone()); } - match &edge.kind { - DependencyKind::ConstraintOnRelation { - columns, - is_primary, - .. - } => { - if columns.is_empty() { - unknown = true; + if self.baseline_fk_dependencies.remove(&rename.old_id) { + self.baseline_fk_dependencies.insert(rename.new_id.clone()); + } + self.baseline_foreign_keys = std::mem::take(&mut self.baseline_foreign_keys) + .into_iter() + .map(|(table, name)| { + if table == rename.old_id { + (rename.new_id.clone(), name) } else { - keys.push((columns.clone(), *is_primary)); + (table, name) } - } - DependencyKind::IndexOnRelation { - is_unique: true, .. - } => unknown = true, - _ => {} - } + }) + .collect(); } - if !keys.is_empty() { - return Some(keys); + if renames_index && self.baseline_indexes.remove(&rename.old_id) { + self.baseline_indexes.insert(rename.new_id.clone()); } - if unknown - || self - .local - .constraints - .iter() - .any(|((table, _), constraint)| { - table == resolved - && matches!( - constraint.kind, - ConstraintKind::PrimaryKey | ConstraintKind::Unique - ) - }) - { - None - } else { - Some(Vec::new()) + for (old_sequence_id, new_sequence_id) in &associated_sequence_moves { + if self.baseline_sequences.remove(old_sequence_id) { + self.baseline_sequences.insert(new_sequence_id.clone()); + } + } + for (old_index_id, new_index_id) in &associated_index_moves { + if self.baseline_indexes.remove(old_index_id) { + self.baseline_indexes.insert(new_index_id.clone()); + } } + + MutationResult::Applied } - pub(super) fn apply_rename_relation(&mut self, rename: &Rename) -> MutationResult { - let renames_relation = self.relation_is_present(&rename.old_id); - let renames_index = self.index_is_present(&rename.old_id); - match self.relation_or_index_lookup(&rename.old_id) { - RelationLookup::Present => {} - _ if self.baseline_covers_family_object( - &rename.old_id, - crate::_internal::db::cache::CatalogFamily::Relations, - ) || self.baseline_covers_family_object( - &rename.old_id, - crate::_internal::db::cache::CatalogFamily::Indexes, - ) => - { - return MutationResult::Conflict { - reason: format!("relation '{}' does not exist", rename.old_id), - }; + // The caller takes a namespace snapshot before changing these coupled identities. + fn rename_index_catalog_references(&mut self, old: &ObjectId, new: &ObjectId) { + let constraints = self + .local + .constraints + .values() + .filter(|constraint| constraint.backing_index.as_ref() == Some(old)) + .cloned() + .collect::>(); + for mut constraint in constraints { + let old_name = constraint.name.clone(); + let owns_index = matches!( + constraint.kind, + ConstraintKind::PrimaryKey | ConstraintKind::Unique | ConstraintKind::Exclusion + ); + if owns_index && old.name != new.name { + constraint.name = new.name.clone(); } - RelationLookup::Tombstone - | RelationLookup::AuthoritativelyAbsent - | RelationLookup::Unknown => { - self.taint(EvidenceCode::UnknownObjectState, EvidenceScope::Chain); - return MutationResult::Skipped; + self.local + .constraints + .remove(&(constraint.table_id.clone(), old_name.clone())); + constraint.backing_index = Some(new.clone()); + if old_name != constraint.name { + self.local.graph.rename_constraint( + &constraint.table_id, + &old_name, + &constraint.name, + ); } - RelationLookup::WrongKind => { - unreachable!("relation renames accept every modeled relation kind") + self.local.constraints.insert( + (constraint.table_id.clone(), constraint.name.clone()), + constraint, + ); + } + if old.name == new.name { + return; + } + let old_replica = format!("USING INDEX {}", old.name); + for (id, overlay) in &mut self.local.relations { + let RelationOverlay::Present(relation) = overlay else { + continue; + }; + if id.schema != old.schema { + continue; + } + if relation.cluster_index.as_deref() == Some(old.name.as_str()) { + relation.cluster_index = Some(new.name.clone()); + } + if relation.replica_identity.as_deref() == Some(old_replica.as_str()) { + relation.replica_identity = Some(format!("USING INDEX {}", new.name)); } } - if rename.old_id != rename.new_id && self.relation_namespace_is_taken(&rename.new_id) { + } + + pub(super) fn apply_change_relation_owner( + &mut self, + id: &ObjectId, + new_owner: &crate::_internal::analysis::facts::RoleFact, + ) -> MutationResult { + let Some((owner, known)) = self.role_fact_identity(new_owner) else { + self.taint(EvidenceCode::UnresolvedReference, EvidenceScope::Chain); + return MutationResult::Skipped; + }; + if !known { + self.taint( + EvidenceCode::CatalogCoverageIncomplete, + EvidenceScope::Chain, + ); + } + if known && self.local.roles_known && self.present_role(&owner).is_none() { return MutationResult::Conflict { - reason: format!("relation '{}' already exists", rename.new_id), + reason: format!("role '{}' does not exist", owner), }; } - if rename.old_id.schema != rename.new_id.schema - && !self.schema_is_present(&rename.new_id.schema) - { - if self.schema_absence_is_authoritative(&rename.new_id.schema) { - return MutationResult::Conflict { - reason: format!("schema '{}' does not exist", rename.new_id.schema), - }; - } + if known && !self.local.roles_known { self.taint( EvidenceCode::CatalogCoverageIncomplete, EvidenceScope::Chain, ); - return MutationResult::Skipped; } - - let schema_move = rename.old_id.schema != rename.new_id.schema; - let associated_sequence_moves: Vec<(ObjectId, ObjectId)> = if schema_move { - self.local - .sequences - .iter() - .filter_map(|(id, overlay)| { - let SequenceOverlay::Present(sequence) = overlay else { - return None; + match self.relation_lookup(id, |_| true) { + RelationLookup::Present => { + let owner_id = ObjectId::new("", owner.clone()); + self.snapshot_relation(id); + { + let Some(RelationOverlay::Present(relation)) = self.local.relations.get_mut(id) + else { + unreachable!("relation lookup established presence") }; - sequence - .owned_by - .as_ref() - .is_some_and(|(table, _)| table == &rename.old_id) - .then(|| { - ( - id.clone(), - ObjectId::new(rename.new_id.schema.clone(), id.name.clone()), - ) - }) - }) - .collect() - } else { - Vec::new() - }; - let associated_index_moves: Vec<(ObjectId, ObjectId)> = if schema_move { - self.local - .graph - .edges() - .iter() - .filter(|edge| { - matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) - && edge.referenced == rename.old_id - }) - .map(|edge| { - ( - edge.dependent.clone(), - ObjectId::new(rename.new_id.schema.clone(), edge.dependent.name.clone()), - ) - }) - .collect() - } else { - Vec::new() - }; - for (old_id, new_id) in associated_sequence_moves - .iter() - .chain(&associated_index_moves) - { - if old_id != new_id && self.relation_namespace_is_taken(new_id) { - return MutationResult::Conflict { - reason: format!("associated object '{}' already exists", new_id), - }; + relation.owner = owner_id.clone(); + } + self.transfer_owned_sequence_owners(id, &owner_id); + MutationResult::Applied + } + RelationLookup::WrongKind => { + unreachable!("all present relation kinds accept owner changes") + } + RelationLookup::Tombstone | RelationLookup::AuthoritativelyAbsent => { + MutationResult::Conflict { + reason: format!("relation '{}' does not exist", id), + } + } + RelationLookup::Unknown => { + self.taint(EvidenceCode::UnknownObjectState, EvidenceScope::Chain); + MutationResult::Skipped } } + } - let publication_scope_updates: Vec<(String, Vec)> = self - .local - .publications - .iter() - .filter_map(|(publication_name, overlay)| { - let crate::_internal::model::replication::PublicationOverlay::Present(publication) = - overlay - else { - return None; - }; - let crate::_internal::analysis::facts::PublicationScope::Explicit(objects) = - &publication.scope - else { - return None; - }; - let indexes = objects - .iter() - .enumerate() - .filter_map(|(index, object)| { - let crate::_internal::analysis::facts::PublicationObjectFact::Table { - name, - .. - } = object - else { - return None; - }; - (self.resolve_relation_id(name) == rename.old_id).then_some(index) - }) - .collect::>(); - (!indexes.is_empty()).then(|| (publication_name.clone(), indexes)) - }) - .collect(); - - self.snapshot_namespace(); - if let Some(RelationOverlay::Present(mut state)) = - self.local.relations.remove(&rename.old_id) - { - state.id = rename.new_id.clone(); - self.local - .relations - .insert(rename.new_id.clone(), RelationOverlay::Present(state)); - } + /// PostgreSQL transfers ownership of sequences owned by table columns + /// together with the table. Keep this dependent metadata synchronized for + /// both ALTER TABLE OWNER and the direct relation-owner mutation path. + fn transfer_owned_sequence_owners(&mut self, table: &ObjectId, owner: &ObjectId) { let owned_sequence_ids: Vec = self .local .sequences .iter() - .filter_map(|(id, overlay)| match overlay { - SequenceOverlay::Present(sequence) - if sequence - .owned_by - .as_ref() - .is_some_and(|(table, _)| table == &rename.old_id) => - { - Some(id.clone()) - } - _ => None, + .filter_map(|(sequence_id, overlay)| { + let SequenceOverlay::Present(sequence) = overlay else { + return None; + }; + sequence + .owned_by + .as_ref() + .is_some_and(|(owned_table, _)| owned_table == table) + .then_some(sequence_id.clone()) }) .collect(); for sequence_id in owned_sequence_ids { self.snapshot_sequence(&sequence_id); if let Some(SequenceOverlay::Present(sequence)) = self.local.sequences.get_mut(&sequence_id) - && let Some((table, _)) = &mut sequence.owned_by { - *table = rename.new_id.clone(); + sequence.owner = owner.clone(); } } - for (old_sequence_id, new_sequence_id) in &associated_sequence_moves { - self.snapshot_sequence(old_sequence_id); - self.snapshot_sequence(new_sequence_id); - let Some(SequenceOverlay::Present(mut sequence)) = - self.local.sequences.remove(old_sequence_id) - else { - continue; - }; - sequence.id = new_sequence_id.clone(); - if let Some((table, _)) = &mut sequence.owned_by { - *table = rename.new_id.clone(); + } + + fn invalidate_descendant_partition_predicates(&mut self, root: &ObjectId) { + // Cached effective predicates include ancestors, not just the local bound. + for descendant in self.inherited_descendants(root) { + self.snapshot_relation(&descendant); + if let Some(RelationOverlay::Present(relation)) = + self.local.relations.get_mut(&descendant) + { + relation.partition_constraint = None; } - self.local - .sequences - .insert(new_sequence_id.clone(), SequenceOverlay::Present(sequence)); - self.local - .graph - .propagate_sequence_rename(old_sequence_id, new_sequence_id); - self.local.graph.add_edge(DependencyEdge::new( - old_sequence_id.clone(), - new_sequence_id.clone(), - DependencyKind::RenameTo, - )); } - for (old_index_id, new_index_id) in &associated_index_moves { - self.local - .graph - .propagate_index_rename(old_index_id, new_index_id); - self.local.graph.add_edge(DependencyEdge::new( - old_index_id.clone(), - new_index_id.clone(), - DependencyKind::RenameTo, - )); + } + + // Effective predicates may reference ancestor columns as well as the immediate key. + fn register_retained_partition_check(&mut self, child: &ObjectId, definition: String) { + use squawk_syntax::ast::{AstNode, SourceFile, Target}; + let parsed = SourceFile::parse(&format!("SELECT {definition}")); + let columns = if parsed.errors().is_empty() && parsed.tree().stmts().count() == 1 { + parsed + .tree() + .syntax() + .descendants() + .find_map(Target::cast) + .and_then(|target| target.expr()) + .and_then(|expr| { + crate::_internal::analysis::expr_visitor::ExprVisitor::convert(expr) + .referenced_columns() + }) + } else { + None + }; + let Some(columns) = columns.filter(|columns| { + matches!(self.local.relations.get(child), Some(RelationOverlay::Present(relation)) + if columns.iter().all(|column| relation.has_column(column))) + }) else { + self.taint(EvidenceCode::UnsupportedSemantics, EvidenceScope::Chain); + return; + }; + let columns: Vec = columns.into_iter().collect(); + let name = self.next_generated_constraint_name_avoiding( + child, + &child.name, + (columns.len() == 1).then(|| columns[0].as_str()), + "check", + &HashSet::new(), + ); + self.snapshot_constraint(child, &name); + self.local.constraints.insert( + (child.clone(), name.clone()), + ConstraintState { + table_id: child.clone(), + name: name.clone(), + kind: ConstraintKind::Check, + validated: true, + definition: Some(definition), + backing_index: None, + }, + ); + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + child.clone(), + child.clone(), + DependencyKind::ConstraintDependency { + constraint_name: name, + columns, + }, + )); + } + + fn retained_check_for_detached_partition( + &mut self, + parent: &ObjectId, + child: &ObjectId, + ) -> RetainedCheckSynthesis { + let Some(RelationOverlay::Present(relation)) = self.local.relations.get(child) else { + return RetainedCheckSynthesis::CantResolve; + }; + let parent_strategy = match self.local.relations.get(parent) { + Some(RelationOverlay::Present(parent)) => parent.partition_type.as_deref(), + _ => None, + }; + let Some(strategy) = parent_strategy else { + return RetainedCheckSynthesis::CantResolve; + }; + if strategy.eq_ignore_ascii_case("HASH") { + return RetainedCheckSynthesis::NoCheck; } - let triggers_to_move: Vec<(ObjectId, crate::_internal::model::trigger::TriggerState)> = - self.local - .triggers - .iter() - .filter_map(|(id, overlay)| match overlay { - TriggerOverlay::Present(trigger) if trigger.table_id == rename.old_id => { - Some((id.clone(), trigger.clone())) + if let Some(predicate) = relation + .partition_constraint + .as_deref() + .filter(|predicate| !predicate.trim().is_empty()) + { + if strategy.eq_ignore_ascii_case("RANGE") { + return RetainedCheckSynthesis::Definition(predicate.to_string()); + } + return self + .fold_list_partition_predicate(predicate, child) + .map(RetainedCheckSynthesis::Definition) + .unwrap_or(RetainedCheckSynthesis::CantResolve); + } + let Some(bound) = relation.partition_bound.as_deref() else { + return RetainedCheckSynthesis::CantResolve; + }; + if self.local.graph.edges().iter().any(|edge| { + edge.dependent == *parent + && matches!( + edge.kind, + DependencyKind::PartitionOf | DependencyKind::PartitionDetachPending + ) + }) { + // A local bound alone cannot reconstruct the ancestor's effective predicate. + return RetainedCheckSynthesis::CantResolve; + } + let Some(keys) = self.partition_key_columns(parent) else { + return RetainedCheckSynthesis::CantResolve; + }; + self.synthesize_partition_check(strategy, bound, &keys, relation) + .map(RetainedCheckSynthesis::Definition) + .unwrap_or(RetainedCheckSynthesis::CantResolve) + } + + /// Split a `PARTITION BY (c1, c2, ...)` key into column names. + /// Plain identifiers and quoted identifiers are accepted; expressions and + /// opclass/collation annotations return `None`, because PostgreSQL then + /// deparses the retained predicate in terms of the expression, not a + /// column. + fn partition_key_columns(&self, parent: &ObjectId) -> Option> { + use squawk_syntax::ast::{AstNode, Expr, PartitionBy, SourceFile}; + let Some(RelationOverlay::Present(parent)) = self.local.relations.get(parent) else { + return None; + }; + let partition_by = parent.partition_by.as_deref()?; + let parsed = SourceFile::parse(&format!("CREATE TABLE __key () {partition_by}")); + if !parsed.errors().is_empty() || parsed.tree().stmts().count() != 1 { + return None; + } + let partition = parsed + .tree() + .syntax() + .descendants() + .find_map(PartitionBy::cast)?; + let mut columns = Vec::new(); + for item in partition.partition_item_list()?.partition_items() { + if item.collate().is_some() + || item.op_class_ref().is_some() + || item.attribute_list().is_some() + || item.nulls_order().is_some() + { + return None; + } + let Expr::NameRef(name) = item.expr()? else { + return None; + }; + columns.push((name.text().to_string(), name.syntax().text().to_string())); + } + if columns.is_empty() { + None + } else { + Some(columns) + } + } + + /// Fold PostgreSQL's `eval_const_expressions` normalizations of a LIST + /// partition predicate into their retained-CHECK forms: + /// `= ANY (ARRAY[...])` becomes an array constant and a single + /// `= true`/`= false` becomes the bare column / `NOT `. + /// Single-datum non-boolean predicates are already in final form and are + /// passed through unchanged. + fn fold_list_partition_predicate(&self, predicate: &str, child: &ObjectId) -> Option { + const ANY_MARKER: &str = "ANY (ARRAY["; + if let Some(marker) = predicate.find(ANY_MARKER) { + let elements_start = marker + ANY_MARKER.len(); + let rest = &predicate[elements_start..]; + let mut chars = rest.char_indices().peekable(); + let mut close = None; + while let Some((index, ch)) = chars.next() { + match ch { + '\'' => { + while let Some((_, quoted)) = chars.next() { + if quoted == '\'' { + match chars.peek() { + Some((_, '\'')) => { + chars.next(); + } + _ => break, + } + } + } + } + ']' => { + close = Some(index); + break; } + _ => {} + } + } + let close = close?; + let elements_txt = &rest[..close]; + let elements = split_top_level(elements_txt, ','); + let column_type = self + .local + .relations + .get(child) + .and_then(|overlay| match overlay { + RelationOverlay::Present(relation) => Some(relation), _ => None, }) - .collect(); - for (old_trigger_id, mut trigger) in triggers_to_move { - let new_trigger_id = Self::trigger_key(&rename.new_id, &trigger.name); - self.local.triggers.remove(&old_trigger_id); - trigger.id = new_trigger_id.clone(); - trigger.table_id = rename.new_id.clone(); - self.local - .triggers - .insert(new_trigger_id.clone(), TriggerOverlay::Present(trigger)); - self.local - .graph - .propagate_trigger_rename(&old_trigger_id, &new_trigger_id); - self.local.graph.add_edge(DependencyEdge::new( - old_trigger_id, - new_trigger_id, - DependencyKind::RenameTo, + .and_then(|relation| { + predicate_column_name(predicate).and_then(|column| { + relation + .columns + .iter() + .find(|candidate| candidate.name == column) + .and_then(|candidate| candidate.data_type.as_deref()) + }) + })?; + let array_type = array_element_type(&elements, column_type)?; + let mut values = Vec::new(); + for element in elements { + values.push(decode_sql_literal(element)?); + } + let mapped = elements_for_array(&values, &array_type)?; + let array_text = serialize_array_literal(&mapped); + let suffix = &rest[close + 1..]; + return Some(format!( + "{}ANY ('{array_text}'::{}[]{suffix}", + &predicate[..marker], + array_type )); } - let constraints_to_move: Vec<(String, ConstraintState)> = self - .local - .constraints - .iter() - .filter(|((table_id, _), _)| table_id == &rename.old_id) - .map(|((_, name), constraint)| (name.clone(), constraint.clone())) - .collect(); - for (name, mut constraint) in constraints_to_move { - self.snapshot_constraint(&rename.old_id, &name); - self.snapshot_constraint(&rename.new_id, &name); - self.local - .constraints - .remove(&(rename.old_id.clone(), name.clone())); - constraint.table_id = rename.new_id.clone(); - self.local - .constraints - .insert((rename.new_id.clone(), name), constraint); - } + fold_boolean_equality(predicate) + } - for (publication_name, object_indexes) in publication_scope_updates { - self.snapshot_publication(&publication_name); - if let Some(crate::_internal::model::replication::PublicationOverlay::Present( - publication, - )) = self.local.publications.get_mut(&publication_name) - && let crate::_internal::analysis::facts::PublicationScope::Explicit(objects) = - &mut publication.scope - { - for index in object_indexes { - let Some(crate::_internal::analysis::facts::PublicationObjectFact::Table { - name, - .. - }) = objects.get_mut(index) - else { - continue; - }; - let name_quoted = name.name.quoted; - let schema_quoted = name.schema.as_ref().is_some_and(|schema| schema.quoted); - name.name = crate::_internal::ast::identifiers::Ident::new( - rename.new_id.name.clone(), - name_quoted, - ); - if name.schema.is_some() || rename.old_id.schema != rename.new_id.schema { - name.schema = Some(crate::_internal::ast::identifiers::Ident::new( - rename.new_id.schema.clone(), - schema_quoted, + /// Synthesize the retained CHECK from a `FOR VALUES ...` bound for a + /// single-column RANGE/LIST partition. + fn synthesize_partition_check( + &self, + strategy: &str, + bound: &str, + keys: &[(String, String)], + relation: &RelationState, + ) -> Option { + if keys.len() != 1 { + return None; + } + let (key_name, key) = &keys[0]; + let column_type = relation + .columns + .iter() + .find(|column| &column.name == key_name) + .and_then(|column| column.data_type.as_deref())?; + let comparison_left = if column_type.starts_with("character varying") { + format!("({key})::text") + } else { + key.clone() + }; + if strategy.eq_ignore_ascii_case("RANGE") { + let lower = extract_paren_group(bound, "FROM (")?; + let upper = extract_paren_group(bound, "TO (")?; + let lower_datums = split_top_level(&lower, ','); + let upper_datums = split_top_level(&upper, ','); + if lower_datums.len() != upper_datums.len() || lower_datums.len() != keys.len() { + return None; + } + let lower = lower_datums[0].trim(); + let upper = upper_datums[0].trim(); + let mut clauses = vec![format!("({key} IS NOT NULL)")]; + if !lower.eq_ignore_ascii_case("MINVALUE") { + let literal = deparse_partition_literal(column_type, lower)?; + clauses.push(format!("({comparison_left} >= {literal})")); + } + if !upper.eq_ignore_ascii_case("MAXVALUE") { + let literal = deparse_partition_literal(column_type, upper)?; + clauses.push(format!("({comparison_left} < {literal})")); + } + Some(format!("({})", clauses.join(" AND "))) + } else if strategy.eq_ignore_ascii_case("LIST") { + let inner = extract_paren_group(bound, "IN (")?; + let datums = split_top_level(&inner, ','); + match datums.as_slice() { + [single] => { + let literal = deparse_partition_literal(column_type, single.trim())?; + if column_type == "boolean" { + let narrow = if literal == "true" { + key.clone() + } else if literal == "false" { + format!("(NOT {key})") + } else { + return None; + }; + return Some(format!("(({key} IS NOT NULL) AND {narrow})")); + } + Some(format!( + "(({key} IS NOT NULL) AND ({comparison_left} = {literal}))" + )) + } + [] => None, + _ => { + let mut values = Vec::new(); + for datum in datums { + values.push(decode_sql_literal(datum.trim())?); + } + let mapped = elements_for_array(&values, column_type)?; + if matches!(column_type, "integer" | "smallint" | "bigint") { + return Some(format!( + "(({key} IS NOT NULL) AND ({comparison_left} = ANY (ARRAY[{}])))", + mapped.join(", ") )); } + let array_text = serialize_array_literal(&mapped); + Some(format!( + "(({key} IS NOT NULL) AND ({comparison_left} = ANY ('{array_text}'::{column_type}[])))" + )) } } + } else { + None } - self.local.pending_validation = std::mem::take(&mut self.local.pending_validation) - .into_iter() - .map(|(table, name)| { - if table == rename.old_id { - (rename.new_id.clone(), name) - } else { - (table, name) + } + + fn synthesize_default_partition_constraint(&self, parent: &ObjectId) -> Option { + let strategy = self + .local + .relations + .get(parent) + .and_then(|overlay| match overlay { + RelationOverlay::Present(parent) => parent.partition_type.as_deref(), + RelationOverlay::Dropped => None, + })?; + let keys = self.partition_key_columns(parent)?; + let predicates = self + .local + .graph + .edges() + .iter() + .filter(|edge| { + edge.referenced == *parent && matches!(edge.kind, DependencyKind::PartitionOf) + }) + .filter_map(|edge| { + let child = match self.local.relations.get(&edge.dependent) { + Some(RelationOverlay::Present(child)) => child, + _ => return None, + }; + let bound = child.partition_bound.as_deref()?; + if bound.eq_ignore_ascii_case("DEFAULT") { + return None; } + let predicate = self.synthesize_partition_check(strategy, bound, &keys, child)?; + let predicate = if predicate.starts_with("((") && predicate.ends_with("))") { + let inner = &predicate[1..predicate.len() - 1]; + if let Some(separator) = inner.find(") AND (") { + let first = &inner[..separator + 1]; + let rest = &inner[separator + 6..]; + format!("({first} AND ({rest}))") + } else { + predicate + } + } else { + predicate + }; + Some(predicate) }) - .collect(); - self.local.graph.add_edge(DependencyEdge::new( - rename.old_id.clone(), - rename.new_id.clone(), - DependencyKind::RenameTo, - )); - if renames_relation { - self.local - .graph - .propagate_relation_rename(&rename.old_id, &rename.new_id); + .collect::>(); + if predicates.is_empty() { + return None; } - if renames_index { - self.local - .graph - .propagate_index_rename(&rename.old_id, &rename.new_id); + if predicates.len() == 1 { + Some(format!("(NOT {})", predicates[0])) + } else { + Some(format!("(NOT ({}))", predicates.join(" OR "))) } + } - if renames_relation { - if self.baseline_relations.remove(&rename.old_id) { - self.baseline_relations.insert(rename.new_id.clone()); - } - if self.baseline_fk_dependencies.remove(&rename.old_id) { - self.baseline_fk_dependencies.insert(rename.new_id.clone()); + fn refresh_default_partition_constraints(&mut self, parent: &ObjectId) { + let defaults = self + .local + .graph + .edges() + .iter() + .filter(|edge| { + edge.referenced == *parent && matches!(edge.kind, DependencyKind::PartitionOf) + }) + .filter_map(|edge| { + let RelationOverlay::Present(relation) = + self.local.relations.get(&edge.dependent)? + else { + return None; + }; + relation + .partition_bound + .as_deref() + .is_some_and(|bound| bound.eq_ignore_ascii_case("DEFAULT")) + .then_some(edge.dependent.clone()) + }) + .collect::>(); + for child in defaults { + let constraint = self.synthesize_default_partition_constraint(parent); + if let Some(RelationOverlay::Present(relation)) = self.local.relations.get_mut(&child) { + relation.partition_constraint = constraint; } - self.baseline_foreign_keys = std::mem::take(&mut self.baseline_foreign_keys) - .into_iter() - .map(|(table, name)| { - if table == rename.old_id { - (rename.new_id.clone(), name) - } else { - (table, name) + } + } +} + +/// Match PostgreSQL's stable `pg_get_expr(relpartbound, ...)` spelling for +/// the bound forms represented by the typed Squawk node. +fn canonical_partition_bound(bound: &str) -> String { + let trimmed = bound.trim(); + if trimmed.eq_ignore_ascii_case("DEFAULT") { + return "DEFAULT".into(); + } + let upper = trimmed.to_ascii_uppercase(); + if upper.starts_with("FOR VALUES IN (") { + let inner = &trimmed["FOR VALUES IN (".len()..trimmed.len().saturating_sub(1)]; + let values = split_top_level(inner, ',') + .into_iter() + .map(str::trim) + .collect::>() + .join(", "); + return format!("FOR VALUES IN ({values})"); + } + if upper.starts_with("FOR VALUES FROM (") + && let (Some(from), Some(to)) = ( + extract_paren_group(trimmed, "FROM ("), + extract_paren_group(trimmed, "TO ("), + ) + { + let from = split_top_level(&from, ',') + .into_iter() + .map(str::trim) + .collect::>() + .join(", "); + let to = split_top_level(&to, ',') + .into_iter() + .map(str::trim) + .collect::>() + .join(", "); + return format!("FOR VALUES FROM ({from}) TO ({to})"); + } + if upper.starts_with("FOR VALUES WITH (") + && let Some(inner) = extract_paren_group(trimmed, "WITH (") + { + let values = split_top_level(&inner, ',') + .into_iter() + .map(str::trim) + .map(|value| { + let mut words = value.splitn(2, char::is_whitespace); + let key = words.next().unwrap_or_default().to_ascii_lowercase(); + let rest = words.next().unwrap_or_default().trim(); + format!("{key} {rest}") + }) + .collect::>() + .join(", "); + return format!("FOR VALUES WITH ({values})"); + } + trimmed.to_string() +} + +/// Whether PostgreSQL retains a CHECK constraint on `DETACH PARTITION +/// CONCURRENTLY` and whether the local model can reproduce its exact text. +#[derive(Debug)] +enum RetainedCheckSynthesis { + /// HASH partitions never gain a retained constraint. + NoCheck, + /// Exact `pg_get_expr(conbin)` text of the retained CHECK. + Definition(String), + /// The predicate is not representable exactly; callers keep the + /// conservative `UnsupportedSemantics` taint. + CantResolve, +} + +/// Split `input` on `separator` at the top nesting level of `()`, `[]`, `{}` +/// and single-quoted SQL string literals (with doubled-quote handling). +fn split_top_level(input: &str, separator: char) -> Vec<&str> { + let mut parts = Vec::new(); + let mut start = 0usize; + let mut depth: i32 = 0; + let mut chars = input.char_indices().peekable(); + while let Some((index, ch)) = chars.next() { + match ch { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth -= 1, + '\'' => { + while let Some((_, quoted)) = chars.next() { + if quoted == '\'' { + match chars.peek() { + Some((_, '\'')) => { + chars.next(); + } + _ => break, + } } - }) - .collect(); + } + } + _ => {} } - if renames_index && self.baseline_indexes.remove(&rename.old_id) { - self.baseline_indexes.insert(rename.new_id.clone()); + if ch == separator && depth <= 0 { + parts.push(&input[start..index]); + start = index + ch.len_utf8(); } - for (old_sequence_id, new_sequence_id) in &associated_sequence_moves { - if self.baseline_sequences.remove(old_sequence_id) { - self.baseline_sequences.insert(new_sequence_id.clone()); + } + parts.push(&input[start..]); + parts +} + +/// Return the SQL string value of a bound/constraint literal token, unwrapping +/// a leading `'...'` (doubled quotes) and any `::type` suffix. Bare tokens +/// (numbers, booleans, identifiers) pass through unchanged. +fn decode_sql_literal(raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + let raw = raw.split("::").next().unwrap_or(raw).trim(); + if let Some(inner) = raw.strip_prefix('\'') { + let mut value = String::new(); + let mut chars = inner.char_indices().peekable(); + while let Some((_, ch)) = chars.next() { + if ch == '\'' { + match chars.peek() { + Some((_, '\'')) => { + chars.next(); + value.push('\''); + } + _ => return Some(value), + } + } else { + value.push(ch); } } - for (old_index_id, new_index_id) in &associated_index_moves { - if self.baseline_indexes.remove(old_index_id) { - self.baseline_indexes.insert(new_index_id.clone()); + None + } else if !raw.chars().any(|ch| matches!(ch, '(' | ')' | '\'')) { + Some(raw.to_string()) + } else { + None + } +} + +/// Extract the balanced parenthesized content following `needle` (which must +/// end with `(`), returning the group's inner text. +fn extract_paren_group(input: &str, needle: &str) -> Option { + let lowercase = input.to_ascii_lowercase(); + let needle_lower = needle.to_ascii_lowercase(); + let start = lowercase.find(&needle_lower)?; + // `needle` ends with `(`, so the group content begins right after it. + let content = &input[start + needle.len()..]; + let mut depth = 0i32; + let mut chars = content.char_indices().peekable(); + while let Some((index, ch)) = chars.next() { + match ch { + '(' => depth += 1, + ')' => { + if depth == 0 { + return Some(content[..index].to_string()); + } + depth -= 1; + } + '\'' => { + while let Some((_, quoted)) = chars.next() { + if quoted == '\'' { + match chars.peek() { + Some((_, '\'')) => { + chars.next(); + } + _ => break, + } + } + } } + _ => {} } + } + None +} - MutationResult::Applied +/// Choose the array element type for a folded `ARRAY[...]` constant. When +/// elements carry homogeneous `::type` casts that cast is used; otherwise the +/// key column type applies. Mixed casts conservatively fail (`None`). +fn array_element_type(elements: &[&str], fallback: &str) -> Option { + let first_cast = elements + .first()? + .split("::") + .nth(1) + .map(str::trim) + .map(str::to_owned); + for element in elements.iter().skip(1) { + let cast = element.split("::").nth(1).map(str::trim).map(str::to_owned); + if cast != first_cast { + return None; + } } + Some(first_cast.unwrap_or_else(|| fallback.to_string())) +} - pub(super) fn apply_change_relation_owner( - &mut self, - id: &ObjectId, - new_owner: &crate::_internal::analysis::facts::RoleFact, - ) -> MutationResult { - let Some((owner, known)) = self.role_fact_identity(new_owner) else { - self.taint(EvidenceCode::UnresolvedReference, EvidenceScope::Chain); - return MutationResult::Skipped; - }; - if !known { - self.taint( - EvidenceCode::CatalogCoverageIncomplete, - EvidenceScope::Chain, - ); +/// Render partition-list values in the array-constant element syntax for the +/// key column type (`t`/`f` for booleans, otherwise the element text as-is). +fn elements_for_array(values: &[String], column_type: &str) -> Option> { + match column_type { + "boolean" => values + .iter() + .map(|value| { + if value.eq_ignore_ascii_case("true") { + Some("t".to_string()) + } else if value.eq_ignore_ascii_case("false") { + Some("f".to_string()) + } else { + None + } + }) + .collect(), + "integer" | "smallint" | "bigint" | "numeric" | "text" | "name" | "citext" => { + Some(values.to_vec()) } - if known && self.local.roles_known && self.present_role(&owner).is_none() { - return MutationResult::Conflict { - reason: format!("role '{}' does not exist", owner), - }; + type_name + if type_name.starts_with("character varying") + || type_name.starts_with("character(") + || type_name.starts_with("bpchar") => + { + Some(values.to_vec()) } - if known && !self.local.roles_known { - self.taint( - EvidenceCode::CatalogCoverageIncomplete, - EvidenceScope::Chain, - ); + _ => None, + } +} + +/// Serialize array element values into the `{...}` array-literal text with +/// PostgreSQL's element quoting (double quotes around elements containing +/// specials, `"` and `\` backslash-escaped) and single-quote doubling for the +/// enclosing string literal. +fn serialize_array_literal(values: &[String]) -> String { + let inner = values + .iter() + .map(|value| { + let special = value.is_empty() + || value.contains([',', '"', '\\', '{', '}']) + || value.starts_with(' ') + || value.ends_with(' ') + || value.starts_with('\n') + || value.ends_with('\n'); + let token = if special { + let mut quoted = String::from("\""); + for ch in value.chars() { + if ch == '"' || ch == '\\' { + quoted.push('\\'); + } + quoted.push(ch); + } + quoted.push('"'); + quoted + } else { + value.to_string() + }; + token.replace('\'', "''") + }) + .collect::>() + .join(","); + format!("{{{inner}}}") +} + +/// Fold a single-datum boolean LIST predicate (`X = true` -> `X`, +/// `X = false` -> `NOT X`). Non-boolean single-datum predicates are already in +/// final form and are returned unchanged. +fn fold_boolean_equality(predicate: &str) -> Option { + let equality = predicate + .find("= true") + .map(|position| (position, "= true", true)) + .or_else(|| { + predicate + .find("= false") + .map(|position| (position, "= false", false)) + }); + let Some((position, needle, is_true)) = equality else { + return Some(predicate.to_string()); + }; + let open = predicate[..position].rfind('(')?; + let variable = predicate[open + 1..position].trim(); + if variable.is_empty() || !variable.chars().all(|ch| ch.is_alphanumeric() || ch == '_') { + return None; + } + let after = &predicate[position + needle.len()..]; + let close = after.find(')')?; + let replacement = if is_true { + variable.to_string() + } else { + format!("(NOT {variable})") + }; + Some(format!( + "{}{}{}", + &predicate[..open], + replacement, + &after[close + 1..] + )) +} + +/// Extract the column variable referenced by an `= ANY (...)`/`= true` clause +/// from a fully-deparsed predicate (`((col IS NOT NULL) AND (col = ...))`). +fn predicate_column_name(predicate: &str) -> Option { + let start = predicate.find(" IS NOT NULL)")?; + let open = predicate[..start].rfind('(')?; + let name = predicate[open + 1..start].trim(); + if name.is_empty() || !name.chars().all(|ch| ch.is_alphanumeric() || ch == '_') { + None + } else { + Some(name.to_string()) + } +} + +/// Deparse a single bound literal exactly as `get_const_expr`/`ruleutils` +/// would for the column's data type: booleans bare, INT4 bare when +/// non-negative and quoted otherwise, smallint/bigint/real/double always +/// quoted, numeric quoted unless it looks like a float literal, and +/// text-like/uuid/ISO-date values quoted with a `::type` cast. +fn deparse_partition_literal(data_type: &str, raw: &str) -> Option { + let decoded = decode_sql_literal(raw)?; + match data_type { + "boolean" => { + if decoded.eq_ignore_ascii_case("true") { + Some("true".to_string()) + } else if decoded.eq_ignore_ascii_case("false") { + Some("false".to_string()) + } else { + None + } } - match self.relation_lookup(id, |_| true) { - RelationLookup::Present => { - let owner_id = ObjectId::new("", owner.clone()); - self.snapshot_relation(id); - { - let Some(RelationOverlay::Present(relation)) = self.local.relations.get_mut(id) - else { - unreachable!("relation lookup established presence") - }; - relation.owner = owner_id.clone(); + "integer" => { + let value: i64 = decoded.parse().ok()?; + if (i32::MIN as i64..=i32::MAX as i64).contains(&value) { + if value >= 0 { + Some(value.to_string()) + } else { + Some(format!("'{}'::integer", value)) } - self.transfer_owned_sequence_owners(id, &owner_id); - MutationResult::Applied + } else { + None } - RelationLookup::WrongKind => { - unreachable!("all present relation kinds accept owner changes") + } + "smallint" => { + let value: i16 = decoded.parse().ok()?; + Some(format!("'{}'::smallint", value)) + } + "bigint" => { + let value: i64 = decoded.parse().ok()?; + Some(format!("'{}'::bigint", value)) + } + "numeric" => { + if numeric_float_like(&decoded) { + Some(decoded) + } else { + Some(format!("'{}'::numeric", decoded)) } - RelationLookup::Tombstone | RelationLookup::AuthoritativelyAbsent => { - MutationResult::Conflict { - reason: format!("relation '{}' does not exist", id), - } + } + "real" | "double precision" => { + if numeric_float_like(&decoded) { + Some(format!("'{}'::{}", decoded, data_type)) + } else { + None } - RelationLookup::Unknown => { - self.taint(EvidenceCode::UnknownObjectState, EvidenceScope::Chain); - MutationResult::Skipped + } + "date" => { + if decoded.len() == 10 + && decoded.as_bytes()[4] == b'-' + && decoded.as_bytes()[7] == b'-' + && decoded + .chars() + .enumerate() + .all(|(index, ch)| (index == 4 || index == 7) || ch.is_ascii_digit()) + { + Some(format!("'{}'::date", decoded)) + } else { + None } } - } - - /// PostgreSQL transfers ownership of sequences owned by table columns - /// together with the table. Keep this dependent metadata synchronized for - /// both ALTER TABLE OWNER and the direct relation-owner mutation path. - fn transfer_owned_sequence_owners(&mut self, table: &ObjectId, owner: &ObjectId) { - let owned_sequence_ids: Vec = self - .local - .sequences - .iter() - .filter_map(|(sequence_id, overlay)| { - let SequenceOverlay::Present(sequence) = overlay else { - return None; - }; - sequence - .owned_by - .as_ref() - .is_some_and(|(owned_table, _)| owned_table == table) - .then_some(sequence_id.clone()) - }) - .collect(); - for sequence_id in owned_sequence_ids { - self.snapshot_sequence(&sequence_id); - if let Some(SequenceOverlay::Present(sequence)) = - self.local.sequences.get_mut(&sequence_id) + "uuid" => { + if decoded.len() == 36 + && decoded.as_bytes()[8] == b'-' + && decoded.as_bytes()[13] == b'-' + && decoded.as_bytes()[18] == b'-' + && decoded.as_bytes()[23] == b'-' + && decoded + .chars() + .all(|ch| ch.is_ascii_hexdigit() || ch == '-') { - sequence.owner = owner.clone(); + Some(format!("'{}'::uuid", decoded)) + } else { + None } } + type_name + if type_name == "text" + || type_name == "name" + || type_name == "citext" + || type_name.starts_with("character varying") + || type_name.starts_with("character(") + || type_name.starts_with("bpchar") => + { + Some(format!("'{}'::{}", decoded.replace('\'', "''"), data_type)) + } + _ => None, } } + +/// `get_const_expr` prints a NUMERIC constant bare when it looks like a float +/// literal (starts with a digit and contains `.`, `e`, or `E`). +fn numeric_float_like(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + first.is_ascii_digit() + && value[first.len_utf8()..] + .chars() + .any(|ch| matches!(ch, '.' | 'e' | 'E')) +} diff --git a/src/_internal/analysis/state/apply_role.rs b/src/_internal/analysis/state/apply_role.rs index 4fb6bd32..791d340e 100644 --- a/src/_internal/analysis/state/apply_role.rs +++ b/src/_internal/analysis/state/apply_role.rs @@ -209,6 +209,61 @@ impl AnalysisState { } } + /// Remove memberships granted by roles whose ADMIN authority was + /// withdrawn, mirroring PostgreSQL's `plan_recursive_revoke`: a revoked + /// record only triggers cascade when it carried the ADMIN option and the + /// affected member would no longer hold ADMIN through any other record + /// for the same parent role. Each removal can free the next grantor, so + /// the queue makes the propagation transitive. Operates exclusively on + /// per-grantor records (the PostgreSQL 16+ model). + fn cascade_memberships_granted_by(&mut self, initial_grantors: &[ObjectId]) { + let mut pending = initial_grantors.to_vec(); + let mut visited = HashSet::new(); + let mut removals: Vec<(ObjectId, ObjectId, ObjectId)> = Vec::new(); + while let Some(grantor) = pending.pop() { + if !visited.insert(grantor.clone()) { + continue; + } + let indices: Vec = self + .local + .role_membership_grantors + .iter() + .enumerate() + .filter_map(|(index, record)| (record.grantor == grantor).then_some(index)) + .collect(); + for index in indices { + let record = self.local.role_membership_grantors[index].clone(); + // The member loses ADMIN for this parent role unless another + // record, untouched so far, still provides it. + if !self.membership_admin_from_other_record( + &record.member, + &record.role, + Some(index), + ) { + removals.push(( + record.member.clone(), + record.role.clone(), + record.grantor.clone(), + )); + if record.admin { + pending.push(record.member.clone()); + } + } + } + } + if removals.is_empty() { + return; + } + self.snapshot_role_membership_grantors(); + for (member, role_id, grantor) in removals { + self.snapshot_role(&member); + self.local.role_membership_grantors.retain(|record| { + record.member != member || record.role != role_id || record.grantor != grantor + }); + self.reconcile_membership_projection(&member); + } + } + pub(super) fn apply_grant(&mut self, grant: &GrantMutation) -> MutationResult { if let Err(result) = self.validate_grant_targets(&grant.target) { return result; @@ -335,7 +390,9 @@ impl AnalysisState { .to_string(), }; } - let grantor = self.grantor_identity(grant.granted_by.as_ref()); + // Role memberships attribute implicit superuser grants to + // the bootstrap superuser, unlike object privileges. + let grantor = self.role_membership_grantor(grant.granted_by.as_ref()); if let Some(grantor) = grantor.as_ref() { if self.local.roles_known { let can_administer = if grant.granted_by.is_some() { @@ -448,45 +505,130 @@ impl AnalysisState { .push(parent.clone()); } } + let record_mode = grantor.is_some() && self.local.role_membership_grantors_complete; + let mut touched = HashSet::new(); if let Some(grantor) = grantor.as_ref() { self.snapshot_role_membership_grantors(); for member in &grantees { + touched.insert(member.clone()); for parent in parents { - let already_member = - self.local.roles.get(member).is_some_and(|overlay| { - matches!(overlay, RoleOverlay::Present(role) if role - .member_of - .contains(parent)) + if record_mode { + // Record-based application: one row per + // (member, role, grantor) triple, mirroring + // AddRoleMems. Role vectors are rebuilt by + // `reconcile_membership_projection` after the + // record set is finalized, so the undo + // snapshot taken in the touched loop is + // unaffected by intermediate mutations. + match self.membership_record_index(member, parent, grantor) { + Some(index) => { + let record = + &mut self.local.role_membership_grantors[index]; + if let Some(admin) = explicit_admin { + record.admin = admin; + } + if let Some(inherit) = explicit_inherit + && self + .pg_version_num + .is_some_and(|version| version >= 160_000) + { + record.inherit = inherit; + } + if let Some(set) = explicit_set { + record.set = set; + } + } + None => { + let member_inherits = self + .local + .roles + .get(member) + .and_then(|overlay| match overlay { + RoleOverlay::Present(role) => Some(role.inherits), + RoleOverlay::Dropped => None, + }) + .unwrap_or(true); + // A new tuple inherits the member + // role's INHERIT attribute by default; + // an existing tuple is only updated + // when an option was specified. + let default_inherit = if self + .pg_version_num + .is_some_and(|version| version >= 160_000) + { + member_inherits + } else { + // Pre-16: every edge is implicitly + // inheritable. + true + }; + self.local.role_membership_grantors.push( + crate::_internal::model::role::RoleMembershipGrantor { + member: member.clone(), + role: parent.clone(), + grantor: grantor.clone(), + admin: explicit_admin.unwrap_or(false), + inherit: explicit_inherit + .unwrap_or(default_inherit), + set: explicit_set.unwrap_or(true), + }, + ); + } + } + } else { + // Legacy edge records without per-record + // options. + let already_member = + self.local.roles.get(member).is_some_and(|overlay| { + matches!(overlay, RoleOverlay::Present(role) if role + .member_of + .contains(parent)) + }); + if already_member + && self.local.role_membership_grantors.iter().any( + |provenance| { + provenance.member == *member + && provenance.role == *parent + }, + ) + { + continue; + } + self.local.role_membership_grantors.retain(|provenance| { + provenance.member != *member || provenance.role != *parent }); - if already_member - && self - .local - .role_membership_grantors - .iter() - .any(|provenance| { - provenance.member == *member && provenance.role == *parent - }) - { - continue; + self.local.role_membership_grantors.push( + crate::_internal::model::role::RoleMembershipGrantor { + member: member.clone(), + role: parent.clone(), + grantor: grantor.clone(), + admin: explicit_admin.unwrap_or(false), + inherit: self + .pg_version_num + .is_some_and(|version| version < 160_000) + || explicit_inherit.unwrap_or(true), + set: explicit_set.unwrap_or(true), + }, + ); } - self.local.role_membership_grantors.retain(|provenance| { - provenance.member != *member || provenance.role != *parent - }); - self.local.role_membership_grantors.push( - crate::_internal::model::role::RoleMembershipGrantor { - member: member.clone(), - role: parent.clone(), - grantor: grantor.clone(), - }, - ); } } } else { self.snapshot_role_membership_grantors(); self.local.role_membership_grantors_complete = false; + for member in &grantees { + touched.insert(member.clone()); + } } - for member in grantees { + for member in touched { self.snapshot_role(&member); + if record_mode { + self.reconcile_membership_projection(&member); + continue; + } + // Legacy edge mutation; kept distinct from the record-based + // projection for catalog completeness that predates + // per-grantor rows. let Some(RoleOverlay::Present(role)) = self.local.roles.get_mut(&member) else { continue; }; @@ -709,7 +851,8 @@ impl AnalysisState { EvidenceScope::Chain, ); } - if let Some(grantor) = self.grantor_identity(revoke.granted_by.as_ref()) { + let resolved_grantor = self.role_membership_grantor(revoke.granted_by.as_ref()); + if let Some(grantor) = resolved_grantor.as_ref() { if self.local.roles_known { let can_administer = if revoke.granted_by.is_some() { self.present_role(&grantor.name) @@ -722,7 +865,7 @@ impl AnalysisState { .then_some(true) } else { parents.iter().try_fold(true, |allowed, parent| { - self.has_admin_privileges_on_role(&grantor, parent) + self.has_admin_privileges_on_role(grantor, parent) .map(|has_admin| allowed && has_admin) }) }; @@ -783,69 +926,161 @@ impl AnalysisState { return MutationResult::Skipped; } let mut cascade_grantors = Vec::new(); - if revoke.cascade - && (revoke_option.is_none() - || matches!(revoke_option, Some(RoleMembershipOptionFact::Admin(false)))) - { - cascade_grantors.extend(revokees.iter().cloned()); - } - if !revokees.is_empty() - && (revoke.cascade || revoke_option.is_none()) - && self - .local - .role_membership_grantors - .iter() - .any(|provenance| { - revokees.contains(&provenance.member) - && parents.contains(&provenance.role) + let record_mode = + resolved_grantor.is_some() && self.local.role_membership_grantors_complete; + if record_mode { + let grantor = resolved_grantor + .as_ref() + .expect("record mode requires a grantor"); + // Harvest the provenance set once, before the first record + // is mutated, when any record will actually be touched. + let any_match = revokees.iter().any(|member| { + parents.iter().any(|parent| { + self.membership_record_index(member, parent, grantor) + .is_some() }) - { - self.snapshot_role_membership_grantors(); - } - for member in revokees { - self.snapshot_role(&member); - let Some(RoleOverlay::Present(role)) = self.local.roles.get_mut(&member) else { - continue; - }; - for parent in parents { - match revoke_option { - Some(RoleMembershipOptionFact::Admin(false)) => { - role.can_administer_membership - .retain(|target| target != parent); + }); + if any_match { + self.snapshot_role_membership_grantors(); + } + let mut cascade_members = Vec::new(); + for member in revokees { + for parent in parents { + let Some(index) = + self.membership_record_index(&member, parent, grantor) + else { + // PostgreSQL warns that the grant does not + // exist and leaves state untouched. + continue; + }; + match revoke_option { + Some(RoleMembershipOptionFact::Admin(false)) => { + let had_admin = + self.local.role_membership_grantors[index].admin; + // PostgreSQL ignores an admin-option + // revoke on a record without admin. + if !had_admin { + continue; + } + let still_admin = self.membership_admin_from_other_record( + &member, + parent, + Some(index), + ); + let record = &mut self.local.role_membership_grantors[index]; + record.admin = false; + self.snapshot_role(&member); + self.reconcile_membership_projection(&member); + if revoke.cascade && !still_admin { + cascade_members.push(member.clone()); + } + } + Some(RoleMembershipOptionFact::Inherit(false)) => { + let record = &mut self.local.role_membership_grantors[index]; + record.inherit = false; + self.snapshot_role(&member); + self.reconcile_membership_projection(&member); + } + Some(RoleMembershipOptionFact::Set(false)) => { + let record = &mut self.local.role_membership_grantors[index]; + record.set = false; + self.snapshot_role(&member); + self.reconcile_membership_projection(&member); + } + None => { + let record = self.local.role_membership_grantors[index].clone(); + let had_admin = record.admin; + let still_admin = self.membership_admin_from_other_record( + &member, + parent, + Some(index), + ); + self.local.role_membership_grantors.remove(index); + self.snapshot_role(&member); + self.reconcile_membership_projection(&member); + if had_admin && !still_admin { + cascade_members.push(member.clone()); + } + } + Some(_) => unreachable!(), } - Some(RoleMembershipOptionFact::Inherit(false)) => { - role.can_inherit_from.retain(|target| target != parent); + } + } + if revoke.cascade && !cascade_members.is_empty() { + self.cascade_memberships_granted_by(&cascade_members); + } + } else { + if revoke.cascade + && (revoke_option.is_none() + || matches!( + revoke_option, + Some(RoleMembershipOptionFact::Admin(false)) + )) + { + cascade_grantors.extend(revokees.iter().cloned()); + } + if !revokees.is_empty() + && (revoke.cascade || revoke_option.is_none()) + && self + .local + .role_membership_grantors + .iter() + .any(|provenance| { + revokees.contains(&provenance.member) + && parents.contains(&provenance.role) + }) + { + self.snapshot_role_membership_grantors(); + } + for member in revokees { + self.snapshot_role(&member); + let Some(RoleOverlay::Present(role)) = self.local.roles.get_mut(&member) + else { + continue; + }; + for parent in parents { + match revoke_option { + Some(RoleMembershipOptionFact::Admin(false)) => { + role.can_administer_membership + .retain(|target| target != parent); + } + Some(RoleMembershipOptionFact::Inherit(false)) => { + role.can_inherit_from.retain(|target| target != parent); + } + Some(RoleMembershipOptionFact::Set(false)) => { + role.can_set_role_to.retain(|target| target != parent); + } + None => { + role.can_administer_membership + .retain(|target| target != parent); + role.can_inherit_from.retain(|target| target != parent); + role.can_set_role_to.retain(|target| target != parent); + role.member_of.retain(|target| target != parent); + if revoke.cascade { + cascade_grantors.push(member.clone()); + } + } + Some(_) => unreachable!(), } - Some(RoleMembershipOptionFact::Set(false)) => { - role.can_set_role_to.retain(|target| target != parent); + if revoke_option.is_some() + && matches!( + revoke_option, + Some(RoleMembershipOptionFact::Admin(false)) + ) + && revoke.cascade + { + cascade_grantors.push(member.clone()); } - None => { - role.can_administer_membership - .retain(|target| target != parent); - role.can_inherit_from.retain(|target| target != parent); - role.can_set_role_to.retain(|target| target != parent); - role.member_of.retain(|target| target != parent); - if revoke.cascade { - cascade_grantors.push(member.clone()); - } + if revoke_option.is_none() { + self.local.role_membership_grantors.retain(|provenance| { + provenance.member != member || provenance.role != *parent + }); } - Some(_) => unreachable!(), - } - if revoke_option.is_some() - && matches!(revoke_option, Some(RoleMembershipOptionFact::Admin(false))) - && revoke.cascade - { - cascade_grantors.push(member.clone()); - } - if revoke_option.is_none() { - self.local.role_membership_grantors.retain(|provenance| { - provenance.member != member || provenance.role != *parent - }); } } - } - if revoke.cascade && !cascade_grantors.is_empty() { - self.cascade_role_memberships(&cascade_grantors); + if revoke.cascade && !cascade_grantors.is_empty() { + self.cascade_role_memberships(&cascade_grantors); + } } } } diff --git a/src/_internal/analysis/state/apply_sequence.rs b/src/_internal/analysis/state/apply_sequence.rs index c9a3dd86..94da12d9 100644 --- a/src/_internal/analysis/state/apply_sequence.rs +++ b/src/_internal/analysis/state/apply_sequence.rs @@ -69,7 +69,9 @@ impl AnalysisState { Self::expression_references_sequence(left, sequence) || Self::expression_references_sequence(right, sequence) } - ExprIr::Cast { expr, .. } => Self::expression_references_sequence(expr, sequence), + ExprIr::Cast { expr, .. } | ExprIr::UnaryOp { expr, .. } => { + Self::expression_references_sequence(expr, sequence) + } ExprIr::ColumnRef(_) | ExprIr::Sentinel(_) | ExprIr::Omitted => false, } } @@ -85,7 +87,9 @@ impl AnalysisState { ExprIr::BinaryOp { left, right, .. } => { Self::expression_contains_nextval(left) || Self::expression_contains_nextval(right) } - ExprIr::Cast { expr, .. } => Self::expression_contains_nextval(expr), + ExprIr::Cast { expr, .. } | ExprIr::UnaryOp { expr, .. } => { + Self::expression_contains_nextval(expr) + } ExprIr::Literal(_) | ExprIr::ColumnRef(_) | ExprIr::Sentinel(_) | ExprIr::Omitted => { false } @@ -280,6 +284,17 @@ impl AnalysisState { self.snapshot_generation_counter(); self.local.generation_counter += 1; let generation = self.local.generation_counter; + let Some(parameters) = Self::apply_identity_sequence_options( + crate::_internal::model::sequence::SequenceParameters { + persistence: create.persistence, + ..Default::default() + }, + &create.options, + ) else { + return MutationResult::Conflict { + reason: "invalid sequence parameters".to_string(), + }; + }; self.local.sequences.insert( create.id.clone(), SequenceOverlay::Present(SequenceState { @@ -291,6 +306,7 @@ impl AnalysisState { } else { SequenceKind::Standalone }, + parameters, generation, }), ); diff --git a/src/_internal/analysis/state/apply_transaction.rs b/src/_internal/analysis/state/apply_transaction.rs index 6758df99..13c4baf2 100644 --- a/src/_internal/analysis/state/apply_transaction.rs +++ b/src/_internal/analysis/state/apply_transaction.rs @@ -1,9 +1,10 @@ use super::{AnalysisState, MutationResult}; use crate::_internal::analysis::evidence::{EvidenceCode, EvidenceScope}; use crate::_internal::analysis::mutations::{ - ReleaseSavepointMutation, RollbackToSavepointMutation, SavepointMutation, + DropTable, ReleaseSavepointMutation, RollbackToSavepointMutation, SavepointMutation, }; use crate::_internal::analysis::transaction::TransactionFrame; +use crate::_internal::model::relation::{OnCommitAction, Persistence, RelationOverlay}; impl AnalysisState { pub(super) fn apply_begin_transaction(&mut self) -> MutationResult { @@ -30,6 +31,7 @@ impl AnalysisState { } else { while self.local.transactions.pop().is_some() {} self.restore_persistent_role_context(); + self.apply_on_commit_actions(); } self.local.transaction_aborted = false; if chain { @@ -38,6 +40,56 @@ impl AnalysisState { MutationResult::Applied } + fn apply_on_commit_actions(&mut self) { + let actions = self + .local + .relations + .iter() + .filter_map(|(id, overlay)| match overlay { + RelationOverlay::Present(relation) + if relation.persistence == Persistence::Temporary => + { + relation.on_commit.map(|action| (id.clone(), action)) + } + _ => None, + }) + .collect::>(); + + for (id, action) in actions { + match action { + OnCommitAction::PreserveRows => {} + OnCommitAction::DeleteRows => { + if let Some(RelationOverlay::Present(relation)) = + self.local.relations.get_mut(&id) + { + relation.estimated_rows = Some(0); + relation.relpages = Some(0); + } + } + OnCommitAction::Drop => { + // PostgreSQL removes the temporary relation and all of + // its dependent local objects at commit. + let _ = self.apply_drop_table( + &DropTable { + ids: vec![id], + if_exists: false, + cascade: true, + }, + None, + ); + } + } + } + } + + /// PostgreSQL wraps each statement outside an explicit transaction in its + /// own transaction, so ON COMMIT actions run after that statement. + pub(crate) fn apply_implicit_commit_actions(&mut self) { + if self.local.transactions.is_empty() && !self.local.transaction_aborted { + self.apply_on_commit_actions(); + } + } + pub(super) fn apply_rollback_transaction(&mut self, chain: bool) -> MutationResult { if chain && self.local.transactions.is_empty() { self.taint(EvidenceCode::TransactionStateUnknown, EvidenceScope::Chain); diff --git a/src/_internal/analysis/state/apply_view_index.rs b/src/_internal/analysis/state/apply_view_index.rs index 5a61fabb..06da3de9 100644 --- a/src/_internal/analysis/state/apply_view_index.rs +++ b/src/_internal/analysis/state/apply_view_index.rs @@ -441,6 +441,9 @@ impl AnalysisState { has_predicate: create.has_predicate, is_concurrent: create.concurrently, is_unique: create.unique, + // PostgreSQL-created indexes are immediate unless a + // constraint later adopts them with deferred semantics. + is_immediate: true, is_valid: true, is_ready: true, is_live: true, @@ -591,7 +594,7 @@ impl AnalysisState { // Scoped index rows do not yet carry a complete backing-constraint // identity or every external dependency, so PostgreSQL's DROP // INDEX conflict semantics cannot be proven from the partial - // graph. Leave the baseline unchanged until V7 index ownership + // graph. Leave the baseline unchanged until index ownership // coverage is object-complete. self.taint( EvidenceCode::CatalogCoverageIncomplete, @@ -682,10 +685,34 @@ impl AnalysisState { return MutationResult::Skipped; } } - self.snapshot_graph(); + let table_indexes = self + .local + .graph + .edges() + .iter() + .filter(|edge| { + matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) + && targets.iter().any(|target| { + self.local.graph.resolve_rename(target) + == self.local.graph.resolve_rename(&edge.dependent) + }) + }) + .map(|edge| (edge.referenced.clone(), edge.dependent.name.clone())) + .collect::>(); + for (table, index_name) in table_indexes { + self.snapshot_relation(&table); + if let Some(RelationOverlay::Present(relation)) = self.local.relations.get_mut(&table) { + relation.clear_index_settings(&index_name); + } + } + self.snapshot_graph_full(); + let resolution_graph = self.local.graph.clone(); self.local.graph.retain_edges(|edge| { !(matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) - && targets.contains(&edge.dependent)) + && targets.iter().any(|target| { + resolution_graph.resolve_rename(target) + == resolution_graph.resolve_rename(&edge.dependent) + })) }); MutationResult::Applied } diff --git a/src/_internal/analysis/transaction.rs b/src/_internal/analysis/transaction.rs index fd4ae0af..c5013129 100644 --- a/src/_internal/analysis/transaction.rs +++ b/src/_internal/analysis/transaction.rs @@ -7,7 +7,7 @@ use crate::_internal::model::types::TypeOverlay; use std::collections::{HashMap, HashSet}; #[derive(Debug, Clone)] -pub struct NamespaceSnapshot { +pub(crate) struct NamespaceSnapshot { pub schemas: HashMap, pub relations: HashMap, pub types: HashMap, @@ -34,7 +34,7 @@ pub struct NamespaceSnapshot { } #[derive(Debug, Clone)] -pub enum StateChange { +pub(crate) enum StateChange { SchemaSnapshot { name: String, previous: Option, @@ -124,33 +124,33 @@ pub enum StateChange { } #[derive(Debug, Clone, PartialEq, Eq)] -pub enum TransactionFrameKind { +pub(crate) enum TransactionFrameKind { Root, Savepoint(String), } #[derive(Debug, Clone)] -pub struct TransactionFrame { +pub(crate) struct TransactionFrame { pub kind: TransactionFrameKind, pub undo_log: Vec, } impl TransactionFrame { - pub fn root() -> Self { + pub(crate) fn root() -> Self { Self { kind: TransactionFrameKind::Root, undo_log: Vec::new(), } } - pub fn savepoint(name: impl Into) -> Self { + pub(crate) fn savepoint(name: impl Into) -> Self { Self { kind: TransactionFrameKind::Savepoint(name.into()), undo_log: Vec::new(), } } - pub fn is_named_savepoint(&self, name: &str) -> bool { + pub(crate) fn is_named_savepoint(&self, name: &str) -> bool { matches!(&self.kind, TransactionFrameKind::Savepoint(candidate) if candidate == name) } } diff --git a/src/_internal/ast/identifiers.rs b/src/_internal/ast/identifiers.rs index 0805d775..e88b7235 100644 --- a/src/_internal/ast/identifiers.rs +++ b/src/_internal/ast/identifiers.rs @@ -1,13 +1,13 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct Ident { +pub(crate) struct Ident { pub text: String, pub quoted: bool, } impl Ident { - pub fn new(text: impl Into, quoted: bool) -> Self { + pub(crate) fn new(text: impl Into, quoted: bool) -> Self { Self { text: text.into(), quoted, @@ -18,7 +18,7 @@ impl Ident { /// identifiers preserve case, unquoted identifiers are folded, and both are /// clipped to PostgreSQL's default `NAMEDATALEN - 1` byte limit without /// splitting a UTF-8 code point. - pub fn resolve(&self) -> String { + pub(crate) fn resolve(&self) -> String { let resolved = if self.quoted { self.text.clone() } else { @@ -39,13 +39,13 @@ fn truncate_postgres_identifier(value: &str) -> &str { } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct QualifiedName { +pub(crate) struct QualifiedName { pub schema: Option, pub name: Ident, } impl QualifiedName { - pub fn new(schema: Option, name: Ident) -> Self { + pub(crate) fn new(schema: Option, name: Ident) -> Self { Self { schema, name } } } @@ -75,7 +75,7 @@ mod tests { /// ObjectId represents a fully resolved, state-machine tracked database object. /// Its schema and name must already use their resolved lookup spelling. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ObjectId { +pub(crate) struct ObjectId { pub schema: String, pub name: String, pub inferred_schema: bool, @@ -97,7 +97,7 @@ impl std::hash::Hash for ObjectId { } impl ObjectId { - pub fn new(schema: impl Into, name: impl Into) -> Self { + pub(crate) fn new(schema: impl Into, name: impl Into) -> Self { Self { schema: schema.into(), name: name.into(), diff --git a/src/_internal/ast/mod.rs b/src/_internal/ast/mod.rs index bb937d62..e838d9a0 100644 --- a/src/_internal/ast/mod.rs +++ b/src/_internal/ast/mod.rs @@ -1,5 +1,5 @@ -pub mod identifiers; -pub mod visitor; +pub(crate) mod identifiers; +pub(crate) mod visitor; #[cfg(test)] mod visitor_tests; diff --git a/src/_internal/ast/visitor.rs b/src/_internal/ast/visitor.rs index 26751b2a..02e33108 100644 --- a/src/_internal/ast/visitor.rs +++ b/src/_internal/ast/visitor.rs @@ -1,7 +1,8 @@ use crate::_internal::analysis::expr_ir::ExprIr; use crate::_internal::analysis::facts::{ AlterIndexActionFact, AlterTableActionFact, AlterTypeActionFact, AlterTypeFact, ColumnFact, - CreateTypeFact, FkFact, PersistenceFact, ResetSettingTarget, SearchPathTarget, StatementFact, + CreateTypeFact, FkFact, LikePropertiesFact, LikeSourceFact, LockModeFact, PersistenceFact, + RelationTargetFact, ReplicaIdentityFact, ResetSettingTarget, SearchPathTarget, StatementFact, TableConstraintFact, TimeoutSetting, TimeoutSettingValue, TypeCreationKind, }; use crate::_internal::ast::identifiers::{Ident, QualifiedName}; @@ -11,13 +12,13 @@ use squawk_syntax::ast::{ Constraint, CreateDatabase, CreateDomain, CreateIndex, CreateMaterializedView, CreatePolicy, CreateSequence, CreateTable, CreateTableAs, CreateTrigger, CreateType, CreateView, CteName, DetachPartition, DropDomain, DropIndex, DropMaterializedView, DropPolicy, DropSequence, - DropTable, DropTrigger, DropType, DropView, Grant, NameRef, PartitionType, Path, PathSegment, - PathSegmentRef, RelationNameRef, ReleaseSavepoint, Revoke, RevokeCommand, Rollback, Set, Stmt, - TableArg, TableConstraint, + DropTable, DropTrigger, DropType, DropView, Grant, Lock, NameRef, PartitionType, Path, + PathSegment, PathSegmentRef, RelationNameRef, ReleaseSavepoint, Revoke, RevokeCommand, + Rollback, SelectInto, Set, Stmt, TableArg, TableConstraint, Truncate, }; use squawk_syntax::{SyntaxKind, ast}; -pub struct AstVisitor; +pub(crate) struct AstVisitor; impl AstVisitor { fn expr_columns(expr: crate::_internal::analysis::expr_ir::ExprIr) -> Vec { @@ -38,7 +39,7 @@ impl AstVisitor { walk(*left, columns); walk(*right, columns); } - ExprIr::Cast { expr, .. } => walk(*expr, columns), + ExprIr::Cast { expr, .. } | ExprIr::UnaryOp { expr, .. } => walk(*expr, columns), ExprIr::Literal(_) | ExprIr::Sentinel(_) | ExprIr::Omitted => {} } } @@ -116,11 +117,12 @@ impl AstVisitor { matches!(scope, Some(ast::SetScope::LocalScope(_))) } - pub fn extract(stmt: &Stmt) -> Option { + pub(crate) fn extract(stmt: &Stmt) -> Option { let syntax = stmt.syntax(); match stmt { Stmt::CreateTable(node) => return Self::extract_create_table(node), Stmt::CreateTableAs(node) => return Self::extract_create_table_as(node), + Stmt::SelectInto(node) => return Self::extract_select_into(node), Stmt::CreateView(node) => return Self::extract_create_view(node), Stmt::CreateMaterializedView(node) => { return Self::extract_create_materialized_view(node); @@ -132,6 +134,8 @@ impl AstVisitor { Stmt::DropView(node) => return Self::extract_drop_view(node), Stmt::DropMaterializedView(node) => return Self::extract_drop_materialized_view(node), Stmt::DropIndex(node) => return Self::extract_drop_index(node), + Stmt::Lock(node) => return Self::extract_lock(node), + Stmt::Truncate(node) => return Self::extract_truncate(node), Stmt::Set(node) => return Self::extract_set(node), Stmt::Reset(node) => return Self::extract_reset(node), Stmt::Grant(node) => return Self::extract_grant(node), @@ -393,21 +397,6 @@ impl AstVisitor { let path = node.table_name()?.path()?; let name = Self::path_to_qualified_name(&path)?; - // These forms copy inherited/type/LIKE metadata or add transaction - // lifecycle semantics that the current table facts cannot represent. - // Keep them on the engine's opaque path instead of creating an - // incomplete table while claiming an exact state transition. - if node.inherits().is_some() - || node.of_type().is_some() - || node.on_commit().is_some() - || node.table_arg_list().is_some_and(|args| { - args.args() - .any(|arg| matches!(arg, TableArg::LikeClause(_))) - }) - { - return None; - } - let persistence = match node .persistence() .map(|p| p.syntax().text().to_string().to_lowercase()) @@ -417,48 +406,95 @@ impl AstVisitor { Some("unlogged") => PersistenceFact::Unlogged, _ => PersistenceFact::Permanent, }; + let on_commit = match node + .on_commit() + .and_then(|clause| clause.on_commit_action()) + { + Some(ast::OnCommitAction::PreserveRows(_)) => { + Some(crate::_internal::analysis::facts::OnCommitFact::PreserveRows) + } + Some(ast::OnCommitAction::DeleteRows(_)) => { + Some(crate::_internal::analysis::facts::OnCommitFact::DeleteRows) + } + Some(ast::OnCommitAction::Drop(_)) => { + Some(crate::_internal::analysis::facts::OnCommitFact::Drop) + } + None => None, + }; + if on_commit.is_some() && !matches!(persistence, PersistenceFact::Temporary) { + return None; + } let partition_by = node.partition_by().map(|p| p.syntax().text().to_string()); + let partition_strategy = node + .partition_by() + .and_then(|partition| partition.partition_strategy()) + .and_then(|strategy| strategy.range_token().or_else(|| strategy.ident_token())) + .map(|token| Self::resolve_identifier_token(token.text())); let partition_of = node .partition_of() .and_then(|po| po.table_name_ref()) .and_then(|t| t.path_ref()) .and_then(|p| Self::path_ref_to_qualified_name(&p)); - let partition_type = node + let partition_bound = node .partition_type() .map(|pt| pt.syntax().text().to_string()); + let inherits = match node.inherits() { + Some(inherits) => inherits + .table_name_refs() + .map(|table| { + table + .path_ref() + .and_then(|path| Self::path_ref_to_qualified_name(&path)) + }) + .collect::>>()?, + None => Vec::new(), + }; + let of_type = node + .of_type() + .and_then(|of_type| of_type.ty()) + .and_then(|ty| match ty { + ast::Type::PathType(path_type) + if path_type.arg_list().is_none() && path_type.setof_token().is_none() => + { + path_type + .path_ref() + .and_then(|path| Self::path_ref_to_qualified_name(&path)) + } + _ => None, + }); + if node.of_type().is_some() && of_type.is_none() { + return None; + } - let (columns, foreign_keys, table_constraints) = node - .table_arg_list() - .map(|tal| { - let (columns, foreign_keys, table_constraints) = - Self::extract_table_body(tal.args()); - (columns, foreign_keys, table_constraints) - }) - .unwrap_or_else(|| (Vec::new(), Vec::new(), Vec::new())); + let (columns, foreign_keys, table_constraints, like_sources) = match node.table_arg_list() { + Some(args) => Self::extract_table_body(args.args())?, + None => (Vec::new(), Vec::new(), Vec::new(), Vec::new()), + }; Some(StatementFact::CreateTable { name, if_not_exists: node.if_not_exists().is_some(), as_select: false, persistence, + on_commit, columns, foreign_keys, table_constraints, partition_by, + partition_strategy, partition_of, - partition_type, + partition_bound, + inherits, + like_sources, + of_type, + select_source: None, + select_outputs: Vec::new(), + select_projection_complete: false, }) } fn extract_create_table_as(node: &CreateTableAs) -> Option { - // CTAS relations with ON COMMIT actions do not have the same - // transaction lifecycle as an ordinary relation. We do not model - // that lifecycle yet, so preserve the engine's opaque-statement path - // instead of claiming the relation survives (or disappears) exactly. - if node.on_commit().is_some() { - return None; - } let path = node.table_name()?.path()?; let persistence = match node .persistence() @@ -469,17 +505,128 @@ impl AstVisitor { Some("unlogged") => PersistenceFact::Unlogged, _ => PersistenceFact::Permanent, }; + let on_commit = match node + .on_commit() + .and_then(|clause| clause.on_commit_action()) + { + Some(ast::OnCommitAction::PreserveRows(_)) => { + Some(crate::_internal::analysis::facts::OnCommitFact::PreserveRows) + } + Some(ast::OnCommitAction::DeleteRows(_)) => { + Some(crate::_internal::analysis::facts::OnCommitFact::DeleteRows) + } + Some(ast::OnCommitAction::Drop(_)) => { + Some(crate::_internal::analysis::facts::OnCommitFact::Drop) + } + None => None, + }; + if on_commit.is_some() && !matches!(persistence, PersistenceFact::Temporary) { + return None; + } Some(StatementFact::CreateTable { name: Self::path_to_qualified_name(&path)?, if_not_exists: node.if_not_exists().is_some(), as_select: true, persistence, + on_commit, + columns: Vec::new(), + foreign_keys: Vec::new(), + table_constraints: Vec::new(), + partition_by: None, + partition_strategy: None, + partition_of: None, + partition_bound: None, + inherits: Vec::new(), + like_sources: Vec::new(), + of_type: None, + select_source: None, + select_outputs: Vec::new(), + select_projection_complete: false, + }) + } + + fn extract_select_into(node: &SelectInto) -> Option { + let into = node.into_clause()?; + let name = into.table_name()?.path()?; + let persistence = match into + .persistence() + .map(|persistence| persistence.syntax().text().to_string().to_lowercase()) + .as_deref() + { + Some("temporary") | Some("temp") => PersistenceFact::Temporary, + Some("unlogged") => PersistenceFact::Unlogged, + _ => PersistenceFact::Permanent, + }; + let select_source = node.from_clause().and_then(|from| { + let mut items = from.items(); + let item = items.next()?; + if items.next().is_some() { + return None; + } + let ast::FromListItem::FromItem(ast::FromItem::RelationFromItem(relation)) = item + else { + return None; + }; + if relation.tablesample_clause().is_some() { + return None; + } + relation + .relation_name_ref()? + .path_ref() + .and_then(|path| Self::path_ref_to_qualified_name(&path)) + }); + let select_outputs = node + .select_clause() + .and_then(|select| select.target_list()) + .and_then(|targets| { + targets + .targets() + .map(|target| { + if target.star_token().is_some() { + return Some( + crate::_internal::analysis::facts::SelectOutputFact::AllColumns, + ); + } + let ast::Expr::NameRef(name) = target.expr()? else { + return None; + }; + let source_name = + Self::resolve_identifier_token(name.syntax().first_token()?.text()); + let output_name = target + .as_name() + .and_then(|alias| alias.name()) + .and_then(|name| name.syntax().first_token()) + .map(|token| Self::resolve_identifier_token(token.text())) + .unwrap_or_else(|| source_name.clone()); + Some( + crate::_internal::analysis::facts::SelectOutputFact::Column { + source_name, + output_name, + }, + ) + }) + .collect::>>() + }); + let select_projection_complete = select_source.is_some() && select_outputs.is_some(); + Some(StatementFact::CreateTable { + name: Self::path_to_qualified_name(&name)?, + if_not_exists: false, + as_select: true, + persistence, + on_commit: None, columns: Vec::new(), foreign_keys: Vec::new(), table_constraints: Vec::new(), partition_by: None, + partition_strategy: None, partition_of: None, - partition_type: None, + partition_bound: None, + inherits: Vec::new(), + like_sources: Vec::new(), + of_type: None, + select_source, + select_outputs: select_outputs.unwrap_or_default(), + select_projection_complete, }) } @@ -499,6 +646,59 @@ impl AstVisitor { }) } + fn extract_lock(node: &Lock) -> Option { + let targets = node + .relation_list()? + .relation_names() + .map(|relation| { + Some(RelationTargetFact { + name: Self::path_ref_to_qualified_name( + &relation.relation_name_ref()?.path_ref()?, + )?, + only: relation.only_token().is_some(), + }) + }) + .collect::>>()?; + let mode = match node.lock_mode_clause()?.lock_mode()? { + ast::LockMode::AccessShare(_) => LockModeFact::AccessShare, + ast::LockMode::RowShare(_) => LockModeFact::RowShare, + ast::LockMode::RowExclusive(_) => LockModeFact::RowExclusive, + ast::LockMode::ShareUpdateExclusive(_) => LockModeFact::ShareUpdateExclusive, + ast::LockMode::Share(_) => LockModeFact::Share, + ast::LockMode::ShareRowExclusive(_) => LockModeFact::ShareRowExclusive, + ast::LockMode::Exclusive(_) => LockModeFact::Exclusive, + ast::LockMode::AccessExclusive(_) => LockModeFact::AccessExclusive, + }; + (!targets.is_empty()).then_some(StatementFact::Lock { + targets, + mode, + nowait: node.nowait().is_some(), + }) + } + + fn extract_truncate(node: &Truncate) -> Option { + let targets = node + .table_list()? + .table_relation_names() + .map(|relation| { + Some(RelationTargetFact { + name: Self::path_ref_to_qualified_name( + &relation.table_name_ref()?.path_ref()?, + )?, + only: relation.only_token().is_some(), + }) + }) + .collect::>>()?; + (!targets.is_empty()).then_some(StatementFact::Truncate { + targets, + cascade: Self::is_cascade(node.drop_behavior()), + restart_identity: matches!( + node.identity_action(), + Some(ast::IdentityAction::RestartIdentity(_)) + ), + }) + } + fn extract_alter_table(node: &AlterTable) -> Option { let path = node.table_relation_name()?.table_name_ref()?.path_ref()?; let table_name = Self::path_ref_to_qualified_name(&path)?; @@ -518,7 +718,14 @@ impl AstVisitor { Some(PartitionType::PartitionForValuesWith(_)) => Some("HASH".to_string()), Some(PartitionType::PartitionDefault(_)) | None => None, }; - actions.push(AlterTableActionFact::AttachPartition { child, strategy }); + let bound = ap + .partition_type() + .map(|bound| bound.syntax().text().to_string()); + actions.push(AlterTableActionFact::AttachPartition { + child, + strategy, + bound, + }); } continue; } @@ -528,7 +735,16 @@ impl AstVisitor { .and_then(|tn| tn.path_ref()) .and_then(|p| Self::path_ref_to_qualified_name(&p)) { - actions.push(AlterTableActionFact::DetachPartition { child }); + let mode = match dp.detach_partition_option() { + Some(ast::DetachPartitionOption::DetachConcurrently(_)) => { + crate::_internal::analysis::facts::DetachPartitionMode::Concurrently + } + Some(ast::DetachPartitionOption::DetachFinalize(_)) => { + crate::_internal::analysis::facts::DetachPartitionMode::Finalize + } + None => crate::_internal::analysis::facts::DetachPartitionMode::Immediate, + }; + actions.push(AlterTableActionFact::DetachPartition { child, mode }); } continue; } @@ -579,6 +795,9 @@ impl AstVisitor { let mut default = None; let mut generation = crate::_internal::analysis::facts::ColumnGeneration::Ordinary; + let mut identity_sequence = None; + let mut generated_expr = None; + let mut generated_expr_sql = None; for c in add.constraints() { match c { Constraint::NotNullConstraint(_) => not_null = true, @@ -592,9 +811,45 @@ impl AstVisitor { Some(ast::GeneratedAs::GeneratedIdentity(_)) ) => { - generation = crate::_internal::analysis::facts::ColumnGeneration::Identity; + let Some(ast::GeneratedAs::GeneratedIdentity(identity)) = + generated.generated_as() + else { + unreachable!("guard requires an identity clause") + }; + generation = match identity.generated_when() { + Some(ast::GeneratedWhen::GeneratedAlways(_)) => crate::_internal::analysis::facts::ColumnGeneration::IdentityAlways, + Some(ast::GeneratedWhen::GeneratedByDefault(_)) => crate::_internal::analysis::facts::ColumnGeneration::IdentityByDefault, + None => return None, + }; + identity_sequence = + Some(Box::new(Self::identity_sequence_options(&identity)?)); not_null = true; } + Constraint::GeneratedConstraint(generated) + if matches!( + generated.generated_as(), + Some(ast::GeneratedAs::GeneratedStored(_)) + ) => + { + generation = match generated.generated_as() { + Some(ast::GeneratedAs::GeneratedStored(stored)) => match stored.generated_kind() { + Some(ast::GeneratedKind::Stored(_)) => crate::_internal::analysis::facts::ColumnGeneration::GeneratedStored, + Some(ast::GeneratedKind::Virtual(_)) => crate::_internal::analysis::facts::ColumnGeneration::GeneratedVirtual, + None => return None, + }, + _ => unreachable!("guard requires a generated expression"), + }; + generated_expr_sql = match generated.generated_as() { + Some(ast::GeneratedAs::GeneratedStored(stored)) => stored + .expr() + .map(|expr| expr.syntax().text().to_string()), + _ => None, + }; + generated_expr = match generated.generated_as() { + Some(ast::GeneratedAs::GeneratedStored(stored)) => stored.expr().map(crate::_internal::analysis::expr_visitor::ExprVisitor::convert), + _ => None, + }; + } _ => {} } } @@ -611,6 +866,9 @@ impl AstVisitor { } else { generation }, + identity_sequence, + generated_expr, + generated_expr_sql, }); } } @@ -732,11 +990,16 @@ impl AstVisitor { }) }); - if let Some(col_name) = col_ident - && let Some(opt) = alter_col.option() - && let Some(fact) = Self::extract_alter_column_option(col_name, opt) - { - actions.push(fact); + if let (Some(col_name), Some(opt)) = (col_ident, alter_col.option()) { + if let Some(fact) = Self::extract_alter_column_option(col_name, opt) { + actions.push(fact); + } else { + // A typed but unmodeled column option must not be + // mistaken for an exact no-op. + unsupported_action = true; + } + } else { + unsupported_action = true; } } AlterTableAction::ValidateConstraint(vc) => { @@ -750,9 +1013,11 @@ impl AstVisitor { } } AlterTableAction::SetAccessMethod(sam) => { - if sam.access_method_ref().is_some() { - actions.push(AlterTableActionFact::SetAccessMethod); - } + let access_method = sam + .access_method_ref() + .and_then(|method| method.ident_token()) + .map(|token| Self::resolve_identifier_token(token.text())); + actions.push(AlterTableActionFact::SetAccessMethod { access_method }); } AlterTableAction::DisableTrigger(dt) => { let trigger_name = match dt.trigger_target() { @@ -760,7 +1025,14 @@ impl AstVisitor { .ident_token() .map(|name| Self::resolve_identifier_token(name.text())), Some(ast::TriggerTarget::All(_)) => Some("ALL".to_string()), - Some(ast::TriggerTarget::User(_)) | None => None, + // Constraint triggers are modeled as constraints, not + // entries in the user-trigger catalog. Preserve USER + // so state can target every tracked user trigger. + Some(ast::TriggerTarget::User(_)) => Some("USER".to_string()), + None => { + unsupported_action = true; + continue; + } }; actions.push(AlterTableActionFact::DisableTrigger { trigger_name }); } @@ -770,7 +1042,11 @@ impl AstVisitor { .ident_token() .map(|name| Self::resolve_identifier_token(name.text())), Some(ast::TriggerTarget::All(_)) => Some("ALL".to_string()), - Some(ast::TriggerTarget::User(_)) | None => None, + Some(ast::TriggerTarget::User(_)) => Some("USER".to_string()), + None => { + unsupported_action = true; + continue; + } }; actions.push(AlterTableActionFact::EnableTrigger { trigger_name }); } @@ -801,23 +1077,29 @@ impl AstVisitor { AlterTableAction::ReplicaIdentity(ri) => { let option = match ri.replica_identity_option() { Some(ast::ReplicaIdentityOption::ReplicaIdentityDefault(_)) => { - "DEFAULT".to_string() + ReplicaIdentityFact::Default } Some(ast::ReplicaIdentityOption::ReplicaIdentityFull(_)) => { - "FULL".to_string() + ReplicaIdentityFact::Full } Some(ast::ReplicaIdentityOption::ReplicaIdentityNothing(_)) => { - "NOTHING".to_string() + ReplicaIdentityFact::Nothing } Some(ast::ReplicaIdentityOption::UsingIndexName(using_index)) => { using_index .index_ref() .and_then(|index| index.path_ref()) .and_then(|path| Self::path_ref_to_qualified_name(&path)) - .map(|name| name.name.resolve()) - .unwrap_or_default() + .map(ReplicaIdentityFact::UsingIndex) + .unwrap_or_else(|| { + unsupported_action = true; + ReplicaIdentityFact::Default + }) + } + None => { + unsupported_action = true; + ReplicaIdentityFact::Default } - None => String::new(), }; actions.push(AlterTableActionFact::ReplicaIdentity { option }); } @@ -825,16 +1107,38 @@ impl AstVisitor { let index = co .index_ref() .and_then(|ir| ir.path_ref()) - .and_then(|pr| Self::path_ref_to_qualified_name(&pr)) - .map(|qn| qn.name.resolve()) - .or_else(|| { - co.syntax() - .descendants() - .find_map(NameRef::cast) - .map(|nr| Self::resolve_name_ref(&nr)) - }) - .unwrap_or_default(); - actions.push(AlterTableActionFact::ClusterOn { index }); + .and_then(|pr| Self::path_ref_to_qualified_name(&pr)); + if let Some(index) = index { + actions.push(AlterTableActionFact::ClusterOn { index }); + } else { + unsupported_action = true; + } + } + AlterTableAction::SetWithoutCluster(_) => { + actions.push(AlterTableActionFact::SetWithoutCluster); + } + AlterTableAction::SetOptions(options) => { + if let Some(attributes) = + Self::extract_attribute_options(options.attribute_list()) + { + actions.push(AlterTableActionFact::SetTableOptions { attributes }); + } else { + unsupported_action = true; + } + } + AlterTableAction::ResetOptions(options) => { + let names = options.attribute_list().map(|list| { + list.attribute_options() + .filter_map(|option| { + option.name().map(|name| name.syntax().text().to_string()) + }) + .collect::>() + }); + if let Some(names) = names.filter(|names| !names.is_empty()) { + actions.push(AlterTableActionFact::ResetTableOptions { names }); + } else { + unsupported_action = true; + } } AlterTableAction::InheritTable(it) => { if let Some(path) = it.table_name_ref().and_then(|t| t.path_ref()) @@ -850,6 +1154,25 @@ impl AstVisitor { actions.push(AlterTableActionFact::NoInheritTable { parent }); } } + AlterTableAction::OfType(of_type) => { + let type_name = of_type.ty().and_then(|ty| match ty { + ast::Type::PathType(path_type) + if path_type.arg_list().is_none() + && path_type.setof_token().is_none() => + { + path_type + .path_ref() + .and_then(|path| Self::path_ref_to_qualified_name(&path)) + } + _ => None, + }); + if let Some(type_name) = type_name { + actions.push(AlterTableActionFact::OfType { type_name }); + } else { + unsupported_action = true; + } + } + AlterTableAction::NotOf(_) => actions.push(AlterTableActionFact::NotOf), AlterTableAction::MergePartitions(mp) => { if let Some(path) = mp.table_name().and_then(|t| t.path()) && let Some(parent) = Self::path_to_qualified_name(&path) @@ -863,12 +1186,51 @@ impl AstVisitor { AlterTableAction::ForceRls(_) => { actions.push(AlterTableActionFact::ForceRls); } + AlterTableAction::NoForceRls(_) => { + actions.push(AlterTableActionFact::NoForceRls); + } AlterTableAction::EnableRls(_) => { actions.push(AlterTableActionFact::EnableRls); } AlterTableAction::DisableRls(_) => { actions.push(AlterTableActionFact::DisableRls); } + AlterTableAction::EnableRule(rule) => { + actions.push(AlterTableActionFact::SetRuleMode { + rule_name: rule + .rule_ref() + .and_then(|rule| rule.ident_token()) + .map(|token| Self::resolve_identifier_token(token.text())), + mode: crate::_internal::analysis::facts::RuleEnableModeFact::Origin, + }); + } + AlterTableAction::DisableRule(rule) => { + actions.push(AlterTableActionFact::SetRuleMode { + rule_name: rule + .rule_ref() + .and_then(|rule| rule.ident_token()) + .map(|token| Self::resolve_identifier_token(token.text())), + mode: crate::_internal::analysis::facts::RuleEnableModeFact::Disabled, + }); + } + AlterTableAction::EnableReplicaRule(rule) => { + actions.push(AlterTableActionFact::SetRuleMode { + rule_name: rule + .rule_ref() + .and_then(|rule| rule.ident_token()) + .map(|token| Self::resolve_identifier_token(token.text())), + mode: crate::_internal::analysis::facts::RuleEnableModeFact::Replica, + }); + } + AlterTableAction::EnableAlwaysRule(rule) => { + actions.push(AlterTableActionFact::SetRuleMode { + rule_name: rule + .rule_ref() + .and_then(|rule| rule.ident_token()) + .map(|token| Self::resolve_identifier_token(token.text())), + mode: crate::_internal::analysis::facts::RuleEnableModeFact::Always, + }); + } AlterTableAction::EnableAlwaysTrigger(eat) => { let trigger_name = eat .trigger_ref() @@ -899,13 +1261,11 @@ impl AstVisitor { let txt = action.syntax().text().to_string().to_lowercase(); if txt.contains("set storage") { - let parts: Vec<&str> = txt.split_whitespace().collect(); - if let Some(idx) = parts.iter().position(|&p| p == "column") - && idx + 1 < parts.len() - { - let c_name = Self::resolve_identifier_token(parts[idx + 1]); - actions.push(AlterTableActionFact::SetStorage { column: c_name }); - } + // A storage clause belongs to the typed ALTER COLUMN + // option path. If Squawk does not expose that child, + // preserve conservative handling instead of guessing + // its target or mode from source text. + unsupported_action = true; } else { // Parser-accepted actions that are not represented in // our fact model must take the engine's explicit @@ -922,35 +1282,110 @@ impl AstVisitor { Some(StatementFact::AlterTable { name: table_name, + only: node.table_relation_name()?.only_token().is_some(), actions, }) } + fn extract_like_properties(like: &ast::LikeClause) -> Option { + let mut properties = LikePropertiesFact::default(); + for option in like.like_options() { + let (including, property) = match option { + ast::LikeOption::IncludingProperty(option) => (true, option.table_property()?), + ast::LikeOption::ExcludingProperty(option) => (false, option.table_property()?), + }; + match property { + ast::TableProperty::PropertyAll(_) => { + properties = LikePropertiesFact { + defaults: including, + generated: including, + storage: including, + compression: including, + statistics: including, + constraints: including, + indexes: including, + identity: including, + }; + } + // Comments are not catalog semantics consumed by the analyzer. + ast::TableProperty::PropertyComments(_) => {} + ast::TableProperty::PropertyDefaults(_) => properties.defaults = including, + ast::TableProperty::PropertyGenerated(_) => properties.generated = including, + ast::TableProperty::PropertyStorage(_) => properties.storage = including, + ast::TableProperty::PropertyCompression(_) => properties.compression = including, + ast::TableProperty::PropertyStatistics(_) => properties.statistics = including, + ast::TableProperty::PropertyConstraints(_) => properties.constraints = including, + ast::TableProperty::PropertyIndexes(_) => properties.indexes = including, + ast::TableProperty::PropertyIdentity(_) => properties.identity = including, + } + } + Some(properties) + } + + #[allow(clippy::type_complexity)] fn extract_table_body( args: impl Iterator, - ) -> (Vec, Vec, Vec) { + ) -> Option<( + Vec, + Vec, + Vec, + Vec, + )> { let mut columns = Vec::new(); let mut foreign_keys = Vec::new(); let mut table_constraints = Vec::new(); + let mut like_sources = Vec::new(); for arg in args { match arg { TableArg::Column(col) => { for fk in Self::extract_column_fk_facts(&col) { foreign_keys.push(fk); } + let column_name = col + .name() + .and_then(|name| name.ident_token()) + .or_else(|| { + col.syntax() + .descendants_with_tokens() + .filter_map(|element| element.into_token()) + .find(|token| token.kind() != SyntaxKind::WHITESPACE) + }) + .map(|token| Self::resolve_identifier_token(token.text()))?; + for constraint in Self::column_constraints(&col) { + let ColumnConstraint::CheckConstraint(check) = constraint else { + continue; + }; + let (columns, columns_complete) = check + .expr() + .map(crate::_internal::analysis::expr_visitor::ExprVisitor::convert) + .map(Self::expr_columns_with_completeness) + .unwrap_or((Vec::new(), false)); + table_constraints.push(TableConstraintFact::Check { + constraint_name: check + .constraint_name_clause() + .and_then(|clause| clause.constraint_name()) + .and_then(|name| name.ident_token()) + .map(|token| Self::resolve_identifier_token(token.text())), + name_hint: Some(column_name.clone()), + definition: check.expr()?.syntax().text().to_string(), + columns, + columns_complete, + }); + } if let Some(fact) = Self::extract_column_fact(&col) { columns.push(fact); } } TableArg::LikeClause(like) => { - if let Some(path) = like.syntax().descendants().find_map(Path::cast) - && let Some(_parent) = Self::path_to_qualified_name(&path) - { - // In the future, we may need to track 'Like' clauses as a specific - // mutation fact to properly model schema dependency and inheritance. - // For now, we omit them from the core table creation facts as - // they do not create column definitions in the current AST. - } + let properties = Self::extract_like_properties(&like)?; + let source = like + .relation_name_ref() + .and_then(|name| name.path_ref()) + .and_then(|path| Self::path_ref_to_qualified_name(&path))?; + like_sources.push(LikeSourceFact { + relation: source, + properties, + }); } TableArg::TableConstraint(tc) => { if let Some(fk) = Self::extract_table_fk_fact(&tc) { @@ -962,7 +1397,7 @@ impl AstVisitor { } } } - (columns, foreign_keys, table_constraints) + Some((columns, foreign_keys, table_constraints, like_sources)) } fn column_constraints(col: &Column) -> impl Iterator + '_ { @@ -972,6 +1407,60 @@ impl AstVisitor { }) } + fn identity_sequence_options( + identity: &ast::GeneratedIdentity, + ) -> Option { + let Some(options) = identity.sequence_option_list() else { + return Some(Default::default()); + }; + Self::extract_sequence_options(options.sequence_options()) + } + + fn extract_sequence_options( + options: impl Iterator, + ) -> Option { + let mut result = crate::_internal::analysis::facts::IdentitySequenceOptionsFact::default(); + let integer = |expr: ast::Expr| expr.syntax().text().to_string().trim().parse::().ok(); + for option in options { + match option { + ast::SequenceOption::OptionAsType(option) => { + result.data_type = Some(option.ty()?.syntax().text().to_string()); + } + ast::SequenceOption::OptionCache(option) => { + result.cache_size = Some(integer(option.expr()?)?); + } + ast::SequenceOption::OptionCycle(_) => result.cycle = Some(true), + ast::SequenceOption::OptionNoCycle(_) => result.cycle = Some(false), + ast::SequenceOption::OptionIncrement(option) => { + result.increment = Some(integer(option.expr()?)?); + } + ast::SequenceOption::OptionMaxValue(option) => { + result.max_value = Some(Some(integer(option.expr()?)?)); + } + ast::SequenceOption::OptionNoMaxValue(_) => result.max_value = Some(None), + ast::SequenceOption::OptionMinValue(option) => { + result.min_value = Some(Some(integer(option.expr()?)?)); + } + ast::SequenceOption::OptionNoMinValue(_) => result.min_value = Some(None), + ast::SequenceOption::OptionStart(option) => { + result.start_value = Some(integer(option.expr()?)?); + } + ast::SequenceOption::OptionLogged(_) => result.persistence = Some(true), + ast::SequenceOption::OptionUnlogged(_) => result.persistence = Some(false), + ast::SequenceOption::OptionSequenceName(option) => { + result.sequence_name = option + .sequence() + .and_then(|sequence| sequence.path()) + .and_then(|path| Self::path_to_qualified_name(&path)); + result.sequence_name.as_ref()?; + } + ast::SequenceOption::OptionOwnedBy(_) => {} + ast::SequenceOption::OptionRestart(_) => return None, + } + } + Some(result) + } + fn extract_column_fact(col: &Column) -> Option { let name_token = col.name().and_then(|n| n.ident_token()).or_else(|| { col.syntax() @@ -981,10 +1470,15 @@ impl AstVisitor { })?; let name = Self::resolve_identifier_token(name_token.text()); let ty = col.ty().map(|t| t.syntax().text().to_string()); - let is_identity = Self::column_constraints(col).any(|constraint| { - matches!(constraint, ColumnConstraint::GeneratedConstraint(generated) - if matches!(generated.generated_as(), Some(ast::GeneratedAs::GeneratedIdentity(_)))) + let generated_as = Self::column_constraints(col).find_map(|constraint| { + let ColumnConstraint::GeneratedConstraint(generated) = constraint else { + return None; + }; + generated.generated_as() }); + let is_identity = matches!(generated_as, Some(ast::GeneratedAs::GeneratedIdentity(_))); + let is_stored_generated = + matches!(generated_as, Some(ast::GeneratedAs::GeneratedStored(_))); let not_null = is_identity || Self::column_constraints(col) .any(|c| matches!(c, ColumnConstraint::NotNullConstraint(_))); @@ -1025,11 +1519,38 @@ impl AstVisitor { }); let generation = if Self::is_serial_type(ty.as_deref()) { crate::_internal::analysis::facts::ColumnGeneration::Serial - } else if is_identity { - crate::_internal::analysis::facts::ColumnGeneration::Identity + } else if let Some(ast::GeneratedAs::GeneratedIdentity(identity)) = &generated_as { + match identity.generated_when() { + Some(ast::GeneratedWhen::GeneratedAlways(_)) => { + crate::_internal::analysis::facts::ColumnGeneration::IdentityAlways + } + Some(ast::GeneratedWhen::GeneratedByDefault(_)) => { + crate::_internal::analysis::facts::ColumnGeneration::IdentityByDefault + } + None => return None, + } + } else if is_stored_generated { + let Some(ast::GeneratedAs::GeneratedStored(generated)) = &generated_as else { + unreachable!("generated-expression guard checked above") + }; + match generated.generated_kind() { + Some(ast::GeneratedKind::Stored(_)) => { + crate::_internal::analysis::facts::ColumnGeneration::GeneratedStored + } + Some(ast::GeneratedKind::Virtual(_)) => { + crate::_internal::analysis::facts::ColumnGeneration::GeneratedVirtual + } + None => return None, + } } else { crate::_internal::analysis::facts::ColumnGeneration::Ordinary }; + let identity_sequence = match &generated_as { + Some(ast::GeneratedAs::GeneratedIdentity(identity)) => { + Some(Self::identity_sequence_options(identity)?) + } + _ => None, + }; Some(ColumnFact { name, ty, @@ -1040,6 +1561,19 @@ impl AstVisitor { unique_constraint_name, default, generation, + identity_sequence, + generated_expr_sql: match &generated_as { + Some(ast::GeneratedAs::GeneratedStored(stored)) => { + stored.expr().map(|expr| expr.syntax().text().to_string()) + } + _ => None, + }, + generated_expr: match generated_as { + Some(ast::GeneratedAs::GeneratedStored(stored)) => stored + .expr() + .map(crate::_internal::analysis::expr_visitor::ExprVisitor::convert), + _ => None, + }, }) } @@ -1059,8 +1593,48 @@ impl AstVisitor { opt: AlterColumnOption, ) -> Option { match opt { - AlterColumnOption::SetStorage(_) => { - Some(AlterTableActionFact::SetStorage { column: col_name }) + AlterColumnOption::SetStorage(storage) => Some(AlterTableActionFact::SetStorage { + column: col_name, + mode: storage + .storage_mode()? + .syntax() + .text() + .to_string() + .to_ascii_uppercase(), + }), + AlterColumnOption::SetCompression(compression) => { + let method = compression.compression_method_name()?; + Some(AlterTableActionFact::SetCompression { + column: col_name, + method: method + .ident_token() + .map(|token| Self::resolve_identifier_token(token.text())), + }) + } + AlterColumnOption::SetStatistics(statistics) => { + let target = if statistics.default_token().is_some() { + None + } else { + Some( + statistics + .expr()? + .syntax() + .text() + .to_string() + .parse::() + .ok()?, + ) + }; + Some(AlterTableActionFact::SetStatistics { + column: col_name, + target, + }) + } + AlterColumnOption::DropExpression(drop_expression) => { + Some(AlterTableActionFact::DropExpression { + column: col_name, + if_exists: drop_expression.if_exists().is_some(), + }) } AlterColumnOption::SetNotNull(_) => { Some(AlterTableActionFact::SetNotNull { column: col_name }) @@ -1088,34 +1662,27 @@ impl AstVisitor { }), AlterColumnOption::SetExpression(se) => Some(AlterTableActionFact::SetExpression { column: col_name, + expression_sql: se.expr()?.syntax().text().to_string(), expr: se .expr() .map(crate::_internal::analysis::expr_visitor::ExprVisitor::convert) .unwrap_or(ExprIr::Omitted), }), - AlterColumnOption::SetOptions(so) => Some(AlterTableActionFact::SetOptions { + AlterColumnOption::SetOptions(options) => Some(AlterTableActionFact::SetOptions { column: col_name, - attributes: so - .attribute_list() - .map(|al| { - al.attribute_options() - .map(|ao| crate::_internal::analysis::facts::AttributeFact { - name: ao - .name() - .and_then(|n| n.ident_token()) - .map(|t| t.text().to_string()) - .unwrap_or_default(), - value: ao - .syntax() - .descendants() - .find_map(ast::Literal::cast) - .map(|l| l.syntax().text().to_string()) - .unwrap_or_default(), - }) - .collect() - }) - .unwrap_or_default(), + attributes: Self::extract_attribute_options(options.attribute_list())?, }), + AlterColumnOption::ResetOptions(options) => { + let names = options + .attribute_list()? + .attribute_options() + .map(|option| option.name().map(|name| name.syntax().text().to_string())) + .collect::>>()?; + (!names.is_empty()).then_some(AlterTableActionFact::ResetOptions { + column: col_name, + names, + }) + } AlterColumnOption::Inherit(i) => Some(AlterTableActionFact::Inherit { column: col_name, parent: i @@ -1142,6 +1709,21 @@ impl AstVisitor { } } + fn extract_attribute_options( + list: Option, + ) -> Option> { + let attributes = list? + .attribute_options() + .map(|option| { + Some(crate::_internal::analysis::facts::AttributeFact { + name: option.name()?.syntax().text().to_string(), + value: option.attribute_value()?.syntax().text().to_string(), + }) + }) + .collect::>>()?; + (!attributes.is_empty()).then_some(attributes) + } + fn extract_add_constraint_fact( ac: &squawk_syntax::ast::AddConstraint, ) -> Option { @@ -1204,6 +1786,7 @@ impl AstVisitor { .unwrap_or((Vec::new(), false)); return Some(AlterTableActionFact::AddCheckConstraint { constraint_name, + definition: cc.expr()?.syntax().text().to_string(), columns, columns_complete, not_valid, @@ -1342,6 +1925,8 @@ impl AstVisitor { .and_then(|clause| clause.constraint_name()) .and_then(|name| name.ident_token()) .map(|token| Self::resolve_identifier_token(token.text())), + name_hint: (columns_complete && columns.len() == 1).then(|| columns[0].clone()), + definition: check.expr()?.syntax().text().to_string(), columns, columns_complete, }) @@ -1448,7 +2033,8 @@ impl AstVisitor { fn extract_constraint_column_list_names(cl: ast::ConstraintColumnRefList) -> Vec { cl.column_name_refs() - .filter_map(|column| column.ident_token()) + // Squawk's pg_name keeps legal keyword names as keyword tokens. + .filter_map(|column| column.syntax().first_token()) .map(|token| Self::resolve_identifier_token(token.text())) .collect() } @@ -1838,6 +2424,16 @@ impl AstVisitor { ast::SequenceOption::OptionOwnedBy(owned_by) => Self::extract_owned_by(&owned_by), _ => None, }), + persistence: match node + .persistence() + .map(|value| value.syntax().text().to_string().to_lowercase()) + .as_deref() + { + Some("temporary") | Some("temp") => PersistenceFact::Temporary, + Some("unlogged") => PersistenceFact::Unlogged, + _ => PersistenceFact::Permanent, + }, + options: Self::extract_sequence_options(node.sequence_options())?, }) } @@ -2044,13 +2640,23 @@ impl AstVisitor { }) .collect::>>()?, }, - // Range/composite/base types carry subtype, attribute, function, - // and/or catalog dependency metadata that TypeState does not - // retain. Keep enum creation exact, but route these forms through - // the explicit opaque path. - ast::CreateTypeKind::RangeType(_) - | ast::CreateTypeKind::CompositeType(_) - | ast::CreateTypeKind::BaseType(_) => return None, + ast::CreateTypeKind::CompositeType(composite) => TypeCreationKind::Composite { + fields: composite + .composite_field_list()? + .composite_field_defs() + .map(|field| { + Some(crate::_internal::analysis::facts::CompositeFieldFact { + name: Self::resolve_identifier_token( + field.name()?.ident_token()?.text(), + ), + data_type: field.ty()?.syntax().text().to_string(), + }) + }) + .collect::>>()?, + }, + // Range/base types carry subtype or function metadata that the + // state model does not retain. + ast::CreateTypeKind::RangeType(_) | ast::CreateTypeKind::BaseType(_) => return None, }; Some(StatementFact::CreateType(CreateTypeFact { name, kind })) @@ -2186,6 +2792,7 @@ impl AstVisitor { name, table, function, + row_level: matches!(node.trigger_level(), Some(ast::TriggerLevel::ForEachRow(_))), }) } @@ -2264,6 +2871,9 @@ impl AstVisitor { unique_constraint_name: None, default: None, generation: crate::_internal::analysis::facts::ColumnGeneration::Ordinary, + identity_sequence: None, + generated_expr: None, + generated_expr_sql: None, }) }) .collect(); diff --git a/src/_internal/ast/visitor_tests.rs b/src/_internal/ast/visitor_tests.rs index 5f079b03..e8477201 100644 --- a/src/_internal/ast/visitor_tests.rs +++ b/src/_internal/ast/visitor_tests.rs @@ -34,6 +34,88 @@ mod tests { .and_then(|stmt| AstVisitor::extract(&stmt)) } + #[test] + fn key_constraint_columns_preserve_keyword_and_quoted_names() { + let facts = parse_and_extract( + "ALTER TABLE items ADD CONSTRAINT items_key UNIQUE (value, \"Mixed\");", + ); + assert!( + matches!(&facts[0], StatementFact::AlterTable { actions, .. } + if matches!(actions.as_slice(), [AlterTableActionFact::AddUniqueConstraint { columns, .. }] + if columns == &["value", "Mixed"])) + ); + } + + #[test] + fn generated_prefix_operators_preserve_semantics_and_dependencies() { + for (source, expected) in [("-base", "-"), ("+base", "+"), ("NOT base", "NOT")] { + let facts = parse_and_extract(&format!( + "CREATE TABLE expressions (base integer, computed integer GENERATED ALWAYS AS ({source}) STORED);" + )); + let StatementFact::CreateTable { columns, .. } = &facts[0] else { + panic!("expected table") + }; + let expression = columns[1].generated_expr.as_ref().unwrap(); + assert!( + matches!(expression, ExprIr::UnaryOp { op, expr } if op == expected && matches!(expr.as_ref(), ExprIr::ColumnRef(name) if name == "base")) + ); + assert_eq!( + expression.referenced_columns().unwrap(), + ["base".into()].into_iter().collect() + ); + assert!(!expression.contains_opaque()); + } + let expression = ExprIr::UnaryOp { + op: "-".into(), + expr: Box::new(ExprIr::FunctionCall { + name: "random".into(), + args: vec![], + }), + }; + assert!(expression.is_volatile()); + } + + #[test] + fn expression_column_rename_preserves_functions_literals_and_unicode() { + use crate::_internal::analysis::expr_visitor::ExprVisitor; + assert_eq!( + ExprVisitor::rename_column_source( + "abs(abs) + length('abs')", + "items", + "abs", + "Renamed" + ), + Some("abs(\"Renamed\") + length('abs')".into()) + ); + assert_eq!( + ExprVisitor::rename_column_source("\"café\" + 1", "items", "café", "new\"name"), + Some("\"new\"\"name\" + 1".into()) + ); + assert_eq!( + ExprVisitor::rename_column_source( + "items.value + payload.value", + "items", + "value", + "new_value" + ), + Some("items.new_value + payload.value".into()) + ); + assert!(ExprVisitor::rename_column_source("base +", "items", "base", "renamed").is_none()); + assert_eq!( + ExprVisitor::rename_column_source( + "pg_catalog.abs(pg_catalog) + pg_catalog.abs(items.pg_catalog)", + "items", + "pg_catalog", + "renamed" + ), + Some("pg_catalog.abs(renamed) + pg_catalog.abs(items.renamed)".into()) + ); + assert_eq!( + ExprVisitor::rename_column_source("(a + b), (a * b)", "items", "a", "renamed"), + Some("(renamed + b), (renamed * b)".into()) + ); + } + #[test] fn test_grant_extracts_individual_table_privileges() { let fact = parse_and_extract_statement( @@ -155,7 +237,7 @@ mod tests { assert_eq!(columns.len(), 1); assert_eq!( columns[0].generation, - crate::_internal::analysis::facts::ColumnGeneration::Identity + crate::_internal::analysis::facts::ColumnGeneration::IdentityAlways ); assert!(columns[0].not_null); } @@ -197,6 +279,17 @@ mod tests { )); } + #[test] + fn alter_table_preserves_only_scope() { + for (sql, expected) in [ + ("ALTER TABLE ONLY t RENAME COLUMN a TO b;", true), + ("ALTER TABLE t RENAME COLUMN a TO b;", false), + ] { + assert!(matches!(parse_and_extract_statement(sql), + Some(StatementFact::AlterTable { only, .. }) if only == expected)); + } + } + #[test] fn create_table_preserves_table_check_and_exclusion_constraint_names() { let fact = parse_and_extract_statement( @@ -220,6 +313,8 @@ mod tests { [ TableConstraintFact::Check { constraint_name: Some(check), + name_hint: Some(hint), + definition: _, columns: check_columns, columns_complete: check_complete, }, @@ -229,6 +324,7 @@ mod tests { columns_complete: exclude_complete, }, ] if check == "reservations_id_check" + && hint == "id" && check_columns == &["id".to_string()] && exclude == "reservations_period_excl" && exclude_columns == &["period".to_string()] @@ -299,6 +395,7 @@ mod tests { let StatementFact::CreateTrigger { function: Some(function), + row_level, .. } = fact else { @@ -309,6 +406,19 @@ mod tests { Some("s".into()) ); assert_eq!(function.name.resolve(), "notify_func"); + assert!(row_level); + + let statement_fact = parse_and_extract_statement( + "CREATE TRIGGER trg AFTER INSERT ON s.t1 EXECUTE FUNCTION s.notify_func();", + ) + .expect("statement trigger fact"); + assert!(matches!( + statement_fact, + StatementFact::CreateTrigger { + row_level: false, + .. + } + )); } #[test] @@ -386,6 +496,22 @@ mod tests { ); } + #[test] + fn create_composite_type_preserves_ordered_fields() { + let fact = + parse_and_extract_statement(r#"CREATE TYPE address AS (street text, zip integer);"#) + .expect("composite type fact"); + let StatementFact::CreateType(create_type) = fact else { + panic!("expected create type fact"); + }; + assert!(matches!( + create_type.kind, + crate::_internal::analysis::facts::TypeCreationKind::Composite { fields } + if fields.iter().map(|field| (field.name.as_str(), field.data_type.as_str())).collect::>() + == vec![("street", "text"), ("zip", "integer")] + )); + } + #[test] fn alter_type_rename_value_extracts_qualified_identity_and_labels() { let fact = parse_and_extract_statement( @@ -520,7 +646,7 @@ mod tests { let facts = parse_and_extract_statement(sql); assert!(facts.is_some()); match facts.unwrap() { - StatementFact::AlterTable { name, actions } => { + StatementFact::AlterTable { name, actions, .. } => { assert_eq!(name.name.resolve(), "users"); assert!(!actions.is_empty()); } @@ -534,7 +660,7 @@ mod tests { let facts = parse_and_extract_statement(sql); assert!(facts.is_some()); match facts.unwrap() { - StatementFact::AlterTable { name, actions } => { + StatementFact::AlterTable { name, actions, .. } => { assert_eq!(name.name.resolve(), "users"); assert!(!actions.is_empty()); } @@ -548,7 +674,7 @@ mod tests { let facts = parse_and_extract_statement(sql); assert!(facts.is_some()); match facts.unwrap() { - StatementFact::AlterTable { name, actions } => { + StatementFact::AlterTable { name, actions, .. } => { assert_eq!(name.name.resolve(), "users"); assert!(!actions.is_empty()); } @@ -562,7 +688,7 @@ mod tests { let facts = parse_and_extract_statement(sql); assert!(facts.is_some()); match facts.unwrap() { - StatementFact::AlterTable { name, actions } => { + StatementFact::AlterTable { name, actions, .. } => { assert_eq!(name.name.resolve(), "users"); assert!(!actions.is_empty()); } @@ -576,7 +702,7 @@ mod tests { let facts = parse_and_extract_statement(sql); assert!(facts.is_some()); match facts.unwrap() { - StatementFact::AlterTable { name, actions } => { + StatementFact::AlterTable { name, actions, .. } => { assert_eq!(name.name.resolve(), "users"); assert!(!actions.is_empty()); } @@ -605,7 +731,7 @@ mod tests { let facts = parse_and_extract_statement(sql); assert!(facts.is_some()); match facts.unwrap() { - StatementFact::AlterTable { name, actions } => { + StatementFact::AlterTable { name, actions, .. } => { assert_eq!(name.name.resolve(), "users"); assert!(!actions.is_empty()); } @@ -829,6 +955,23 @@ mod tests { } } + #[test] + fn drop_index_preserves_qualified_quoted_identity() { + let StatementFact::DropIndex { names, .. } = + parse_and_extract_statement("DROP INDEX sm_core.\"IdentityIndex\";") + .expect("drop index fact") + else { + panic!("expected drop index fact") + }; + assert_eq!(names.len(), 1); + assert_eq!( + names[0].schema.as_ref().map(Ident::resolve).as_deref(), + Some("sm_core") + ); + assert_eq!(names[0].name.resolve(), "IdentityIndex"); + assert!(names[0].name.quoted); + } + #[test] fn test_vacuum_full() { let sql = "VACUUM FULL;"; @@ -874,6 +1017,231 @@ mod tests { } } + #[test] + fn lock_and_truncate_preserve_targets_and_options() { + let lock = parse_and_extract_statement( + "LOCK TABLE ONLY public.events IN ACCESS EXCLUSIVE MODE NOWAIT;", + ) + .expect("LOCK TABLE fact"); + match lock { + StatementFact::Lock { + targets, + mode, + nowait, + } => { + assert_eq!(targets.len(), 1); + assert!(targets[0].only); + assert_eq!(targets[0].name.schema.as_ref().unwrap().resolve(), "public"); + assert_eq!(targets[0].name.name.resolve(), "events"); + assert_eq!( + mode, + crate::_internal::analysis::facts::LockModeFact::AccessExclusive + ); + assert!(nowait); + } + other => panic!("expected LOCK TABLE fact, got {other:?}"), + } + + let truncate = parse_and_extract_statement( + "TRUNCATE TABLE ONLY public.events, audit RESTART IDENTITY CASCADE;", + ) + .expect("TRUNCATE fact"); + match truncate { + StatementFact::Truncate { + targets, + cascade, + restart_identity, + } => { + assert_eq!(targets.len(), 2); + assert!(targets[0].only); + assert_eq!(targets[0].name.name.resolve(), "events"); + assert!(!targets[1].only); + assert_eq!(targets[1].name.name.resolve(), "audit"); + assert!(cascade); + assert!(restart_identity); + } + other => panic!("expected TRUNCATE fact, got {other:?}"), + } + } + + #[test] + fn detach_partition_preserves_its_lifecycle_mode() { + let facts = parse_and_extract( + "ALTER TABLE measurements DETACH PARTITION measurements_2025; + ALTER TABLE measurements DETACH PARTITION measurements_2026 CONCURRENTLY; + ALTER TABLE measurements DETACH PARTITION measurements_2027 FINALIZE;", + ); + for (fact, expected_child, expected_mode) in [ + ( + &facts[0], + "measurements_2025", + crate::_internal::analysis::facts::DetachPartitionMode::Immediate, + ), + ( + &facts[1], + "measurements_2026", + crate::_internal::analysis::facts::DetachPartitionMode::Concurrently, + ), + ( + &facts[2], + "measurements_2027", + crate::_internal::analysis::facts::DetachPartitionMode::Finalize, + ), + ] { + assert!(matches!( + fact, + StatementFact::AlterTable { actions, .. } + if matches!(actions.as_slice(), [AlterTableActionFact::DetachPartition { child, mode }] + if child.name.resolve() == expected_child && *mode == expected_mode) + )); + } + } + + #[test] + fn select_into_preserves_relation_name_and_persistence() { + let facts = parse_and_extract( + "SELECT id INTO TEMPORARY TABLE recent_events FROM events; + SELECT id INTO UNLOGGED TABLE snapshot FROM events; + SELECT id AS copied_id, name INTO projected FROM events;", + ); + assert!(matches!( + &facts[0], + StatementFact::CreateTable { + name, + as_select: true, + persistence, + select_source: Some(source), + select_outputs, + select_projection_complete: true, + .. + } + if name.name.resolve() == "recent_events" + && source.name.resolve() == "events" + && matches!(select_outputs.as_slice(), [ + crate::_internal::analysis::facts::SelectOutputFact::Column { + source_name, + output_name, + } + ] if source_name == "id" && output_name == "id") + && *persistence + == crate::_internal::analysis::facts::PersistenceFact::Temporary + )); + assert!(matches!( + &facts[1], + StatementFact::CreateTable { name, as_select: true, persistence, .. } + if name.name.resolve() == "snapshot" + && *persistence + == crate::_internal::analysis::facts::PersistenceFact::Unlogged + )); + assert!( + matches!( + &facts[2], + StatementFact::CreateTable { + select_outputs, + select_projection_complete: true, + .. + } if matches!(select_outputs.as_slice(), [ + crate::_internal::analysis::facts::SelectOutputFact::Column { + source_name, + output_name, + }, + crate::_internal::analysis::facts::SelectOutputFact::Column { + source_name: second_source, + output_name: second_output, + } + ] if source_name == "id" && output_name == "copied_id" + && second_source == "name" && second_output == "name") + ), + "unexpected projected SELECT INTO fact: {:?}", + facts[2] + ); + } + + #[test] + fn create_table_inherits_preserves_all_parent_references() { + let fact = parse_and_extract_statement( + "CREATE TABLE child (local_value integer) INHERITS (public.parent_a, parent_b);", + ) + .expect("CREATE TABLE INHERITS fact"); + assert!(matches!( + fact, + StatementFact::CreateTable { inherits, .. } + if inherits.len() == 2 + && inherits[0].schema.as_ref().is_some_and(|schema| schema.resolve() == "public") + && inherits[0].name.resolve() == "parent_a" + && inherits[1].name.resolve() == "parent_b" + )); + } + + #[test] + fn create_table_like_preserves_supported_property_selection() { + let fact = parse_and_extract_statement("CREATE TABLE copy (LIKE public.source);") + .expect("unadorned LIKE fact"); + assert!(matches!( + fact, + StatementFact::CreateTable { like_sources, .. } + if like_sources.len() == 1 + && like_sources[0].relation.schema.as_ref().is_some_and(|schema| schema.resolve() == "public") + && like_sources[0].relation.name.resolve() == "source" + )); + let with_properties = parse_and_extract_statement( + "CREATE TABLE copy_options (LIKE source INCLUDING DEFAULTS INCLUDING GENERATED INCLUDING STORAGE INCLUDING COMPRESSION INCLUDING STATISTICS);", + ) + .expect("supported LIKE properties"); + assert!(matches!( + with_properties, + StatementFact::CreateTable { like_sources, .. } + if matches!(like_sources.as_slice(), [source] + if source.properties.defaults + && source.properties.generated + && source.properties.storage + && source.properties.compression + && source.properties.statistics) + )); + assert!(matches!( + parse_and_extract_statement( + "CREATE TABLE indexed_like (LIKE source INCLUDING INDEXES);" + ), + Some(StatementFact::CreateTable { like_sources, .. }) + if like_sources[0].properties.indexes + )); + assert!(parse_and_extract_statement( + "CREATE TABLE all_but_objects (LIKE source INCLUDING ALL EXCLUDING INDEXES EXCLUDING CONSTRAINTS EXCLUDING IDENTITY);" + ) + .is_some()); + } + + #[test] + fn temporary_table_on_commit_uses_the_typed_action() { + let fact = parse_and_extract_statement( + "CREATE TEMPORARY TABLE work (id integer) ON COMMIT DELETE ROWS;", + ) + .expect("temporary ON COMMIT fact"); + assert!(matches!( + fact, + StatementFact::CreateTable { + persistence: crate::_internal::analysis::facts::PersistenceFact::Temporary, + on_commit: Some(crate::_internal::analysis::facts::OnCommitFact::DeleteRows), + .. + } + )); + } + + #[test] + fn alter_table_options_preserve_typed_keys_and_values() { + let fact = parse_and_extract_statement( + "ALTER TABLE entries SET (fillfactor = 70, autovacuum_enabled = false);", + ) + .expect("table options fact"); + assert!(matches!( + fact, + StatementFact::AlterTable { actions, .. } + if matches!(actions.as_slice(), [crate::_internal::analysis::facts::AlterTableActionFact::SetTableOptions { attributes }] + if attributes.iter().any(|attribute| attribute.name == "fillfactor" && attribute.value == "70") + && attributes.iter().any(|attribute| attribute.name == "autovacuum_enabled" && attribute.value == "false")) + )); + } + #[test] fn test_begin_transaction() { let sql = "BEGIN;"; @@ -1659,11 +2027,32 @@ mod tests { actions.as_slice(), [AlterTableActionFact::AttachPartition { strategy: Some(strategy), + bound: Some(bound), .. - }] if strategy == "RANGE" + }] if strategy == "RANGE" && bound == "FOR VALUES FROM (1) TO (100)" )); } + #[test] + fn partition_strategy_ignores_comments_and_preserves_bound_identity() { + for (sql_strategy, expected) in [("RANGE", "range"), ("list", "list"), ("\"HASH\"", "HASH")] + { + let sql = format!( + "CREATE TABLE child PARTITION OF parent DEFAULT PARTITION /* key */ BY {sql_strategy} /* bound is separate */ (id);" + ); + let StatementFact::CreateTable { + partition_strategy, + partition_bound, + .. + } = parse_and_extract_statement(&sql).expect("partition fact") + else { + panic!("expected create table") + }; + assert_eq!(partition_strategy.as_deref(), Some(expected)); + assert_eq!(partition_bound.as_deref(), Some("DEFAULT")); + } + } + #[test] fn attach_partition_strategy_comes_from_typed_partition_node() { // The range bound deliberately contains the words "FOR VALUES IN". @@ -2216,7 +2605,7 @@ mod tests { actions.as_slice(), [AlterTableActionFact::AddColumn { name, - generation: crate::_internal::analysis::facts::ColumnGeneration::Identity, + generation: crate::_internal::analysis::facts::ColumnGeneration::IdentityAlways, not_null: true, .. }] if name == "generated_id" @@ -2244,6 +2633,7 @@ mod tests { columns, not_valid: true, columns_complete: true, + definition: _, }] if name == "events_id_positive" && columns == &["generated_id".to_string()] ) @@ -2271,49 +2661,206 @@ mod tests { StatementFact::AlterTable { actions, .. } if matches!( actions.as_slice(), - [AlterTableActionFact::ReplicaIdentity { option }] - if option == "events_identity_idx" + [AlterTableActionFact::ReplicaIdentity { + option: crate::_internal::analysis::facts::ReplicaIdentityFact::UsingIndex(index) + }] + if index.name.resolve() == "events_identity_idx" ) )); } #[test] - fn unsupported_alter_table_actions_are_not_silent_noops() { - let parsed = SourceFile::parse("ALTER TABLE events SET WITHOUT CLUSTER;"); - let statement = parsed.tree().stmts().next().expect("statement"); - - assert!( - AstVisitor::extract(&statement).is_none(), - "parser-accepted but unmodeled ALTER TABLE actions must use the opaque engine path" + fn alter_table_rule_modes_use_typed_names_and_modes() { + let facts = parse_and_extract( + "ALTER TABLE events ENABLE RULE rewrite_rule; + ALTER TABLE events DISABLE RULE rewrite_rule; + ALTER TABLE events ENABLE REPLICA RULE rewrite_rule; + ALTER TABLE events ENABLE ALWAYS RULE rewrite_rule;", ); + let expected = [ + crate::_internal::analysis::facts::RuleEnableModeFact::Origin, + crate::_internal::analysis::facts::RuleEnableModeFact::Disabled, + crate::_internal::analysis::facts::RuleEnableModeFact::Replica, + crate::_internal::analysis::facts::RuleEnableModeFact::Always, + ]; + assert_eq!(facts.len(), expected.len()); + for (fact, expected_mode) in facts.iter().zip(expected) { + assert!(matches!( + fact, + StatementFact::AlterTable { actions, .. } + if matches!(actions.as_slice(), [AlterTableActionFact::SetRuleMode { rule_name: Some(name), mode }] + if name == "rewrite_rule" && *mode == expected_mode) + )); + } } #[test] - fn unsupported_create_table_copy_forms_are_not_silent_noops() { - for sql in [ - "CREATE TABLE copied (LIKE source);", - "CREATE TABLE child () INHERITS (parent);", - "CREATE TABLE typed OF composite_type;", - ] { - let parsed = SourceFile::parse(sql); - let statement = parsed.tree().stmts().next().expect("statement"); - assert!( - AstVisitor::extract(&statement).is_none(), - "unsupported CREATE TABLE form must use the opaque engine path: {sql}" - ); + fn trigger_user_target_is_preserved_without_including_constraint_triggers() { + let facts = parse_and_extract( + "ALTER TABLE events DISABLE TRIGGER USER; + ALTER TABLE events ENABLE TRIGGER USER;", + ); + for fact in facts { + assert!(matches!( + fact, + StatementFact::AlterTable { actions, .. } + if matches!(actions.as_slice(), + [AlterTableActionFact::DisableTrigger { trigger_name: Some(name) }] + | [AlterTableActionFact::EnableTrigger { trigger_name: Some(name) }] + if name == "USER") + )); } } + #[test] + fn typed_column_storage_and_generated_expression_options_preserve_values() { + let facts = parse_and_extract( + "ALTER TABLE events ALTER COLUMN payload SET STORAGE EXTERNAL; + ALTER TABLE events ALTER COLUMN payload SET COMPRESSION lz4; + ALTER TABLE events ALTER COLUMN payload SET COMPRESSION DEFAULT; + ALTER TABLE events ALTER COLUMN payload SET STATISTICS 100; + ALTER TABLE events ALTER COLUMN payload SET (n_distinct = -0.5); + ALTER TABLE events ALTER COLUMN payload RESET (n_distinct); + ALTER TABLE events ALTER COLUMN payload SET EXPRESSION AS (source_id + 1); + ALTER TABLE events ALTER COLUMN payload DROP EXPRESSION IF EXISTS;", + ); + assert!(matches!( + &facts[0], + StatementFact::AlterTable { actions, .. } + if matches!(actions.as_slice(), [AlterTableActionFact::SetStorage { column, mode }] + if column == "payload" && mode == "EXTERNAL") + )); + assert!(matches!( + &facts[1], + StatementFact::AlterTable { actions, .. } + if matches!(actions.as_slice(), [AlterTableActionFact::SetCompression { column, method }] + if column == "payload" && method.as_deref() == Some("lz4")) + )); + assert!(matches!( + &facts[2], + StatementFact::AlterTable { actions, .. } + if matches!(actions.as_slice(), [AlterTableActionFact::SetCompression { method: None, .. }]) + )); + assert!(matches!( + &facts[3], + StatementFact::AlterTable { actions, .. } + if matches!(actions.as_slice(), [AlterTableActionFact::SetStatistics { column, target }] + if column == "payload" && target == &Some(100)) + )); + assert!(matches!( + &facts[4], + StatementFact::AlterTable { actions, .. } + if matches!(actions.as_slice(), [AlterTableActionFact::SetOptions { column, attributes }] + if column == "payload" + && attributes.len() == 1 + && attributes[0].name == "n_distinct" + && attributes[0].value == "-0.5") + )); + assert!(matches!( + &facts[5], + StatementFact::AlterTable { actions, .. } + if matches!(actions.as_slice(), [AlterTableActionFact::ResetOptions { column, names }] + if column == "payload" && names == &vec!["n_distinct".to_string()]) + )); + assert!(matches!( + &facts[6], + StatementFact::AlterTable { actions, .. } + if matches!(actions.as_slice(), [AlterTableActionFact::SetExpression { column, .. }] + if column == "payload") + )); + assert!(matches!( + &facts[7], + StatementFact::AlterTable { actions, .. } + if matches!(actions.as_slice(), [AlterTableActionFact::DropExpression { column, if_exists }] + if column == "payload" && *if_exists) + )); + } + + #[test] + fn typed_column_default_metadata_options_preserve_reset_semantics() { + let facts = parse_and_extract( + "ALTER TABLE events ALTER COLUMN payload SET STORAGE DEFAULT; + ALTER TABLE events ALTER COLUMN payload SET STATISTICS DEFAULT;", + ); + assert!(matches!( + &facts[0], + StatementFact::AlterTable { actions, .. } + if matches!(actions.as_slice(), [AlterTableActionFact::SetStorage { mode, .. }] + if mode == "DEFAULT") + )); + assert!(matches!( + &facts[1], + StatementFact::AlterTable { actions, .. } + if matches!(actions.as_slice(), [AlterTableActionFact::SetStatistics { target: None, .. }]) + )); + } + + #[test] + fn create_table_preserves_stored_generated_column_expression() { + let fact = parse_and_extract_statement( + "CREATE TABLE metrics (value integer, doubled integer GENERATED ALWAYS AS (value * 2) STORED);", + ) + .expect("generated-column CREATE TABLE fact"); + assert!(matches!( + fact, + StatementFact::CreateTable { columns, .. } + if matches!(columns.as_slice(), [_, column] + if column.generation == crate::_internal::analysis::facts::ColumnGeneration::GeneratedStored + && column.generated_expr.as_ref().and_then(ExprIr::referenced_columns) + .is_some_and(|references| references.len() == 1 && references.contains("value"))) + )); + } + + #[test] + fn create_table_preserves_virtual_generated_column_kind() { + let fact = parse_and_extract_statement( + "CREATE TABLE metrics (value integer, doubled integer GENERATED ALWAYS AS (value * 2) VIRTUAL);", + ) + .expect("virtual generated-column CREATE TABLE fact"); + assert!(matches!( + fact, + StatementFact::CreateTable { columns, .. } + if matches!(columns.as_slice(), [_, column] + if column.generation == crate::_internal::analysis::facts::ColumnGeneration::GeneratedVirtual + && column.generated_expr.as_ref().and_then(ExprIr::referenced_columns) + .is_some_and(|references| references.contains("value"))) + )); + } + + #[test] + fn create_table_like_preserves_catalog_object_selections() { + let fact = parse_and_extract_statement( + "CREATE TABLE copied (LIKE source INCLUDING ALL EXCLUDING DEFAULTS);", + ) + .expect("typed LIKE fact"); + assert!(matches!( + fact, + StatementFact::CreateTable { like_sources, .. } + if matches!(like_sources.as_slice(), [source] + if source.properties.constraints + && source.properties.indexes + && source.properties.identity + && source.properties.generated + && source.properties.storage + && source.properties.compression + && source.properties.statistics + && !source.properties.defaults) + )); + } + #[test] fn unsupported_create_table_as_transaction_lifecycle_is_not_silent() { let parsed = SourceFile::parse( "CREATE TEMP TABLE snapshot ON COMMIT DROP AS SELECT id FROM source;", ); let statement = parsed.tree().stmts().next().expect("statement"); - assert!( - AstVisitor::extract(&statement).is_none(), - "CTAS transaction lifecycle must use the opaque engine path" - ); + assert!(matches!( + AstVisitor::extract(&statement), + Some(StatementFact::CreateTable { + as_select: true, + .. + }) + )); let parsed = SourceFile::parse("CREATE TABLE snapshot AS SELECT id FROM source WITH NO DATA;"); @@ -2371,17 +2918,13 @@ mod tests { #[test] fn unsupported_create_non_enum_types_are_not_silent() { - for sql in [ - "CREATE TYPE address AS (street text, city text);", - "CREATE TYPE floatrange AS RANGE (subtype = float8);", - ] { - let parsed = SourceFile::parse(sql); - let statement = parsed.tree().stmts().next().expect("statement"); - assert!( - AstVisitor::extract(&statement).is_none(), - "unmodeled CREATE TYPE semantics must use the opaque engine path: {sql}" - ); - } + let sql = "CREATE TYPE floatrange AS RANGE (subtype = float8);"; + let parsed = SourceFile::parse(sql); + let statement = parsed.tree().stmts().next().expect("statement"); + assert!( + AstVisitor::extract(&statement).is_none(), + "unmodeled CREATE TYPE semantics must use the opaque engine path: {sql}" + ); } #[test] @@ -2539,6 +3082,33 @@ mod tests { ))); } + #[test] + fn create_sequence_preserves_typed_parameters_and_persistence() { + let facts = parse_and_extract( + "CREATE UNLOGGED SEQUENCE public.event_ids AS integer INCREMENT BY -3 \ + MINVALUE -99 MAXVALUE -3 START WITH -3 CACHE 7 CYCLE;", + ); + let StatementFact::CreateSequence { + persistence, + options, + .. + } = &facts[0] + else { + panic!("expected create sequence fact"); + }; + assert_eq!( + *persistence, + crate::_internal::analysis::facts::PersistenceFact::Unlogged + ); + assert_eq!(options.data_type.as_deref(), Some("integer")); + assert_eq!(options.increment, Some(-3)); + assert_eq!(options.min_value, Some(Some(-99))); + assert_eq!(options.max_value, Some(Some(-3))); + assert_eq!(options.start_value, Some(-3)); + assert_eq!(options.cache_size, Some(7)); + assert_eq!(options.cycle, Some(true)); + } + #[test] fn function_options_use_typed_targets_and_decode_literals() { let fact = parse_and_extract_statement( diff --git a/src/_internal/db/cache.rs b/src/_internal/db/cache.rs index 91e0fc76..a71865e2 100644 --- a/src/_internal/db/cache.rs +++ b/src/_internal/db/cache.rs @@ -5,18 +5,18 @@ use crate::_internal::model::relation::{RelationKind, RelationState}; use crate::_internal::model::replication::{PublicationState, SubscriptionState}; use crate::_internal::model::role::{RoleMembershipGrantor, RoleState}; use crate::_internal::model::schema::SchemaState; -use crate::_internal::model::sequence::SequenceState; +use crate::_internal::model::sequence::{SequenceKind, SequenceState}; use crate::_internal::model::trigger::TriggerEnableMode; -use crate::_internal::model::types::TypeState; +use crate::_internal::model::types::{TypeKind, TypeState}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeSet, HashMap, HashSet}; /// Catalog families whose completeness is independently meaningful to the -/// analyzer. The V7 cache records this explicitly instead of treating one +/// analyzer. The V8 cache records this explicitly instead of treating one /// optional schema list as evidence for every object class. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum CatalogFamily { +pub(crate) enum CatalogFamily { Schemas, Relations, Sequences, @@ -33,7 +33,7 @@ pub enum CatalogFamily { } impl CatalogFamily { - pub const fn as_str(self) -> &'static str { + pub(crate) const fn as_str(self) -> &'static str { match self { Self::Schemas => "schemas", Self::Relations => "relations", @@ -55,27 +55,27 @@ impl CatalogFamily { /// Schema boundary for the schema-scoped catalog families in [`CatalogCoverage`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum SchemaCoverage { +pub(crate) enum SchemaCoverage { AllNonSystem, Explicit(BTreeSet), } impl SchemaCoverage { - pub fn from_sync_scope(schemas: Option<&[String]>) -> Self { + pub(crate) fn from_sync_scope(schemas: Option<&[String]>) -> Self { match schemas { Some(schemas) => Self::Explicit(schemas.iter().cloned().collect()), None => Self::AllNonSystem, } } - pub fn covers(&self, schema: &str) -> bool { + pub(crate) fn covers(&self, schema: &str) -> bool { match self { Self::AllNonSystem => true, Self::Explicit(schemas) => schemas.contains(schema), } } - pub fn explicit_schemas(&self) -> Option> { + pub(crate) fn explicit_schemas(&self) -> Option> { match self { Self::AllNonSystem => None, Self::Explicit(schemas) => Some(schemas.iter().cloned().collect()), @@ -87,13 +87,13 @@ impl SchemaCoverage { /// validation. The schema boundary applies to schema-scoped families; role, /// publication, and subscription rows are recorded separately in `families`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CatalogCoverage { +pub(crate) struct CatalogCoverage { pub schema_scope: SchemaCoverage, pub families: BTreeSet, } impl CatalogCoverage { - pub fn from_sync_scope(schemas: Option<&[String]>) -> Self { + pub(crate) fn from_sync_scope(schemas: Option<&[String]>) -> Self { Self { schema_scope: SchemaCoverage::from_sync_scope(schemas), families: [ @@ -116,11 +116,11 @@ impl CatalogCoverage { } } - pub fn has(&self, family: CatalogFamily) -> bool { + pub(crate) fn has(&self, family: CatalogFamily) -> bool { self.families.contains(&family) } - pub fn family_names(&self) -> impl Iterator + '_ { + pub(crate) fn family_names(&self) -> impl Iterator + '_ { self.families.iter().copied().map(CatalogFamily::as_str) } } @@ -129,18 +129,18 @@ impl Default for CatalogCoverage { fn default() -> Self { // Programmatic test baselines retain the historical all-schema // assumption. Production sync always overwrites this with its actual - // requested scope before a V7 cache can be written. + // requested scope before a V8 cache can be written. Self::from_sync_scope(None) } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ForeignKeyCache { +pub(crate) struct ForeignKeyCache { pub constraint_name: String, pub from_table: ObjectId, pub to_table: ObjectId, /// Ordered `pg_constraint.conkey` identities resolved through - /// `pg_attribute`. Empty vectors are invalid for V7 FK records. + /// `pg_attribute`. Empty vectors are invalid for V8 FK records. pub from_columns: Vec, /// Ordered `pg_constraint.confkey` identities resolved through /// `pg_attribute`. Position pairs with `from_columns`. @@ -156,7 +156,7 @@ pub struct ForeignKeyCache { } impl ForeignKeyCache { - pub fn has_complete_operator_evidence(&self) -> bool { + pub(crate) fn has_complete_operator_evidence(&self) -> bool { let count = self.from_columns.len(); count > 0 && self.to_columns.len() == count @@ -175,9 +175,9 @@ impl ForeignKeyCache { /// Ordered key columns for a primary or unique constraint. This is separate /// from `ConstraintState` so the runtime state model remains focused on the -/// mutable constraint lifecycle while Cache V7 can preserve catalog proof. +/// mutable constraint lifecycle while Cache V8 can preserve catalog proof. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ConstraintKeyCache { +pub(crate) struct ConstraintKeyCache { pub table_id: ObjectId, pub constraint_name: String, pub columns: Vec, @@ -188,7 +188,7 @@ pub struct ConstraintKeyCache { /// (currently CHECK and EXCLUDE). An empty vector is authoritative: the /// expression has no relation-column dependency. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ConstraintDependencyCache { +pub(crate) struct ConstraintDependencyCache { pub table_id: ObjectId, pub constraint_name: String, pub columns: Vec, @@ -199,7 +199,7 @@ pub struct ConstraintDependencyCache { /// remove only its own expression edge; source-column drops remain /// conservative until dependent-column CASCADE is modeled. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GeneratedColumnDependencyCache { +pub(crate) struct GeneratedColumnDependencyCache { pub table_id: ObjectId, pub column_name: String, pub depends_on_column: String, @@ -209,14 +209,14 @@ pub struct GeneratedColumnDependencyCache { /// This is distinct from sequence OWNED BY metadata: a default can reference /// a standalone sequence without making that sequence owned by the table. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct DefaultSequenceDependencyCache { +pub(crate) struct DefaultSequenceDependencyCache { pub table_id: ObjectId, pub column_name: String, pub sequence_id: ObjectId, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct IndexCache { +pub(crate) struct IndexCache { pub index_id: ObjectId, pub table_id: ObjectId, pub using_method: String, @@ -235,6 +235,8 @@ pub struct IndexCache { pub has_expression_keys: bool, pub has_predicate: bool, pub is_unique: bool, + /// `pg_index.indimmediate`; deferred unique indexes cannot be a replica identity. + pub is_immediate: bool, pub is_valid: bool, pub is_ready: bool, pub is_live: bool, @@ -244,10 +246,14 @@ pub struct IndexCache { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TriggerCache { +pub(crate) struct TriggerCache { pub trigger_id: ObjectId, pub table_id: ObjectId, pub function_id: ObjectId, + #[serde(default)] + pub row_level: bool, + #[serde(default)] + pub parent_trigger_id: Option, pub enabled_mode: TriggerEnableMode, } @@ -259,7 +265,7 @@ pub struct TriggerCache { /// alongside names made the old record look more authoritative without adding /// a transition consumer. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ViewDependencyCache { +pub(crate) struct ViewDependencyCache { pub dependent: ObjectId, pub referenced: ObjectId, /// `None` means PostgreSQL reported a relation-level dependency. Such a @@ -274,7 +280,7 @@ pub struct ViewDependencyCache { /// direct parent/child direction for partition-cycle and publication scope /// reasoning. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct InheritanceCache { +pub(crate) struct InheritanceCache { pub child: ObjectId, pub parent: ObjectId, pub sequence: i32, @@ -285,7 +291,7 @@ pub struct InheritanceCache { } #[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct CacheMetadata { +pub(crate) struct CacheMetadata { /// Seconds since the Unix epoch when `safe-migrate sync` assembled this /// baseline. `None` represents a cache written before provenance support. pub created_at_unix_secs: Option, @@ -319,7 +325,7 @@ pub struct CacheMetadata { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DbCache { +pub(crate) struct DbCache { pub pg_version_num: Option, pub metadata: CacheMetadata, pub coverage: CatalogCoverage, @@ -337,11 +343,18 @@ pub struct DbCache { pub types: HashMap, pub roles: HashMap, /// Grantor provenance for role memberships, used by membership CASCADE. + /// PostgreSQL bookkeeping allows several records per (member, role) pair, + /// one per grantor, each carrying its own option flags. #[serde(default)] pub role_membership_grantors: Vec, /// True only when the synchronizer queried every membership grantor row. #[serde(default)] pub role_membership_grantors_complete: bool, + /// The cluster's bootstrap superuser (the role PG attacks implicit + /// superuser-issued role grants to, via `BOOTSTRAP_SUPERUSERID`). Absent + /// when a programmatic cache did not record it. + #[serde(default)] + pub bootstrap_superuser: Option, pub schemas: HashMap, pub sequences: HashMap, pub dependencies: Vec, @@ -352,19 +365,19 @@ pub struct DbCache { pub scoped_external_relation_dependencies: Vec, pub scoped_external_type_dependencies: Vec, pub scoped_external_routine_dependencies: Vec, + pub scoped_external_index_dependencies: Vec, pub inheritances: Vec, pub publications: HashMap, pub subscriptions: HashMap, } -pub const CACHE_FORMAT_VERSION: u32 = 7; +pub(crate) const CACHE_FORMAT_VERSION: u32 = 8; -/// Current durable cache header. V7 adds PostgreSQL-selected FK equality -/// operator evidence to the normalized catalog snapshot. -pub const CACHE_V7_MAGIC: &[u8] = b"SMCACHE07"; +/// Current durable cache header. V8 adds typed-table row-type identity. +pub(crate) const CACHE_V8_MAGIC: &[u8] = b"SMCACHE08"; #[derive(Debug, Clone, Serialize, Deserialize)] -pub enum DbCacheVersioned { +pub(crate) enum DbCacheVersioned { // Unit variants reserve the historic bincode discriminants. The reader // rejects non-current headers before decoding, so legacy layouts are not part // of the production model and cannot be converted accidentally. @@ -375,10 +388,11 @@ pub enum DbCacheVersioned { V5(Box), V6(Box), V7(Box), + V8(Box), } impl DbCacheVersioned { - pub fn format_version(&self) -> u32 { + pub(crate) fn format_version(&self) -> u32 { match self { DbCacheVersioned::V1 => 1, DbCacheVersioned::V2 => 2, @@ -387,21 +401,23 @@ impl DbCacheVersioned { DbCacheVersioned::V5(_) => 5, DbCacheVersioned::V6(_) => 6, DbCacheVersioned::V7(_) => 7, + DbCacheVersioned::V8(_) => 8, } } - pub fn into_cache(self) -> Result { + pub(crate) fn into_cache(self) -> Result { match self { DbCacheVersioned::V1 | DbCacheVersioned::V2 | DbCacheVersioned::V3 | DbCacheVersioned::V4 | DbCacheVersioned::V5(_) - | DbCacheVersioned::V6(_) => Err( + | DbCacheVersioned::V6(_) + | DbCacheVersioned::V7(_) => Err( "This cache format is unsupported. Run `safe-migrate sync` to rebuild it." .to_string(), ), - DbCacheVersioned::V7(c) => { + DbCacheVersioned::V8(c) => { c.validate_semantics()?; Ok(*c) } @@ -416,7 +432,7 @@ impl Default for DbCache { } impl DbCache { - pub fn new() -> Self { + pub(crate) fn new() -> Self { Self { pg_version_num: None, metadata: CacheMetadata::default(), @@ -436,23 +452,26 @@ impl DbCache { roles: HashMap::new(), role_membership_grantors: Vec::new(), role_membership_grantors_complete: false, + bootstrap_superuser: None, schemas: HashMap::new(), sequences: HashMap::new(), dependencies: Vec::new(), scoped_external_relation_dependencies: Vec::new(), scoped_external_type_dependencies: Vec::new(), scoped_external_routine_dependencies: Vec::new(), + scoped_external_index_dependencies: Vec::new(), inheritances: Vec::new(), publications: HashMap::new(), subscriptions: HashMap::new(), } } - pub fn insert_baseline(&mut self, id: ObjectId, state: RelationState) { + #[cfg(test)] + pub(crate) fn insert_baseline(&mut self, id: ObjectId, state: RelationState) { self.relations.insert(id, state); } - pub fn baseline_relations(&self) -> impl Iterator { + pub(crate) fn baseline_relations(&self) -> impl Iterator { self.relations.iter() } @@ -490,7 +509,7 @@ impl DbCache { /// Validate cross-record identity, relationship, and catalog-coverage /// invariants before a cache is used as an authoritative baseline. - pub fn validate_semantics(&self) -> Result<(), String> { + pub(crate) fn validate_semantics(&self) -> Result<(), String> { // Cache identities are authoritative catalog names, never resolver // guesses. Reject malformed or inferred IDs at the boundary so every // downstream map lookup has one canonical representation. @@ -523,6 +542,10 @@ impl DbCache { Ok(()) }; + if self.pg_version_num.is_some_and(|version| version < 140_000) { + return Err("Cache V8 was synchronized from an unsupported PostgreSQL version; PostgreSQL 14 or newer is required".to_string()); + } + if let Some(schemas) = &self.metadata.schemas { let mut seen = HashSet::new(); for schema in schemas { @@ -586,7 +609,7 @@ impl DbCache { for family in required_families { if !self.coverage.has(family) { return Err(format!( - "Cache V7 coverage is missing the required '{}' catalog family", + "Cache V8 coverage is missing the required '{}' catalog family", family.as_str(), )); } @@ -622,6 +645,10 @@ impl DbCache { "scoped external routine dependency", &self.scoped_external_routine_dependencies, ), + ( + "scoped external index dependency", + &self.scoped_external_index_dependencies, + ), ] { let mut seen = HashSet::new(); for id in ids { @@ -655,9 +682,10 @@ impl DbCache { .map(|schemas| schemas.iter().cloned().collect::>()); if coverage_scope != metadata_scope { return Err( - "Cache V7 schema coverage disagrees with legacy metadata schema scope".to_string(), + "Cache V8 schema coverage disagrees with legacy metadata schema scope".to_string(), ); } + let mut extended_statistics_ids = HashSet::new(); for (id, relation) in &self.relations { validate_id("relation cache identity", id, true)?; validate_id("relation embedded identity", &relation.id, true)?; @@ -676,6 +704,22 @@ impl DbCache { id )); } + for (label, value) in [ + ("tablespace", relation.tablespace.as_deref()), + ("access method", relation.access_method.as_deref()), + ("cluster index", relation.cluster_index.as_deref()), + ] { + if value.is_some_and(str::is_empty) { + return Err(format!("relation '{}' has an empty {label}", id)); + } + } + if relation + .table_options + .iter() + .any(|(key, value)| key.is_empty() || value.is_empty()) + { + return Err(format!("relation '{}' has a malformed table option", id)); + } let mut column_names = HashSet::new(); for column in &relation.columns { if column.name.is_empty() { @@ -690,6 +734,135 @@ impl DbCache { id, column.name )); } + if column + .options + .iter() + .any(|(key, value)| key.is_empty() || value.is_empty()) + { + return Err(format!( + "relation '{}.{}' has a malformed column option", + id, column.name + )); + } + } + for (statistics_id, statistics) in &relation.extended_statistics { + validate_id("extended statistics identity", statistics_id, true)?; + if statistics_id != &statistics.id + || !extended_statistics_ids.insert(statistics_id.clone()) + { + return Err(format!( + "extended statistics '{}' have a duplicate or inconsistent identity", + statistics_id + )); + } + if statistics.kinds.is_empty() + || statistics + .kinds + .iter() + .any(|kind| !matches!(kind.as_str(), "d" | "f" | "m")) + || (statistics.columns.is_empty() + && statistics.expressions.as_deref().is_none_or(str::is_empty)) + || statistics + .columns + .iter() + .any(|column| !column_names.contains(column.as_str())) + || statistics.target.is_some_and(|target| target < -1) + { + return Err(format!( + "extended statistics '{}' contain malformed catalog metadata", + statistics_id + )); + } + } + if relation.rules.keys().any(String::is_empty) { + return Err(format!("relation '{}' contains an empty rule identity", id)); + } + for (column, provenance) in &relation.column_inheritance { + if !column_names.contains(column.as_str()) + || provenance.parent_count > i32::MAX as u32 + || (provenance.parent_count == 0 && !provenance.is_local) + { + return Err(format!( + "relation '{}' contains invalid inheritance provenance for column '{}'", + id, column + )); + } + } + for column in relation.identity_columns.keys() { + let Some(column_state) = relation.get_column(column) else { + return Err(format!( + "relation '{}' identifies missing column '{}' as an identity column", + id, column + )); + }; + if column_state.generated == Some(true) { + return Err(format!( + "relation '{}.{}' cannot be both identity and generated", + id, column + )); + } + } + for (column, generated) in &relation.generated_columns { + let Some(column_state) = relation.get_column(column) else { + return Err(format!( + "relation '{}' identifies missing column '{}' as generated", + id, column + )); + }; + if relation.identity_columns.contains_key(column) + || column_state.generated == Some(false) + { + return Err(format!( + "relation '{}.{}' has inconsistent generated-column metadata", + id, column + )); + } + if generated.expression.as_deref().is_some_and(str::is_empty) { + return Err(format!( + "relation '{}.{}' has an empty generated expression", + id, column + )); + } + } + for column in &relation.columns { + if column.generated == Some(true) + && !relation.generated_columns.contains_key(&column.name) + { + return Err(format!( + "relation '{}.{}' lacks generated-column metadata", + id, column.name + )); + } + } + if let Some(type_id) = &relation.of_type { + validate_id("typed-table composite type identity", type_id, true)?; + let Some(type_state) = self.types.get(type_id) else { + return Err(format!( + "typed table '{}' references missing composite type '{}'", + id, type_id + )); + }; + let TypeKind::Composite { fields } = &type_state.kind else { + return Err(format!( + "typed table '{}' references non-composite type '{}'", + id, type_id + )); + }; + let matches_layout = relation.columns.len() == fields.len() + && relation.columns.iter().zip(fields).all(|(column, field)| { + column.name == field.name + && column.data_type.as_deref().is_some_and(|data_type| { + data_type + .trim() + .eq_ignore_ascii_case(field.data_type.trim()) + }) + }); + if !matches_layout { + return Err(format!( + "typed table '{}' does not match composite type '{}' column layout", + id, type_id + )); + } } for (grantee, privileges) in &relation.privileges.grants { validate_id("relation privilege grantee", grantee, false)?; @@ -838,6 +1011,27 @@ impl DbCache { id )); } + let (type_min, type_max) = match sequence.parameters.data_type.as_str() { + "smallint" => (i16::MIN as i64, i16::MAX as i64), + "integer" => (i32::MIN as i64, i32::MAX as i64), + "bigint" => (i64::MIN, i64::MAX), + other => { + return Err(format!( + "sequence '{}' has unsupported data type '{}'", + id, other + )); + } + }; + if sequence.parameters.increment == 0 + || sequence.parameters.cache_size < 1 + || sequence.parameters.min_value < type_min + || sequence.parameters.max_value > type_max + || sequence.parameters.min_value >= sequence.parameters.max_value + || !(sequence.parameters.min_value..=sequence.parameters.max_value) + .contains(&sequence.parameters.start_value) + { + return Err(format!("sequence '{}' has invalid sequence parameters", id)); + } } for (name, publication) in &self.publications { if name.is_empty() || publication.name.is_empty() { @@ -1033,6 +1227,33 @@ impl DbCache { id, table_id, column_name )); } + if matches!(sequence.kind, SequenceKind::Identity) + && !relation.identity_columns.contains_key(column_name) + { + return Err(format!( + "identity sequence '{}' owns non-identity column '{}.{}'", + id, table_id, column_name + )); + } + } + } + for relation in self.relations.values() { + for column in relation.identity_columns.keys() { + let owners = self + .sequences + .values() + .filter(|sequence| { + matches!(sequence.kind, SequenceKind::Identity) + && sequence.owned_by.as_ref() + == Some(&(relation.id.clone(), column.clone())) + }) + .count(); + if owners != 1 { + return Err(format!( + "identity column '{}.{}' must have exactly one owned identity sequence", + relation.id, column + )); + } } } @@ -1139,17 +1360,27 @@ impl DbCache { provenance.member, provenance.role )); } - if !membership_grantors.insert((provenance.member.clone(), provenance.role.clone())) { + // PostgreSQL 16+ may record several memberships for the same + // edge, one per grantor. Uniqueness applies to the full tuple, + // not the (member, role) pair. + if !membership_grantors.insert(( + provenance.member.clone(), + provenance.role.clone(), + provenance.grantor.clone(), + )) { return Err(format!( - "duplicate role membership provenance for '{}' -> '{}'", - provenance.member, provenance.role + "duplicate role membership provenance for '{}' -> '{}' by '{}'", + provenance.member, provenance.role, provenance.grantor )); } } if self.role_membership_grantors_complete { for (member_id, role) in &self.roles { for role_id in &role.member_of { - if !membership_grantors.contains(&(member_id.clone(), role_id.clone())) { + if !membership_grantors + .iter() + .any(|(m, r, _)| m == member_id && r == role_id) + { return Err(format!( "complete role membership provenance is missing for '{}' -> '{}'", member_id, role_id @@ -1186,6 +1417,17 @@ impl DbCache { constraint.table_id, constraint.name )); } + if matches!(constraint.kind, ConstraintKind::Check) + && constraint + .definition + .as_deref() + .is_none_or(|definition| definition.trim().is_empty()) + { + return Err(format!( + "CHECK constraint '{}.{}' lacks its expression definition", + constraint.table_id, constraint.name + )); + } if let Some(backing_index) = &constraint.backing_index { if !matches!( constraint.kind, @@ -1360,6 +1602,76 @@ impl DbCache { } } + // These relation fields are consumed by typed ALTER TABLE handling. + // Validate their cross-catalog references at the cache boundary so a + // decoded baseline cannot turn malformed metadata into an exact state. + for relation in self.relations.values() { + if let Some(cluster_index) = &relation.cluster_index { + let index_id = ObjectId::new(&relation.id.schema, cluster_index); + if !self + .indexes + .iter() + .any(|index| index.index_id == index_id && index.table_id == relation.id) + { + return Err(format!( + "relation '{}' clusters on index '{}' that does not belong to it", + relation.id, cluster_index + )); + } + } + let Some(replica_identity) = &relation.replica_identity else { + continue; + }; + let Some(index_name) = replica_identity.strip_prefix("USING INDEX ") else { + if matches!( + replica_identity.as_str(), + "DEFAULT" | "NOTHING" | "FULL" | "USING INDEX" + ) { + continue; + } + return Err(format!( + "relation '{}' has an invalid replica identity '{}'", + relation.id, replica_identity + )); + }; + if index_name.is_empty() { + return Err(format!( + "relation '{}' has an empty replica identity index", + relation.id + )); + } + let index_id = ObjectId::new(&relation.id.schema, index_name); + let Some(index) = self + .indexes + .iter() + .find(|index| index.index_id == index_id && index.table_id == relation.id) + else { + return Err(format!( + "relation '{}' uses missing replica identity index '{}'", + relation.id, index_name + )); + }; + let all_keys_not_null = index.key_columns.iter().all(|column| { + relation + .get_column(column) + .is_some_and(|column| !column.is_nullable) + }); + if !index.is_unique + || index.has_predicate + || index.has_expression_keys + || !index.is_valid + || !index.is_immediate + || !index.is_ready + || !index.is_live + || !all_keys_not_null + { + return Err(format!( + "relation '{}' uses ineligible replica identity index '{}'", + relation.id, index_name + )); + } + } + let mut trigger_ids = HashSet::new(); for trigger in &self.triggers { validate_id("trigger identity", &trigger.trigger_id, true)?; @@ -1383,13 +1695,39 @@ impl DbCache { trigger.trigger_id )); } - if !trigger_ids.insert(trigger.trigger_id.clone()) { + if !trigger_ids.insert((trigger.table_id.clone(), trigger.trigger_id.name.clone())) { return Err(format!( - "trigger '{}' appears more than once", - trigger.trigger_id + "trigger '{}' appears more than once on relation '{}'", + trigger.trigger_id.name, trigger.table_id )); } } + let trigger_keys: HashSet = self + .triggers + .iter() + .map(|trigger| { + ObjectId::new( + &trigger.table_id.schema, + format!("{}\0{}", trigger.table_id.name, trigger.trigger_id.name), + ) + }) + .collect(); + for trigger in &self.triggers { + if let Some(parent_id) = &trigger.parent_trigger_id { + if !trigger.row_level { + return Err(format!( + "statement-level trigger '{}' cannot be a partition clone", + trigger.trigger_id + )); + } + if !trigger_keys.contains(parent_id) { + return Err(format!( + "trigger '{}' references missing parent trigger '{}'", + trigger.trigger_id, parent_id + )); + } + } + } let mut foreign_key_ids = HashSet::new(); for foreign_key in &self.foreign_keys { @@ -1520,6 +1858,7 @@ impl DbCache { } let mut inheritance_pairs = HashSet::new(); + let mut pending_parents = HashSet::new(); for inheritance in &self.inheritances { validate_id("inheritance child identity", &inheritance.child, true)?; validate_id("inheritance parent identity", &inheritance.parent, true)?; @@ -1530,10 +1869,23 @@ impl DbCache { )); } if inheritance.detach_pending { - return Err(format!( - "inheritance '{} -> {}' is being detached; synchronize after the detach completes", - inheritance.child, inheritance.parent - )); + if !inheritance.is_partition + || self + .relations + .get(&inheritance.parent) + .is_some_and(|parent| parent.partition_type.is_none()) + || self + .relations + .get(&inheritance.child) + .is_some_and(|child| child.partition_bound.is_none()) + { + return Err( + "pending detach requires a bounded partition and partitioned parent".into(), + ); + } + if !pending_parents.insert(inheritance.parent.clone()) { + return Err("a partitioned parent cannot have multiple pending detaches".into()); + } } let omitted_schema = |schema: &str| { self.metadata @@ -1794,7 +2146,7 @@ impl DbCache { /// Return this cache only when all semantic invariants pass validation. /// This is the supported constructor for library callers that build a /// cache without going through the on-disk decoder. - pub fn validated(self) -> Result { + pub(crate) fn validated(self) -> Result { self.validate_semantics()?; Ok(self) } @@ -1825,6 +2177,11 @@ mod tests { avg_width: Some(4), default_expr_text: None, type_modifier: None, + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, }) .collect(); relation @@ -1859,10 +2216,49 @@ mod tests { } #[test] - fn current_cache_format_is_v7() { - assert_eq!(CACHE_FORMAT_VERSION, 7); - assert_eq!(DbCacheVersioned::V7(Box::default()).format_version(), 7); - assert_eq!(CACHE_V7_MAGIC, b"SMCACHE07"); + fn current_cache_format_is_v8() { + assert_eq!(CACHE_FORMAT_VERSION, 8); + assert_eq!(DbCacheVersioned::V8(Box::default()).format_version(), 8); + assert_eq!(CACHE_V8_MAGIC, b"SMCACHE08"); + } + + #[test] + fn current_cache_rejects_an_unsupported_postgresql_version() { + let mut cache = DbCache::new(); + cache.pg_version_num = Some(130_000); + + let error = cache.validate_semantics().unwrap_err(); + assert!(error.contains("PostgreSQL 14 or newer")); + } + + #[test] + fn current_cache_rejects_malformed_relation_and_column_options() { + let id = ObjectId::new("public", "entries"); + let mut cache = DbCache::new(); + let mut relation = table(id.clone(), &["id"]); + relation + .table_options + .insert("".to_string(), "1".to_string()); + cache.insert_baseline(id.clone(), relation); + assert!( + cache + .validate_semantics() + .unwrap_err() + .contains("malformed table option") + ); + + let mut cache = DbCache::new(); + let mut relation = table(id.clone(), &["id"]); + relation.columns[0] + .options + .insert("n_distinct".to_string(), "".to_string()); + cache.insert_baseline(id, relation); + assert!( + cache + .validate_semantics() + .unwrap_err() + .contains("malformed column option") + ); } #[test] @@ -1891,7 +2287,7 @@ mod tests { }, ); - let error = DbCacheVersioned::V7(Box::new(cache)) + let error = DbCacheVersioned::V8(Box::new(cache)) .into_cache() .unwrap_err(); assert!(error.contains("schema cache key 'app'")); @@ -1902,12 +2298,48 @@ mod tests { let mut cache = DbCache::new(); cache.metadata.schemas = Some(vec!["app".to_string()]); - let error = DbCacheVersioned::V7(Box::new(cache)) + let error = DbCacheVersioned::V8(Box::new(cache)) .into_cache() .unwrap_err(); assert!(error.contains("schema coverage disagrees")); } + #[test] + fn current_cache_rejects_typed_table_with_missing_or_mismatched_type() { + let table_id = ObjectId::new("public", "addresses"); + let type_id = ObjectId::new("public", "address"); + let mut cache = DbCache::new(); + let mut relation = table(table_id.clone(), &["zip"]); + relation.of_type = Some(type_id.clone()); + cache.insert_baseline(table_id.clone(), relation.clone()); + assert!( + cache + .validate_semantics() + .unwrap_err() + .contains("references missing composite type") + ); + + cache.types.insert( + type_id, + TypeState { + id: ObjectId::new("public", "address"), + generation: 0, + kind: TypeKind::Composite { + fields: vec![crate::_internal::model::types::CompositeFieldState { + name: "street".to_string(), + data_type: "text".to_string(), + }], + }, + }, + ); + assert!( + cache + .validate_semantics() + .unwrap_err() + .contains("does not match composite type") + ); + } + #[test] fn current_cache_rejects_invalid_foreign_key_column_identity() { let child = ObjectId::new("public", "child"); @@ -1920,6 +2352,7 @@ mod tests { name: "child_parent_id_fkey".to_string(), kind: crate::_internal::model::constraint::ConstraintKind::ForeignKey, validated: true, + definition: None, backing_index: None, }); cache.foreign_keys.push(ForeignKeyCache { @@ -1949,6 +2382,7 @@ mod tests { name: "child_parent_fkey".to_string(), kind: ConstraintKind::ForeignKey, validated: true, + definition: None, backing_index: None, }); cache.foreign_keys.push(ForeignKeyCache { @@ -1978,6 +2412,7 @@ mod tests { name: "child_parent_fkey".into(), kind: ConstraintKind::ForeignKey, validated: true, + definition: None, backing_index: None, }); cache.foreign_keys.push(ForeignKeyCache { @@ -2005,6 +2440,7 @@ mod tests { name: "parent_pkey".to_string(), kind: ConstraintKind::PrimaryKey, validated: true, + definition: None, backing_index: None, }); cache.constraint_keys.push(ConstraintKeyCache { @@ -2034,6 +2470,7 @@ mod tests { has_expression_keys: false, has_predicate: false, is_unique: false, + is_immediate: true, is_valid: true, is_ready: true, is_live: true, @@ -2046,6 +2483,7 @@ mod tests { name: "ranges_excl_constraint".to_string(), kind: ConstraintKind::Exclusion, validated: true, + definition: None, backing_index: Some(index_id), }); @@ -2230,6 +2668,33 @@ mod tests { assert!(error.contains("cluster role namespace")); } + #[test] + fn current_cache_rejects_invalid_column_inheritance_provenance() { + for (column, parent_count, is_local) in [ + ("missing", 1, false), + ("id", 0, false), + ("id", u32::MAX, true), + ] { + let id = ObjectId::new("public", "entries"); + let mut relation = table(id.clone(), &["id"]); + relation.column_inheritance.insert( + column.into(), + crate::_internal::model::relation::ColumnInheritance { + parent_count, + is_local, + }, + ); + let mut cache = DbCache::new(); + cache.insert_baseline(id, relation); + assert!( + cache + .validate_semantics() + .unwrap_err() + .contains("inheritance provenance") + ); + } + } + #[test] fn current_cache_rejects_ambiguous_relation_columns() { let table_id = ObjectId::new("public", "entries"); @@ -2360,7 +2825,7 @@ mod tests { }, ); - let error = DbCacheVersioned::V7(Box::new(cache)) + let error = DbCacheVersioned::V8(Box::new(cache)) .into_cache() .unwrap_err(); assert!(error.contains("empty or duplicate table column")); @@ -2396,7 +2861,7 @@ mod tests { }, ); - let error = DbCacheVersioned::V7(Box::new(cache)) + let error = DbCacheVersioned::V8(Box::new(cache)) .into_cache() .unwrap_err(); assert!(error.contains("owner 'missing_owner' is absent")); @@ -2487,6 +2952,7 @@ mod tests { name: "entries_key".to_string(), kind: ConstraintKind::Unique, validated: true, + definition: None, backing_index: None, }); cache.constraint_keys.push(ConstraintKeyCache { @@ -2592,6 +3058,7 @@ mod tests { name: "entries_check".to_string(), kind: crate::_internal::model::constraint::ConstraintKind::Check, validated: true, + definition: Some("id > 0".to_string()), backing_index: None, }); cache @@ -2679,6 +3146,7 @@ mod tests { has_expression_keys: false, has_predicate: false, is_unique: false, + is_immediate: true, is_valid: true, is_ready: true, is_live: true, @@ -2687,7 +3155,7 @@ mod tests { has_default_collations: true, }); - let error = DbCacheVersioned::V7(Box::new(cache)) + let error = DbCacheVersioned::V8(Box::new(cache)) .into_cache() .unwrap_err(); assert!(error.contains("references missing relation 'public.items'")); @@ -2709,6 +3177,7 @@ mod tests { has_expression_keys: false, has_predicate: false, is_unique: false, + is_immediate: true, is_valid: true, is_ready: true, is_live: true, @@ -2717,7 +3186,7 @@ mod tests { has_default_collations: true, }); - let error = DbCacheVersioned::V7(Box::new(cache)) + let error = DbCacheVersioned::V8(Box::new(cache)) .into_cache() .unwrap_err(); assert!(error.contains("must be in the same schema as indexed relation")); @@ -2741,6 +3210,7 @@ mod tests { has_expression_keys: false, has_predicate: false, is_unique: false, + is_immediate: true, is_valid: true, is_ready: true, is_live: true, @@ -2749,7 +3219,7 @@ mod tests { has_default_collations: true, }); - let error = DbCacheVersioned::V7(Box::new(cache)) + let error = DbCacheVersioned::V8(Box::new(cache)) .into_cache() .unwrap_err(); assert!(error.contains("collides with another relation-namespace object")); @@ -2769,11 +3239,12 @@ mod tests { owner: ObjectId::new("", "postgres"), owned_by: None, kind: crate::_internal::model::sequence::SequenceKind::Owned, + parameters: Default::default(), generation: 0, }, ); - let error = DbCacheVersioned::V7(Box::new(cache)) + let error = DbCacheVersioned::V8(Box::new(cache)) .into_cache() .unwrap_err(); assert!(error.contains("collides with another relation-namespace object")); @@ -2788,10 +3259,12 @@ mod tests { trigger_id: ObjectId::new("other", "items_trigger"), table_id, function_id: ObjectId::new("public", "items_trigger_fn()"), + row_level: true, + parent_trigger_id: None, enabled_mode: TriggerEnableMode::Origin, }); - let error = DbCacheVersioned::V7(Box::new(cache)) + let error = DbCacheVersioned::V8(Box::new(cache)) .into_cache() .unwrap_err(); assert!(error.contains("must be in the same schema as trigger table")); @@ -2813,6 +3286,7 @@ mod tests { has_expression_keys: false, has_predicate: false, is_unique: false, + is_immediate: true, is_valid: true, is_ready: true, is_live: true, @@ -2822,7 +3296,7 @@ mod tests { }); assert!( - DbCacheVersioned::V7(Box::new(cache)) + DbCacheVersioned::V8(Box::new(cache)) .into_cache() .unwrap_err() .contains("missing complete dependency-column evidence") @@ -2845,16 +3319,38 @@ mod tests { detach_pending: false, }); assert!( - DbCacheVersioned::V7(Box::new(cache.clone())) + DbCacheVersioned::V8(Box::new(cache.clone())) .into_cache() .is_ok() ); cache.inheritances[0].detach_pending = true; - let error = DbCacheVersioned::V7(Box::new(cache)) + let error = DbCacheVersioned::V8(Box::new(cache)) .into_cache() .unwrap_err(); - assert!(error.contains("being detached")); + assert!(error.contains("pending detach requires")); + } + + #[test] + fn current_cache_accepts_pending_partition_detach() { + let parent = ObjectId::new("public", "parent"); + let child = ObjectId::new("public", "child"); + let mut cache = DbCache::new(); + cache.search_path.clear(); + let mut parent_relation = table(parent.clone(), &["id"]); + parent_relation.partition_type = Some("RANGE".into()); + let mut child_relation = table(child.clone(), &["id"]); + child_relation.partition_bound = Some("FOR VALUES FROM (0) TO (10)".into()); + cache.insert_baseline(parent.clone(), parent_relation); + cache.insert_baseline(child.clone(), child_relation); + cache.inheritances.push(InheritanceCache { + child, + parent, + sequence: 1, + is_partition: true, + detach_pending: true, + }); + assert!(DbCacheVersioned::V8(Box::new(cache)).into_cache().is_ok()); } #[test] @@ -2884,6 +3380,7 @@ mod tests { owner: ObjectId::new("", "postgres"), owned_by: Some((ObjectId::new("public", "items"), "id".to_string())), kind: crate::_internal::model::sequence::SequenceKind::Owned, + parameters: Default::default(), generation: 0, }, ); @@ -2904,6 +3401,7 @@ mod tests { owner: ObjectId::new("", "postgres"), owned_by: Some((ObjectId::new("public", "items"), "id".to_string())), kind: crate::_internal::model::sequence::SequenceKind::Owned, + parameters: Default::default(), generation: 0, }, ); diff --git a/src/_internal/db/cache_file.rs b/src/_internal/db/cache_file.rs index d0ce8b30..75e13b3d 100644 --- a/src/_internal/db/cache_file.rs +++ b/src/_internal/db/cache_file.rs @@ -6,14 +6,15 @@ use chacha20poly1305::{ use std::fs::File; use std::io::Read; use std::path::Path; +use zeroize::{Zeroize, Zeroizing}; -pub const CACHE_KEY_ENV: &str = "SAFE_MIGRATE_CACHE_KEY"; -pub const MAX_CACHE_FILE_BYTES: u64 = 64 * 1024 * 1024; -pub const MAX_CACHE_DECODE_BYTES: usize = 256 * 1024 * 1024; +pub(crate) const CACHE_KEY_ENV: &str = "SAFE_MIGRATE_CACHE_KEY"; +pub(crate) const MAX_CACHE_FILE_BYTES: u64 = 64 * 1024 * 1024; +pub(crate) const MAX_CACHE_DECODE_BYTES: usize = 256 * 1024 * 1024; const ENCRYPTED_CACHE_MAGIC: &[u8] = b"SMENC001"; const NONCE_LENGTH: usize = 24; -pub fn read_cache_bytes(cache_path: &Path) -> Result> { +pub(crate) fn read_cache_bytes(cache_path: &Path) -> Result> { read_cache_bytes_with_limit(cache_path, MAX_CACHE_FILE_BYTES) } @@ -39,23 +40,39 @@ fn read_cache_bytes_with_limit(cache_path: &Path, max_bytes: u64) -> Result bool { +pub(crate) fn is_encrypted_cache_bytes(cache_bytes: &[u8]) -> bool { cache_bytes.starts_with(ENCRYPTED_CACHE_MAGIC) } /// Encrypts an encoded cache when cache encryption is enabled. The on-disk /// envelope includes only a format marker and random nonce; the authenticated /// ciphertext contains all cache metadata. -pub fn protect_cache_bytes(cache_bytes: Vec, encryption_enabled: bool) -> Result> { +pub(crate) fn protect_cache_bytes( + cache_bytes: Vec, + encryption_enabled: bool, +) -> Result> { if !encryption_enabled { return Ok(cache_bytes); } let cipher = cipher_from_environment()?; + encrypt_cache_bytes(cache_bytes, &cipher) +} + +pub(crate) fn protect_cache_bytes_with_key( + cache_bytes: Vec, + key: &[u8; 32], +) -> Result> { + let cipher = XChaCha20Poly1305::new_from_slice(key) + .map_err(|_| anyhow!("Cache encryption key must contain exactly 32 bytes"))?; + encrypt_cache_bytes(cache_bytes, &cipher) +} + +fn encrypt_cache_bytes(mut cache_bytes: Vec, cipher: &XChaCha20Poly1305) -> Result> { let nonce = XNonce::generate(); - let ciphertext = cipher - .encrypt(&nonce, cache_bytes.as_ref()) - .map_err(|_| anyhow!("Failed to encrypt cache payload"))?; + let encrypted = cipher.encrypt(&nonce, cache_bytes.as_ref()); + cache_bytes.zeroize(); + let ciphertext = encrypted.map_err(|_| anyhow!("Failed to encrypt cache payload"))?; let mut envelope = Vec::with_capacity(ENCRYPTED_CACHE_MAGIC.len() + NONCE_LENGTH + ciphertext.len()); @@ -75,7 +92,10 @@ pub(crate) fn validate_cache_encryption_configuration(encryption_enabled: bool) /// Returns plaintext encoded cache bytes. Encrypted files require both an /// enabled configuration and the environment-only key; authentication failures /// intentionally do not distinguish a wrong key from modified ciphertext. -pub fn unprotect_cache_bytes(cache_bytes: Vec, encryption_enabled: bool) -> Result> { +pub(crate) fn unprotect_cache_bytes( + cache_bytes: Vec, + encryption_enabled: bool, +) -> Result> { if !is_encrypted_cache_bytes(&cache_bytes) { if encryption_enabled { bail!( @@ -92,12 +112,28 @@ pub fn unprotect_cache_bytes(cache_bytes: Vec, encryption_enabled: bool) -> ); } + let cipher = cipher_from_environment()?; + decrypt_cache_bytes(cache_bytes, &cipher) +} + +pub(crate) fn unprotect_cache_bytes_with_key( + cache_bytes: Vec, + key: &[u8; 32], +) -> Result> { + if !is_encrypted_cache_bytes(&cache_bytes) { + bail!("Cache file is not encrypted, but an encryption key was provided"); + } + let cipher = XChaCha20Poly1305::new_from_slice(key) + .map_err(|_| anyhow!("Cache encryption key must contain exactly 32 bytes"))?; + decrypt_cache_bytes(cache_bytes, &cipher) +} + +fn decrypt_cache_bytes(cache_bytes: Vec, cipher: &XChaCha20Poly1305) -> Result> { let nonce_end = ENCRYPTED_CACHE_MAGIC.len() + NONCE_LENGTH; if cache_bytes.len() <= nonce_end { bail!("Encrypted cache file is truncated"); } - let cipher = cipher_from_environment()?; let nonce = XNonce::try_from(&cache_bytes[ENCRYPTED_CACHE_MAGIC.len()..nonce_end]) .map_err(|_| anyhow!("Encrypted cache has an invalid nonce"))?; cipher @@ -106,33 +142,30 @@ pub fn unprotect_cache_bytes(cache_bytes: Vec, encryption_enabled: bool) -> } fn cipher_from_environment() -> Result { - let raw_key = std::env::var(CACHE_KEY_ENV).with_context(|| { + let raw_key = Zeroizing::new(std::env::var(CACHE_KEY_ENV).with_context(|| { format!( "{} must contain a 64-character hexadecimal key when cache_encryption is enabled", CACHE_KEY_ENV ) - })?; - let key = decode_hex_key(raw_key.trim())?; - XChaCha20Poly1305::new_from_slice(&key) + })?); + let key = Zeroizing::new(decode_hex_key(raw_key.trim())?); + XChaCha20Poly1305::new_from_slice(key.as_ref()) .map_err(|_| anyhow!("{} must contain exactly 32 key bytes", CACHE_KEY_ENV)) } -fn decode_hex_key(input: &str) -> Result<[u8; 32]> { +pub(crate) fn decode_hex_key(input: &str) -> Result<[u8; 32]> { if input.len() != 64 { - bail!( - "{} must be exactly 64 hexadecimal characters", - CACHE_KEY_ENV - ); + bail!("Cache encryption key must be exactly 64 hexadecimal characters"); } - let mut key = [0u8; 32]; + let mut key = Zeroizing::new([0u8; 32]); for (index, byte) in key.iter_mut().enumerate() { let offset = index * 2; let high = hex_nibble(input.as_bytes()[offset])?; let low = hex_nibble(input.as_bytes()[offset + 1])?; *byte = (high << 4) | low; } - Ok(key) + Ok(*key) } fn hex_nibble(byte: u8) -> Result { @@ -140,7 +173,7 @@ fn hex_nibble(byte: u8) -> Result { b'0'..=b'9' => Ok(byte - b'0'), b'a'..=b'f' => Ok(byte - b'a' + 10), b'A'..=b'F' => Ok(byte - b'A' + 10), - _ => bail!("{} must contain only hexadecimal characters", CACHE_KEY_ENV), + _ => bail!("Cache encryption key must contain only hexadecimal characters"), } } @@ -190,6 +223,18 @@ mod tests { }); } + #[test] + fn explicit_key_round_trip_does_not_require_process_environment() { + let key = decode_hex_key(&"42".repeat(32)).unwrap(); + let plaintext = b"cache payload".to_vec(); + let encrypted = protect_cache_bytes_with_key(plaintext.clone(), &key).unwrap(); + + assert_eq!( + unprotect_cache_bytes_with_key(encrypted, &key).unwrap(), + plaintext + ); + } + #[test] fn encryption_required_rejects_plaintext_cache_bytes() { let error = unprotect_cache_bytes(b"plaintext cache".to_vec(), true) diff --git a/src/_internal/db/mod.rs b/src/_internal/db/mod.rs index a109c757..b70fc4e8 100644 --- a/src/_internal/db/mod.rs +++ b/src/_internal/db/mod.rs @@ -1,2 +1,2 @@ -pub mod cache; -pub mod cache_file; +pub(crate) mod cache; +pub(crate) mod cache_file; diff --git a/src/_internal/engine/config.rs b/src/_internal/engine/config.rs deleted file mode 100644 index 2343baf9..00000000 --- a/src/_internal/engine/config.rs +++ /dev/null @@ -1,262 +0,0 @@ -use anyhow::{Result, bail}; -use serde::{Deserialize, Serialize}; -use std::collections::{BTreeSet, HashMap}; -use std::fs; -use std::path::Path; - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(deny_unknown_fields)] -pub struct RuleConfig { - pub disabled: Option, - pub tier1_threshold_rows: Option, - pub tier2_threshold_rows: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct Config { - pub tier1_threshold_rows: u64, - pub tier2_threshold_rows: u64, - pub stale_stats_days: u64, - pub toast_width_threshold_bytes: i32, - pub default_rows: u64, // Fallback for offline/unanalyzed tables - pub auto_sync: bool, - pub cache_encryption: bool, - pub rules: HashMap, // Per-rule configuration - pub assume_pg_version: u32, - pub disabled_rules: Vec, - pub schemas: Option>, -} - -impl Default for Config { - fn default() -> Self { - Self { - tier1_threshold_rows: 100_000, - tier2_threshold_rows: 10_000, - stale_stats_days: 7, - toast_width_threshold_bytes: 2048, - default_rows: 10_000, - auto_sync: false, - cache_encryption: false, - assume_pg_version: 100000, - disabled_rules: Vec::new(), - rules: HashMap::new(), - schemas: None, - } - } -} - -impl Config { - pub fn load_from_file(path: &Path) -> Result { - match fs::read_to_string(path) { - Ok(contents) => Self::parse_file(path, &contents), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), - Err(error) => Err(error.into()), - } - } - - pub fn load_required_from_file(path: &Path) -> Result { - let contents = fs::read_to_string(path)?; - Self::parse_file(path, &contents) - } - - fn parse_file(path: &Path, contents: &str) -> Result { - toml::from_str(contents).map_err(|error| { - anyhow::anyhow!("Failed to parse config at {}: {}", path.display(), error) - }) - } - - /// Checks if a rule is completely disabled - pub fn is_rule_disabled(&self, rule_id: &str) -> bool { - if self.disabled_rules.contains(&rule_id.to_string()) { - return true; - } - self.rules - .get(rule_id) - .and_then(|r| r.disabled) - .unwrap_or(false) - } - - /// Gets the Tier 1 threshold for a specific rule, falling back to the global default - pub fn rule_tier1_threshold(&self, rule_id: &str) -> u64 { - self.rules - .get(rule_id) - .and_then(|r| r.tier1_threshold_rows) - .unwrap_or(self.tier1_threshold_rows) - } - - /// Gets the Tier 2 threshold for a specific rule, falling back to the global default - pub fn rule_tier2_threshold(&self, rule_id: &str) -> u64 { - self.rules - .get(rule_id) - .and_then(|r| r.tier2_threshold_rows) - .unwrap_or(self.tier2_threshold_rows) - } - - /// Returns the schema filter for a direct sync. An explicit CLI value wins - /// over the team-wide configuration default. - pub fn sync_schemas<'a>( - &'a self, - cli_schemas: Option<&'a [String]>, - ) -> Result> { - let schemas = cli_schemas.or(self.schemas.as_deref()); - if schemas.is_some_and(|schemas| { - schemas.is_empty() || schemas.iter().any(|schema| schema.trim().is_empty()) - }) { - bail!("schemas must not be empty and no schema name may be blank"); - } - Ok(schemas) - } - - /// Validates configured rule IDs against the primary rule registry. - pub fn validate_rule_ids<'a>( - &self, - primary_rule_ids: impl IntoIterator, - ) -> Result<(), anyhow::Error> { - let valid: BTreeSet = primary_rule_ids.into_iter().map(str::to_owned).collect(); - let unknown: BTreeSet<&str> = self - .rules - .keys() - .map(String::as_str) - .chain(self.disabled_rules.iter().map(String::as_str)) - .filter(|rule_id| !valid.contains(*rule_id)) - .collect(); - - if unknown.is_empty() { - return Ok(()); - } - - Err(anyhow::anyhow!( - "Unknown primary rule ID(s): {}. Valid primary rule IDs: {}", - unknown.into_iter().collect::>().join(", "), - valid.into_iter().collect::>().join(", ") - )) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - use tempfile::NamedTempFile; - - #[test] - fn test_granular_rule_config() { - let mut file = NamedTempFile::new().expect("Failed to create temp file"); - writeln!( - file, - r#" - tier1_threshold_rows = 500000 - - [rules.blocking-constraint] - tier1_threshold_rows = 5000 - - [rules.missing-idempotency] - disabled = true - "# - ) - .expect("Failed to write temp config"); - - let config = Config::load_from_file(file.path()).expect("Failed to load valid config"); - - assert_eq!(config.tier1_threshold_rows, 500_000); - - assert_eq!(config.rule_tier1_threshold("blocking-constraint"), 5000); - assert_eq!(config.rule_tier1_threshold("unspecified-rule"), 500_000); - assert!(!config.auto_sync); - assert!(!config.cache_encryption); - - assert!(config.is_rule_disabled("missing-idempotency")); - assert!(!config.is_rule_disabled("blocking-constraint")); - } - - #[test] - fn test_direct_sync_prefers_cli_schema_filter_over_configured_default() { - let config = Config { - schemas: Some(vec!["public".to_string()]), - ..Config::default() - }; - let cli_schemas = vec!["auth".to_string()]; - - assert_eq!( - config.sync_schemas(None).unwrap(), - Some(["public".to_string()].as_slice()) - ); - assert_eq!( - config.sync_schemas(Some(&cli_schemas)).unwrap(), - Some(["auth".to_string()].as_slice()) - ); - } - - #[test] - fn test_direct_sync_rejects_empty_schema_scope() { - let config = Config::default(); - assert!(config.sync_schemas(Some(&[])).is_err()); - assert!(config.sync_schemas(Some(&["".to_string()])).is_err()); - } - - #[test] - fn missing_optional_config_uses_defaults_but_required_config_fails() { - let directory = tempfile::tempdir().unwrap(); - let missing = directory.path().join("missing.toml"); - - assert_eq!( - Config::load_from_file(&missing) - .unwrap() - .tier1_threshold_rows, - Config::default().tier1_threshold_rows - ); - assert!(Config::load_required_from_file(&missing).is_err()); - } - - #[test] - fn rule_id_validation_rejects_unknown_rule_keys_and_disabled_ids() { - let mut config = Config::default(); - config - .rules - .insert("typo-rule".to_string(), RuleConfig::default()); - config.disabled_rules = vec!["known-rule".to_string(), "other-typo".to_string()]; - - let error = config - .validate_rule_ids(["known-rule"]) - .expect_err("unknown rule IDs must fail validation") - .to_string(); - - assert!(error.contains("other-typo, typo-rule")); - assert!(error.contains("Valid primary rule IDs: known-rule")); - } - - #[test] - fn rule_id_validation_accepts_known_rule_keys_and_disabled_ids() { - let mut config = Config::default(); - config - .rules - .insert("known-rule".to_string(), RuleConfig::default()); - config.disabled_rules = vec!["known-rule".to_string()]; - - config - .validate_rule_ids(["known-rule"]) - .expect("known rule IDs must pass validation"); - } - - #[test] - fn config_rejects_unknown_top_level_setting() { - let error = toml::from_str::("auto_syn = true") - .expect_err("unknown top-level settings must fail") - .to_string(); - - assert!(error.contains("unknown field `auto_syn`")); - assert!(error.contains("auto_sync")); - } - - #[test] - fn config_rejects_unknown_per_rule_setting() { - let error = - toml::from_str::("[rules.blocking-constraint]\ntier1_threshold_row = 1") - .expect_err("unknown per-rule settings must fail") - .to_string(); - - assert!(error.contains("unknown field `tier1_threshold_row`")); - assert!(error.contains("tier1_threshold_rows")); - } -} diff --git a/src/_internal/engine/engine.rs b/src/_internal/engine/engine.rs index b4af334f..8900f64e 100644 --- a/src/_internal/engine/engine.rs +++ b/src/_internal/engine/engine.rs @@ -4,10 +4,10 @@ use crate::_internal::analysis::outcome::AnalysisOutcome; use crate::_internal::analysis::resolver::Resolver; use crate::_internal::analysis::state::{AnalysisState, PreState}; use crate::_internal::ast::visitor::AstVisitor; -use crate::_internal::engine::config::Config; use crate::_internal::report::violations::{ReportFinding, SourceLocation, Violation}; use crate::_internal::rules::registry; use crate::_internal::rules::{Rule, RuleContext}; +use crate::api::config::Config; use squawk_syntax::{ Parse, SyntaxKind, ast::{AstNode, SourceFile}, @@ -68,13 +68,13 @@ impl StatementCheckpoint { } } -pub struct SafeMigrateEngine { +pub(crate) struct SafeMigrateEngine { config: Config, rules: Vec>, } impl SafeMigrateEngine { - pub fn new(config: Config) -> Self { + pub(crate) fn new(config: Config) -> Self { Self { config, rules: registry::build_primary_rules(), @@ -82,11 +82,13 @@ impl SafeMigrateEngine { } /// Returns primary rule IDs in evaluation order. - pub fn primary_rule_ids(&self) -> Vec<&'static str> { + #[cfg(test)] + pub(crate) fn primary_rule_ids(&self) -> Vec<&'static str> { registry::primary_rule_ids().collect() } - pub fn analyze_chain( + #[cfg(test)] + pub(crate) fn analyze_chain( &self, files: &[(String, String)], state: &mut AnalysisState, @@ -115,7 +117,8 @@ impl SafeMigrateEngine { Ok(all_violations) } - pub fn analyze( + #[cfg(test)] + pub(crate) fn analyze( &self, sql: &str, state: &mut AnalysisState, @@ -126,7 +129,7 @@ impl SafeMigrateEngine { /// Analyze ordered files and retain reportable source locations for every /// finding. The original `analyze_chain` API remains available to callers /// that only need violations. - pub fn analyze_chain_with_locations( + pub(crate) fn analyze_chain_with_locations( &self, files: &[(String, String)], state: &mut AnalysisState, @@ -184,7 +187,8 @@ impl SafeMigrateEngine { Ok(findings.into_iter().map(|(_, finding)| finding).collect()) } - pub fn analyze_with_locations( + #[cfg(test)] + pub(crate) fn analyze_with_locations( &self, filename: String, sql: String, @@ -195,7 +199,7 @@ impl SafeMigrateEngine { /// Analyze a migration chain and return immutable findings, confidence, and /// conservative-analysis evidence together. - pub fn analyze_chain_outcome_with_locations( + pub(crate) fn analyze_chain_outcome_with_locations( &self, files: &[(String, String)], state: &mut AnalysisState, @@ -210,7 +214,8 @@ impl SafeMigrateEngine { /// Analyze one migration and return immutable findings, confidence, and /// conservative-analysis evidence together. - pub fn analyze_outcome_with_locations( + #[cfg(test)] + pub(crate) fn analyze_outcome_with_locations( &self, filename: String, sql: String, @@ -219,6 +224,7 @@ impl SafeMigrateEngine { self.analyze_chain_outcome_with_locations(&[(filename, sql)], state) } + #[cfg(test)] fn analyze_single_file( &self, filename: &str, @@ -229,6 +235,7 @@ impl SafeMigrateEngine { self.analyze_normalized_file(filename, &sql, state) } + #[cfg(test)] fn analyze_normalized_file( &self, filename: &str, @@ -319,7 +326,22 @@ impl SafeMigrateEngine { if squawk_linter::analyze::possibly_slow_stmt(&stmt) { mutations.push(Mutation::CheckTimeouts); } + let started_in_transaction = state.in_transaction(); + let transaction_control = mutations.iter().any(|mutation| { + matches!( + mutation, + Mutation::BeginTransaction + | Mutation::CommitTransaction + | Mutation::CommitAndChain + | Mutation::RollbackTransaction + | Mutation::RollbackAndChain + | Mutation::RollbackToSavepoint(_) + | Mutation::Savepoint(_) + | Mutation::ReleaseSavepoint(_) + ) + }); let mut statement_checkpoint = StatementCheckpoint::capture(state, &mutations); + let mut statement_failed = false; for mutation in mutations { let pre_cascade = match &mutation { @@ -332,7 +354,7 @@ impl SafeMigrateEngine { state.capture_pre_state_into(&mut pre_state); let result = state.apply(&mutation, pre_cascade.as_ref()); - let statement_failed = matches!( + statement_failed = matches!( result, crate::_internal::analysis::state::MutationResult::Conflict { .. } ); @@ -447,6 +469,14 @@ impl SafeMigrateEngine { } } + if !statement_failed + && !started_in_transaction + && !transaction_control + && !state.in_transaction() + { + state.apply_implicit_commit_actions(); + } + warned_keys.extend(statement_warned_keys); all_violations.extend(statement_violations); } diff --git a/src/_internal/engine/mod.rs b/src/_internal/engine/mod.rs index d37ff12b..ab2716e1 100644 --- a/src/_internal/engine/mod.rs +++ b/src/_internal/engine/mod.rs @@ -1,32 +1,3 @@ #![allow(clippy::module_inception)] -pub mod config; -pub mod engine; - -use squawk_syntax::ast::SourceFile; -use squawk_syntax::ast::Stmt; - -/// Represents a parsed SQL migration file. -/// Retained for backward compatibility with CLI wrappers. -pub struct MigrationFile { - source: SourceFile, -} - -impl MigrationFile { - pub fn parse(sql: &str) -> Result> { - let parsed = SourceFile::parse(sql); - let errors: Vec = parsed.errors().iter().map(|e| e.to_string()).collect(); - - if !errors.is_empty() { - return Err(errors); - } - - Ok(Self { - source: parsed.tree(), - }) - } - - pub fn statements(&self) -> impl Iterator + '_ { - self.source.stmts() - } -} +pub(crate) mod engine; diff --git a/src/_internal/model/column.rs b/src/_internal/model/column.rs index 437b9d2f..ad6c0e86 100644 --- a/src/_internal/model/column.rs +++ b/src/_internal/model/column.rs @@ -1,9 +1,10 @@ use crate::_internal::analysis::expr_ir::ExprIr; use crate::_internal::ast::identifiers::ObjectId; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Column { +pub(crate) struct Column { pub name: String, pub data_type: Option, /// Resolved identity for a tracked user-defined type. The display spelling @@ -20,4 +21,47 @@ pub struct Column { /// Raw type modifier integer from pg_attribute.atttypmod. /// For VARCHAR(50), PostgreSQL stores the character limit plus VARHDRSZ: 54. pub type_modifier: Option, + /// PostgreSQL TOAST storage mode, when catalog or migration evidence is available. + #[serde(default)] + pub storage: Option, + /// Explicit per-column compression method. `None` means the relation default. + #[serde(default)] + pub compression: Option, + /// Per-column statistics target. PostgreSQL uses `-1` for its default. + #[serde(default)] + pub statistics_target: Option, + /// Non-default per-column planner options such as `n_distinct`. + #[serde(default)] + pub options: BTreeMap, + /// Whether PostgreSQL stores this column as a generated column. + /// + /// `None` represents a V7 cache written before this metadata was captured. + #[serde(default)] + pub generated: Option, +} + +impl Column { + /// Construct a migration-created column without catalog-only metadata. + pub(crate) fn migration_created( + name: String, + data_type: Option, + is_nullable: bool, + default: Option, + ) -> Self { + Self { + name, + data_type, + type_id: None, + is_nullable, + default, + avg_width: None, + default_expr_text: None, + type_modifier: None, + storage: None, + compression: None, + statistics_target: None, + options: BTreeMap::new(), + generated: Some(false), + } + } } diff --git a/src/_internal/model/constraint.rs b/src/_internal/model/constraint.rs index d6e43a11..13658a4c 100644 --- a/src/_internal/model/constraint.rs +++ b/src/_internal/model/constraint.rs @@ -2,7 +2,7 @@ use crate::_internal::ast::identifiers::ObjectId; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -pub enum ConstraintKind { +pub(crate) enum ConstraintKind { Check, ForeignKey, PrimaryKey, @@ -12,11 +12,15 @@ pub enum ConstraintKind { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ConstraintState { +pub(crate) struct ConstraintState { pub table_id: ObjectId, pub name: String, pub kind: ConstraintKind, pub validated: bool, + /// PostgreSQL-normalized definition text for constraints whose expression + /// identity affects inheritance and cloning semantics. + #[serde(default)] + pub definition: Option, /// The PostgreSQL index adopted by a primary/unique/exclusion /// constraint, when catalog evidence provides `pg_constraint.conindid`. /// Local constraints and constraint kinds without a backing index retain diff --git a/src/_internal/model/function.rs b/src/_internal/model/function.rs index 8dfa7b3b..5aa73aed 100644 --- a/src/_internal/model/function.rs +++ b/src/_internal/model/function.rs @@ -2,20 +2,20 @@ use crate::_internal::ast::identifiers::ObjectId; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum Volatility { +pub(crate) enum Volatility { Volatile, Stable, Immutable, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum SecurityMode { +pub(crate) enum SecurityMode { Invoker, Definer, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -pub enum RoutineKind { +pub(crate) enum RoutineKind { #[default] Function, Procedure, @@ -24,7 +24,7 @@ pub enum RoutineKind { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct FunctionState { +pub(crate) struct FunctionState { pub id: ObjectId, pub routine_kind: RoutineKind, pub arg_types: Vec, @@ -44,7 +44,7 @@ pub struct FunctionState { #[derive(Debug, Clone, PartialEq)] #[allow(clippy::large_enum_variant)] // Overlay transitions stay allocation-free in the hot state path. -pub enum FunctionOverlay { +pub(crate) enum FunctionOverlay { Present(FunctionState), Dropped, } diff --git a/src/_internal/model/mod.rs b/src/_internal/model/mod.rs index bb4d717b..457f35d4 100644 --- a/src/_internal/model/mod.rs +++ b/src/_internal/model/mod.rs @@ -1,10 +1,10 @@ -pub mod column; -pub mod constraint; -pub mod function; -pub mod relation; -pub mod replication; -pub mod role; -pub mod schema; -pub mod sequence; -pub mod trigger; -pub mod types; +pub(crate) mod column; +pub(crate) mod constraint; +pub(crate) mod function; +pub(crate) mod relation; +pub(crate) mod replication; +pub(crate) mod role; +pub(crate) mod schema; +pub(crate) mod sequence; +pub(crate) mod trigger; +pub(crate) mod types; diff --git a/src/_internal/model/relation.rs b/src/_internal/model/relation.rs index b5a8d8cb..e5ace7de 100644 --- a/src/_internal/model/relation.rs +++ b/src/_internal/model/relation.rs @@ -1,10 +1,11 @@ +use crate::_internal::analysis::expr_ir::ExprIr; use crate::_internal::ast::identifiers::ObjectId; use crate::_internal::model::column::Column; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum Privilege { +pub(crate) enum Privilege { Select, Insert, Update, @@ -21,7 +22,7 @@ pub enum Privilege { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct PrivilegeMatrix { +pub(crate) struct PrivilegeMatrix { /// Maps role identity to the set of privileges they possess on this relation pub grants: HashMap>, /// Maps role identity to privileges that role may re-grant. This is kept @@ -39,11 +40,11 @@ pub struct PrivilegeMatrix { } impl PrivilegeMatrix { - pub fn grant(&mut self, role: ObjectId, privileges: HashSet) { + pub(crate) fn grant(&mut self, role: ObjectId, privileges: HashSet) { self.grants.entry(role).or_default().extend(privileges); } - pub fn grant_with_option(&mut self, role: ObjectId, privileges: HashSet) { + pub(crate) fn grant_with_option(&mut self, role: ObjectId, privileges: HashSet) { self.grant(role.clone(), privileges.clone()); self.grant_options .entry(role) @@ -51,7 +52,7 @@ impl PrivilegeMatrix { .extend(privileges); } - pub fn grant_from( + pub(crate) fn grant_from( &mut self, role: ObjectId, privileges: HashSet, @@ -79,7 +80,7 @@ impl PrivilegeMatrix { } } - pub fn revoke(&mut self, role: &ObjectId, privileges: &HashSet) { + pub(crate) fn revoke(&mut self, role: &ObjectId, privileges: &HashSet) { if let Some(owned) = self.grants.get_mut(role) { if privileges.contains(&Privilege::All) { owned.clear(); @@ -96,14 +97,14 @@ impl PrivilegeMatrix { self.remove_grant_provenance(role, privileges, None); } - pub fn has_privilege(&self, role: &ObjectId, privilege: Privilege) -> bool { + pub(crate) fn has_privilege(&self, role: &ObjectId, privilege: Privilege) -> bool { self.grants.get(role).is_some_and(|set| { set.contains(&privilege) || (privilege != Privilege::All && set.contains(&Privilege::All)) }) } - pub fn has_grant_option(&self, role: &ObjectId, privilege: Privilege) -> bool { + pub(crate) fn has_grant_option(&self, role: &ObjectId, privilege: Privilege) -> bool { self.grant_options.get(role).is_some_and(|set| { set.contains(&privilege) || (privilege != Privilege::All && set.contains(&Privilege::All)) @@ -114,12 +115,12 @@ impl PrivilegeMatrix { /// authorization input. Role inheritance is resolved by the analysis /// state, because the relation matrix intentionally stores only direct /// ACL entries. - pub fn has_direct_privilege(&self, role: &ObjectId, privilege: Privilege) -> bool { + pub(crate) fn has_direct_privilege(&self, role: &ObjectId, privilege: Privilege) -> bool { self.has_privilege(role, privilege) || self.has_privilege(&ObjectId::new("", "public"), privilege) } - pub fn has_direct_grant_option(&self, role: &ObjectId, privilege: Privilege) -> bool { + pub(crate) fn has_direct_grant_option(&self, role: &ObjectId, privilege: Privilege) -> bool { self.has_grant_option(role, privilege) || self.has_grant_option(&ObjectId::new("", "public"), privilege) } @@ -128,7 +129,7 @@ impl PrivilegeMatrix { /// the requested privilege(s). A missing entry is meaningful only for a /// privilege that is actually present; absent privileges need no /// provenance to revoke. - pub fn targeted_revoke_provenance_is_known( + pub(crate) fn targeted_revoke_provenance_is_known( &self, role: &ObjectId, privileges: &HashSet, @@ -149,7 +150,7 @@ impl PrivilegeMatrix { /// Equivalent provenance check for `REVOKE ... GRANT OPTION FOR`, which /// changes only the grant-option map and therefore uses its separate /// source index. - pub fn targeted_grant_option_revoke_provenance_is_known( + pub(crate) fn targeted_grant_option_revoke_provenance_is_known( &self, role: &ObjectId, privileges: &HashSet, @@ -170,7 +171,7 @@ impl PrivilegeMatrix { }) } - pub fn revoke_grant_option(&mut self, role: &ObjectId, privileges: &HashSet) { + pub(crate) fn revoke_grant_option(&mut self, role: &ObjectId, privileges: &HashSet) { if let Some(options) = self.grant_options.get_mut(role) { if privileges.contains(&Privilege::All) { options.clear(); @@ -199,7 +200,7 @@ impl PrivilegeMatrix { /// Remove provenance for a revoke. `grantor = Some(x)` limits the /// operation to grants made by x; `None` removes all known sources. - pub fn remove_grant_provenance( + pub(crate) fn remove_grant_provenance( &mut self, role: &ObjectId, privileges: &HashSet, @@ -249,7 +250,7 @@ impl PrivilegeMatrix { } } - pub fn expand_privileges( + pub(crate) fn expand_privileges( &self, role: &ObjectId, privileges: &HashSet, @@ -267,7 +268,7 @@ impl PrivilegeMatrix { } } - pub fn revoke_from( + pub(crate) fn revoke_from( &mut self, role: &ObjectId, privileges: &HashSet, @@ -317,7 +318,7 @@ impl PrivilegeMatrix { /// Revoke a grant and, when requested, recursively remove grants whose /// grantor lost its last known grant option for the same privilege. - pub fn revoke_from_cascade( + pub(crate) fn revoke_from_cascade( &mut self, role: &ObjectId, privileges: &HashSet, @@ -357,7 +358,7 @@ impl PrivilegeMatrix { } } - pub fn revoke_grant_option_from( + pub(crate) fn revoke_grant_option_from( &mut self, role: &ObjectId, privileges: &HashSet, @@ -402,31 +403,127 @@ impl PrivilegeMatrix { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum RelationKind { +pub(crate) enum RelationKind { Table, View, MaterializedView, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum Persistence { +pub(crate) enum Persistence { Permanent, Temporary, Unlogged, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) enum OnCommitAction { + PreserveRows, + DeleteRows, + Drop, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) enum RuleEnableMode { + Origin, + Disabled, + Replica, + Always, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) enum IdentityGeneration { + Always, + ByDefault, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) enum GeneratedColumnKind { + Stored, + Virtual, +} + +impl GeneratedColumnKind { + pub(crate) fn from_pg_code(code: char) -> Option { + match code { + 's' => Some(Self::Stored), + 'v' => Some(Self::Virtual), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct GeneratedColumnState { + pub kind: GeneratedColumnKind, + /// Canonical catalog text when synchronized; local AST dependencies remain + /// authoritative for mutations in the current analysis chain. + pub expression: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct ExtendedStatisticsState { + pub id: ObjectId, + pub kinds: Vec, + pub columns: Vec, + pub expressions: Option, + pub target: Option, +} + +impl IdentityGeneration { + pub(crate) fn from_pg_code(code: char) -> Option { + match code { + 'a' => Some(Self::Always), + 'd' => Some(Self::ByDefault), + _ => None, + } + } +} + +impl RuleEnableMode { + pub(crate) fn from_pg_code(code: char) -> Option { + match code { + 'O' => Some(Self::Origin), + 'D' => Some(Self::Disabled), + 'R' => Some(Self::Replica), + 'A' => Some(Self::Always), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct ColumnInheritance { + pub parent_count: u32, + pub is_local: bool, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RelationState { +pub(crate) struct RelationState { pub id: ObjectId, pub owner: ObjectId, pub columns: Vec, + /// Missing entries mean inheritance provenance has not been captured. + #[serde(default)] + pub column_inheritance: std::collections::HashMap, pub generation: u64, pub estimated_rows: Option, pub relpages: Option, pub kind: RelationKind, pub persistence: Persistence, + /// Present only for locally-created temporary relations. + #[serde(default)] + pub on_commit: Option, pub triggers: HashSet, pub policies: HashSet, + #[serde(default)] + pub rules: std::collections::HashMap, + #[serde(default)] + pub identity_columns: std::collections::HashMap, + #[serde(default)] + pub generated_columns: std::collections::HashMap, + #[serde(default)] + pub extended_statistics: std::collections::HashMap, pub last_analyze: Option, pub last_autoanalyze: Option, /// Transaction depth at creation, used for same-transaction index checks. @@ -434,11 +531,35 @@ pub struct RelationState { pub privileges: PrivilegeMatrix, pub partition_type: Option, // e.g., "RANGE", "LIST", "HASH" pub partition_by: Option, // The partition key expression + #[serde(default)] + pub partition_bound: Option, + /// PostgreSQL's effective partition predicate, including ancestor bounds. + #[serde(default)] + pub partition_constraint: Option, pub is_fk_dependency: bool, /// Whether a materialized view has been populated. `None` means the /// catalog did not provide this relation-specific fact; it is ignored for /// tables and ordinary views and treated conservatively for refreshes. pub is_populated: Option, + /// Physical/catalog attributes are optional for caches produced before + /// this metadata was synchronized. + #[serde(default)] + pub tablespace: Option, + #[serde(default)] + pub access_method: Option, + #[serde(default)] + pub cluster_index: Option, + #[serde(default)] + pub row_security: Option, + #[serde(default)] + pub force_row_security: Option, + #[serde(default)] + pub replica_identity: Option, + #[serde(default)] + pub table_options: std::collections::BTreeMap, + /// Composite row type selected by `CREATE TABLE ... OF`. + #[serde(default)] + pub of_type: Option, } impl Default for RelationState { @@ -452,22 +573,49 @@ impl Default for RelationState { relpages: None, kind: RelationKind::Table, persistence: Persistence::Permanent, + on_commit: None, triggers: HashSet::new(), policies: HashSet::new(), + rules: Default::default(), + column_inheritance: Default::default(), + identity_columns: Default::default(), + generated_columns: Default::default(), + extended_statistics: Default::default(), last_analyze: None, last_autoanalyze: None, created_at_tx_depth: 0, privileges: PrivilegeMatrix::default(), partition_type: None, partition_by: None, + partition_bound: None, + partition_constraint: None, is_fk_dependency: false, is_populated: None, + tablespace: None, + access_method: None, + cluster_index: None, + row_security: None, + force_row_security: None, + replica_identity: None, + table_options: Default::default(), + of_type: None, } } } impl RelationState { - pub fn new( + pub(crate) fn normalize_column_default(default: &Option) -> Option { + if matches!( + default, + Some(ExprIr::Literal(value)) if value.trim().eq_ignore_ascii_case("null") + ) { + None + } else { + default.clone() + } + } + + pub(crate) fn new( id: ObjectId, owner: ObjectId, generation: u64, @@ -485,24 +633,50 @@ impl RelationState { relpages: None, kind, persistence, + on_commit: None, triggers: HashSet::new(), policies: HashSet::new(), + rules: Default::default(), + column_inheritance: Default::default(), + identity_columns: Default::default(), + generated_columns: Default::default(), + extended_statistics: Default::default(), last_analyze: None, last_autoanalyze: None, created_at_tx_depth, privileges: PrivilegeMatrix::default(), partition_type: None, partition_by: None, + partition_bound: None, + partition_constraint: None, is_fk_dependency: false, is_populated: None, + tablespace: None, + access_method: None, + cluster_index: None, + row_security: None, + force_row_security: None, + replica_identity: None, + table_options: Default::default(), + of_type: None, } } - pub fn mark_fk_dependency(&mut self) { + pub(crate) fn mark_fk_dependency(&mut self) { self.is_fk_dependency = true; } - pub fn apply_column_action(&mut self, action: &ColumnAction) { + pub(crate) fn clear_index_settings(&mut self, index_name: &str) { + if self.cluster_index.as_deref() == Some(index_name) { + self.cluster_index = None; + } + if self.replica_identity.as_deref() == Some(format!("USING INDEX {index_name}").as_str()) { + // PostgreSQL retains relreplident='i' after its identity index is dropped. + self.replica_identity = Some("USING INDEX".into()); + } + } + + pub(crate) fn apply_column_action(&mut self, action: &ColumnAction) { match action { ColumnAction::Add { name, @@ -527,37 +701,75 @@ impl RelationState { name: "nextval".to_string(), args: Vec::new(), }) - } else if matches!( - default, - Some(crate::_internal::analysis::expr_ir::ExprIr::Literal(value)) - if value.trim().eq_ignore_ascii_case("null") - ) { - None } else { - default.clone() + Self::normalize_column_default(default) }; - self.columns.push(Column { - name: name.clone(), - data_type: serial_type + self.columns.push(Column::migration_created( + name.clone(), + serial_type .map(str::to_string) .or_else(|| data_type.clone()), - type_id: None, - default: normalized_default, - is_nullable: !(*not_null || is_serial), - avg_width: None, - default_expr_text: None, - type_modifier: None, - }); + !(*not_null || is_serial), + normalized_default, + )); + self.column_inheritance.insert( + name.clone(), + ColumnInheritance { + parent_count: 0, + is_local: true, + }, + ); } } ColumnAction::Drop { name } => { self.columns.retain(|c| c.name != *name); + self.column_inheritance.remove(name); + self.identity_columns.remove(name); + self.generated_columns.remove(name); } ColumnAction::Rename { from, to } => { if let Some(pos) = self.columns.iter().position(|c| c.name == *from) && !self.columns.iter().any(|c| c.name == *to) { self.columns[pos].name = to.clone(); + if let Some(provenance) = self.column_inheritance.remove(from) { + self.column_inheritance.insert(to.clone(), provenance); + } + self.partition_by = self.partition_by.as_deref().and_then(|source| { + crate::_internal::analysis::expr_visitor::ExprVisitor::rename_partition_key_source(source, &self.id.name, from, to) + }); + self.partition_constraint = self.partition_constraint.as_deref().and_then(|source| { + crate::_internal::analysis::expr_visitor::ExprVisitor::rename_column_source(source, &self.id.name, from, to) + }); + if let Some(generation) = self.identity_columns.remove(from) { + self.identity_columns.insert(to.clone(), generation); + } + if let Some(generated) = self.generated_columns.remove(from) { + self.generated_columns.insert(to.clone(), generated); + } + for generated in self.generated_columns.values_mut() { + generated.expression = generated.expression.as_deref().and_then(|source| { + crate::_internal::analysis::expr_visitor::ExprVisitor::rename_column_source(source, &self.id.name, from, to) + }); + } + for statistics in self.extended_statistics.values_mut() { + for column in &mut statistics.columns { + if column == from { + *column = to.clone(); + } + } + statistics.expressions = statistics + .expressions + .as_deref() + .and_then(|source| { + crate::_internal::analysis::expr_visitor::ExprVisitor::rename_column_source( + source, + &self.id.name, + from, + to, + ) + }); + } } } ColumnAction::SetNotNull { name } => { @@ -579,35 +791,57 @@ impl RelationState { col.type_id = None; col.type_modifier = None; col.avg_width = None; + // ALTER TYPE resets these to the destination type's defaults. + col.storage = None; + col.compression = None; } } ColumnAction::SetDefault { name, default } => { if let Some(col) = self.columns.iter_mut().find(|c| c.name == *name) { - col.default = if matches!( - default, - Some(crate::_internal::analysis::expr_ir::ExprIr::Literal(value)) - if value.trim().eq_ignore_ascii_case("null") - ) { - None - } else { - default.clone() - }; + col.default = Self::normalize_column_default(default); // A migration mutation supersedes raw baseline catalog text. col.default_expr_text = None; } } + ColumnAction::SetStorage { name, mode } => { + if let Some(col) = self.columns.iter_mut().find(|c| c.name == *name) { + col.storage = (!mode.eq_ignore_ascii_case("default")).then(|| mode.clone()); + } + } + ColumnAction::SetCompression { name, method } => { + if let Some(col) = self.columns.iter_mut().find(|c| c.name == *name) { + col.compression = method.clone(); + } + } + ColumnAction::SetStatistics { name, target } => { + if let Some(col) = self.columns.iter_mut().find(|c| c.name == *name) { + col.statistics_target = *target; + } + } + ColumnAction::SetOptions { name, options } => { + if let Some(col) = self.columns.iter_mut().find(|c| c.name == *name) { + col.options.extend(options.clone()); + } + } + ColumnAction::ResetOptions { name, names } => { + if let Some(col) = self.columns.iter_mut().find(|c| c.name == *name) { + for option in names { + col.options.remove(option); + } + } + } } } - pub fn has_column(&self, name: &str) -> bool { + pub(crate) fn has_column(&self, name: &str) -> bool { self.columns.iter().any(|c| c.name == name) } - pub fn get_column(&self, name: &str) -> Option<&Column> { + pub(crate) fn get_column(&self, name: &str) -> Option<&Column> { self.columns.iter().find(|c| c.name == name) } - pub fn is_stale(&self) -> bool { + pub(crate) fn is_stale(&self) -> bool { self.last_analyze.is_none() && self.last_autoanalyze.is_none() } } @@ -637,6 +871,11 @@ mod tests { avg_width: Some(32), default_expr_text: None, type_modifier: Some(259), + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, }); relation.apply_column_action(&ColumnAction::SetType { @@ -713,7 +952,7 @@ mod tests { } #[derive(Debug, Clone, PartialEq)] -pub enum ColumnAction { +pub(crate) enum ColumnAction { Add { name: String, data_type: Option, @@ -741,11 +980,31 @@ pub enum ColumnAction { name: String, default: Option, }, + SetStorage { + name: String, + mode: String, + }, + SetCompression { + name: String, + method: Option, + }, + SetStatistics { + name: String, + target: Option, + }, + SetOptions { + name: String, + options: std::collections::BTreeMap, + }, + ResetOptions { + name: String, + names: Vec, + }, } #[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq)] -pub enum RelationOverlay { +pub(crate) enum RelationOverlay { Present(RelationState), Dropped, } diff --git a/src/_internal/model/replication.rs b/src/_internal/model/replication.rs index 87bf0327..4720fdb4 100644 --- a/src/_internal/model/replication.rs +++ b/src/_internal/model/replication.rs @@ -2,7 +2,7 @@ use crate::_internal::analysis::facts::{AttributeFact, ConnectionTarget, Publica use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PublicationState { +pub(crate) struct PublicationState { pub name: String, pub owner: Option, pub scope: PublicationScope, @@ -11,13 +11,13 @@ pub struct PublicationState { } #[derive(Debug, Clone, PartialEq)] -pub enum PublicationOverlay { +pub(crate) enum PublicationOverlay { Present(PublicationState), Dropped, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SubscriptionState { +pub(crate) struct SubscriptionState { pub name: String, pub owner: Option, pub connection: ConnectionTarget, @@ -29,7 +29,7 @@ pub struct SubscriptionState { } #[derive(Debug, Clone, PartialEq)] -pub enum SubscriptionOverlay { +pub(crate) enum SubscriptionOverlay { Present(SubscriptionState), Dropped, } diff --git a/src/_internal/model/role.rs b/src/_internal/model/role.rs index 037e3b4f..a1dced04 100644 --- a/src/_internal/model/role.rs +++ b/src/_internal/model/role.rs @@ -2,7 +2,7 @@ use crate::_internal::ast::identifiers::ObjectId; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RoleState { +pub(crate) struct RoleState { pub id: ObjectId, // role name, no schema pub can_login: bool, pub is_superuser: bool, @@ -24,18 +24,24 @@ pub struct RoleState { pub can_set_role_to: Vec, } -/// PostgreSQL records the role that granted each membership. Keeping this -/// provenance separate from the option vectors lets revoke-CASCADE remove only -/// memberships delegated by a grantor whose authority was withdrawn. +/// PostgreSQL records the role that granted each membership alongside the +/// per-record options. Keeping this provenance separate from the option +/// vectors lets revoke-CASCADE remove only memberships delegated by a +/// grantor whose authority was withdrawn. PostgreSQL 16+ stores one row per +/// (member, role, grantor) triple, so several records may describe the same +/// edge with different grantors and option values. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RoleMembershipGrantor { +pub(crate) struct RoleMembershipGrantor { pub member: ObjectId, pub role: ObjectId, pub grantor: ObjectId, + pub admin: bool, + pub inherit: bool, + pub set: bool, } #[derive(Debug, Clone, PartialEq)] -pub enum RoleOverlay { +pub(crate) enum RoleOverlay { Present(RoleState), Dropped, } diff --git a/src/_internal/model/schema.rs b/src/_internal/model/schema.rs index b39f238e..f8935fcf 100644 --- a/src/_internal/model/schema.rs +++ b/src/_internal/model/schema.rs @@ -2,14 +2,14 @@ use crate::_internal::ast::identifiers::ObjectId; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct SchemaState { +pub(crate) struct SchemaState { pub name: String, pub owner: ObjectId, pub generation: u64, } #[derive(Debug, Clone, PartialEq)] -pub enum SchemaOverlay { +pub(crate) enum SchemaOverlay { Present(SchemaState), Dropped, } diff --git a/src/_internal/model/sequence.rs b/src/_internal/model/sequence.rs index e630ca84..f0d3f06a 100644 --- a/src/_internal/model/sequence.rs +++ b/src/_internal/model/sequence.rs @@ -2,19 +2,54 @@ use crate::_internal::ast::identifiers::ObjectId; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum SequenceKind { +pub(crate) enum SequenceKind { Standalone, Owned, SerialLike, Identity, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) enum SequencePersistence { + Permanent, + Temporary, + Unlogged, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SequenceParameters { + pub data_type: String, + pub start_value: i64, + pub increment: i64, + pub min_value: i64, + pub max_value: i64, + pub cache_size: i64, + pub cycle: bool, + pub persistence: SequencePersistence, +} + +impl Default for SequenceParameters { + fn default() -> Self { + Self { + data_type: "bigint".to_string(), + start_value: 1, + increment: 1, + min_value: 1, + max_value: i64::MAX, + cache_size: 1, + cycle: false, + persistence: SequencePersistence::Permanent, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct SequenceState { +pub(crate) struct SequenceState { pub id: ObjectId, pub owner: ObjectId, pub owned_by: Option<(ObjectId, String)>, pub kind: SequenceKind, + pub parameters: SequenceParameters, pub generation: u64, } @@ -23,7 +58,7 @@ pub struct SequenceState { // lookup would add allocation and widespread indirection. #[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq)] -pub enum SequenceOverlay { +pub(crate) enum SequenceOverlay { Present(SequenceState), Dropped, } diff --git a/src/_internal/model/trigger.rs b/src/_internal/model/trigger.rs index 6219d2d2..9548f125 100644 --- a/src/_internal/model/trigger.rs +++ b/src/_internal/model/trigger.rs @@ -2,7 +2,7 @@ use crate::_internal::ast::identifiers::ObjectId; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum TriggerEnableMode { +pub(crate) enum TriggerEnableMode { Disabled, #[default] Origin, @@ -11,7 +11,7 @@ pub enum TriggerEnableMode { } impl TriggerEnableMode { - pub fn from_pg_code(code: &str) -> Option { + pub(crate) fn from_pg_code(code: &str) -> Option { match code { "D" => Some(Self::Disabled), "O" => Some(Self::Origin), @@ -23,19 +23,26 @@ impl TriggerEnableMode { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TriggerState { +pub(crate) struct TriggerState { /// PostgreSQL trigger names are scoped to their table, not their schema. /// `id` is an internal composite key; retain the display name separately /// for matching ALTER/DROP TRIGGER statements and diagnostics. pub name: String, pub id: ObjectId, pub table_id: ObjectId, + pub function_id: ObjectId, + #[serde(default)] + pub row_level: bool, + /// The parent trigger whose partition clone this trigger represents. + #[serde(default)] + pub parent_trigger_id: Option, pub enabled_mode: TriggerEnableMode, pub generation: u64, } +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq)] -pub enum TriggerOverlay { +pub(crate) enum TriggerOverlay { Present(TriggerState), Dropped, } diff --git a/src/_internal/model/types.rs b/src/_internal/model/types.rs index 7d266e6c..da64d7cc 100644 --- a/src/_internal/model/types.rs +++ b/src/_internal/model/types.rs @@ -2,14 +2,14 @@ use crate::_internal::ast::identifiers::ObjectId; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TypeState { +pub(crate) struct TypeState { pub id: ObjectId, pub generation: u64, pub kind: TypeKind, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum TypeKind { +pub(crate) enum TypeKind { Enum { variants: Vec, }, @@ -21,12 +21,20 @@ pub enum TypeKind { base_type_id: Option, }, Base, - Composite, + Composite { + fields: Vec, + }, Range, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct CompositeFieldState { + pub name: String, + pub data_type: String, +} + #[derive(Debug, Clone, PartialEq)] -pub enum TypeOverlay { +pub(crate) enum TypeOverlay { Present(TypeState), Dropped, } diff --git a/src/_internal/report/interactive.rs b/src/_internal/report/interactive.rs index 88e821ec..123ce91a 100644 --- a/src/_internal/report/interactive.rs +++ b/src/_internal/report/interactive.rs @@ -1,4 +1,5 @@ use crate::_internal::analysis::state::Confidence; +use crate::_internal::report::reporter::{terminal_block, terminal_inline}; use crate::_internal::report::violations::Violation; use anyhow::Result; use crossterm::{ @@ -39,7 +40,7 @@ fn require_interactive_terminal(stdin_is_terminal: bool, stdout_is_terminal: boo Ok(()) } -pub fn run_interactive(violations: &[Violation], confidence: &Confidence) -> Result<()> { +pub(crate) fn run_interactive(violations: &[Violation], confidence: &Confidence) -> Result<()> { if violations.is_empty() { println!("No violations found!"); return Ok(()); @@ -99,7 +100,11 @@ pub fn run_interactive(violations: &[Violation], confidence: &Confidence) -> Res SetForegroundColor(color), Print(format!("[{:?}] ", v.tier)), ResetColor, - Print(format!("{} (rule: {})\r\n", v.operation_kind, v.rule_id)) + Print(format!( + "{} (rule: {})\r\n", + terminal_inline(&v.operation_kind.to_string()), + terminal_inline(v.rule_id) + )) )?; } @@ -118,11 +123,11 @@ pub fn run_interactive(violations: &[Violation], confidence: &Confidence) -> Res SetForegroundColor(Color::White), Print("Reason: "), ResetColor, - Print(format!("{}\r\n", active.reason)), + Print(format!("{}\r\n", terminal_inline(&active.reason))), SetForegroundColor(Color::White), Print("Recipe: "), ResetColor, - Print(format!("{}\r\n", active.recipe)), + Print(format!("{}\r\n", terminal_inline(active.recipe))), )?; if let Some(sql) = &active.sql { @@ -140,7 +145,10 @@ pub fn run_interactive(violations: &[Violation], confidence: &Confidence) -> Res Print("\r\nSQL Context:\r\n"), SetForegroundColor(Color::DarkGrey), // Raw terminal output uses CRLF line endings. - Print(format!("{}\r\n", sql_lines.join("\r\n"))), + Print(format!( + "{}\r\n", + terminal_block(&sql_lines.join("\n")).replace('\n', "\r\n") + )), )?; if truncated { diff --git a/src/_internal/report/mod.rs b/src/_internal/report/mod.rs index 5dc35a91..e5abe495 100644 --- a/src/_internal/report/mod.rs +++ b/src/_internal/report/mod.rs @@ -1,5 +1,5 @@ -pub mod interactive; -pub mod reporter; +pub(crate) mod interactive; +pub(crate) mod reporter; #[cfg(test)] mod reporter_tests; -pub mod violations; +pub(crate) mod violations; diff --git a/src/_internal/report/reporter.rs b/src/_internal/report/reporter.rs index cfa40db1..2f76841a 100644 --- a/src/_internal/report/reporter.rs +++ b/src/_internal/report/reporter.rs @@ -9,7 +9,7 @@ use owo_colors::{OwoColorize, Style}; /// Four-way verdict classification based on violation tiers. #[derive(Debug, PartialEq, Eq)] -pub enum Verdict { +pub(crate) enum Verdict { Halt, // any Tier 1 Cautious, // Tier 2 present, no Tier 1 SafeWithRisk, // Tier 3 irreversible present, no Tier 1 or 2 @@ -17,7 +17,7 @@ pub enum Verdict { } impl Verdict { - pub fn label(&self) -> &'static str { + pub(crate) fn label(&self) -> &'static str { match self { Verdict::Halt => "HALT", Verdict::Cautious => "CAUTIOUS", @@ -26,7 +26,7 @@ impl Verdict { } } - pub fn recommendation(&self, confidence: &Confidence) -> &'static str { + pub(crate) fn recommendation(&self, confidence: &Confidence) -> &'static str { if confidence == &Confidence::Tainted { return match self { Verdict::Halt => "do not deploy", @@ -48,7 +48,7 @@ impl Verdict { } /// Compute the overall verdict from a set of violations. -pub fn compute_verdict(violations: &[Violation]) -> Verdict { +pub(crate) fn compute_verdict(violations: &[Violation]) -> Verdict { let has_tier1 = violations.iter().any(|v| v.tier == ViolationTier::Tier1); let has_tier2 = violations.iter().any(|v| v.tier == ViolationTier::Tier2); let has_irreversible_tier3 = violations @@ -67,19 +67,23 @@ fn no_color() -> bool { std::env::var("NO_COLOR").is_ok() } pub(crate) fn tier_label_colored(tier: &ViolationTier) -> String { + tier_label_with_color(tier, !no_color()) +} + +pub(super) fn tier_label_with_color(tier: &ViolationTier, color: bool) -> String { let label = match tier { ViolationTier::Tier1 => "HALT", ViolationTier::Tier2 => "WARN", ViolationTier::Tier3 => "SAFE", }; - if no_color() { - label.to_string() - } else { + if color { match tier { ViolationTier::Tier1 => label.style(Style::new().red().bold()).to_string(), ViolationTier::Tier2 => label.style(Style::new().yellow().bold()).to_string(), ViolationTier::Tier3 => label.style(Style::new().green().bold()).to_string(), } + } else { + label.to_string() } } @@ -90,12 +94,15 @@ fn terminal_width() -> usize { .max(60) } -pub struct Reporter; +pub(crate) struct Reporter; impl Reporter { - pub const JSON_SCHEMA_VERSION: u32 = 2; + pub(crate) const JSON_SCHEMA_VERSION: u32 = 2; - pub fn json_report(violations: &[Violation], confidence: &Confidence) -> serde_json::Value { + pub(crate) fn json_report( + violations: &[Violation], + confidence: &Confidence, + ) -> serde_json::Value { let verdict = compute_verdict(violations); let tier1 = violations .iter() @@ -129,7 +136,7 @@ impl Reporter { /// Serialize a complete immutable analysis outcome. Schema v2 adds stable /// evidence alongside the existing finding contract. - pub fn json_outcome_with_locations( + pub(crate) fn json_outcome_with_locations( outcome: &AnalysisOutcome, ) -> serde_json::Value { let mut report = Self::json_report_with_locations(&outcome.findings, &outcome.confidence); @@ -140,7 +147,7 @@ impl Reporter { /// Additive JSON rendering that includes file/line locations when analysis /// was invoked with source-aware reporting. - pub fn json_report_with_locations( + pub(crate) fn json_report_with_locations( findings: &[ReportFinding], confidence: &Confidence, ) -> serde_json::Value { @@ -176,7 +183,7 @@ impl Reporter { /// Deterministic Markdown rendering for pull-request artifacts. It uses /// the same verdict, confidence, tier, and finding data as JSON output. - pub fn markdown_report(findings: &[ReportFinding], confidence: &Confidence) -> String { + pub(crate) fn markdown_report(findings: &[ReportFinding], confidence: &Confidence) -> String { let violations: Vec<_> = findings .iter() .map(|finding| finding.violation.clone()) @@ -267,17 +274,17 @@ impl Reporter { } /// Render findings and structured conservative-analysis evidence. - pub fn markdown_outcome(outcome: &AnalysisOutcome) -> String { + pub(crate) fn markdown_outcome(outcome: &AnalysisOutcome) -> String { let mut output = Self::markdown_report(&outcome.findings, &outcome.confidence); append_markdown_evidence(&mut output, &outcome.evidence); output } - pub fn should_halt(violations: &[Violation]) -> bool { + pub(crate) fn should_halt(violations: &[Violation]) -> bool { compute_verdict(violations) == Verdict::Halt } - pub fn print_report(violations: &[Violation], confidence: &Confidence) -> bool { + pub(crate) fn print_report(violations: &[Violation], confidence: &Confidence) -> bool { let mut tier1 = 0usize; let mut tier2 = 0usize; let mut tier3 = 0usize; @@ -360,8 +367,7 @@ impl Reporter { let display_name = match &v.object_kind { crate::_internal::report::violations::ObjectKind::Database | crate::_internal::report::violations::ObjectKind::Role - | crate::_internal::report::violations::ObjectKind::Publication - | crate::_internal::report::violations::ObjectKind::Subscription => { + | crate::_internal::report::violations::ObjectKind::Publication => { let step1 = if let Some(idx) = v.object_name.find('.') { &v.object_name[idx + 1..] } else { @@ -376,12 +382,16 @@ impl Reporter { }; if v.object_kind == crate::_internal::report::violations::ObjectKind::Unknown { - println!(" object : {}", display_name); + println!(" object : {}", terminal_inline(&display_name)); } else { - println!(" object : {} {}", v.object_kind, display_name); + println!( + " object : {} {}", + v.object_kind, + terminal_inline(&display_name) + ); } - println!(" reason : {}", v.reason); + println!(" reason : {}", terminal_inline(&v.reason)); let clean_recipe = v .recipe @@ -390,12 +400,12 @@ impl Reporter { .filter(|l| !l.is_empty()) .collect::>() .join(" "); - println!(" recipe : {}", clean_recipe); + println!(" recipe : {}", terminal_inline(&clean_recipe)); if let Some(sql) = &v.sql { let sql_trimmed = sql.trim(); if !sql_trimmed.is_empty() { - println!(" sql : {}", sql_trimmed); + println!(" sql : {}", terminal_block(sql_trimmed)); } } @@ -436,7 +446,7 @@ impl Reporter { } /// Print findings and a compact, deterministic evidence summary. - pub fn print_outcome(outcome: &AnalysisOutcome) -> bool { + pub(crate) fn print_outcome(outcome: &AnalysisOutcome) -> bool { let violations: Vec<_> = outcome .findings .iter() @@ -452,10 +462,11 @@ impl Reporter { .map_or_else(String::new, |location| { format!( " ({} statement {})", - location.file, location.statement_index + terminal_inline(&location.file), + location.statement_index ) }); - println!(" - {}{}", evidence.summary, location); + println!(" - {}{}", terminal_inline(evidence.summary), location); } println!(); } @@ -494,14 +505,31 @@ fn markdown_tier_label(tier: &ViolationTier) -> &'static str { } fn markdown_escape(value: &str) -> String { - value.replace('\\', "\\\\").replace('|', "\\|") + markdown_inline_text(value) + .replace('\\', "\\\\") + .replace('|', "\\|") + .replace('<', "\\<") + .replace('>', "\\>") } fn markdown_code(value: &str) -> String { - value.replace('`', "'") + markdown_inline_text(value).replace('`', "'") +} + +fn markdown_inline_text(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '\r' | '\n' => output.push(' '), + character if character.is_control() => output.extend(character.escape_default()), + character => output.push(character), + } + } + output } fn markdown_sql_block(sql: &str) -> String { + let sql = markdown_block_text(sql); let longest_backtick_run = sql .split(|character| character != '`') .map(str::len) @@ -510,3 +538,39 @@ fn markdown_sql_block(sql: &str) -> String { let fence = "`".repeat(longest_backtick_run.max(2) + 1); format!("\n{fence}sql\n{sql}\n{fence}\n") } + +fn markdown_block_text(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + for character in value.chars() { + if matches!(character, '\n' | '\t') { + output.push(character); + } else if character.is_control() { + output.extend(character.escape_default()); + } else { + output.push(character); + } + } + output +} + +pub(super) fn terminal_inline(value: &str) -> String { + terminal_text(value, false) +} + +pub(super) fn terminal_block(value: &str) -> String { + terminal_text(value, true) +} + +fn terminal_text(value: &str, preserve_layout: bool) -> String { + let mut output = String::with_capacity(value.len()); + for character in value.chars() { + if preserve_layout && matches!(character, '\n' | '\t') { + output.push(character); + } else if character.is_control() { + output.extend(character.escape_default()); + } else { + output.push(character); + } + } + output +} diff --git a/src/_internal/report/reporter_tests.rs b/src/_internal/report/reporter_tests.rs index 4ff050fb..31713797 100644 --- a/src/_internal/report/reporter_tests.rs +++ b/src/_internal/report/reporter_tests.rs @@ -5,7 +5,9 @@ mod tests { }; use crate::_internal::analysis::outcome::AnalysisOutcome; use crate::_internal::analysis::state::Confidence; - use crate::_internal::report::reporter::{Reporter, Verdict, compute_verdict}; + use crate::_internal::report::reporter::{ + Reporter, Verdict, compute_verdict, terminal_block, terminal_inline, tier_label_with_color, + }; use crate::_internal::report::violations::{ ObjectKind, OperationKind, ReportFinding, SourceLocation, Violation, ViolationTier, }; @@ -209,6 +211,91 @@ mod tests { assert!(markdown.contains("\n````sql\nSELECT '```';\n````\n")); } + #[test] + fn markdown_sql_blocks_render_controls_inertly() { + let mut violation = make_violation("test-rule", ViolationTier::Tier2, "review"); + violation.sql = Some("SELECT '\x1b[2J';\n\tSELECT 1;".to_string()); + let finding = ReportFinding { + violation, + location: None, + statement_index: None, + }; + + let markdown = Reporter::markdown_report(&[finding], &Confidence::Exact); + assert!(!markdown.contains('\x1b')); + assert!(markdown.contains("SELECT '\\u{1b}[2J';\n\tSELECT 1;")); + } + + #[test] + fn markdown_inline_values_cannot_create_report_structure() { + let finding = ReportFinding { + location: Some(SourceLocation { + file: "migrations/001.sql\n## forged".to_string(), + line: 1, + column: 1, + }), + statement_index: Some(1), + violation: Violation { + source_range: None, + rule_id: "test-rule", + operation_kind: OperationKind::Other("test".to_string()), + object_kind: ObjectKind::Table, + object_name: "entry\n## forged
".to_string(), + tier: ViolationTier::Tier2, + reason: "review\r\n## forged".to_string(), + recipe: "escape | inline ", + dedup_key: None, + sql: None, + fk_dependency_related: false, + }, + }; + + let markdown = Reporter::markdown_report(&[finding], &Confidence::Exact); + assert!(!markdown.contains("\n## forged")); + assert!(markdown.contains("entry ## forged \\")); + assert!(markdown.contains("escape \\| inline \\")); + } + + #[test] + fn markdown_inline_values_render_controls_inertly() { + let finding = ReportFinding { + location: Some(SourceLocation { + file: "migrations/\x1b[2J.sql".to_string(), + line: 1, + column: 1, + }), + statement_index: None, + violation: Violation { + source_range: None, + rule_id: "test-rule", + operation_kind: OperationKind::Other("test".to_string()), + object_kind: ObjectKind::Table, + object_name: "entry\x1b[2J".to_string(), + tier: ViolationTier::Tier2, + reason: "review\x1b[2J".to_string(), + recipe: "review", + dedup_key: None, + sql: None, + fk_dependency_related: false, + }, + }; + + let markdown = Reporter::markdown_report(&[finding], &Confidence::Exact); + assert!(!markdown.contains('\x1b')); + assert!(markdown.contains("entry\\\\u{1b}[2J")); + assert!(markdown.contains("migrations/\\u{1b}[2J.sql")); + } + + #[test] + fn terminal_values_render_control_characters_inertly() { + assert_eq!(terminal_inline("name\n\u{1b}[31m"), "name\\n\\u{1b}[31m"); + assert_eq!(terminal_inline("café_日本"), "café_日本"); + assert_eq!( + terminal_block("SELECT\n'\u{1b}[31m';"), + "SELECT\n'\\u{1b}[31m';" + ); + } + #[test] fn test_verdict_halt_tier1() { let violations = vec![make_violation("test-rule", ViolationTier::Tier1, "halt")]; @@ -283,18 +370,10 @@ mod tests { #[test] fn test_no_color_toggling() { - unsafe { - std::env::set_var("NO_COLOR", "1"); - } - let tier1_colored = - crate::_internal::report::reporter::tier_label_colored(&ViolationTier::Tier1); - assert_eq!(tier1_colored, "HALT"); + let tier1_plain = tier_label_with_color(&ViolationTier::Tier1, false); + assert_eq!(tier1_plain, "HALT"); - unsafe { - std::env::remove_var("NO_COLOR"); - } - let tier1_colored_style = - crate::_internal::report::reporter::tier_label_colored(&ViolationTier::Tier1); + let tier1_colored_style = tier_label_with_color(&ViolationTier::Tier1, true); assert!(tier1_colored_style.contains("HALT")); assert!(tier1_colored_style.contains("\x1b[")); } diff --git a/src/_internal/report/violations.rs b/src/_internal/report/violations.rs index 9fbf4849..1608aeda 100644 --- a/src/_internal/report/violations.rs +++ b/src/_internal/report/violations.rs @@ -1,5 +1,5 @@ #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] -pub enum OperationKind { +pub(crate) enum OperationKind { DropColumn, DropTable, DropIndex, @@ -21,30 +21,24 @@ pub enum OperationKind { CreateIndex, CreateTable, CreateView, - CreateFunction, - CreateProcedure, AlterFunction, AlterProcedure, RefreshMaterializedView, AttachPartition, DetachPartition, VacuumFull, + LockTable, + TruncateTable, Grant, - RevokeGrant, AlterType, - CreateTrigger, CreatePolicy, DisableTrigger, EnableTrigger, - RenameTable, - RenameColumn, Rename, OpaqueSql, CreateSchema, SetDefault, CreateSequence, - CreateDomain, - AlterSchema, Conflict, Irreversible, UnresolvedReference, @@ -75,30 +69,24 @@ impl std::fmt::Display for OperationKind { OperationKind::CreateIndex => write!(f, "create_index"), OperationKind::CreateTable => write!(f, "create_table"), OperationKind::CreateView => write!(f, "create_view"), - OperationKind::CreateFunction => write!(f, "create_function"), - OperationKind::CreateProcedure => write!(f, "create_procedure"), OperationKind::AlterFunction => write!(f, "alter_function"), OperationKind::AlterProcedure => write!(f, "alter_procedure"), OperationKind::RefreshMaterializedView => write!(f, "refresh_materialized_view"), OperationKind::AttachPartition => write!(f, "attach_partition"), OperationKind::DetachPartition => write!(f, "detach_partition"), OperationKind::VacuumFull => write!(f, "vacuum_full"), + OperationKind::LockTable => write!(f, "lock_table"), + OperationKind::TruncateTable => write!(f, "truncate_table"), OperationKind::Grant => write!(f, "grant"), - OperationKind::RevokeGrant => write!(f, "revoke_grant"), OperationKind::AlterType => write!(f, "alter_type"), - OperationKind::CreateTrigger => write!(f, "create_trigger"), OperationKind::CreatePolicy => write!(f, "create_policy"), OperationKind::DisableTrigger => write!(f, "disable_trigger"), OperationKind::EnableTrigger => write!(f, "enable_trigger"), - OperationKind::RenameTable => write!(f, "rename_table"), - OperationKind::RenameColumn => write!(f, "rename_column"), OperationKind::Rename => write!(f, "rename"), OperationKind::OpaqueSql => write!(f, "opaque_sql"), OperationKind::CreateSchema => write!(f, "create_schema"), OperationKind::SetDefault => write!(f, "set_default"), OperationKind::CreateSequence => write!(f, "create_sequence"), - OperationKind::CreateDomain => write!(f, "create_domain"), - OperationKind::AlterSchema => write!(f, "alter_schema"), OperationKind::Conflict => write!(f, "conflict"), OperationKind::Irreversible => write!(f, "irreversible"), OperationKind::UnresolvedReference => write!(f, "unresolved_reference"), @@ -108,7 +96,7 @@ impl std::fmt::Display for OperationKind { } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] -pub enum ObjectKind { +pub(crate) enum ObjectKind { Table, Index, View, @@ -120,7 +108,6 @@ pub enum ObjectKind { Schema, Role, Publication, - Subscription, Database, Domain, Policy, @@ -143,7 +130,6 @@ impl std::fmt::Display for ObjectKind { ObjectKind::Schema => write!(f, "schema"), ObjectKind::Role => write!(f, "role"), ObjectKind::Publication => write!(f, "publication"), - ObjectKind::Subscription => write!(f, "subscription"), ObjectKind::Database => write!(f, "database"), ObjectKind::Domain => write!(f, "domain"), ObjectKind::Policy => write!(f, "policy"), @@ -157,14 +143,14 @@ impl std::fmt::Display for ObjectKind { /// ViolationTier represents the severity of a finding. /// Tier1 is declared first so `derive(Ord)` sorts it before Tier2 and Tier3. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] -pub enum ViolationTier { +pub(crate) enum ViolationTier { Tier1, // HALT — Access Exclusive / data-destructive, sorts first Tier2, // WARN — Share Row Exclusive / cautious Tier3, // SAFE — informational / low risk, sorts last } #[derive(Debug, Clone, serde::Serialize)] -pub struct Violation { +pub(crate) struct Violation { #[serde(skip)] pub source_range: Option, pub rule_id: &'static str, @@ -183,7 +169,7 @@ pub struct Violation { /// Stable source location attached at reporting time. Rules remain independent /// of file layout; the engine derives this from the parsed statement range. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] -pub struct SourceLocation { +pub(crate) struct SourceLocation { pub file: String, pub line: usize, pub column: usize, @@ -192,7 +178,7 @@ pub struct SourceLocation { /// A violation paired with the file and line that produced it. The flattened /// serialization keeps the JSON violation schema additive. #[derive(Debug, Clone, serde::Serialize)] -pub struct ReportFinding { +pub(crate) struct ReportFinding { #[serde(flatten)] pub violation: Violation, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/_internal/rules/conflict.rs b/src/_internal/rules/conflict.rs index 41b9bf18..299bf18c 100644 --- a/src/_internal/rules/conflict.rs +++ b/src/_internal/rules/conflict.rs @@ -2,7 +2,7 @@ use crate::_internal::analysis::state::MutationResult; use crate::_internal::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier}; use crate::_internal::rules::{Rule, RuleContext}; -pub struct ConflictRule; +pub(crate) struct ConflictRule; impl ConflictRule { const ID: &'static str = "chain-conflict"; @@ -65,7 +65,7 @@ mod tests { use super::*; use crate::_internal::analysis::mutations::Mutation; use crate::_internal::analysis::state::MutationResult; - use crate::_internal::engine::config::Config; + use crate::api::config::Config; use std::collections::{HashMap, HashSet}; #[test] diff --git a/src/_internal/rules/constraints.rs b/src/_internal/rules/constraints.rs index 6a994a9a..88a3bef3 100644 --- a/src/_internal/rules/constraints.rs +++ b/src/_internal/rules/constraints.rs @@ -4,7 +4,7 @@ use crate::_internal::model::relation::Persistence; use crate::_internal::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier}; use crate::_internal::rules::{BASELINE_STATS_CAPABILITIES, Rule, RuleCapability, RuleContext}; -pub struct BlockingConstraintRule; +pub(crate) struct BlockingConstraintRule; impl Rule for BlockingConstraintRule { fn id(&self) -> &'static str { @@ -68,7 +68,7 @@ impl Rule for BlockingConstraintRule { } | AlterTableActionMutation::AddExcludeConstraint { .. } | AlterTableActionMutation::SetStorage { .. } - | AlterTableActionMutation::SetAccessMethod + | AlterTableActionMutation::SetAccessMethod { .. } ); if !action_is_relevant { return violations; @@ -280,7 +280,7 @@ impl Rule for BlockingConstraintRule { fk_dependency_related: false, }); } - AlterTableActionMutation::SetStorage { column } => { + AlterTableActionMutation::SetStorage { column, .. } => { let mut reason = format!( "Changing storage parameter for {}.{} causes a table rewrite", alter.id, column @@ -302,7 +302,7 @@ impl Rule for BlockingConstraintRule { fk_dependency_related: false, }); } - AlterTableActionMutation::SetAccessMethod => { + AlterTableActionMutation::SetAccessMethod { .. } => { let mut reason = format!( "Changing access method for {} causes a table rewrite", alter.id diff --git a/src/_internal/rules/destructive.rs b/src/_internal/rules/destructive.rs index a4b64e4c..51df5cb6 100644 --- a/src/_internal/rules/destructive.rs +++ b/src/_internal/rules/destructive.rs @@ -5,9 +5,9 @@ use crate::_internal::rules::{ BASELINE_RELATION_CAPABILITIES, BASELINE_STATS_CAPABILITIES, Rule, RuleCapability, RuleContext, }; -pub const IRREVERSIBLE_MIGRATION_RULE_ID: &str = "irreversible-migration"; +pub(crate) const IRREVERSIBLE_MIGRATION_RULE_ID: &str = "irreversible-migration"; -pub struct CascadingDropRule; +pub(crate) struct CascadingDropRule; impl Rule for CascadingDropRule { fn id(&self) -> &'static str { @@ -106,7 +106,7 @@ impl Rule for CascadingDropRule { } } -pub struct SizeAwareAddColumnRule; +pub(crate) struct SizeAwareAddColumnRule; impl Rule for SizeAwareAddColumnRule { fn id(&self) -> &'static str { @@ -232,7 +232,7 @@ impl Rule for SizeAwareAddColumnRule { } } -pub struct DropDatabaseRule; +pub(crate) struct DropDatabaseRule; impl Rule for DropDatabaseRule { fn id(&self) -> &'static str { @@ -266,7 +266,7 @@ impl Rule for DropDatabaseRule { } } -pub struct DropSchemaCascadeRule; +pub(crate) struct DropSchemaCascadeRule; impl Rule for DropSchemaCascadeRule { fn id(&self) -> &'static str { @@ -305,7 +305,7 @@ impl Rule for DropSchemaCascadeRule { } } -pub struct CreateTableAsSelectRule; +pub(crate) struct CreateTableAsSelectRule; impl Rule for CreateTableAsSelectRule { fn id(&self) -> &'static str { @@ -345,13 +345,13 @@ impl Rule for CreateTableAsSelectRule { } } -pub enum Reversibility { +pub(crate) enum Reversibility { Reversible, ConditionallyReversible, Irreversible, } -pub fn classify(mutation: &Mutation) -> Reversibility { +pub(crate) fn classify(mutation: &Mutation) -> Reversibility { match mutation { Mutation::Rename(_) => Reversibility::Reversible, Mutation::CreateIndex(_) | Mutation::CreateTable(_) => Reversibility::Reversible, @@ -361,12 +361,14 @@ pub fn classify(mutation: &Mutation) -> Reversibility { AlterTableActionMutation::SetType { .. } => Reversibility::ConditionallyReversible, _ => Reversibility::Reversible, }, - Mutation::DropTable(_) | Mutation::DropDatabase(_) => Reversibility::Irreversible, + Mutation::DropTable(_) | Mutation::DropDatabase(_) | Mutation::Truncate(_) => { + Reversibility::Irreversible + } _ => Reversibility::ConditionallyReversible, } } -pub struct ReversibilityRule; +pub(crate) struct ReversibilityRule; impl Rule for ReversibilityRule { fn id(&self) -> &'static str { @@ -391,6 +393,7 @@ impl Rule for ReversibilityRule { Mutation::AlterTable(crate::_internal::analysis::mutations::AlterTable { id: _, action: AlterTableActionMutation::DropColumn { .. }, + .. }) ) && matches!( @@ -457,6 +460,18 @@ impl Rule for ReversibilityRule { .filter_map(|id| pre_state.relations.get(id).and_then(|r| r.estimated_rows)) .max() .unwrap_or(config.default_rows) + } else if let Mutation::Truncate(truncate) = mutation { + truncate + .targets + .iter() + .filter_map(|target| { + pre_state + .relations + .get(&target.id) + .and_then(|relation| relation.estimated_rows) + }) + .max() + .unwrap_or(config.default_rows) } else { config.default_rows }; @@ -502,6 +517,16 @@ impl Rule for ReversibilityRule { ObjectKind::Database, d.id.to_string(), ), + Mutation::Truncate(truncate) => ( + OperationKind::TruncateTable, + ObjectKind::Table, + truncate + .targets + .iter() + .map(|target| target.id.to_string()) + .collect::>() + .join(", "), + ), _ => ( OperationKind::Irreversible, ObjectKind::Table, @@ -612,7 +637,7 @@ fn integer_type_size_bits(ty: &str) -> Option { } } -pub struct GeneralCascadeRule; +pub(crate) struct GeneralCascadeRule; impl Rule for GeneralCascadeRule { fn id(&self) -> &'static str { @@ -701,7 +726,7 @@ impl Rule for GeneralCascadeRule { } } -pub struct TypeChangeRewriteRule; +pub(crate) struct TypeChangeRewriteRule; impl TypeChangeRewriteRule { fn is_type_change_safe(old_type: &str, new_type: &str, pg_version: u32) -> bool { @@ -761,7 +786,7 @@ impl TypeChangeRewriteRule { /// /// A smaller typmod means a smaller character limit, which is lossy. /// Returns true if the new modifier represents a smaller limit than the old. - pub fn is_lossy_varchar_narrowing( + pub(crate) fn is_lossy_varchar_narrowing( old_modifier: Option, new_modifier: Option, ) -> bool { @@ -799,7 +824,7 @@ fn parse_numeric_params(ty: &str) -> Option<(i32, i32)> { /// Extracts a synthetic type_modifier-like value from a type string. /// Used when the new type comes from the migration SQL (not from the cache). /// For varchar(N), derives the atttypmod from the character limit. -pub fn extract_type_modifier_from_type_string(ty: &str) -> Option { +pub(crate) fn extract_type_modifier_from_type_string(ty: &str) -> Option { let lower = ty.to_lowercase().trim().to_string(); // Check for varchar(N) or character varying(N) if lower.starts_with("varchar(") || lower.starts_with("character varying(") { diff --git a/src/_internal/rules/drift.rs b/src/_internal/rules/drift.rs index 7e24a39d..205e3965 100644 --- a/src/_internal/rules/drift.rs +++ b/src/_internal/rules/drift.rs @@ -3,7 +3,7 @@ use crate::_internal::ast::identifiers::ObjectId; use crate::_internal::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier}; use crate::_internal::rules::{BASELINE_RELATION_CAPABILITIES, Rule, RuleCapability, RuleContext}; -pub struct DriftDetectionRule; +pub(crate) struct DriftDetectionRule; impl Rule for DriftDetectionRule { fn id(&self) -> &'static str { diff --git a/src/_internal/rules/expressions.rs b/src/_internal/rules/expressions.rs index d1fd2a53..1416a3eb 100644 --- a/src/_internal/rules/expressions.rs +++ b/src/_internal/rules/expressions.rs @@ -3,7 +3,7 @@ use crate::_internal::analysis::state::MutationResult; use crate::_internal::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier}; use crate::_internal::rules::{Rule, RuleContext}; -pub struct VolatileDefaultRule; +pub(crate) struct VolatileDefaultRule; impl Rule for VolatileDefaultRule { fn id(&self) -> &'static str { diff --git a/src/_internal/rules/functions.rs b/src/_internal/rules/functions.rs index 9ca67031..9704db72 100644 --- a/src/_internal/rules/functions.rs +++ b/src/_internal/rules/functions.rs @@ -5,7 +5,7 @@ use crate::_internal::rules::{ FUNCTION_CAPABILITIES, FUNCTION_DEPENDENCY_CAPABILITIES, Rule, RuleCapability, RuleContext, }; -pub struct FunctionVolatilityRule; +pub(crate) struct FunctionVolatilityRule; impl Rule for FunctionVolatilityRule { fn id(&self) -> &'static str { @@ -72,7 +72,7 @@ impl Rule for FunctionVolatilityRule { } } -pub struct BrokenComputeRule; +pub(crate) struct BrokenComputeRule; impl Rule for BrokenComputeRule { fn id(&self) -> &'static str { diff --git a/src/_internal/rules/idempotency.rs b/src/_internal/rules/idempotency.rs index a8db087f..2082ac3a 100644 --- a/src/_internal/rules/idempotency.rs +++ b/src/_internal/rules/idempotency.rs @@ -2,7 +2,7 @@ use crate::_internal::analysis::mutations::{AlterTableActionMutation, Mutation}; use crate::_internal::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier}; use crate::_internal::rules::{Rule, RuleContext}; -pub struct IdempotencyRule; +pub(crate) struct IdempotencyRule; impl Rule for IdempotencyRule { fn id(&self) -> &'static str { diff --git a/src/_internal/rules/indexes.rs b/src/_internal/rules/indexes.rs index 0af4ecdd..5aee6781 100644 --- a/src/_internal/rules/indexes.rs +++ b/src/_internal/rules/indexes.rs @@ -6,7 +6,7 @@ use crate::_internal::rules::{ BASELINE_STATS_DEPENDENCY_CAPABILITIES, Rule, RuleCapability, RuleContext, }; -pub struct ConcurrentIndexRule; +pub(crate) struct ConcurrentIndexRule; impl Rule for ConcurrentIndexRule { fn id(&self) -> &'static str { @@ -26,7 +26,7 @@ impl Rule for ConcurrentIndexRule { fn evaluate(&self, context: &RuleContext<'_>) -> Vec { if *context.result() == MutationResult::Skipped { // An index that is present in the pre-state still incurs the - // synchronous DROP INDEX risk even when V6 metadata is too + // synchronous DROP INDEX risk even when catalog metadata is too // incomplete to mutate it exactly (for example, eligibility for // a backing constraint is not serialized). A truly absent, // guarded drop remains a no-op and is correctly suppressed. diff --git a/src/_internal/rules/mod.rs b/src/_internal/rules/mod.rs index 57e7fd91..5508193b 100644 --- a/src/_internal/rules/mod.rs +++ b/src/_internal/rules/mod.rs @@ -1,26 +1,25 @@ -pub mod conflict; -pub mod constraints; -pub mod destructive; -pub mod drift; -pub mod expressions; -pub mod functions; -pub mod idempotency; -pub mod indexes; -pub mod opaque; -pub mod partitions; -pub mod policies; -pub mod registry; -pub mod security; -pub mod timeouts; -pub mod transactions; -pub mod triggers; -pub mod views; +pub(crate) mod conflict; +pub(crate) mod constraints; +pub(crate) mod destructive; +pub(crate) mod drift; +pub(crate) mod expressions; +pub(crate) mod functions; +pub(crate) mod idempotency; +pub(crate) mod indexes; +pub(crate) mod opaque; +pub(crate) mod partitions; +pub(crate) mod policies; +pub(crate) mod registry; +pub(crate) mod security; +pub(crate) mod timeouts; +pub(crate) mod transactions; +pub(crate) mod triggers; +pub(crate) mod views; -use crate::_internal::analysis::evidence::EvidenceRecord; use crate::_internal::analysis::mutations::Mutation; -use crate::_internal::analysis::state::{AnalysisState, CascadeResult, Confidence, MutationResult}; -use crate::_internal::engine::config::Config; +use crate::_internal::analysis::state::{AnalysisState, CascadeResult, MutationResult}; use crate::_internal::report::violations::{Violation, ViolationTier}; +use crate::api::config::Config; /// Read-only inputs supplied to a rule for one analyzed mutation. /// @@ -32,8 +31,6 @@ pub(crate) struct TransitionRecord<'a> { result: &'a MutationResult, pre_state: &'a crate::_internal::analysis::state::PreState, cascade_closure: Option<&'a CascadeResult>, - evidence: &'a [EvidenceRecord], - confidence: &'a Confidence, } impl<'a> TransitionRecord<'a> { @@ -42,21 +39,17 @@ impl<'a> TransitionRecord<'a> { result: &'a MutationResult, pre_state: &'a crate::_internal::analysis::state::PreState, cascade_closure: Option<&'a CascadeResult>, - evidence: &'a [EvidenceRecord], - confidence: &'a Confidence, ) -> Self { Self { mutation, result, pre_state, cascade_closure, - evidence, - confidence, } } } -pub struct RuleContext<'a> { +pub(crate) struct RuleContext<'a> { pub(crate) transition: TransitionRecord<'a>, pub(crate) state: &'a AnalysisState, pub(crate) config: &'a Config, @@ -66,7 +59,7 @@ pub struct RuleContext<'a> { /// result. Declarations are checked centrally so new rules cannot silently /// depend on an untracked part of the transition state. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RuleCapability { +pub(crate) enum RuleCapability { BaselineRelations, CatalogDependencies, RowStatistics, @@ -206,55 +199,40 @@ impl<'a> RuleContext<'a> { cascade_closure: Option<&'a CascadeResult>, ) -> Self { Self { - transition: TransitionRecord::new( - mutation, - result, - pre_state, - cascade_closure, - state.evidence(), - state.confidence(), - ), + transition: TransitionRecord::new(mutation, result, pre_state, cascade_closure), state, config, } } - pub fn evidence(&self) -> &[EvidenceRecord] { - self.transition.evidence - } - - pub fn confidence(&self) -> &Confidence { - self.transition.confidence - } - - pub fn mutation(&self) -> &Mutation { + pub(crate) fn mutation(&self) -> &Mutation { self.transition.mutation } - pub fn result(&self) -> &MutationResult { + pub(crate) fn result(&self) -> &MutationResult { self.transition.result } - pub fn pre_state(&self) -> &crate::_internal::analysis::state::PreState { + pub(crate) fn pre_state(&self) -> &crate::_internal::analysis::state::PreState { self.transition.pre_state } - pub fn state(&self) -> &AnalysisState { + pub(crate) fn state(&self) -> &AnalysisState { self.state } - pub fn config(&self) -> &Config { + pub(crate) fn config(&self) -> &Config { self.config } - pub fn cascade_closure(&self) -> Option<&CascadeResult> { + pub(crate) fn cascade_closure(&self) -> Option<&CascadeResult> { self.transition.cascade_closure } } /// Supported rule interface. Implementations receive one immutable context /// object, so future inputs can be added without another argument explosion. -pub trait Rule { +pub(crate) trait Rule { fn id(&self) -> &'static str; fn default_tier(&self) -> ViolationTier; fn recipe(&self) -> &'static str; diff --git a/src/_internal/rules/opaque.rs b/src/_internal/rules/opaque.rs index 5623adf4..e245c8bd 100644 --- a/src/_internal/rules/opaque.rs +++ b/src/_internal/rules/opaque.rs @@ -2,7 +2,7 @@ use crate::_internal::analysis::mutations::Mutation; use crate::_internal::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier}; use crate::_internal::rules::{Rule, RuleContext}; -pub struct OpaqueDynamicSqlRule; +pub(crate) struct OpaqueDynamicSqlRule; impl Rule for OpaqueDynamicSqlRule { fn id(&self) -> &'static str { diff --git a/src/_internal/rules/partitions.rs b/src/_internal/rules/partitions.rs index d6ea4f37..f99ea66c 100644 --- a/src/_internal/rules/partitions.rs +++ b/src/_internal/rules/partitions.rs @@ -4,7 +4,7 @@ use crate::_internal::model::relation::Persistence; use crate::_internal::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier}; use crate::_internal::rules::{BASELINE_STATS_CAPABILITIES, Rule, RuleCapability, RuleContext}; -pub struct PartitionLockRule; +pub(crate) struct PartitionLockRule; impl Rule for PartitionLockRule { fn id(&self) -> &'static str { @@ -139,7 +139,7 @@ impl Rule for PartitionLockRule { } } -pub struct PartitionStrategyMismatchRule; +pub(crate) struct PartitionStrategyMismatchRule; impl Rule for PartitionStrategyMismatchRule { fn id(&self) -> &'static str { @@ -164,7 +164,9 @@ impl Rule for PartitionStrategyMismatchRule { let mut violations = Vec::new(); if let Mutation::AlterTable(alter) = context.mutation() - && let AlterTableActionMutation::AttachPartition { child, strategy } = &alter.action + && let AlterTableActionMutation::AttachPartition { + child, strategy, .. + } = &alter.action { let parent_partition_type = context .pre_state() diff --git a/src/_internal/rules/policies.rs b/src/_internal/rules/policies.rs index 3ebf2a9f..a29e5ab5 100644 --- a/src/_internal/rules/policies.rs +++ b/src/_internal/rules/policies.rs @@ -3,7 +3,7 @@ use crate::_internal::analysis::state::MutationResult; use crate::_internal::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier}; use crate::_internal::rules::{Rule, RuleContext}; -pub struct RestrictivePolicyRule; +pub(crate) struct RestrictivePolicyRule; impl Rule for RestrictivePolicyRule { fn id(&self) -> &'static str { diff --git a/src/_internal/rules/registry.rs b/src/_internal/rules/registry.rs index 7ba74a0b..58cabec7 100644 --- a/src/_internal/rules/registry.rs +++ b/src/_internal/rules/registry.rs @@ -17,7 +17,7 @@ use crate::_internal::rules::policies::RestrictivePolicyRule; use crate::_internal::rules::security::OverbroadGrantRule; use crate::_internal::rules::timeouts::{RequireLockTimeoutRule, RequireStatementTimeoutRule}; use crate::_internal::rules::transactions::{ - AlterTypeAddValueRule, ConcurrentInsideTransactionRule, VacuumFullRule, + AlterTypeAddValueRule, ConcurrentInsideTransactionRule, LockTableRule, VacuumFullRule, }; use crate::_internal::rules::triggers::DisableTriggerRule; use crate::_internal::rules::views::MaterializedViewRefreshRule; @@ -27,7 +27,7 @@ use crate::_internal::rules::views::MaterializedViewRefreshRule; /// Keep this registry in evaluation order. Discovery, configuration validation, /// documentation checks, and engine construction all read it. Auxiliary /// findings emitted by a primary rule are not entries. -pub struct RuleDescriptor { +pub(crate) struct RuleDescriptor { pub id: &'static str, pub title: &'static str, pub summary: &'static str, @@ -37,22 +37,12 @@ pub struct RuleDescriptor { } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RuleConfigurationField { +pub(crate) enum RuleConfigurationField { Disabled, Tier1ThresholdRows, Tier2ThresholdRows, } -impl RuleConfigurationField { - pub const fn as_str(self) -> &'static str { - match self { - Self::Disabled => "disabled", - Self::Tier1ThresholdRows => "tier1_threshold_rows", - Self::Tier2ThresholdRows => "tier2_threshold_rows", - } - } -} - const DISABLED_ONLY: &[RuleConfigurationField] = &[RuleConfigurationField::Disabled]; const WITH_TIER1_THRESHOLD: &[RuleConfigurationField] = &[ RuleConfigurationField::Disabled, @@ -65,19 +55,19 @@ const WITH_ROW_THRESHOLDS: &[RuleConfigurationField] = &[ ]; impl RuleDescriptor { - pub fn build(&self) -> Box { + pub(crate) fn build(&self) -> Box { (self.factory)() } - pub fn default_tier(&self) -> ViolationTier { + pub(crate) fn default_tier(&self) -> ViolationTier { self.build().default_tier() } - pub fn recipe(&self) -> &'static str { + pub(crate) fn recipe(&self) -> &'static str { self.build().recipe() } - pub fn supports(&self, field: RuleConfigurationField) -> bool { + pub(crate) fn supports(&self, field: RuleConfigurationField) -> bool { self.supported_configuration_fields.contains(&field) } } @@ -100,7 +90,7 @@ macro_rules! descriptor { // Marker rules are currently zero-sized; their constructors are kept in this // registry so future initialized rules can supply a dedicated factory. -pub static PRIMARY_RULES: &[RuleDescriptor] = &[ +pub(crate) static PRIMARY_RULES: &[RuleDescriptor] = &[ descriptor!( "irreversible-migration", "Irreversible migration", @@ -109,6 +99,13 @@ pub static PRIMARY_RULES: &[RuleDescriptor] = &[ ReversibilityRule, WITH_TIER1_THRESHOLD ), + descriptor!( + "table-lock", + "Strong table lock", + "Flags explicit locks that can block concurrent work.", + "availability", + LockTableRule + ), descriptor!( "drop-database", "Drop database", @@ -306,16 +303,16 @@ pub static PRIMARY_RULES: &[RuleDescriptor] = &[ ), ]; -pub fn primary_rule_ids() -> impl Iterator { +pub(crate) fn primary_rule_ids() -> impl Iterator { PRIMARY_RULES.iter().map(|rule| rule.id) } -pub fn find_primary_rule(id: &str) -> Option<&'static RuleDescriptor> { +pub(crate) fn find_primary_rule(id: &str) -> Option<&'static RuleDescriptor> { PRIMARY_RULES.iter().find(|rule| rule.id == id) } -pub fn validate_rule_configuration( - config: &crate::_internal::engine::config::Config, +pub(crate) fn validate_rule_configuration( + config: &crate::api::config::Config, ) -> Result<(), String> { if config.tier1_threshold_rows < config.tier2_threshold_rows { return Err(format!( @@ -361,7 +358,7 @@ pub fn validate_rule_configuration( Ok(()) } -pub fn build_primary_rules() -> Vec> { +pub(crate) fn build_primary_rules() -> Vec> { PRIMARY_RULES.iter().map(RuleDescriptor::build).collect() } @@ -453,10 +450,10 @@ mod tests { #[test] fn threshold_validation_requires_tier1_at_or_above_tier2() { - let globally_reversed = crate::_internal::engine::config::Config { + let globally_reversed = crate::api::config::Config { tier1_threshold_rows: 9, tier2_threshold_rows: 10, - ..crate::_internal::engine::config::Config::default() + ..crate::api::config::Config::default() }; assert!( validate_rule_configuration(&globally_reversed) @@ -464,13 +461,13 @@ mod tests { .contains("tier1_threshold_rows (9)") ); - let mut per_rule_reversed = crate::_internal::engine::config::Config::default(); + let mut per_rule_reversed = crate::api::config::Config::default(); per_rule_reversed.rules.insert( "blocking-constraint".into(), - crate::_internal::engine::config::RuleConfig { + crate::api::config::RuleConfig { tier1_threshold_rows: Some(5), tier2_threshold_rows: Some(6), - ..crate::_internal::engine::config::RuleConfig::default() + ..crate::api::config::RuleConfig::default() }, ); assert!( @@ -482,12 +479,12 @@ mod tests { #[test] fn unsupported_per_rule_thresholds_are_rejected() { - let mut config = crate::_internal::engine::config::Config::default(); + let mut config = crate::api::config::Config::default(); config.rules.insert( "require-lock-timeout".to_string(), - crate::_internal::engine::config::RuleConfig { + crate::api::config::RuleConfig { tier1_threshold_rows: Some(1), - ..crate::_internal::engine::config::RuleConfig::default() + ..crate::api::config::RuleConfig::default() }, ); diff --git a/src/_internal/rules/security.rs b/src/_internal/rules/security.rs index 29c19f3b..7d24141e 100644 --- a/src/_internal/rules/security.rs +++ b/src/_internal/rules/security.rs @@ -3,7 +3,7 @@ use crate::_internal::analysis::state::MutationResult; use crate::_internal::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier}; use crate::_internal::rules::{Rule, RuleContext}; -pub struct OverbroadGrantRule; +pub(crate) struct OverbroadGrantRule; impl Rule for OverbroadGrantRule { fn id(&self) -> &'static str { diff --git a/src/_internal/rules/timeouts.rs b/src/_internal/rules/timeouts.rs index e6dff2f4..a0c6bf06 100644 --- a/src/_internal/rules/timeouts.rs +++ b/src/_internal/rules/timeouts.rs @@ -3,7 +3,7 @@ use crate::_internal::analysis::state::MutationResult; use crate::_internal::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier}; use crate::_internal::rules::{Rule, RuleContext}; -pub struct RequireLockTimeoutRule; +pub(crate) struct RequireLockTimeoutRule; impl Rule for RequireLockTimeoutRule { fn id(&self) -> &'static str { @@ -56,7 +56,7 @@ impl Rule for RequireLockTimeoutRule { } } -pub struct RequireStatementTimeoutRule; +pub(crate) struct RequireStatementTimeoutRule; impl Rule for RequireStatementTimeoutRule { fn id(&self) -> &'static str { diff --git a/src/_internal/rules/transactions.rs b/src/_internal/rules/transactions.rs index a1e988bc..56659bb5 100644 --- a/src/_internal/rules/transactions.rs +++ b/src/_internal/rules/transactions.rs @@ -1,8 +1,9 @@ +use crate::_internal::analysis::facts::LockModeFact; use crate::_internal::analysis::mutations::{AlterTypeActionMutation, Mutation}; use crate::_internal::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier}; use crate::_internal::rules::{Rule, RuleCapability, RuleContext, TRANSACTION_CAPABILITIES}; -pub struct ConcurrentInsideTransactionRule; +pub(crate) struct ConcurrentInsideTransactionRule; impl Rule for ConcurrentInsideTransactionRule { fn id(&self) -> &'static str { @@ -84,7 +85,7 @@ impl Rule for ConcurrentInsideTransactionRule { } } -pub struct AlterTypeAddValueRule; +pub(crate) struct AlterTypeAddValueRule; impl Rule for AlterTypeAddValueRule { fn id(&self) -> &'static str { @@ -127,7 +128,61 @@ impl Rule for AlterTypeAddValueRule { } } -pub struct VacuumFullRule; +pub(crate) struct VacuumFullRule; + +pub(crate) struct LockTableRule; + +impl Rule for LockTableRule { + fn id(&self) -> &'static str { + "table-lock" + } + + fn default_tier(&self) -> ViolationTier { + ViolationTier::Tier2 + } + + fn recipe(&self) -> &'static str { + "Use the weakest lock mode that preserves correctness, set lock_timeout, and prefer NOWAIT when the migration can retry safely." + } + + fn evaluate(&self, context: &RuleContext<'_>) -> Vec { + let Mutation::LockTable(lock) = context.mutation() else { + return Vec::new(); + }; + let tier = match lock.mode { + LockModeFact::AccessExclusive => ViolationTier::Tier1, + LockModeFact::Exclusive | LockModeFact::ShareRowExclusive => self.default_tier(), + _ => return Vec::new(), + }; + let mode = match lock.mode { + LockModeFact::ShareRowExclusive => "SHARE ROW EXCLUSIVE", + LockModeFact::Exclusive => "EXCLUSIVE", + LockModeFact::AccessExclusive => "ACCESS EXCLUSIVE", + _ => unreachable!("only strong lock modes reach this rule"), + }; + lock.targets + .iter() + .map(|target| Violation { + source_range: None, + rule_id: self.id(), + operation_kind: OperationKind::LockTable, + object_kind: ObjectKind::Table, + object_name: target.id.to_string(), + tier: tier.clone(), + reason: format!( + "LOCK TABLE {} uses {}{}", + target.id, + mode, + if lock.nowait { " with NOWAIT" } else { "" } + ), + recipe: self.recipe(), + dedup_key: None, + sql: None, + fk_dependency_related: false, + }) + .collect() + } +} impl Rule for VacuumFullRule { fn id(&self) -> &'static str { diff --git a/src/_internal/rules/triggers.rs b/src/_internal/rules/triggers.rs index 0a25413f..978023f9 100644 --- a/src/_internal/rules/triggers.rs +++ b/src/_internal/rules/triggers.rs @@ -3,7 +3,7 @@ use crate::_internal::analysis::state::MutationResult; use crate::_internal::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier}; use crate::_internal::rules::{Rule, RuleContext}; -pub struct DisableTriggerRule; +pub(crate) struct DisableTriggerRule; impl Rule for DisableTriggerRule { fn id(&self) -> &'static str { diff --git a/src/_internal/rules/views.rs b/src/_internal/rules/views.rs index 5808200b..73e8f5d1 100644 --- a/src/_internal/rules/views.rs +++ b/src/_internal/rules/views.rs @@ -4,7 +4,7 @@ use crate::_internal::model::relation::Persistence; use crate::_internal::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier}; use crate::_internal::rules::{BASELINE_STATS_CAPABILITIES, Rule, RuleCapability, RuleContext}; -pub struct MaterializedViewRefreshRule; +pub(crate) struct MaterializedViewRefreshRule; impl Rule for MaterializedViewRefreshRule { fn id(&self) -> &'static str { diff --git a/src/_internal/sync.rs b/src/_internal/sync.rs index 4e4def1a..b67ec1f2 100644 --- a/src/_internal/sync.rs +++ b/src/_internal/sync.rs @@ -1,12 +1,12 @@ use crate::_internal::ast::identifiers::ObjectId; use crate::_internal::db::cache::{ - CACHE_V7_MAGIC, CatalogCoverage, ConstraintDependencyCache, ConstraintKeyCache, DbCache, + CACHE_V8_MAGIC, CatalogCoverage, ConstraintDependencyCache, ConstraintKeyCache, DbCache, DbCacheVersioned, DefaultSequenceDependencyCache, ForeignKeyCache, GeneratedColumnDependencyCache, IndexCache, InheritanceCache, ViewDependencyCache, }; use crate::_internal::db::cache_file::{ MAX_CACHE_DECODE_BYTES, MAX_CACHE_FILE_BYTES, protect_cache_bytes, - validate_cache_encryption_configuration, + protect_cache_bytes_with_key, validate_cache_encryption_configuration, }; use crate::_internal::model::relation::{Persistence, RelationKind, RelationState}; use anyhow::{Context, Result}; @@ -17,6 +17,7 @@ use std::io::{self, Write}; use std::path::Path; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tempfile::NamedTempFile; +use zeroize::Zeroizing; #[cfg(windows)] use std::fs; @@ -24,31 +25,74 @@ use std::fs; const MIN_POSTGRES_VERSION_NUM: u32 = 140_000; const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -pub fn sync_cache( +pub(crate) fn sync_cache( out_path: &Path, schemas: Option<&[String]>, cache_encryption: bool, ) -> Result<()> { validate_cache_encryption_configuration(cache_encryption) .context("Invalid cache encryption configuration")?; - // Strict env-only credential enforcement - let db_url = std::env::var("DATABASE_URL") - .context("DATABASE_URL environment variable is required to sync PostgreSQL schema metadata and statistics. Do not pass credentials via CLI flags or config files.")?; + let db_url = Zeroizing::new( + std::env::var("DATABASE_URL") + .context("DATABASE_URL environment variable is required to sync PostgreSQL schema metadata and statistics. Do not pass credentials via CLI flags or config files.")?, + ); if db_url.trim().is_empty() { anyhow::bail!("DATABASE_URL must not be empty or whitespace"); } - let mut client = connect_database(&db_url)?; + sync_cache_with_database_url(out_path, schemas, cache_encryption, &db_url) +} + +pub(crate) fn validate_database_url(db_url: &str) -> Result<()> { + parse_database_config(db_url).map(|_| ()) +} + +pub(crate) fn sync_cache_with_database_url( + out_path: &Path, + schemas: Option<&[String]>, + cache_encryption: bool, + db_url: &str, +) -> Result<()> { + validate_cache_encryption_configuration(cache_encryption) + .context("Invalid cache encryption configuration")?; + let mut client = connect_database(db_url)?; let cache = populate_cache(&mut client, schemas)?; write_cache(out_path, cache, cache_encryption) } +pub(crate) fn sync_cache_with_secrets( + out_path: &Path, + schemas: Option<&[String]>, + db_url: &str, + cache_key: Option<&[u8; 32]>, +) -> Result<()> { + let mut client = connect_database(db_url)?; + let cache = populate_cache(&mut client, schemas)?; + match cache_key { + Some(key) => write_cache_with_key(out_path, cache, key), + None => write_cache(out_path, cache, false), + } +} + fn connect_database(db_url: &str) -> Result { - let mut config: PostgresConfig = db_url + let mut config = parse_database_config(db_url)?; + + apply_connection_safety_defaults(&mut config); + + config + .connect(NoTls) + .context("Failed to connect to PostgreSQL") +} + +fn parse_database_config(db_url: &str) -> Result { + if db_url.trim().is_empty() { + anyhow::bail!("PostgreSQL connection string must not be empty or whitespace"); + } + let config: PostgresConfig = db_url .parse() - .context("DATABASE_URL is not a valid PostgreSQL connection string")?; + .context("Invalid PostgreSQL connection string")?; if !database_config_is_local(&config) { anyhow::bail!( @@ -56,11 +100,7 @@ fn connect_database(db_url: &str) -> Result { ); } - apply_connection_safety_defaults(&mut config); - - config - .connect(NoTls) - .context("Failed to connect to PostgreSQL") + Ok(config) } fn apply_connection_safety_defaults(config: &mut PostgresConfig) { @@ -196,6 +236,48 @@ fn persistence_from_pg(code: u8) -> Result { } } +fn column_storage_from_pg(code: &str) -> Result { + match code { + "p" => Ok("PLAIN".to_string()), + "e" => Ok("EXTERNAL".to_string()), + "x" => Ok("EXTENDED".to_string()), + "m" => Ok("MAIN".to_string()), + _ => anyhow::bail!("unknown pg_attribute.attstorage value '{code}'"), + } +} + +fn column_compression_from_pg(code: Option<&str>) -> Result> { + match code { + None | Some("") | Some("\0") => Ok(None), + Some("p") => Ok(Some("pglz".to_string())), + Some("l") => Ok(Some("lz4".to_string())), + // PostgreSQL exposes named values on some drivers and releases; keep + // those canonical rather than rejecting an otherwise valid catalog. + Some("pglz") => Ok(Some("pglz".to_string())), + Some("lz4") => Ok(Some("lz4".to_string())), + Some(value) => anyhow::bail!("unknown pg_attribute.attcompression value '{value}'"), + } +} + +pub(crate) fn catalog_options( + values: Option>, + object: &str, +) -> Result> { + let mut options = std::collections::BTreeMap::new(); + for option in values.unwrap_or_default() { + let Some((key, value)) = option.split_once('=') else { + anyhow::bail!("PostgreSQL returned malformed {object} option '{option}'"); + }; + if key.is_empty() || value.is_empty() { + anyhow::bail!("PostgreSQL returned malformed {object} option '{option}'"); + } + if options.insert(key.to_string(), value.to_string()).is_some() { + anyhow::bail!("PostgreSQL returned duplicate {object} option '{key}'"); + } + } + Ok(options) +} + fn partition_strategy_from_pg(code: Option<&str>) -> Result> { match code { None => Ok(None), @@ -249,6 +331,12 @@ fn write_cache(out_path: &Path, cache: DbCache, cache_encryption: bool) -> Resul }) } +fn write_cache_with_key(out_path: &Path, cache: DbCache, key: &[u8; 32]) -> Result<()> { + write_cache_with_protection(out_path, cache, |compressed| { + protect_cache_bytes_with_key(compressed, key) + }) +} + fn write_cache_with_protection( out_path: &Path, cache: DbCache, @@ -273,7 +361,7 @@ fn write_cache_with_protection_and_limits( cache .validate_semantics() .map_err(anyhow::Error::msg) - .context("Refusing to write a semantically invalid Cache V7 baseline")?; + .context("Refusing to write a semantically invalid Cache V8 baseline")?; let parent = cache_parent(out_path); let mut temp_file = NamedTempFile::new_in(parent).with_context(|| { format!( @@ -286,17 +374,17 @@ fn write_cache_with_protection_and_limits( .context("Failed to init zstd compression")?; let mut encoder = SizeLimitedWriter::new(encoder, max_decode_bytes); - if let Err(error) = encoder.write_all(CACHE_V7_MAGIC) { + if let Err(error) = encoder.write_all(CACHE_V8_MAGIC) { if encoder.limit_exceeded() { anyhow::bail!( "Cache payload exceeds the {} MiB decoded-size limit", max_decode_bytes / (1024 * 1024) ); } - return Err(error).context("Failed to write cache V7 payload header"); + return Err(error).context("Failed to write cache V8 payload header"); } - let versioned = DbCacheVersioned::V7(Box::new(cache)); + let versioned = DbCacheVersioned::V8(Box::new(cache)); let bincode_config = bincode::config::standard().with_variable_int_encoding(); let encode_result = @@ -477,7 +565,7 @@ fn replace_cache(temp_file: NamedTempFile, out_path: &Path) -> Result<()> { } } -pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result { +pub(crate) fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result { let mut transaction = client .build_transaction() .isolation_level(IsolationLevel::RepeatableRead) @@ -492,7 +580,8 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result } #[doc(hidden)] -pub fn populate_cache_in_current_transaction( +#[cfg(test)] +pub(crate) fn populate_cache_in_current_transaction( client: &mut Client, schemas: Option<&[String]>, ) -> Result { @@ -567,6 +656,133 @@ fn load_view_dependencies( /// explicit schema boundary. The query covers the catalog classes that can /// expose a dependent relation (relations/indexes, constraints, rewrites, /// defaults, and triggers); unscoped synchronization needs no boundary list. +fn load_scoped_external_index_dependencies( + client: &mut impl GenericClient, + schema_values: &Option>, +) -> Result> { + let Some(schemas) = schema_values else { + return Ok(Vec::new()); + }; + if schemas.is_empty() { + return Ok(Vec::new()); + } + let query = r#" + SELECT DISTINCT ref_n.nspname AS ref_schema, ref_c.relname AS ref_name + FROM pg_depend d + JOIN pg_class ref_c ON d.refclassid = 'pg_class'::regclass + AND d.refobjid = ref_c.oid + JOIN pg_namespace ref_n ON ref_n.oid = ref_c.relnamespace + JOIN pg_class dep_c ON d.classid = 'pg_class'::regclass + AND d.objid = dep_c.oid + JOIN pg_namespace dep_n ON dep_n.oid = dep_c.relnamespace + WHERE ref_c.relkind IN ('i', 'I') + AND ref_n.nspname = ANY($1) + AND NOT (dep_n.nspname = ANY($1)) + AND dep_n.nspname NOT LIKE 'pg\_%' ESCAPE '\' + AND dep_n.nspname <> 'information_schema' + UNION + SELECT DISTINCT ref_n.nspname, ref_c.relname + FROM pg_depend d + JOIN pg_class ref_c ON d.refclassid = 'pg_class'::regclass + AND d.refobjid = ref_c.oid + JOIN pg_namespace ref_n ON ref_n.oid = ref_c.relnamespace + JOIN pg_constraint dep_con ON d.classid = 'pg_constraint'::regclass + AND d.objid = dep_con.oid + JOIN pg_class dep_c ON dep_c.oid = dep_con.conrelid + JOIN pg_namespace dep_n ON dep_n.oid = dep_c.relnamespace + WHERE ref_c.relkind IN ('i', 'I') + AND ref_n.nspname = ANY($1) + AND NOT (dep_n.nspname = ANY($1)) + AND dep_n.nspname NOT LIKE 'pg\_%' ESCAPE '\' + AND dep_n.nspname <> 'information_schema' + UNION + SELECT DISTINCT ref_n.nspname, ref_c.relname + FROM pg_depend d + JOIN pg_class ref_c ON d.refclassid = 'pg_class'::regclass + AND d.refobjid = ref_c.oid + JOIN pg_namespace ref_n ON ref_n.oid = ref_c.relnamespace + JOIN pg_rewrite dep_rw ON d.classid = 'pg_rewrite'::regclass + AND d.objid = dep_rw.oid + JOIN pg_class dep_c ON dep_c.oid = dep_rw.ev_class + JOIN pg_namespace dep_n ON dep_n.oid = dep_c.relnamespace + WHERE ref_c.relkind IN ('i', 'I') + AND ref_n.nspname = ANY($1) + AND NOT (dep_n.nspname = ANY($1)) + AND dep_n.nspname NOT LIKE 'pg\_%' ESCAPE '\' + AND dep_n.nspname <> 'information_schema' + UNION + SELECT DISTINCT ref_n.nspname, ref_c.relname + FROM pg_depend d + JOIN pg_class ref_c ON d.refclassid = 'pg_class'::regclass + AND d.refobjid = ref_c.oid + JOIN pg_attrdef dep_ad ON d.classid = 'pg_attrdef'::regclass + AND d.objid = dep_ad.oid + JOIN pg_class dep_c ON dep_c.oid = dep_ad.adrelid + JOIN pg_namespace ref_n ON ref_n.oid = ref_c.relnamespace + JOIN pg_namespace dep_n ON dep_n.oid = dep_c.relnamespace + WHERE ref_c.relkind IN ('i', 'I') + AND ref_n.nspname = ANY($1) + AND NOT (dep_n.nspname = ANY($1)) + AND dep_n.nspname NOT LIKE 'pg\_%' ESCAPE '\' + AND dep_n.nspname <> 'information_schema' + UNION + SELECT DISTINCT ref_n.nspname, ref_c.relname + FROM pg_depend d + JOIN pg_class ref_c ON d.refclassid = 'pg_class'::regclass + AND d.refobjid = ref_c.oid + JOIN pg_trigger dep_tg ON d.classid = 'pg_trigger'::regclass + AND d.objid = dep_tg.oid + JOIN pg_class dep_c ON dep_c.oid = dep_tg.tgrelid + JOIN pg_namespace ref_n ON ref_n.oid = ref_c.relnamespace + JOIN pg_namespace dep_n ON dep_n.oid = dep_c.relnamespace + WHERE ref_c.relkind IN ('i', 'I') + AND ref_n.nspname = ANY($1) + AND NOT (dep_n.nspname = ANY($1)) + AND dep_n.nspname NOT LIKE 'pg\_%' ESCAPE '\' + AND dep_n.nspname <> 'information_schema' + UNION + SELECT DISTINCT ref_n.nspname, ref_c.relname + FROM pg_depend d + JOIN pg_class ref_c ON d.refclassid = 'pg_class'::regclass + AND d.refobjid = ref_c.oid + JOIN pg_namespace ref_n ON ref_n.oid = ref_c.relnamespace + JOIN pg_proc dep_p ON d.classid = 'pg_proc'::regclass + AND d.objid = dep_p.oid + JOIN pg_namespace dep_n ON dep_n.oid = dep_p.pronamespace + WHERE ref_c.relkind IN ('i', 'I') + AND ref_n.nspname = ANY($1) + AND NOT (dep_n.nspname = ANY($1)) + AND dep_n.nspname NOT LIKE 'pg\_%' ESCAPE '\' + AND dep_n.nspname <> 'information_schema' + UNION + SELECT DISTINCT ref_n.nspname, ref_c.relname + FROM pg_depend d + JOIN pg_class ref_c ON d.refclassid = 'pg_class'::regclass + AND d.refobjid = ref_c.oid + JOIN pg_namespace ref_n ON ref_n.oid = ref_c.relnamespace + JOIN pg_policy dep_pol ON d.classid = 'pg_policy'::regclass + AND d.objid = dep_pol.oid + JOIN pg_class dep_c ON dep_c.oid = dep_pol.polrelid + JOIN pg_namespace dep_n ON dep_n.oid = dep_c.relnamespace + WHERE ref_c.relkind IN ('i', 'I') + AND ref_n.nspname = ANY($1) + AND NOT (dep_n.nspname = ANY($1)) + AND dep_n.nspname NOT LIKE 'pg\_%' ESCAPE '\' + AND dep_n.nspname <> 'information_schema' + "#; + client + .query(query, &[schemas]) + .context("Failed to load scoped index dependency boundaries")? + .into_iter() + .map(|row| { + Ok(ObjectId::new( + row.try_get::<_, String>("ref_schema")?, + row.try_get::<_, String>("ref_name")?, + )) + }) + .collect() +} + fn load_scoped_external_relation_dependencies( client: &mut impl GenericClient, schema_values: &Option>, @@ -1006,6 +1222,10 @@ fn load_roles( let memberships = client .query(membership_query, &[]) .context("Failed to load role memberships from pg_auth_members")?; + // PostgreSQL 16+ may store several rows for the same (member, role) pair, + // one per grantor. The effective projection is the union of their + // options, so aggregate rows instead of duplicating the edge. + let mut seen_edges = std::collections::HashSet::new(); for row in memberships { let member = ObjectId::new("", row.try_get::<_, String>(0).context("member role")?); let parent = ObjectId::new("", row.try_get::<_, String>(1).context("parent role")?); @@ -1013,15 +1233,27 @@ fn load_roles( let inherit_option: bool = row.try_get(3).context("role membership INHERIT option")?; let set_option: bool = row.try_get(4).context("role membership SET option")?; if let Some(role) = roles.get_mut(&member) { - role.member_of.push(parent.clone()); - if admin_option { - role.can_administer_membership.push(parent.clone()); - } - if inherit_option { - role.can_inherit_from.push(parent.clone()); - } - if set_option { - role.can_set_role_to.push(parent); + if seen_edges.insert((member, parent.clone())) { + role.member_of.push(parent.clone()); + if admin_option { + role.can_administer_membership.push(parent.clone()); + } + if inherit_option { + role.can_inherit_from.push(parent.clone()); + } + if set_option { + role.can_set_role_to.push(parent.clone()); + } + } else { + if admin_option && !role.can_administer_membership.contains(&parent) { + role.can_administer_membership.push(parent.clone()); + } + if inherit_option && !role.can_inherit_from.contains(&parent) { + role.can_inherit_from.push(parent.clone()); + } + if set_option && !role.can_set_role_to.contains(&parent) { + role.can_set_role_to.push(parent); + } } } } @@ -1030,17 +1262,29 @@ fn load_roles( fn load_role_membership_grantors( client: &mut impl GenericClient, + pg_version_num: u32, ) -> Result> { + let query = if pg_version_num >= 160_000 { + "SELECT member.rolname, parent.rolname, grantor.rolname, + membership.admin_option, membership.inherit_option, + membership.set_option + FROM pg_auth_members membership + JOIN pg_roles member ON member.oid = membership.member + JOIN pg_roles parent ON parent.oid = membership.roleid + JOIN pg_roles grantor ON grantor.oid = membership.grantor + ORDER BY member.rolname, parent.rolname, grantor.rolname;" + } else { + "SELECT member.rolname, parent.rolname, grantor.rolname, + membership.admin_option, true AS inherit_option, + true AS set_option + FROM pg_auth_members membership + JOIN pg_roles member ON member.oid = membership.member + JOIN pg_roles parent ON parent.oid = membership.roleid + JOIN pg_roles grantor ON grantor.oid = membership.grantor + ORDER BY member.rolname, parent.rolname, grantor.rolname;" + }; let rows = client - .query( - "SELECT member.rolname, parent.rolname, grantor.rolname - FROM pg_auth_members membership - JOIN pg_roles member ON member.oid = membership.member - JOIN pg_roles parent ON parent.oid = membership.roleid - JOIN pg_roles grantor ON grantor.oid = membership.grantor - ORDER BY member.rolname, parent.rolname, grantor.rolname;", - &[], - ) + .query(query, &[]) .context("Failed to load role membership grantors")?; rows.into_iter() .map(|row| { @@ -1048,6 +1292,9 @@ fn load_role_membership_grantors( member: ObjectId::new("", row.try_get::<_, String>(0)?), role: ObjectId::new("", row.try_get::<_, String>(1)?), grantor: ObjectId::new("", row.try_get::<_, String>(2)?), + admin: row.try_get(3)?, + inherit: row.try_get(4)?, + set: row.try_get(5)?, }) }) .collect() @@ -1188,9 +1435,18 @@ fn load_sequences( d.deptype::text AS dependency_type, CASE WHEN ad.adbin IS NULL THEN false ELSE pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) LIKE '%nextval(%' - END AS has_nextval_default + END AS has_nextval_default, + pg_catalog.format_type(q.seqtypid, NULL) AS sequence_data_type, + q.seqstart AS sequence_start, + q.seqincrement AS sequence_increment, + q.seqmin AS sequence_min, + q.seqmax AS sequence_max, + q.seqcache AS sequence_cache, + q.seqcycle AS sequence_cycle, + s.relpersistence::text AS sequence_persistence FROM pg_class s JOIN pg_namespace n ON n.oid = s.relnamespace + JOIN pg_sequence q ON q.seqrelid = s.oid LEFT JOIN pg_depend d ON d.classid = 'pg_class'::regclass AND d.objid = s.oid @@ -1234,6 +1490,15 @@ fn load_sequences( .map(|((schema, table), column)| (ObjectId::new(schema, table), column)); let kind = sequence_kind_from_pg(dependency_type.as_deref(), has_nextval_default) .with_context(|| format!("sequence '{}' dependency kind", id))?; + let persistence: String = row + .try_get("sequence_persistence") + .context("sequence persistence")?; + let persistence = match persistence.as_str() { + "p" => crate::_internal::model::sequence::SequencePersistence::Permanent, + "t" => crate::_internal::model::sequence::SequencePersistence::Temporary, + "u" => crate::_internal::model::sequence::SequencePersistence::Unlogged, + other => anyhow::bail!("unsupported sequence persistence code '{other}'"), + }; Ok(( id.clone(), crate::_internal::model::sequence::SequenceState { @@ -1241,6 +1506,20 @@ fn load_sequences( owner, owned_by, kind, + parameters: crate::_internal::model::sequence::SequenceParameters { + data_type: row + .try_get("sequence_data_type") + .context("sequence data type")?, + start_value: row.try_get("sequence_start").context("sequence start")?, + increment: row + .try_get("sequence_increment") + .context("sequence increment")?, + min_value: row.try_get("sequence_min").context("sequence minimum")?, + max_value: row.try_get("sequence_max").context("sequence maximum")?, + cache_size: row.try_get("sequence_cache").context("sequence cache")?, + cycle: row.try_get("sequence_cycle").context("sequence cycle")?, + persistence, + }, generation: 0, }, )) @@ -1261,15 +1540,44 @@ fn load_relations_and_columns( c.relname AS relation_name, c.relkind AS relation_kind, c.relpersistence AS persistence, + c.relrowsecurity AS row_security, + c.relforcerowsecurity AS force_row_security, + CASE c.relreplident + WHEN 'd' THEN 'DEFAULT' + WHEN 'n' THEN 'NOTHING' + WHEN 'f' THEN 'FULL' + WHEN 'i' THEN CASE + WHEN replica_index.relname IS NULL THEN 'USING INDEX' + ELSE 'USING INDEX ' || replica_index.relname + END + ELSE NULL + END AS replica_identity, + type_namespace.nspname AS typed_table_type_schema, + table_type.typname AS typed_table_type_name, + c.reloptions AS relation_options, + ts.spcname AS tablespace, + am.amname AS access_method, + cluster_index.relname AS cluster_index, pg_catalog.pg_get_userbyid(c.relowner) AS owner_name, CASE WHEN c.reltuples < 0 THEN -1 ELSE c.reltuples::bigint END AS estimated_rows, c.relpages::bigint AS relpages, to_char(s.last_analyze, 'YYYY-MM-DD HH24:MI:SS') AS last_analyze, to_char(s.last_autoanalyze, 'YYYY-MM-DD HH24:MI:SS') AS last_autoanalyze, p.partstrat::text AS partition_strategy, + pg_catalog.pg_get_partkeydef(c.oid) AS partition_key, + pg_catalog.pg_get_expr(c.relpartbound, c.oid) AS partition_bound, + pg_catalog.pg_get_partition_constraintdef(c.oid) AS partition_constraint, CASE WHEN c.relkind = 'm' THEN c.relispopulated ELSE NULL END AS is_populated FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + LEFT JOIN pg_tablespace ts ON ts.oid = c.reltablespace + LEFT JOIN pg_am am ON am.oid = c.relam + LEFT JOIN pg_type table_type ON table_type.oid = c.reloftype + LEFT JOIN pg_namespace type_namespace ON type_namespace.oid = table_type.typnamespace + LEFT JOIN pg_index cluster_i ON cluster_i.indrelid = c.oid AND cluster_i.indisclustered + LEFT JOIN pg_class cluster_index ON cluster_index.oid = cluster_i.indexrelid + LEFT JOIN pg_index replica_i ON replica_i.indrelid = c.oid AND replica_i.indisreplident + LEFT JOIN pg_class replica_index ON replica_index.oid = replica_i.indexrelid LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid LEFT JOIN pg_partitioned_table p ON p.partrelid = c.oid WHERE c.relkind IN ('r', 'p', 'v', 'm') @@ -1286,6 +1594,32 @@ fn load_relations_and_columns( let relation_name: String = row.try_get("relation_name").context("relation name")?; let relkind: i8 = row.try_get("relation_kind").context("relation kind")?; let persistence_char: i8 = row.try_get("persistence").context("relation persistence")?; + let row_security: bool = row + .try_get("row_security") + .context("relation row security")?; + let force_row_security: bool = row + .try_get("force_row_security") + .context("relation forced row security")?; + let replica_identity: Option = row + .try_get("replica_identity") + .context("relation replica identity")?; + let typed_table_type_schema: Option = row + .try_get("typed_table_type_schema") + .context("typed-table type schema")?; + let typed_table_type_name: Option = row + .try_get("typed_table_type_name") + .context("typed-table type name")?; + let relation_options: Option> = row + .try_get("relation_options") + .context("relation options")?; + let tablespace: Option = + row.try_get("tablespace").context("relation tablespace")?; + let access_method: Option = row + .try_get("access_method") + .context("relation access method")?; + let cluster_index: Option = row + .try_get("cluster_index") + .context("relation cluster index")?; let owner_name: String = row.try_get("owner_name").context("relation owner")?; let raw_rows: i64 = row .try_get("estimated_rows") @@ -1333,6 +1667,24 @@ fn load_relations_and_columns( state.partition_type = partition_strategy_from_pg(partition_strategy.as_deref()) .with_context(|| format!("relation '{}' partition strategy", object_id))?; state.is_populated = is_populated; + state.partition_by = row + .try_get::<_, Option>("partition_key")? + .map(|key| format!("PARTITION BY {key}")); + state.partition_bound = row.try_get("partition_bound")?; + state.partition_constraint = row.try_get("partition_constraint")?; + state.row_security = Some(row_security); + state.force_row_security = Some(force_row_security); + state.replica_identity = replica_identity; + state.of_type = match (typed_table_type_schema, typed_table_type_name) { + (Some(schema), Some(name)) => Some(ObjectId::new(schema, name)), + (None, None) => None, + _ => anyhow::bail!("PostgreSQL returned incomplete typed-table type identity"), + }; + state.table_options = catalog_options(relation_options, "relation") + .with_context(|| format!("relation '{}' options", object_id))?; + state.tablespace = tablespace; + state.access_method = access_method; + state.cluster_index = cluster_index; if let Some(scoped_schemas) = schemas && !scoped_schemas.contains(&schema_name) { @@ -1347,12 +1699,26 @@ fn load_relations_and_columns( n.nspname AS schema_name, c.relname AS relation_name, a.attname AS column_name, - pg_catalog.format_type(a.atttypid, a.atttypmod) AS type_name, + CASE WHEN type_ns.nspname <> 'pg_catalog' + AND pg_catalog.pg_type_is_visible(a.atttypid) + THEN quote_ident(type_ns.nspname) || '.' || pg_catalog.format_type(a.atttypid, a.atttypmod) + ELSE pg_catalog.format_type(a.atttypid, a.atttypmod) + END AS type_name, a.attnotnull AS not_null, s.avg_width AS avg_width, pg_get_expr(ad.adbin, ad.adrelid) AS default_expr_text, - a.atttypmod AS type_modifier + a.atttypmod AS type_modifier, + a.attstorage::text AS storage, + NULLIF(a.attcompression::text, '') AS compression, + a.attstattarget::integer AS statistics_target, + a.attoptions AS column_options, + a.attinhcount::integer AS inheritance_count, + a.attislocal AS is_local, + NULLIF(a.attgenerated::text, '') AS generated_kind, + NULLIF(a.attidentity::text, '') AS identity_generation FROM pg_attribute a + JOIN pg_type column_type ON column_type.oid = a.atttypid + JOIN pg_namespace type_ns ON type_ns.oid = column_type.typnamespace JOIN pg_class c ON a.attrelid = c.oid JOIN pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_stats s ON s.schemaname = n.nspname AND s.tablename = c.relname AND s.attname = a.attname @@ -1380,10 +1746,61 @@ fn load_relations_and_columns( relation_id ) })?; + let column_name: String = row.try_get("column_name").context("column name")?; + let identity_generation: Option = row + .try_get("identity_generation") + .context("column identity generation")?; + if let Some(code) = identity_generation { + let mut chars = code.chars(); + let generation = chars + .next() + .filter(|_| chars.next().is_none()) + .and_then(crate::_internal::model::relation::IdentityGeneration::from_pg_code) + .with_context(|| { + format!( + "column '{}.{}' identity generation", + relation_id, column_name + ) + })?; + relation + .identity_columns + .insert(column_name.clone(), generation); + } + let generated_kind: Option = row + .try_get("generated_kind") + .context("column generated kind")?; + let expression_text: Option = row + .try_get("default_expr_text") + .context("column expression")?; + if let Some(code) = generated_kind.as_deref() { + let mut chars = code.chars(); + let kind = chars + .next() + .filter(|_| chars.next().is_none()) + .and_then(crate::_internal::model::relation::GeneratedColumnKind::from_pg_code) + .with_context(|| { + format!("column '{}.{}' generated kind", relation_id, column_name) + })?; + relation.generated_columns.insert( + column_name.clone(), + crate::_internal::model::relation::GeneratedColumnState { + kind, + expression: expression_text.clone(), + }, + ); + } + relation.column_inheritance.insert( + column_name.clone(), + crate::_internal::model::relation::ColumnInheritance { + parent_count: u32::try_from(row.try_get::<_, i32>("inheritance_count")?) + .context("negative column inheritance count")?, + is_local: row.try_get("is_local")?, + }, + ); relation .columns .push(crate::_internal::model::column::Column { - name: row.try_get("column_name").context("column name")?, + name: column_name, data_type: Some(row.try_get("type_name").context("column type")?), type_id: None, is_nullable: !row @@ -1391,14 +1808,114 @@ fn load_relations_and_columns( .context("column nullability")?, default: None, avg_width: row.try_get("avg_width").context("column average width")?, - default_expr_text: row - .try_get("default_expr_text") - .context("column default expression")?, + default_expr_text: generated_kind + .is_none() + .then_some(expression_text) + .flatten(), type_modifier: row .try_get("type_modifier") .context("column type modifier")?, + storage: column_storage_from_pg( + &row.try_get::<_, String>("storage") + .context("column storage")?, + ) + .context("column storage") + .map(Some)?, + compression: column_compression_from_pg( + row.try_get::<_, Option>("compression") + .context("column compression")? + .as_deref(), + ) + .context("column compression")?, + statistics_target: row + .try_get("statistics_target") + .context("column statistics target")?, + options: catalog_options( + row.try_get("column_options").context("column options")?, + "column", + ) + .with_context(|| format!("column options on relation '{}'", relation_id))?, + generated: Some(generated_kind.is_some()), }); } + + let statistics_query = format!( + " + SELECT + n.nspname AS table_schema, + c.relname AS table_name, + stats_ns.nspname AS statistics_schema, + stats.stxname AS statistics_name, + ARRAY(SELECT kind::text FROM unnest(stats.stxkind) AS kind) AS kinds, + ARRAY( + SELECT attribute.attname + FROM ( + SELECT key.attnum + FROM unnest(stats.stxkeys::smallint[]) AS key(attnum) + UNION + SELECT dependency.refobjsubid::smallint + FROM pg_depend dependency + WHERE dependency.classid = 'pg_statistic_ext'::regclass + AND dependency.objid = stats.oid + AND dependency.refclassid = 'pg_class'::regclass + AND dependency.refobjid = stats.stxrelid + AND dependency.refobjsubid > 0 + ) AS key + JOIN pg_attribute attribute + ON attribute.attrelid = stats.stxrelid + AND attribute.attnum = key.attnum + ORDER BY attribute.attname + ) AS columns, + pg_get_expr(stats.stxexprs, stats.stxrelid, false) AS expressions, + stats.stxstattarget::integer AS statistics_target + FROM pg_statistic_ext stats + JOIN pg_class c ON c.oid = stats.stxrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_namespace stats_ns ON stats_ns.oid = stats.stxnamespace + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') + {schema_filter_with_fk} + ORDER BY stats_ns.nspname, stats.stxname; + " + ); + for row in client + .query(&statistics_query, &[schema_values]) + .context("Failed to load extended statistics from pg_statistic_ext")? + { + let relation_id = ObjectId::new( + row.try_get::<_, String>("table_schema") + .context("extended statistics table schema")?, + row.try_get::<_, String>("table_name") + .context("extended statistics table name")?, + ); + let statistics_id = ObjectId::new( + row.try_get::<_, String>("statistics_schema") + .context("extended statistics schema")?, + row.try_get::<_, String>("statistics_name") + .context("extended statistics name")?, + ); + let relation = relations.get_mut(&relation_id).with_context(|| { + format!( + "extended statistics '{}' reference omitted relation '{}'", + statistics_id, relation_id + ) + })?; + relation.extended_statistics.insert( + statistics_id.clone(), + crate::_internal::model::relation::ExtendedStatisticsState { + id: statistics_id, + kinds: row.try_get("kinds").context("extended statistics kinds")?, + columns: row + .try_get("columns") + .context("extended statistics columns")?, + expressions: row + .try_get("expressions") + .context("extended statistics expressions")?, + target: row + .try_get("statistics_target") + .context("extended statistics target")?, + }, + ); + } Ok(relations) } @@ -1406,6 +1923,7 @@ struct RelationDecoration { relation_id: ObjectId, triggers: Vec, policies: Vec, + rules: std::collections::HashMap, } struct RelationGrant { @@ -1427,14 +1945,26 @@ fn load_relation_decorations( n.nspname AS schema_name, c.relname AS relation_name, COALESCE(array_agg(DISTINCT t.tgname) FILTER (WHERE t.tgname IS NOT NULL AND t.tgisinternal = false), '{{}}') as triggers, - COALESCE(array_agg(DISTINCT p.polname) FILTER (WHERE p.polname IS NOT NULL), '{{}}') as policies + COALESCE(array_agg(DISTINCT p.polname) FILTER (WHERE p.polname IS NOT NULL), '{{}}') as policies, + ARRAY( + SELECT r.rulename + FROM pg_rewrite r + WHERE r.ev_class = c.oid AND r.rulename <> '_RETURN' + ORDER BY r.oid + ) AS rule_names, + ARRAY( + SELECT r.ev_enabled::text + FROM pg_rewrite r + WHERE r.ev_class = c.oid AND r.rulename <> '_RETURN' + ORDER BY r.oid + ) AS rule_modes FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_trigger t ON t.tgrelid = c.oid LEFT JOIN pg_policy p ON p.polrelid = c.oid WHERE c.relkind IN ('r', 'p', 'v', 'm') AND n.nspname NOT IN ('pg_catalog', 'information_schema') {schema_filter_with_fk} - GROUP BY n.nspname, c.relname; + GROUP BY n.nspname, c.relname, c.oid; " ); let decorations = client @@ -1442,6 +1972,25 @@ fn load_relation_decorations( .context("Failed to load relation triggers and policies")? .into_iter() .map(|row| { + let rule_names: Vec = row.try_get("rule_names").context("relation rules")?; + let rule_modes: Vec = + row.try_get("rule_modes").context("relation rule modes")?; + if rule_names.len() != rule_modes.len() { + anyhow::bail!("PostgreSQL returned mismatched relation rule metadata"); + } + let rules = rule_names + .into_iter() + .zip(rule_modes) + .map(|(name, code)| { + let mut chars = code.chars(); + let mode = chars + .next() + .filter(|_| chars.next().is_none()) + .and_then(crate::_internal::model::relation::RuleEnableMode::from_pg_code) + .with_context(|| format!("rule '{name}' enable mode"))?; + Ok((name, mode)) + }) + .collect::>>()?; Ok(RelationDecoration { relation_id: ObjectId::new( row.try_get::<_, String>("schema_name") @@ -1451,6 +2000,7 @@ fn load_relation_decorations( ), triggers: row.try_get("triggers").context("relation trigger names")?, policies: row.try_get("policies").context("relation policy names")?, + rules, }) }) .collect::>>()?; @@ -1534,6 +2084,10 @@ fn load_triggers( c.relname AS table_name, t.tgname AS trigger_name, t.tgenabled::text AS enabled_mode, + (t.tgtype & 1) <> 0 AS row_level, + COALESCE(pn.nspname, inferred_pn.nspname) AS parent_table_schema, + COALESCE(pc.relname, inferred_pc.relname) AS parent_table_name, + COALESCE(pt.tgname, inferred_pt.tgname) AS parent_trigger_name, fn.nspname AS function_schema, f.proname || '()' AS function_name FROM pg_trigger t @@ -1541,7 +2095,33 @@ fn load_triggers( JOIN pg_namespace n ON n.oid = c.relnamespace JOIN pg_proc f ON f.oid = t.tgfoid JOIN pg_namespace fn ON fn.oid = f.pronamespace - WHERE t.tgisinternal = false + LEFT JOIN pg_trigger pt ON pt.oid = t.tgparentid + LEFT JOIN pg_class pc ON pc.oid = pt.tgrelid + LEFT JOIN pg_namespace pn ON pn.oid = pc.relnamespace + LEFT JOIN LATERAL ( + SELECT parent_t.oid AS trigger_oid, + parent_c.oid AS table_oid, + parent_c.relname, + parent_n.nspname, + parent_t.tgname + FROM pg_inherits inheritance + JOIN pg_trigger parent_t + ON parent_t.tgrelid = inheritance.inhparent + AND parent_t.tgname = t.tgname + AND parent_t.tgisinternal = false + JOIN pg_class parent_c ON parent_c.oid = parent_t.tgrelid + JOIN pg_namespace parent_n ON parent_n.oid = parent_c.relnamespace + WHERE inheritance.inhrelid = t.tgrelid + -- A cloned trigger normally keeps the same function OID. Older + -- PostgreSQL releases have catalog cases where that linkage is + -- not stable, so prefer an exact function match but retain the + -- unique parent/name relationship as a compatibility fallback. + ORDER BY (parent_t.tgfoid = t.tgfoid) DESC, parent_t.oid + LIMIT 1 + ) inferred_pt ON true + LEFT JOIN pg_class inferred_pc ON inferred_pc.oid = inferred_pt.table_oid + LEFT JOIN pg_namespace inferred_pn ON inferred_pn.oid = inferred_pc.relnamespace + WHERE (t.tgisinternal = false OR (t.tgparentid <> 0 AND pt.tgisinternal = false)) AND c.relkind IN ('r', 'p', 'v', 'm') AND n.nspname NOT IN ('pg_catalog', 'information_schema') {schema_filter_with_fk}; @@ -1573,6 +2153,21 @@ fn load_triggers( row.try_get::<_, String>("function_name") .context("trigger function name")?, ), + row_level: row.try_get("row_level").context("trigger level")?, + parent_trigger_id: match ( + row.try_get::<_, Option>("parent_table_schema") + .context("parent trigger table schema")?, + row.try_get::<_, Option>("parent_table_name") + .context("parent trigger table name")?, + row.try_get::<_, Option>("parent_trigger_name") + .context("parent trigger name")?, + ) { + (Some(schema), Some(table), Some(name)) => { + Some(ObjectId::new(schema, format!("{table}\0{name}"))) + } + (None, None, None) => None, + _ => anyhow::bail!("PostgreSQL returned incomplete parent trigger identity"), + }, enabled_mode: crate::_internal::model::trigger::TriggerEnableMode::from_pg_code( &enabled_mode, ) @@ -1597,6 +2192,10 @@ fn load_constraints( con.conname AS constraint_name, con.contype::text AS constraint_type, con.convalidated AS validated, + CASE WHEN con.contype = 'c' + THEN pg_get_expr(con.conbin, con.conrelid, false) + ELSE NULL + END AS definition, NULLIF(backing_n.nspname, '') AS backing_index_schema, NULLIF(backing.relname, '') AS backing_index_name FROM pg_constraint con @@ -1631,7 +2230,7 @@ fn load_constraints( // foreign key stores the referenced key index. ConstraintState's // backing index is intentionally only the former; retaining the // FK's referenced index here would make a valid cross-table cache - // look internally inconsistent during V7 validation. + // look internally inconsistent during V8 validation. let backing_index = if matches!( kind, crate::_internal::model::constraint::ConstraintKind::PrimaryKey @@ -1660,6 +2259,7 @@ fn load_constraints( validated: row .try_get("validated") .context("constraint validation state")?, + definition: row.try_get("definition").context("constraint definition")?, backing_index, }) }) @@ -1732,19 +2332,29 @@ fn load_constraint_dependencies( c.relname AS table_name, con.conname AS constraint_name, ARRAY( - SELECT DISTINCT a.attname - FROM pg_depend d - JOIN pg_attribute a - ON a.attrelid = d.refobjid - AND a.attnum = d.refobjsubid - AND NOT a.attisdropped - WHERE d.classid = 'pg_constraint'::regclass - AND d.objid = con.oid - AND d.refclassid = 'pg_class'::regclass - AND d.refobjid = con.conrelid - AND d.refobjsubid > 0 - AND d.deptype = 'n' - ORDER BY a.attname + SELECT DISTINCT names.attname + FROM ( + SELECT a.attname + FROM pg_depend d + JOIN pg_attribute a + ON a.attrelid = d.refobjid + AND a.attnum = d.refobjsubid + AND NOT a.attisdropped + WHERE d.classid = 'pg_constraint'::regclass + AND d.objid = con.oid + AND d.refclassid = 'pg_class'::regclass + AND d.refobjid = con.conrelid + AND d.refobjsubid > 0 + AND d.deptype = 'n' + UNION ALL + SELECT a.attname + FROM unnest(con.conkey) AS key(attnum) + JOIN pg_attribute a + ON a.attrelid = con.conrelid + AND a.attnum = key.attnum + AND NOT a.attisdropped + ) AS names + ORDER BY names.attname ) AS dependency_columns FROM pg_constraint con JOIN pg_class c ON c.oid = con.conrelid @@ -2103,6 +2713,7 @@ fn load_indexes( x.indisready AS is_ready, x.indislive AS is_live, x.indisunique AS is_unique, + x.indimmediate AS is_immediate, x.indpred IS NOT NULL AS has_predicate, ARRAY( SELECT a.attname @@ -2236,6 +2847,9 @@ fn load_indexes( has_expression_keys, has_predicate, is_unique: row.try_get("is_unique").context("index uniqueness flag")?, + is_immediate: row + .try_get("is_immediate") + .context("index immediacy flag")?, is_valid: row.try_get("is_valid").context("index validity flag")?, is_ready: row.try_get("is_ready").context("index readiness flag")?, is_live: row.try_get("is_live").context("index liveness flag")?, @@ -2354,12 +2968,28 @@ fn load_types( array_agg(e.enumlabel ORDER BY e.enumsortorder) FILTER (WHERE e.enumlabel IS NOT NULL), ARRAY[]::text[] - ) AS enum_labels + ) AS enum_labels, + COALESCE( + array_agg(a.attname ORDER BY a.attnum) + FILTER (WHERE a.attname IS NOT NULL), + ARRAY[]::text[] + ) AS composite_field_names, + COALESCE( + array_agg(pg_catalog.format_type(a.atttypid, a.atttypmod) ORDER BY a.attnum) + FILTER (WHERE a.attname IS NOT NULL), + ARRAY[]::text[] + ) AS composite_field_types FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace LEFT JOIN pg_enum e ON e.enumtypid = t.oid + LEFT JOIN pg_class composite_rel ON composite_rel.oid = t.typrelid + LEFT JOIN pg_attribute a + ON a.attrelid = t.typrelid + AND a.attnum > 0 + AND NOT a.attisdropped WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') - AND t.typtype IN ('e', 'd') + AND t.typtype IN ('e', 'd', 'c') + AND (t.typtype <> 'c' OR composite_rel.relkind = 'c') {schema_filter} GROUP BY n.nspname, t.typname, t.typtype, t.typbasetype, t.typtypmod; " @@ -2381,6 +3011,29 @@ fn load_types( .context("PostgreSQL omitted the base type for a domain")?, base_type_id: None, }, + "c" => { + let names: Vec = row + .try_get("composite_field_names") + .context("composite field names")?; + let data_types: Vec = row + .try_get("composite_field_types") + .context("composite field types")?; + if names.len() != data_types.len() { + anyhow::bail!("PostgreSQL returned mismatched composite field metadata"); + } + crate::_internal::model::types::TypeKind::Composite { + fields: names + .into_iter() + .zip(data_types) + .map(|(name, data_type)| { + crate::_internal::model::types::CompositeFieldState { + name, + data_type, + } + }) + .collect(), + } + } other => anyhow::bail!("unsupported pg_type.typtype '{other}'"), }; let id = ObjectId::new( @@ -2868,6 +3521,7 @@ fn populate_cache_from_client( })?; relation.triggers.extend(decoration.triggers); relation.policies.extend(decoration.policies); + relation.rules.extend(decoration.rules); } for grant in relation_grants { let relation = cache @@ -2919,7 +3573,7 @@ fn populate_cache_from_client( // Only view dependencies are consumed by cache hydration. Generic // pg_depend rows use PostgreSQL dependency codes (n/a/i) and were ignored - // after synchronization, so avoid loading them into Cache V7. + // after synchronization, so avoid loading them into Cache V8. cache.dependencies = load_view_dependencies(client, &schema_values)?; cache.scoped_external_relation_dependencies = load_scoped_external_relation_dependencies(client, &schema_values)?; @@ -2927,6 +3581,8 @@ fn populate_cache_from_client( load_scoped_external_type_dependencies(client, &schema_values)?; cache.scoped_external_routine_dependencies = load_scoped_external_routine_dependencies(client, &schema_values)?; + cache.scoped_external_index_dependencies = + load_scoped_external_index_dependencies(client, &schema_values)?; // All scope-boundary queries above completed inside the same repeatable // read transaction. Mark this only after every query succeeds; a cache // that was assembled programmatically or by a partial loader remains @@ -2937,13 +3593,26 @@ fn populate_cache_from_client( // `SET ROLE` from a migration that PostgreSQL would reject. pg_roles does // not expose password hashes or other credentials. cache.roles = load_roles(client, cache.pg_version_num.unwrap_or_default())?; - cache.role_membership_grantors = load_role_membership_grantors(client)?; + cache.role_membership_grantors = + load_role_membership_grantors(client, cache.pg_version_num.unwrap_or_default())?; cache.role_membership_grantors_complete = true; + // The bootstrap superuser (pg_authid OID 10, `BOOTSTRAP_SUPERUSERID`) + // receives implicit superuser-issued role grantor attribution on modern + // PostgreSQL. Recording its name lets state resolution distinguish it + // from a session role when replaying unchecked GRANT statements. + cache.bootstrap_superuser = client + .query("SELECT rolname FROM pg_roles WHERE oid = 10;", &[]) + .context("Failed to load the bootstrap superuser role")? + .first() + .map(|row| row.try_get::<_, String>(0)) + .transpose() + .context("bootstrap superuser name")?; + cache .validate_semantics() .map_err(anyhow::Error::msg) - .context("PostgreSQL catalogs produced a semantically invalid Cache V7 baseline")?; + .context("PostgreSQL catalogs produced a semantically invalid Cache V8 baseline")?; Ok(cache) } @@ -3070,8 +3739,8 @@ mod atomic_write_tests { let mut payload = Vec::new(); decoder.read_to_end(&mut payload).unwrap(); let payload = payload - .strip_prefix(CACHE_V7_MAGIC) - .expect("writer must prefix V7 cache payloads"); + .strip_prefix(CACHE_V8_MAGIC) + .expect("writer must prefix V8 cache payloads"); let config = bincode::config::standard().with_variable_int_encoding(); let versioned: DbCacheVersioned = bincode::serde::decode_from_slice(payload, config) .unwrap() @@ -3166,7 +3835,7 @@ mod atomic_write_tests { DbCache::new(), Ok, MAX_CACHE_FILE_BYTES, - CACHE_V7_MAGIC.len(), + CACHE_V8_MAGIC.len(), ) .unwrap_err(); diff --git a/src/_internal/sync_tests.rs b/src/_internal/sync_tests.rs index afc3eb94..c3848468 100644 --- a/src/_internal/sync_tests.rs +++ b/src/_internal/sync_tests.rs @@ -6,8 +6,9 @@ use crate::_internal::model::relation::{Persistence, RelationKind, RelationState mod tests { use super::*; use crate::_internal::sync::{ - cache_search_path, database_config_is_local, ensure_supported_postgres_version, - is_local_host, is_system_schema, parse_search_path_setting, relation_owner_id, sync_cache, + cache_search_path, catalog_options, database_config_is_local, + ensure_supported_postgres_version, is_local_host, is_system_schema, + parse_search_path_setting, relation_owner_id, sync_cache, }; use crate::_internal::test_support::EnvironmentValueGuard; use serde::Serialize; @@ -79,6 +80,19 @@ mod tests { assert!(!is_local_host("127.0.0.1.attacker.example")); } + #[test] + fn catalog_options_preserve_complete_column_and_relation_metadata() { + let options = catalog_options( + Some(vec!["fillfactor=80".into(), "n_distinct=-0.25".into()]), + "column", + ) + .expect("valid PostgreSQL options"); + assert_eq!(options.get("fillfactor"), Some(&"80".to_string())); + assert_eq!(options.get("n_distinct"), Some(&"-0.25".to_string())); + assert!(catalog_options(Some(vec!["broken".into()]), "column").is_err()); + assert!(catalog_options(Some(vec!["x=1".into(), "x=2".into()]), "column").is_err()); + } + #[test] fn test_database_config_rejects_remote_hostaddr() { let local: postgres::Config = "host=localhost hostaddr=127.0.0.1 dbname=safe_migrate" @@ -209,6 +223,11 @@ mod tests { avg_width: Some(4), default_expr_text: None, type_modifier: None, + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, }); cache.insert_baseline(id.clone(), rel); @@ -238,6 +257,7 @@ mod tests { has_expression_keys: false, has_predicate: false, is_unique: false, + is_immediate: true, is_valid: true, is_ready: true, is_live: true, @@ -303,8 +323,8 @@ mod tests { }, ); - // Cache V7 uses bincode. - let versioned = crate::_internal::db::cache::DbCacheVersioned::V7(Box::new(cache)); + // Cache V8 uses bincode. + let versioned = crate::_internal::db::cache::DbCacheVersioned::V8(Box::new(cache)); let config = bincode::config::standard().with_variable_int_encoding(); let encoded = bincode::serde::encode_to_vec(&versioned, config).unwrap(); @@ -312,8 +332,8 @@ mod tests { bincode::serde::decode_from_slice(&encoded, config) .unwrap() .0; - let crate::_internal::db::cache::DbCacheVersioned::V7(deserialized) = decoded else { - panic!("Expected V7"); + let crate::_internal::db::cache::DbCacheVersioned::V8(deserialized) = decoded else { + panic!("Expected V8"); }; assert_eq!(deserialized.pg_version_num, Some(160000)); assert_eq!( @@ -398,18 +418,23 @@ mod tests { avg_width: Some(10), default_expr_text: Some("now()".into()), type_modifier: Some(255 + 4), + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, }); cache.insert_baseline(id.clone(), rel); - let versioned = crate::_internal::db::cache::DbCacheVersioned::V7(Box::new(cache)); + let versioned = crate::_internal::db::cache::DbCacheVersioned::V8(Box::new(cache)); let config = bincode::config::standard().with_variable_int_encoding(); let encoded = bincode::serde::encode_to_vec(&versioned, config).unwrap(); let decoded: crate::_internal::db::cache::DbCacheVersioned = bincode::serde::decode_from_slice(&encoded, config) .unwrap() .0; - let crate::_internal::db::cache::DbCacheVersioned::V7(deserialized) = decoded else { - panic!("Expected V7"); + let crate::_internal::db::cache::DbCacheVersioned::V8(deserialized) = decoded else { + panic!("Expected V8"); }; let rel = deserialized.relations.get(&id).unwrap(); assert_eq!(rel.columns[0].default_expr_text, Some("now()".into())); @@ -487,7 +512,7 @@ mod tests { config, ) .is_err(), - "the pre-release routine layout must require a fresh V7 sync" + "the pre-release routine layout must require a fresh V8 sync" ); } @@ -516,7 +541,7 @@ mod tests { &bytes, config ) .is_err(), - "cache payloads without default/type evidence must require a fresh V7 sync" + "cache payloads without default/type evidence must require a fresh V8 sync" ); } diff --git a/src/_internal/test_support.rs b/src/_internal/test_support.rs index 71c154fe..f7e5b521 100644 --- a/src/_internal/test_support.rs +++ b/src/_internal/test_support.rs @@ -14,6 +14,7 @@ impl EnvironmentValueGuard { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); let previous = std::env::var(name).ok(); + // The process-wide lock serializes every test helper that mutates the environment. unsafe { std::env::set_var(name, value); } @@ -29,6 +30,7 @@ impl EnvironmentValueGuard { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); let previous = std::env::var(name).ok(); + // The process-wide lock serializes every test helper that mutates the environment. unsafe { std::env::remove_var(name); } @@ -42,6 +44,7 @@ impl EnvironmentValueGuard { impl Drop for EnvironmentValueGuard { fn drop(&mut self) { + // This guard still owns the process-wide environment lock. unsafe { if let Some(previous) = &self.previous { std::env::set_var(self.name, previous); diff --git a/src/api.rs b/src/api.rs index 83b81d67..221bd684 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,57 +1,1920 @@ -use std::fmt; +//! Stable, supported Rust API for safe-migrate. +//! +//! The public API deliberately owns its configuration, baseline, and report +//! types. The analyzer implementation and its mutable schema model remain +//! crate-private implementation details. +//! +//! ```no_run +//! # fn main() -> Result<(), safe_migrate::api::Error> { +//! use safe_migrate::api::{self, Baseline, Config}; +//! use std::path::Path; +//! +//! let config = Config::load_from_file(Path::new("safe-migrate.toml"))?; +//! let baseline = Baseline::load_optional(Path::new(".safe-migrate.cache"), &config)?; +//! let outcome = api::analyze(&config, "001.sql", "CREATE TABLE users (id bigint);", &baseline)?; +//! if outcome.should_halt() { +//! eprintln!("{}", outcome.markdown()); +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! ```compile_fail +//! // Internal state-machine types are intentionally not a downstream API. +//! use safe_migrate::_internal::analysis::state::AnalysisState; +//! ``` +//! +//! Analysis outcomes preserve their internal reporting invariants: +//! +//! ```compile_fail +//! # use safe_migrate::api::{self, Baseline, Config}; +//! # fn example() -> Result<(), api::Error> { +//! let config = Config::default(); +//! let baseline = Baseline::unavailable(); +//! let mut outcome = api::analyze(&config, "001.sql", "", &baseline)?; +//! outcome.findings.clear(); +//! # Ok(()) +//! # } +//! ``` + +pub(crate) mod config; + +pub use config::{Config, RuleConfig}; -pub use crate::_internal::analysis::evidence::{ - EvidenceCode, EvidenceLocation, EvidenceRecord, EvidenceScope, +/// Current schema version emitted by [`AnalysisOutcome::json`]. +pub const REPORT_SCHEMA_VERSION: u32 = InternalReporter::JSON_SCHEMA_VERSION; + +use crate::_internal::analysis::evidence as internal_evidence; +use crate::_internal::analysis::outcome::AnalysisOutcome as InternalOutcome; +use crate::_internal::analysis::state::{ + AnalysisState as InternalAnalysisState, Confidence as InternalConfidence, +}; +use crate::_internal::db::cache::{ + CACHE_FORMAT_VERSION, CACHE_V8_MAGIC, DbCache as InternalDbCache, DbCacheVersioned, }; -pub use crate::_internal::analysis::outcome::AnalysisOutcome; -pub use crate::_internal::analysis::state::AnalysisState; -pub use crate::_internal::db::cache::DbCache; -pub use crate::_internal::engine::config::Config; -pub use crate::_internal::engine::engine::SafeMigrateEngine; -pub use crate::_internal::report::reporter::Reporter; -pub use crate::_internal::report::violations::ReportFinding; -pub use crate::_internal::rules::RuleCapability; - -/// Stable error boundary for high-level library analysis helpers. +use crate::_internal::db::cache_file::{ + MAX_CACHE_DECODE_BYTES, decode_hex_key, is_encrypted_cache_bytes, read_cache_bytes, + unprotect_cache_bytes, unprotect_cache_bytes_with_key, +}; +use crate::_internal::engine::engine::SafeMigrateEngine; +use crate::_internal::model::function::RoutineKind; +use crate::_internal::model::relation::RelationKind; +use crate::_internal::report::reporter::{ + Reporter as InternalReporter, Verdict as InternalVerdict, compute_verdict, +}; +use crate::_internal::report::violations::{ + ObjectKind as InternalObjectKind, OperationKind as InternalOperationKind, + ReportFinding as InternalFinding, Violation as InternalViolation, + ViolationTier as InternalTier, +}; +use crate::_internal::rules::registry::{ + self, RuleConfigurationField as InternalRuleConfigurationField, +}; +use serde::Serialize; +use std::fmt; +use std::io::Read; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; +use zeroize::{Zeroize, Zeroizing}; + +/// Broad category of a supported API failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum ErrorKind { + /// Configuration could not be read, parsed, or validated. + Configuration, + /// A baseline could not be read, authenticated, decoded, or validated. + Cache, + /// A requested primary rule ID does not exist. + UnknownRule, + /// One or more SQL sources could not be analyzed. + Analysis, + /// A report could not be rendered or presented. + Report, + /// PostgreSQL metadata synchronization failed. + Sync, +} + +/// Error returned by the supported API. +/// +/// The stable [`ErrorKind`] supports programmatic handling while [`Self::message`] +/// and [`std::error::Error::source`] retain diagnostic detail. #[derive(Debug)] -pub enum AnalysisError { - InvalidCache(String), - Parse(Vec), +pub struct Error { + kind: ErrorKind, + message: String, + source: Option>, +} + +impl Error { + fn new(kind: ErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + source: None, + } + } + + fn with_source( + kind: ErrorKind, + message: impl Into, + source: impl std::error::Error + Send + Sync + 'static, + ) -> Self { + Self { + kind, + message: message.into(), + source: Some(Box::new(source)), + } + } + + fn with_anyhow_source( + kind: ErrorKind, + message: impl Into, + source: anyhow::Error, + ) -> Self { + Self { + kind, + message: message.into(), + source: Some(source.into_boxed_dyn_error()), + } + } + + fn configuration(message: impl Into) -> Self { + Self::new(ErrorKind::Configuration, message) + } + + fn cache(message: impl Into) -> Self { + Self::new(ErrorKind::Cache, message) + } + + fn analysis(errors: Vec) -> Self { + Self::new(ErrorKind::Analysis, errors.join("; ")) + } + + /// Return the stable category of this failure. + pub fn kind(&self) -> ErrorKind { + self.kind + } + + /// Return the operation context without its category prefix. + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self { + kind: ErrorKind::Configuration, + message, + .. + } => write!(formatter, "invalid configuration: {message}"), + Self { + kind: ErrorKind::Cache, + message, + .. + } => write!(formatter, "invalid baseline cache: {message}"), + Self { + kind: ErrorKind::UnknownRule, + message, + .. + } => write!(formatter, "unknown primary rule: {message}"), + Self { + kind: ErrorKind::Analysis, + message, + .. + } => write!(formatter, "analysis failed: {message}"), + Self { + kind: ErrorKind::Report, + message, + .. + } => write!(formatter, "report failed: {message}"), + Self { + kind: ErrorKind::Sync, + message, + .. + } => write!(formatter, "baseline sync failed: {message}"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.source + .as_deref() + .map(|source| source as &(dyn std::error::Error + 'static)) + } +} + +/// Validated PostgreSQL connection input for embedded synchronization. +/// +/// Its debug representation is always redacted. Connections must target +/// localhost or a Unix socket, matching the CLI security boundary. +pub struct DatabaseUrl(String); + +impl DatabaseUrl { + /// Validate and retain a PostgreSQL connection string without connecting. + /// + /// # Errors + /// + /// Returns [`ErrorKind::Configuration`] for empty, malformed, or remote + /// connection strings. + pub fn new(value: impl Into) -> Result { + let mut value = value.into(); + if let Err(error) = crate::_internal::sync::validate_database_url(&value) { + value.zeroize(); + let message = error.to_string(); + return Err(Error::with_anyhow_source( + ErrorKind::Configuration, + message, + error, + )); + } + Ok(Self(value)) + } + + fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for DatabaseUrl { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("DatabaseUrl([REDACTED])") + } +} + +impl Drop for DatabaseUrl { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +/// Validated 256-bit key for encrypted baseline caches. +/// +/// Key material is never exposed through formatting or serialization. +pub struct CacheKey([u8; 32]); + +impl CacheKey { + /// Retain an already decoded 256-bit cache key. + pub fn from_bytes(value: [u8; 32]) -> Self { + Self(value) + } + + /// Decode a 64-character hexadecimal cache key. + /// + /// # Errors + /// + /// Returns [`ErrorKind::Configuration`] when the value is not exactly 32 + /// bytes of hexadecimal key material. + pub fn from_hex(value: &str) -> Result { + decode_hex_key(value.trim()) + .map(Self) + .map_err(|error| Error::configuration(error.to_string())) + } + + fn expose(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Debug for CacheKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("CacheKey([REDACTED])") + } +} + +impl Drop for CacheKey { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +impl From<[u8; 32]> for CacheKey { + fn from(value: [u8; 32]) -> Self { + Self::from_bytes(value) + } +} + +/// Confidence in the final migration result. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub enum Confidence { + /// The result is fully supported by the available modeled evidence. + Exact, + /// At least one relevant fact was unavailable or could not be modeled exactly. + Tainted, +} + +/// Stable severity assigned to a finding. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[non_exhaustive] +pub enum Tier { + /// Blocking safety problem. + Tier1, + /// Risk requiring explicit review. + Tier2, + /// Informational or operability guidance. + Tier3, +} + +/// Stable category of the SQL operation that produced a finding. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub enum OperationKind { + /// Drops a table column. + DropColumn, + /// Drops a table. + DropTable, + /// Drops an index. + DropIndex, + /// Drops a view. + DropView, + /// Drops a materialized view. + DropMaterializedView, + /// Drops a function. + DropFunction, + /// Drops a procedure. + DropProcedure, + /// Drops a schema. + DropSchema, + /// Drops a database. + DropDatabase, + /// Drops a sequence. + DropSequence, + /// Drops a domain. + DropDomain, + /// Drops a type. + DropType, + /// Drops a publication. + DropPublication, + /// Drops a trigger. + DropTrigger, + /// Drops a row-level security policy. + DropPolicy, + /// Adds a table column. + AddColumn, + /// Changes a column's data type. + AlterColumnType, + /// Adds a table constraint. + AddConstraint, + /// Creates an index. + CreateIndex, + /// Creates a table. + CreateTable, + /// Creates a view. + CreateView, + /// Creates a function. + CreateFunction, + /// Creates a procedure. + CreateProcedure, + /// Changes a function. + AlterFunction, + /// Changes a procedure. + AlterProcedure, + /// Refreshes a materialized view. + RefreshMaterializedView, + /// Attaches a partition. + AttachPartition, + /// Detaches a partition. + DetachPartition, + /// Runs `VACUUM FULL`. + VacuumFull, + /// Acquires an explicit table lock. + LockTable, + /// Removes all rows from one or more tables. + TruncateTable, + /// Grants privileges. + Grant, + /// Revokes privileges. + RevokeGrant, + /// Changes a type definition. + AlterType, + /// Creates a trigger. + CreateTrigger, + /// Creates a row-level security policy. + CreatePolicy, + /// Disables a trigger. + DisableTrigger, + /// Enables a trigger. + EnableTrigger, + /// Renames a table. + RenameTable, + /// Renames a table column. + RenameColumn, + /// Renames an object with no more specific category. + Rename, + /// SQL whose effects cannot be modeled precisely. + OpaqueSql, + /// Creates a schema. + CreateSchema, + /// Sets or drops a column default. + SetDefault, + /// Creates a sequence. + CreateSequence, + /// Creates a domain. + CreateDomain, + /// Changes a schema. + AlterSchema, + /// Represents a conflict detected before execution. + Conflict, + /// Represents an irreversible operation. + Irreversible, + /// Represents a reference that could not be resolved safely. + UnresolvedReference, + /// A named operation outside the stable categories above. + Other(String), +} + +/// Stable category of the database object associated with a finding. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[non_exhaustive] +pub enum ObjectKind { + /// A table. + Table, + /// An index. + Index, + /// A view. + View, + /// A materialized view. + MaterializedView, + /// A function. + Function, + /// A procedure. + Procedure, + /// A trigger. + Trigger, + /// A sequence. + Sequence, + /// A schema. + Schema, + /// A database role. + Role, + /// A logical replication publication. + Publication, + /// A logical replication subscription. + Subscription, + /// A database. + Database, + /// A domain. + Domain, + /// A row-level security policy. + Policy, + /// A PostgreSQL type. + Type, + /// Object whose identity is opaque to the analyzer. + Opaque, + /// Unknown object category. + Unknown, +} + +/// Overall deployment verdict derived from all findings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[non_exhaustive] +pub enum Verdict { + /// At least one Tier 1 finding blocks deployment. + #[serde(rename = "HALT")] + Halt, + /// At least one Tier 2 finding requires review and no Tier 1 finding exists. + #[serde(rename = "CAUTIOUS")] + Cautious, + /// Only non-blocking findings exist, including an irreversible operation. + #[serde(rename = "SAFE WITH RISK")] + SafeWithRisk, + /// No modeled blocking or irreversible finding exists. + #[serde(rename = "SAFE")] + Safe, +} + +impl Verdict { + /// Return the stable human and JSON report label. + pub fn as_str(self) -> &'static str { + match self { + Self::Halt => "HALT", + Self::Cautious => "CAUTIOUS", + Self::SafeWithRisk => "SAFE WITH RISK", + Self::Safe => "SAFE", + } + } +} + +/// Counts of findings by severity tier. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize)] +#[non_exhaustive] +pub struct FindingSummary { + /// Total number of findings. + pub total: usize, + /// Number of blocking Tier 1 findings. + pub tier1: usize, + /// Number of review-required Tier 2 findings. + pub tier2: usize, + /// Number of informational Tier 3 findings. + pub tier3: usize, +} + +/// Stable reason why analysis had to be conservative. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum EvidenceCode { + /// No synchronized database baseline was supplied. + BaselineUnavailable, + /// The supplied baseline exceeded the configured maximum age. + BaselineStale, + /// A required catalog family was absent from the baseline. + CatalogCoverageIncomplete, + /// The parser accepted a statement for which no typed extractor exists. + UnsupportedStatement, + /// The statement was recognized but some behavior could not be modeled. + UnsupportedSemantics, + /// An object reference could not be resolved exactly. + UnresolvedReference, + /// The relevant object state could not be proven. + UnknownObjectState, + /// Transaction state became uncertain. + TransactionStateUnknown, + /// A state transition was deliberately treated as opaque. + UnmodeledState, +} + +/// Whether evidence affects one statement or the entire migration chain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum EvidenceScope { + /// Evidence applies to one statement. + Statement, + /// Evidence applies to the complete ordered migration chain. + Chain, +} + +/// Location of conservative-analysis evidence. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[non_exhaustive] +pub struct EvidenceLocation { + /// Source filename supplied by the caller. + pub file: String, + /// One-based statement position within the source file. + pub statement_index: usize, +} + +/// Stable explanation for a conservative analysis decision. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[non_exhaustive] +pub struct Evidence { + /// Machine-readable reason code. + pub code: EvidenceCode, + /// Portion of the analysis affected by this evidence. + pub scope: EvidenceScope, + /// Human-readable explanation without SQL or credentials. + pub summary: String, + /// Source location when the evidence belongs to one statement. + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, } -impl fmt::Display for AnalysisError { +/// One-based source position of a finding. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub struct SourceLocation { + /// Source filename supplied by the caller. + pub file: String, + /// One-based source line. + pub line: usize, + /// One-based source column. + pub column: usize, +} + +/// A source-aware, machine-readable migration finding. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub struct Finding { + /// Stable primary rule identifier. + pub rule_id: String, + /// Stable operation category. + pub operation_kind: OperationKind, + /// Stable database-object category. + pub object_kind: ObjectKind, + /// Qualified object name when known. + pub object_name: String, + /// Effective finding severity. + pub tier: Tier, + /// Explanation of the detected risk. + pub reason: String, + /// Recommended remediation. + pub recipe: String, + /// Optional key used to deduplicate equivalent findings. + pub dedup_key: Option, + /// SQL statement associated with the finding, when available. + pub sql: Option, + /// Whether a foreign-key dependency contributed to the finding. + #[serde(rename = "fk_dependency_related")] + pub foreign_key_dependency_related: bool, + /// Current human-readable rule title, when the rule is registered. + #[serde(skip_serializing_if = "Option::is_none")] + pub rule_title: Option, + /// Current short rule description, when the rule is registered. + #[serde(skip_serializing_if = "Option::is_none")] + pub rule_summary: Option, + /// Risk category associated with the rule, when registered. + #[serde(skip_serializing_if = "Option::is_none")] + pub impact: Option, + /// Source line and column, when available. + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, + /// One-based statement position within the source file. + #[serde(skip_serializing_if = "Option::is_none")] + pub statement_index: Option, +} + +/// One named SQL migration in its intended analysis order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Migration { + filename: String, + sql: String, +} + +impl Migration { + /// Create one named SQL migration for [`analyze_chain`]. + pub fn new(filename: impl Into, sql: impl Into) -> Self { + Self { + filename: filename.into(), + sql: sql.into(), + } + } + + /// Return the migration's source name, used in finding locations. + pub fn filename(&self) -> &str { + &self.filename + } + + /// Return the SQL source submitted for analysis. + pub fn sql(&self) -> &str { + &self.sql + } +} + +/// Immutable analysis result with API-owned snapshots and built-in renderers. +#[derive(Clone)] +pub struct AnalysisOutcome { + findings: Vec, + confidence: Confidence, + evidence: Vec, + baseline: BaselineReport, + inner: InternalOutcome, +} + +impl fmt::Debug for AnalysisOutcome { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AnalysisOutcome") + .field("findings", &self.findings) + .field("confidence", &self.confidence) + .field("evidence", &self.evidence) + .field("baseline", &self.baseline) + .finish() + } +} + +impl AnalysisOutcome { + /// Return findings in deterministic report order. + pub fn findings(&self) -> &[Finding] { + &self.findings + } + + /// Return the confidence of the complete analysis. + pub fn confidence(&self) -> Confidence { + self.confidence + } + + /// Return the evidence explaining conservative analysis decisions. + pub fn evidence(&self) -> &[Evidence] { + &self.evidence + } + + /// Return the baseline provenance attached to every report format. + pub fn baseline(&self) -> &BaselineReport { + &self.baseline + } + + /// Return whether any finding is a blocking Tier 1 result. + pub fn should_halt(&self) -> bool { + self.verdict() == Verdict::Halt + } + + /// Return the overall deployment verdict. + pub fn verdict(&self) -> Verdict { + compute_verdict(&self.violations()).into() + } + + /// Return the canonical deployment recommendation for this result. + pub fn recommendation(&self) -> &'static str { + compute_verdict(&self.violations()).recommendation(&self.inner.confidence) + } + + /// Return finding counts by severity tier. + pub fn summary(&self) -> FindingSummary { + self.findings.iter().fold( + FindingSummary { + total: self.findings.len(), + ..FindingSummary::default() + }, + |mut summary, finding| { + match finding.tier { + Tier::Tier1 => summary.tier1 += 1, + Tier::Tier2 => summary.tier2 += 1, + Tier::Tier3 => summary.tier3 += 1, + } + summary + }, + ) + } + + /// Render the stable JSON report consumed by automation. + pub fn json(&self) -> serde_json::Value { + let mut report = InternalReporter::json_outcome_with_locations(&self.inner); + report["baseline"] = serde_json::to_value(&self.baseline) + .expect("API-owned baseline report is always serializable"); + report + } + + /// Render the Markdown report used in pull-request summaries. + pub fn markdown(&self) -> String { + let mut report = InternalReporter::markdown_outcome(&self.inner); + report.push_str("\n## Baseline\n\n"); + report.push_str(&format!( + "- **Status:** `{}`\n- **Automatic sync:** `{}`\n", + self.baseline.status.label(), + self.baseline.auto_sync.label() + )); + if let Some(source_database) = &self.baseline.source_database { + report.push_str(&format!( + "- **Source database:** `{}`\n", + markdown_inline_code(source_database) + )); + } + if let Some(schemas) = &self.baseline.schemas { + report.push_str(&format!( + "- **Schemas:** `{}`\n", + markdown_inline_code(&schemas.join(", ")) + )); + } + report.push_str(&format!( + "- **Observed lock timeout:** `{}`\n- **Observed statement timeout:** `{}`\n", + format_timeout(self.baseline.observed_settings.lock_timeout_ms), + format_timeout(self.baseline.observed_settings.statement_timeout_ms) + )); + report + } + + /// Print the human report and return whether it contains a halt result. + pub fn print_human(&self) -> bool { + InternalReporter::print_outcome(&self.inner) + } + + /// Run the terminal report viewer. + pub fn run_interactive(&self) -> Result<(), Error> { + crate::_internal::report::interactive::run_interactive( + &self.violations(), + &self.inner.confidence, + ) + .map_err(|error| { + let message = error.to_string(); + Error::with_anyhow_source(ErrorKind::Report, message, error) + }) + } + + /// Add explicit conservative evidence before rendering an outcome. + /// + /// This is useful for callers that know a prerequisite was unavailable + /// outside safe-migrate's SQL analysis. + pub fn with_evidence(mut self, code: EvidenceCode, scope: EvidenceScope) -> Self { + self.inner = self + .inner + .with_evidence(internal_evidence::EvidenceRecord::new( + code.into(), + scope.into(), + )); + self.evidence = self.inner.evidence.iter().map(Evidence::from).collect(); + self.confidence = self.inner.confidence.clone().into(); + self + } + + /// Attach the result of a caller-managed automatic baseline refresh. + pub fn with_auto_sync_status(mut self, status: AutoSyncStatus) -> Self { + self.baseline.auto_sync = status; + self + } + + fn violations(&self) -> Vec { + self.inner + .findings + .iter() + .map(|finding| finding.violation.clone()) + .collect() + } +} + +/// Opaque, validated database baseline used for analysis. +#[derive(Clone)] +pub struct Baseline { + inner: InternalDbCache, + available: bool, + encrypted: bool, + format_version: Option, + path: Option, +} + +impl fmt::Debug for Baseline { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Baseline") + .field("inspection", &self.inspect()) + .finish() + } +} + +impl Default for Baseline { + fn default() -> Self { + Self::unavailable() + } +} + +impl Baseline { + /// Use default worst-case assumptions when no synchronized baseline exists. + pub fn unavailable() -> Self { + Self { + inner: InternalDbCache::new(), + available: false, + encrypted: false, + format_version: None, + path: None, + } + } + + /// Load a synchronized cache, validate its structure, and keep its internal + /// representation opaque to callers. + /// + /// # Errors + /// + /// Returns [`ErrorKind::Cache`] when the file cannot be read, its encryption + /// configuration or key is wrong, or its encoded contents fail validation. + pub fn load(path: &Path, config: &Config) -> Result { + let (inner, format_version, encrypted) = decode_cache(path, config.cache_encryption())?; + Ok(Self { + inner, + available: true, + encrypted, + format_version: Some(format_version), + path: Some(path.to_path_buf()), + }) + } + + /// Load an encrypted baseline with key material supplied by the caller. + /// + /// This entry point avoids process-global environment mutation in embedded + /// and concurrent applications. + /// + /// # Errors + /// + /// Returns [`ErrorKind::Configuration`] when cache encryption is disabled, + /// or [`ErrorKind::Cache`] when the cache cannot be authenticated or decoded. + pub fn load_with_key(path: &Path, config: &Config, key: &CacheKey) -> Result { + require_cache_encryption(config)?; + let (inner, format_version, encrypted) = decode_cache_with_key(path, key)?; + Ok(Self { + inner, + available: true, + encrypted, + format_version: Some(format_version), + path: Some(path.to_path_buf()), + }) + } + + /// Load a baseline when it exists, while preserving every other loading + /// or validation failure. + /// + /// A missing path produces [`Baseline::unavailable`]. Every other error is + /// returned, so callers cannot silently downgrade a damaged baseline. + /// + /// # Errors + /// + /// Returns [`ErrorKind::Cache`] for any failure other than a missing file. + pub fn load_optional(path: &Path, config: &Config) -> Result { + match std::fs::metadata(path) { + Ok(_) => Self::load(path, config), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::unavailable()), + Err(error) => Err(Error::with_source( + ErrorKind::Cache, + format!("failed to inspect {}", path.display()), + error, + )), + } + } + + /// Load an explicitly keyed baseline when it exists. + /// + /// A missing path produces [`Baseline::unavailable`]. Every other error is + /// returned, including authentication and decoding failures. + /// + /// # Errors + /// + /// Returns [`ErrorKind::Configuration`] when cache encryption is disabled, + /// or [`ErrorKind::Cache`] for any failure other than a missing file. + pub fn load_optional_with_key( + path: &Path, + config: &Config, + key: &CacheKey, + ) -> Result { + require_cache_encryption(config)?; + match std::fs::metadata(path) { + Ok(_) => Self::load_with_key(path, config, key), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::unavailable()), + Err(error) => Err(Error::with_source( + ErrorKind::Cache, + format!("failed to inspect {}", path.display()), + error, + )), + } + } + + /// Return whether this value contains a synchronized baseline. + pub fn is_available(&self) -> bool { + self.available + } + + /// Return whether the baseline is older than the supplied number of days. + pub fn is_stale(&self, stale_days: u64) -> bool { + self.available + && self + .inner + .metadata + .created_at_unix_secs + .is_none_or(|created_at| { + now_unix_seconds() + .checked_sub(created_at) + .is_none_or(|age| age > stale_days.saturating_mul(24 * 60 * 60)) + }) + } + + /// Return a redacted, serializable description of baseline contents. + pub fn inspect(&self) -> BaselineInspection { + BaselineInspection::from_baseline(self) + } + + fn report(&self, stale_days: u64) -> BaselineReport { + BaselineReport { + status: if !self.available { + BaselineStatus::Unavailable + } else if self.is_stale(stale_days) { + BaselineStatus::Stale + } else { + BaselineStatus::Available + }, + created_at_unix_secs: self.inner.metadata.created_at_unix_secs, + source_database: self.inner.metadata.source_database.clone(), + schemas: self.inner.metadata.schemas.clone(), + auto_sync: AutoSyncStatus::NotRequested, + observed_settings: ObservedSettings { + lock_timeout_ms: self + .available + .then_some(self.inner.metadata.source_lock_timeout_ms), + statement_timeout_ms: self + .available + .then_some(self.inner.metadata.source_statement_timeout_ms), + }, + } + } +} + +fn require_cache_encryption(config: &Config) -> Result<(), Error> { + if config.cache_encryption() { + Ok(()) + } else { + Err(Error::configuration( + "cache_encryption must be enabled when an explicit cache key is supplied", + )) + } +} + +/// Session settings observed while synchronizing a baseline. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub struct ObservedSettings { + /// Effective `lock_timeout` in milliseconds. + pub lock_timeout_ms: Option, + /// Effective `statement_timeout` in milliseconds. + pub statement_timeout_ms: Option, +} + +/// Availability of the baseline used for an analysis. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum BaselineStatus { + /// A fresh synchronized baseline was used. + Available, + /// A synchronized baseline older than the configured maximum was used. + Stale, + /// Analysis used conservative defaults without a synchronized baseline. + Unavailable, +} + +impl BaselineStatus { + fn label(self) -> &'static str { + match self { + Self::Available => "available", + Self::Stale => "stale", + Self::Unavailable => "unavailable", + } + } +} + +/// Result of an optional caller-managed baseline refresh. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum AutoSyncStatus { + /// No automatic refresh was requested. + NotRequested, + /// The caller refreshed the baseline before analysis. + Refreshed, + /// A requested refresh failed and analysis continued conservatively. + Failed, + /// The caller explicitly bypassed a configured refresh. + Bypassed, +} + +impl AutoSyncStatus { + fn label(self) -> &'static str { match self { - Self::InvalidCache(error) => write!(formatter, "invalid cache: {error}"), - Self::Parse(errors) => write!(formatter, "analysis failed: {}", errors.join("; ")), + Self::NotRequested => "not_requested", + Self::Refreshed => "refreshed", + Self::Failed => "failed", + Self::Bypassed => "bypassed", } } } -impl std::error::Error for AnalysisError {} +/// Redacted baseline context included in every machine-readable report. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub struct BaselineReport { + /// Availability of the baseline used for analysis. + pub status: BaselineStatus, + /// Baseline creation time as Unix seconds. + pub created_at_unix_secs: Option, + /// Redacted source database name, when captured. + pub source_database: Option, + /// Explicit synchronized schema scope, when configured. + pub schemas: Option>, + /// Result of caller-managed automatic synchronization. + pub auto_sync: AutoSyncStatus, + /// Timeouts observed during synchronization. + pub observed_settings: ObservedSettings, +} + +/// Redacted object counts contained in a baseline. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub struct BaselineContents { + /// Number of schemas. + pub schemas: usize, + /// Number of sequences. + pub sequences: usize, + /// Number of all relations. + pub relations: usize, + /// Number of tables. + pub tables: usize, + /// Number of views. + pub views: usize, + /// Number of materialized views. + pub materialized_views: usize, + /// Number of relation columns. + pub columns: usize, + /// Number of indexes. + pub indexes: usize, + /// Number of foreign keys. + pub foreign_keys: usize, + /// Number of constraints. + pub constraints: usize, + /// Number of cached constraint-key records. + pub constraint_keys: usize, + /// Number of triggers. + pub triggers: usize, + /// Number of functions. + pub functions: usize, + /// Number of procedures. + pub procedures: usize, + /// Number of aggregates. + pub aggregates: usize, + /// Number of window functions. + pub window_functions: usize, + /// Number of publications. + pub publications: usize, + /// Number of subscriptions. + pub subscriptions: usize, + /// Number of PostgreSQL types. + pub types: usize, + /// Number of roles. + pub roles: usize, + /// Number of dependency edges. + pub dependencies: usize, + /// Number of inheritance edges. + pub inheritances: usize, +} + +/// Redacted baseline inspection suitable for display or serialization. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub struct BaselineInspection { + /// Whether a synchronized baseline is present. + pub available: bool, + /// Source cache path, when loaded from disk. + pub path: Option, + /// On-disk cache format version, when a cache is present. + pub format_version: Option, + /// Whether the source cache was encrypted. + pub encrypted: bool, + /// Baseline creation time as Unix seconds. + pub created_at_unix_secs: Option, + /// Age of the baseline in seconds, or `None` when its timestamp is absent + /// or lies in the future. + pub age_seconds: Option, + /// Redacted source database name. + pub source_database: Option, + /// Explicit synchronized schema scope, when configured. + pub schemas: Option>, + /// Catalog coverage captured by synchronization. + pub coverage: BaselineCoverage, + /// Effective PostgreSQL search path. + pub search_path: Vec, + /// PostgreSQL numeric server version. + pub postgresql_version_num: Option, + /// Timeouts observed during synchronization. + pub observed_settings: ObservedSettings, + /// Redacted object counts. + pub contents: BaselineContents, +} + +/// Catalog families and schema scope represented by a baseline. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub struct BaselineCoverage { + /// Whether all non-system or only explicitly selected schemas were read. + pub schema_scope: BaselineSchemaScope, + /// Stable names of captured catalog families. + pub families: Vec, +} + +/// Schema scope captured when the baseline was synchronized. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum BaselineSchemaScope { + /// All visible non-system schemas were synchronized. + AllNonSystem, + /// Synchronization was restricted to an explicit schema list. + Explicit, +} + +impl BaselineInspection { + fn from_baseline(baseline: &Baseline) -> Self { + let cache = &baseline.inner; + let mut tables = 0; + let mut views = 0; + let mut materialized_views = 0; + let mut columns = 0; + for relation in cache.relations.values() { + columns += relation.columns.len(); + match relation.kind { + RelationKind::Table => tables += 1, + RelationKind::View => views += 1, + RelationKind::MaterializedView => materialized_views += 1, + } + } + let mut functions = 0; + let mut procedures = 0; + let mut aggregates = 0; + let mut window_functions = 0; + for routine in cache.functions.values() { + match routine.routine_kind { + RoutineKind::Function => functions += 1, + RoutineKind::Procedure => procedures += 1, + RoutineKind::Aggregate => aggregates += 1, + RoutineKind::Window => window_functions += 1, + } + } + Self { + available: baseline.available, + path: baseline + .path + .as_ref() + .map(|path| path.display().to_string()), + format_version: baseline.format_version, + encrypted: baseline.encrypted, + created_at_unix_secs: cache.metadata.created_at_unix_secs, + age_seconds: cache + .metadata + .created_at_unix_secs + .and_then(|created_at| now_unix_seconds().checked_sub(created_at)), + source_database: cache.metadata.source_database.clone(), + schemas: cache.metadata.schemas.clone(), + coverage: BaselineCoverage { + schema_scope: match cache.coverage.schema_scope { + crate::_internal::db::cache::SchemaCoverage::AllNonSystem => { + BaselineSchemaScope::AllNonSystem + } + crate::_internal::db::cache::SchemaCoverage::Explicit(_) => { + BaselineSchemaScope::Explicit + } + }, + families: cache.coverage.family_names().map(str::to_owned).collect(), + }, + search_path: cache.search_path.clone(), + postgresql_version_num: cache.pg_version_num, + observed_settings: ObservedSettings { + lock_timeout_ms: baseline + .available + .then_some(cache.metadata.source_lock_timeout_ms), + statement_timeout_ms: baseline + .available + .then_some(cache.metadata.source_statement_timeout_ms), + }, + contents: BaselineContents { + schemas: cache.schemas.len(), + sequences: cache.sequences.len(), + relations: cache.relations.len(), + tables, + views, + materialized_views, + columns, + indexes: cache.indexes.len(), + foreign_keys: cache.foreign_keys.len(), + constraints: cache.constraints.len(), + constraint_keys: cache.constraint_keys.len(), + triggers: cache.triggers.len(), + functions, + procedures, + aggregates, + window_functions, + publications: cache.publications.len(), + subscriptions: cache.subscriptions.len(), + types: cache.types.len(), + roles: cache.roles.len(), + dependencies: cache.dependencies.len(), + inheritances: cache.inheritances.len(), + }, + } + } +} + +/// Descriptor and effective configuration of one primary rule. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub struct Rule { + /// Stable rule identifier. + pub id: String, + /// Human-readable rule title. + pub title: String, + /// Short description of the unsafe pattern. + pub summary: String, + /// Risk category. + pub impact: String, + /// Default severity before confidence adjustment. + pub default_tier: Tier, + /// Recommended remediation. + pub remediation: String, + /// Configuration fields accepted by this rule. + pub supported_configuration_fields: Vec, + /// Whether the rule is enabled by the supplied configuration. + pub enabled: bool, + /// Effective Tier 1 row threshold when supported. + pub tier1_threshold_rows: Option, + /// Effective Tier 2 row threshold when supported. + pub tier2_threshold_rows: Option, +} + +/// Configuration field supported by an individual rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum RuleConfigurationField { + /// Enable or disable the rule. + Disabled, + /// Override its Tier 1 row threshold. + Tier1ThresholdRows, + /// Override its Tier 2 row threshold. + Tier2ThresholdRows, +} + +impl RuleConfigurationField { + /// Return the `safe-migrate.toml` field name. + pub fn as_str(self) -> &'static str { + match self { + Self::Disabled => "disabled", + Self::Tier1ThresholdRows => "tier1_threshold_rows", + Self::Tier2ThresholdRows => "tier2_threshold_rows", + } + } +} + +/// Validate configuration against the rule catalog and sync settings. +/// +/// # Errors +/// +/// Returns [`ErrorKind::Configuration`] for unknown rule IDs, unsupported +/// per-rule settings, invalid thresholds, or an invalid schema scope. +pub fn validate_config(config: &Config) -> Result<(), Error> { + if config.default_rows() == 0 { + return Err(Error::configuration( + "default_rows must be greater than zero", + )); + } + if config.toast_width_threshold_bytes() <= 0 { + return Err(Error::configuration( + "toast_width_threshold_bytes must be greater than zero", + )); + } + let assumed_version = config.assumed_postgres_version(); + if assumed_version != 100_000 && !(140_000..=180_999).contains(&assumed_version) { + return Err(Error::configuration( + "assume_pg_version must be 100000 (the conservative no-baseline default) or a PostgreSQL 14–18 version number", + )); + } + config + .validate_rule_ids(registry::primary_rule_ids()) + .and_then(|_| config.sync_schemas(None).map(|_| ()))?; + registry::validate_rule_configuration(config).map_err(Error::configuration) +} + +/// Return every primary rule with its effective configuration. +/// +/// # Errors +/// +/// Returns [`ErrorKind::Configuration`] when `config` is invalid. +pub fn rules(config: &Config) -> Result, Error> { + validate_config(config)?; + Ok(registry::PRIMARY_RULES + .iter() + .map(|descriptor| Rule { + id: descriptor.id.to_owned(), + title: descriptor.title.to_owned(), + summary: descriptor.summary.to_owned(), + impact: descriptor.impact.to_owned(), + default_tier: descriptor.default_tier().into(), + remediation: descriptor.recipe().to_owned(), + supported_configuration_fields: descriptor + .supported_configuration_fields + .iter() + .copied() + .map(RuleConfigurationField::from) + .collect(), + enabled: !config.is_rule_disabled(descriptor.id), + tier1_threshold_rows: descriptor + .supports(InternalRuleConfigurationField::Tier1ThresholdRows) + .then(|| config.rule_tier1_threshold(descriptor.id)), + tier2_threshold_rows: descriptor + .supports(InternalRuleConfigurationField::Tier2ThresholdRows) + .then(|| config.rule_tier2_threshold(descriptor.id)), + }) + .collect()) +} + +/// Look up one primary rule and include its effective configuration. +/// +/// # Errors +/// +/// Returns [`ErrorKind::Configuration`] when `config` is invalid, or +/// [`ErrorKind::UnknownRule`] when `rule_id` is not registered. +pub fn rule(config: &Config, rule_id: &str) -> Result { + rules(config)? + .into_iter() + .find(|rule| rule.id == rule_id) + .ok_or_else(|| Error::new(ErrorKind::UnknownRule, rule_id)) +} + +/// Synchronize PostgreSQL metadata into a cache that can later be loaded as a +/// [`Baseline`]. +/// +/// The connection is read from `DATABASE_URL` and must target localhost or a +/// Unix socket. Encrypted caches read `SAFE_MIGRATE_CACHE_KEY` from the process +/// environment. +/// +/// # Errors +/// +/// Returns [`ErrorKind::Configuration`] for invalid settings and +/// [`ErrorKind::Sync`] when the connection, catalog read, or durable cache +/// replacement fails. +pub fn sync(out: &Path, config: &Config, schemas: Option<&[String]>) -> Result<(), Error> { + validate_config(config)?; + let schemas = config.sync_schemas(schemas)?; + crate::_internal::sync::sync_cache(out, schemas, config.cache_encryption()).map_err(|error| { + let message = error.to_string(); + Error::with_anyhow_source(ErrorKind::Sync, message, error) + }) +} + +/// Synchronize PostgreSQL metadata using caller-owned secret material. +/// +/// This is the embedded equivalent of [`sync`]. It never reads `DATABASE_URL` +/// or `SAFE_MIGRATE_CACHE_KEY` from the process environment. Pass a cache key +/// exactly when `cache_encryption` is enabled in `config`. +/// +/// # Errors +/// +/// Returns [`ErrorKind::Configuration`] for invalid settings or an inconsistent +/// cache-key choice, and [`ErrorKind::Sync`] for connection, catalog, or cache +/// replacement failures. +pub fn sync_with_secrets( + out: &Path, + config: &Config, + schemas: Option<&[String]>, + database_url: &DatabaseUrl, + cache_key: Option<&CacheKey>, +) -> Result<(), Error> { + validate_config(config)?; + match (config.cache_encryption(), cache_key) { + (true, None) => { + return Err(Error::configuration( + "an explicit cache key is required when cache_encryption is enabled", + )); + } + (false, Some(_)) => { + return Err(Error::configuration( + "an explicit cache key requires cache_encryption to be enabled", + )); + } + _ => {} + } + let schemas = config.sync_schemas(schemas)?; + crate::_internal::sync::sync_cache_with_secrets( + out, + schemas, + database_url.expose(), + cache_key.map(CacheKey::expose), + ) + .map_err(|error| { + let message = error.to_string(); + Error::with_anyhow_source(ErrorKind::Sync, message, error) + }) +} -/// Analyze one migration using a validated cache and return immutable -/// findings, confidence, and evidence. The helper owns the mutable state -/// so callers cannot accidentally bypass cache validation or reuse state -/// across unrelated analyses. +/// Analyze a single migration against an opaque baseline. +/// +/// # Errors +/// +/// Returns [`ErrorKind::Configuration`], [`ErrorKind::Cache`], or +/// [`ErrorKind::Analysis`] when validation, state hydration, or SQL analysis +/// fails. pub fn analyze( - config: Config, + config: &Config, filename: impl Into, sql: impl Into, - cache: DbCache, -) -> Result, AnalysisError> { - analyze_chain(config, &[(filename.into(), sql.into())], cache) + baseline: &Baseline, +) -> Result { + analyze_chain(config, [Migration::new(filename, sql)], baseline) } -/// Analyze an ordered migration chain with a fresh validated baseline. +/// Analyze an ordered migration chain against an opaque baseline. +/// +/// # Errors +/// +/// Returns [`ErrorKind::Configuration`], [`ErrorKind::Cache`], or +/// [`ErrorKind::Analysis`] when validation, state hydration, or SQL analysis +/// fails. pub fn analyze_chain( - config: Config, - files: &[(String, String)], - cache: DbCache, -) -> Result, AnalysisError> { - let mut state = AnalysisState::try_new(cache).map_err(AnalysisError::InvalidCache)?; - let engine = SafeMigrateEngine::new(config); - engine - .analyze_chain_outcome_with_locations(files, &mut state) - .map_err(AnalysisError::Parse) + config: &Config, + migrations: impl IntoIterator, + baseline: &Baseline, +) -> Result { + validate_config(config)?; + let baseline_unavailable = !baseline.available; + let baseline_stale = baseline.is_stale(config.stale_stats_days()); + let files: Vec<(String, String)> = migrations + .into_iter() + .map(|migration| (migration.filename, migration.sql)) + .collect(); + let mut state = + InternalAnalysisState::try_with_baseline(baseline.inner.clone(), baseline.available) + .map_err(Error::cache)?; + let engine = SafeMigrateEngine::new(config.clone()); + let inner = engine + .analyze_chain_outcome_with_locations(&files, &mut state) + .map_err(Error::analysis)?; + let mut outcome = + AnalysisOutcome::from_internal(inner, baseline.report(config.stale_stats_days())); + if baseline_unavailable { + outcome = outcome.with_evidence(EvidenceCode::BaselineUnavailable, EvidenceScope::Chain); + } else if baseline_stale { + outcome = outcome.with_evidence(EvidenceCode::BaselineStale, EvidenceScope::Chain); + } + Ok(outcome) +} + +fn decode_cache( + path: &Path, + cache_encryption: bool, +) -> Result<(InternalDbCache, u32, bool), Error> { + let encoded = read_cache_bytes(path).map_err(|error| { + let detail = error.to_string(); + Error::with_anyhow_source( + ErrorKind::Cache, + format!("failed to read {}: {detail}", path.display()), + error, + ) + })?; + let encrypted = is_encrypted_cache_bytes(&encoded); + let decrypted = unprotect_cache_bytes(encoded, cache_encryption).map_err(|error| { + let detail = error.to_string(); + Error::with_anyhow_source( + ErrorKind::Cache, + format!("failed to unlock {}: {detail}", path.display()), + error, + ) + })?; + decode_cache_payload(path, decrypted, encrypted) +} + +fn decode_cache_payload( + path: &Path, + decrypted: Vec, + encrypted: bool, +) -> Result<(InternalDbCache, u32, bool), Error> { + let decrypted = Zeroizing::new(decrypted); + let decoder = zstd::stream::Decoder::new(std::io::Cursor::new(decrypted)).map_err(|error| { + Error::with_source( + ErrorKind::Cache, + format!("{}: zstd initialization failed", path.display()), + error, + ) + })?; + let mut decoder = decoder.take(MAX_CACHE_DECODE_BYTES as u64 + 1); + let mut header = Vec::with_capacity(CACHE_V8_MAGIC.len()); + decoder + .by_ref() + .take(CACHE_V8_MAGIC.len() as u64) + .read_to_end(&mut header) + .map_err(|error| { + Error::with_source( + ErrorKind::Cache, + format!("{} is truncated or corrupted", path.display()), + error, + ) + })?; + if header.len() < CACHE_V8_MAGIC.len() && CACHE_V8_MAGIC.starts_with(&header) { + return Err(Error::cache(format!( + "{} is truncated or corrupted", + path.display() + ))); + } + if header != CACHE_V8_MAGIC { + return Err(Error::cache(format!( + "{} uses an unsupported cache format; run `safe-migrate sync`", + path.display() + ))); + } + let codec = bincode::config::standard() + .with_variable_int_encoding() + .with_limit::(); + let versioned: DbCacheVersioned = bincode::serde::decode_from_std_read(&mut decoder, codec) + .map_err(|error| { + let detail = if matches!(&error, bincode::error::DecodeError::LimitExceeded) { + format!( + "exceeds the {} MiB decoded-size limit", + MAX_CACHE_DECODE_BYTES / (1024 * 1024) + ) + } else { + error.to_string() + }; + Error::with_source( + ErrorKind::Cache, + format!("{} is corrupted (bincode): {detail}", path.display()), + error, + ) + })?; + let remaining_before_trailing = decoder.limit(); + std::io::copy(&mut decoder, &mut std::io::sink()).map_err(|error| { + Error::with_source( + ErrorKind::Cache, + format!("{} is corrupted while decompressing", path.display()), + error, + ) + })?; + let decompressed = (MAX_CACHE_DECODE_BYTES as u64 + 1) - decoder.limit(); + if decompressed > MAX_CACHE_DECODE_BYTES as u64 { + return Err(Error::cache(format!( + "{} exceeds the {} MiB decoded-size limit", + path.display(), + MAX_CACHE_DECODE_BYTES / (1024 * 1024) + ))); + } + if decoder.limit() != remaining_before_trailing { + return Err(Error::cache(format!( + "{} contains trailing payload data", + path.display() + ))); + } + let format_version = versioned.format_version(); + if format_version != CACHE_FORMAT_VERSION { + return Err(Error::cache(format!( + "{} has a mismatched cache format header", + path.display() + ))); + } + let cache = versioned.into_cache().map_err(Error::cache)?; + Ok((cache, format_version, encrypted)) +} + +fn decode_cache_with_key( + path: &Path, + key: &CacheKey, +) -> Result<(InternalDbCache, u32, bool), Error> { + let encoded = read_cache_bytes(path).map_err(|error| { + let detail = error.to_string(); + Error::with_anyhow_source( + ErrorKind::Cache, + format!("failed to read {}: {detail}", path.display()), + error, + ) + })?; + let decrypted = unprotect_cache_bytes_with_key(encoded, key.expose()).map_err(|error| { + let detail = error.to_string(); + Error::with_anyhow_source( + ErrorKind::Cache, + format!("failed to unlock {}: {detail}", path.display()), + error, + ) + })?; + decode_cache_payload(path, decrypted, true) +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +impl AnalysisOutcome { + fn from_internal(inner: InternalOutcome, baseline: BaselineReport) -> Self { + Self { + findings: inner.findings.iter().map(Finding::from).collect(), + confidence: inner.confidence.clone().into(), + evidence: inner.evidence.iter().map(Evidence::from).collect(), + baseline, + inner, + } + } +} + +fn format_timeout(timeout_ms: Option) -> String { + timeout_ms.map_or_else(|| "unknown".to_owned(), |value| format!("{value} ms")) +} + +fn markdown_inline_code(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '`' => output.push('\''), + '\r' | '\n' => output.push(' '), + character if character.is_control() => output.extend(character.escape_default()), + character => output.push(character), + } + } + output +} + +impl From<&InternalFinding> for Finding { + fn from(finding: &InternalFinding) -> Self { + let violation = &finding.violation; + let descriptor = registry::find_primary_rule(violation.rule_id); + Self { + rule_id: violation.rule_id.to_owned(), + operation_kind: (&violation.operation_kind).into(), + object_kind: (&violation.object_kind).into(), + object_name: violation.object_name.clone(), + tier: violation.tier.clone().into(), + reason: violation.reason.clone(), + recipe: violation.recipe.to_owned(), + dedup_key: violation.dedup_key.clone(), + sql: violation.sql.clone(), + foreign_key_dependency_related: violation.fk_dependency_related, + rule_title: descriptor.map(|descriptor| descriptor.title.to_owned()), + rule_summary: descriptor.map(|descriptor| descriptor.summary.to_owned()), + impact: descriptor.map(|descriptor| descriptor.impact.to_owned()), + location: finding.location.as_ref().map(|location| SourceLocation { + file: location.file.clone(), + line: location.line, + column: location.column, + }), + statement_index: finding.statement_index, + } + } +} + +impl From for Confidence { + fn from(value: InternalConfidence) -> Self { + match value { + InternalConfidence::Exact => Self::Exact, + InternalConfidence::Tainted => Self::Tainted, + } + } +} + +impl From for Tier { + fn from(value: InternalTier) -> Self { + match value { + InternalTier::Tier1 => Self::Tier1, + InternalTier::Tier2 => Self::Tier2, + InternalTier::Tier3 => Self::Tier3, + } + } +} + +impl From for Verdict { + fn from(value: InternalVerdict) -> Self { + match value { + InternalVerdict::Halt => Self::Halt, + InternalVerdict::Cautious => Self::Cautious, + InternalVerdict::SafeWithRisk => Self::SafeWithRisk, + InternalVerdict::Safe => Self::Safe, + } + } +} + +impl From for RuleConfigurationField { + fn from(value: InternalRuleConfigurationField) -> Self { + match value { + InternalRuleConfigurationField::Disabled => Self::Disabled, + InternalRuleConfigurationField::Tier1ThresholdRows => Self::Tier1ThresholdRows, + InternalRuleConfigurationField::Tier2ThresholdRows => Self::Tier2ThresholdRows, + } + } +} + +impl From<&InternalOperationKind> for OperationKind { + fn from(value: &InternalOperationKind) -> Self { + match value { + InternalOperationKind::DropColumn => Self::DropColumn, + InternalOperationKind::DropTable => Self::DropTable, + InternalOperationKind::DropIndex => Self::DropIndex, + InternalOperationKind::DropView => Self::DropView, + InternalOperationKind::DropMaterializedView => Self::DropMaterializedView, + InternalOperationKind::DropFunction => Self::DropFunction, + InternalOperationKind::DropProcedure => Self::DropProcedure, + InternalOperationKind::DropSchema => Self::DropSchema, + InternalOperationKind::DropDatabase => Self::DropDatabase, + InternalOperationKind::DropSequence => Self::DropSequence, + InternalOperationKind::DropDomain => Self::DropDomain, + InternalOperationKind::DropType => Self::DropType, + InternalOperationKind::DropPublication => Self::DropPublication, + InternalOperationKind::DropTrigger => Self::DropTrigger, + InternalOperationKind::DropPolicy => Self::DropPolicy, + InternalOperationKind::AddColumn => Self::AddColumn, + InternalOperationKind::AlterColumnType => Self::AlterColumnType, + InternalOperationKind::AddConstraint => Self::AddConstraint, + InternalOperationKind::CreateIndex => Self::CreateIndex, + InternalOperationKind::CreateTable => Self::CreateTable, + InternalOperationKind::CreateView => Self::CreateView, + InternalOperationKind::AlterFunction => Self::AlterFunction, + InternalOperationKind::AlterProcedure => Self::AlterProcedure, + InternalOperationKind::RefreshMaterializedView => Self::RefreshMaterializedView, + InternalOperationKind::AttachPartition => Self::AttachPartition, + InternalOperationKind::DetachPartition => Self::DetachPartition, + InternalOperationKind::VacuumFull => Self::VacuumFull, + InternalOperationKind::LockTable => Self::LockTable, + InternalOperationKind::TruncateTable => Self::TruncateTable, + InternalOperationKind::Grant => Self::Grant, + InternalOperationKind::AlterType => Self::AlterType, + InternalOperationKind::CreatePolicy => Self::CreatePolicy, + InternalOperationKind::DisableTrigger => Self::DisableTrigger, + InternalOperationKind::EnableTrigger => Self::EnableTrigger, + InternalOperationKind::Rename => Self::Rename, + InternalOperationKind::OpaqueSql => Self::OpaqueSql, + InternalOperationKind::CreateSchema => Self::CreateSchema, + InternalOperationKind::SetDefault => Self::SetDefault, + InternalOperationKind::CreateSequence => Self::CreateSequence, + InternalOperationKind::Conflict => Self::Conflict, + InternalOperationKind::Irreversible => Self::Irreversible, + InternalOperationKind::UnresolvedReference => Self::UnresolvedReference, + InternalOperationKind::Other(name) => Self::Other(name.clone()), + } + } +} + +impl From<&InternalObjectKind> for ObjectKind { + fn from(value: &InternalObjectKind) -> Self { + match value { + InternalObjectKind::Table => Self::Table, + InternalObjectKind::Index => Self::Index, + InternalObjectKind::View => Self::View, + InternalObjectKind::MaterializedView => Self::MaterializedView, + InternalObjectKind::Function => Self::Function, + InternalObjectKind::Procedure => Self::Procedure, + InternalObjectKind::Trigger => Self::Trigger, + InternalObjectKind::Sequence => Self::Sequence, + InternalObjectKind::Schema => Self::Schema, + InternalObjectKind::Role => Self::Role, + InternalObjectKind::Publication => Self::Publication, + InternalObjectKind::Database => Self::Database, + InternalObjectKind::Domain => Self::Domain, + InternalObjectKind::Policy => Self::Policy, + InternalObjectKind::Type => Self::Type, + InternalObjectKind::Opaque => Self::Opaque, + InternalObjectKind::Unknown => Self::Unknown, + } + } +} + +impl From<&internal_evidence::EvidenceRecord> for Evidence { + fn from(record: &internal_evidence::EvidenceRecord) -> Self { + Self { + code: record.code.into(), + scope: record.scope.into(), + summary: record.summary.to_owned(), + location: record.location.as_ref().map(|location| EvidenceLocation { + file: location.file.clone(), + statement_index: location.statement_index, + }), + } + } +} + +impl From for EvidenceCode { + fn from(value: internal_evidence::EvidenceCode) -> Self { + match value { + internal_evidence::EvidenceCode::BaselineUnavailable => Self::BaselineUnavailable, + internal_evidence::EvidenceCode::BaselineStale => Self::BaselineStale, + internal_evidence::EvidenceCode::CatalogCoverageIncomplete => { + Self::CatalogCoverageIncomplete + } + internal_evidence::EvidenceCode::UnsupportedStatement => Self::UnsupportedStatement, + internal_evidence::EvidenceCode::UnsupportedSemantics => Self::UnsupportedSemantics, + internal_evidence::EvidenceCode::UnresolvedReference => Self::UnresolvedReference, + internal_evidence::EvidenceCode::UnknownObjectState => Self::UnknownObjectState, + internal_evidence::EvidenceCode::TransactionStateUnknown => { + Self::TransactionStateUnknown + } + internal_evidence::EvidenceCode::UnmodeledState => Self::UnmodeledState, + } + } +} + +impl From for internal_evidence::EvidenceCode { + fn from(value: EvidenceCode) -> Self { + match value { + EvidenceCode::BaselineUnavailable => Self::BaselineUnavailable, + EvidenceCode::BaselineStale => Self::BaselineStale, + EvidenceCode::CatalogCoverageIncomplete => Self::CatalogCoverageIncomplete, + EvidenceCode::UnsupportedStatement => Self::UnsupportedStatement, + EvidenceCode::UnsupportedSemantics => Self::UnsupportedSemantics, + EvidenceCode::UnresolvedReference => Self::UnresolvedReference, + EvidenceCode::UnknownObjectState => Self::UnknownObjectState, + EvidenceCode::TransactionStateUnknown => Self::TransactionStateUnknown, + EvidenceCode::UnmodeledState => Self::UnmodeledState, + } + } +} + +impl From for EvidenceScope { + fn from(value: internal_evidence::EvidenceScope) -> Self { + match value { + internal_evidence::EvidenceScope::Statement => Self::Statement, + internal_evidence::EvidenceScope::Chain => Self::Chain, + } + } +} + +impl From for internal_evidence::EvidenceScope { + fn from(value: EvidenceScope) -> Self { + match value { + EvidenceScope::Statement => Self::Statement, + EvidenceScope::Chain => Self::Chain, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn future_dated_baseline_is_not_treated_as_fresh() { + let mut baseline = Baseline::unavailable(); + baseline.available = true; + baseline.inner.metadata.created_at_unix_secs = Some(u64::MAX); + + assert!(baseline.is_stale(u64::MAX)); + assert_eq!(baseline.inspect().age_seconds, None); + } + + #[test] + fn markdown_inline_values_render_controls_inertly() { + assert_eq!( + markdown_inline_code("cache\x1b[2J\r\n`"), + "cache\\u{1b}[2J '" + ); + } } diff --git a/src/api/config.rs b/src/api/config.rs new file mode 100644 index 00000000..907b7394 --- /dev/null +++ b/src/api/config.rs @@ -0,0 +1,472 @@ +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; + +use super::Error; + +/// Per-rule configuration accepted by `safe-migrate.toml`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct RuleConfig { + pub(crate) disabled: Option, + pub(crate) tier1_threshold_rows: Option, + pub(crate) tier2_threshold_rows: Option, +} + +impl RuleConfig { + /// Start an empty per-rule override. + pub fn new() -> Self { + Self::default() + } + + /// Enable or disable this rule without changing its thresholds. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = Some(disabled); + self + } + + /// Override the row thresholds supported by this rule. + pub fn tier_thresholds(mut self, tier1_rows: Option, tier2_rows: Option) -> Self { + self.tier1_threshold_rows = tier1_rows; + self.tier2_threshold_rows = tier2_rows; + self + } + + /// Return the explicit enabled/disabled override, if one was configured. + pub fn disabled_override(&self) -> Option { + self.disabled + } + + /// Return the explicit Tier 1 row threshold, if one was configured. + pub fn tier1_threshold_rows(&self) -> Option { + self.tier1_threshold_rows + } + + /// Return the explicit Tier 2 row threshold, if one was configured. + pub fn tier2_threshold_rows(&self) -> Option { + self.tier2_threshold_rows + } +} + +/// Complete safe-migrate configuration. +/// +/// This type is defined by the supported API. Internal engine modules consume +/// it, so configuration behavior does not depend on an implementation type +/// leaking through a re-export. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + pub(crate) tier1_threshold_rows: u64, + pub(crate) tier2_threshold_rows: u64, + pub(crate) stale_stats_days: u64, + pub(crate) toast_width_threshold_bytes: i32, + pub(crate) default_rows: u64, + pub(crate) auto_sync: bool, + pub(crate) cache_encryption: bool, + pub(crate) rules: BTreeMap, + pub(crate) assume_pg_version: u32, + pub(crate) disabled_rules: Vec, + pub(crate) schemas: Option>, +} + +impl Default for Config { + fn default() -> Self { + Self { + tier1_threshold_rows: 100_000, + tier2_threshold_rows: 10_000, + stale_stats_days: 7, + toast_width_threshold_bytes: 2048, + default_rows: 10_000, + auto_sync: false, + cache_encryption: false, + assume_pg_version: 100000, + disabled_rules: Vec::new(), + rules: BTreeMap::new(), + schemas: None, + } + } +} + +impl Config { + /// Validate rule IDs, per-rule settings, thresholds, and schema scope. + /// + /// # Errors + /// + /// Returns a configuration error describing every invalid rule ID or the + /// first invalid setting. + pub fn validate(&self) -> Result<(), Error> { + super::validate_config(self) + } + + /// Return whether baseline caches are expected to be encrypted. + pub fn cache_encryption(&self) -> bool { + self.cache_encryption + } + + /// Return whether a CLI caller may refresh stale baselines automatically. + pub fn auto_sync(&self) -> bool { + self.auto_sync + } + + /// Return the maximum accepted age of catalog statistics, in days. + pub fn stale_stats_days(&self) -> u64 { + self.stale_stats_days + } + + /// Return the default Tier 1 row threshold. + pub fn tier1_threshold_rows(&self) -> u64 { + self.tier1_threshold_rows + } + + /// Return the default Tier 2 row threshold. + pub fn tier2_threshold_rows(&self) -> u64 { + self.tier2_threshold_rows + } + + /// Return the conservative row estimate used when statistics are absent. + pub fn default_rows(&self) -> u64 { + self.default_rows + } + + /// Return the TOAST-width threshold used by rewrite analysis. + pub fn toast_width_threshold_bytes(&self) -> i32 { + self.toast_width_threshold_bytes + } + + /// Return the PostgreSQL version assumed when no connected baseline provides one. + /// + /// The default `100000` is a conservative compatibility fallback, not a + /// claim that PostgreSQL 10 is supported. A configured value must name a + /// supported PostgreSQL 14–18 version. + pub fn assumed_postgres_version(&self) -> u32 { + self.assume_pg_version + } + + /// Return the configured schema scope, or `None` for all non-system schemas. + pub fn schema_scope(&self) -> Option<&[String]> { + self.schemas.as_deref() + } + + /// Set whether baseline caches are encrypted. + pub fn with_cache_encryption(mut self, enabled: bool) -> Self { + self.cache_encryption = enabled; + self + } + + /// Set whether a CLI caller may refresh stale baselines automatically. + pub fn with_auto_sync(mut self, enabled: bool) -> Self { + self.auto_sync = enabled; + self + } + + /// Set the maximum accepted age of catalog statistics, in days. + pub fn with_stale_stats_days(mut self, days: u64) -> Self { + self.stale_stats_days = days; + self + } + + /// Set the default row thresholds for Tier 1 and Tier 2 findings. + pub fn with_tier_thresholds(mut self, tier1_rows: u64, tier2_rows: u64) -> Self { + self.tier1_threshold_rows = tier1_rows; + self.tier2_threshold_rows = tier2_rows; + self + } + + /// Set the conservative row estimate used when statistics are absent. + pub fn with_default_rows(mut self, rows: u64) -> Self { + self.default_rows = rows; + self + } + + /// Set the TOAST-width threshold used by rewrite analysis. + pub fn with_toast_width_threshold_bytes(mut self, bytes: i32) -> Self { + self.toast_width_threshold_bytes = bytes; + self + } + + /// Set the PostgreSQL version assumed when no connected baseline provides one. + /// + /// Use a PostgreSQL 14–18 server version number only when the deployment + /// target is known. [`Config::validate`] rejects unsupported values. + pub fn with_assumed_postgres_version(mut self, version_num: u32) -> Self { + self.assume_pg_version = version_num; + self + } + + /// Restrict synchronization and analysis to the supplied non-empty schema names. + pub fn with_schema_scope( + mut self, + schemas: impl IntoIterator>, + ) -> Self { + self.schemas = Some(schemas.into_iter().map(Into::into).collect()); + self + } + + /// Synchronize all visible non-system schemas. + pub fn with_all_non_system_schemas(mut self) -> Self { + self.schemas = None; + self + } + + /// Add or replace a per-rule configuration override. + pub fn with_rule(mut self, rule_id: impl Into, rule: RuleConfig) -> Self { + self.rules.insert(rule_id.into(), rule); + self + } + + /// Return an explicit per-rule override, if configured. + pub fn rule_config(&self, rule_id: &str) -> Option<&RuleConfig> { + self.rules.get(rule_id) + } + + /// Disable one primary rule by ID. + pub fn disable_rule(mut self, rule_id: impl Into) -> Self { + let rule_id = rule_id.into(); + self.disabled_rules.retain(|disabled| disabled != &rule_id); + self.rules.entry(rule_id).or_default().disabled = Some(true); + self + } + + /// Enable one primary rule by ID while preserving its threshold overrides. + pub fn enable_rule(mut self, rule_id: impl Into) -> Self { + let rule_id = rule_id.into(); + self.disabled_rules.retain(|disabled| disabled != &rule_id); + self.rules.entry(rule_id).or_default().disabled = Some(false); + self + } + + /// Load a TOML configuration, returning defaults when the file is absent. + /// + /// # Errors + /// + /// Returns a configuration error when an existing file cannot be read, + /// parsed, or validated. + pub fn load_from_file(path: &Path) -> Result { + match fs::read_to_string(path) { + Ok(contents) => Self::parse_file(path, &contents), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(error) => Err(Error::with_source( + super::ErrorKind::Configuration, + format!("failed to read {}", path.display()), + error, + )), + } + } + + /// Load a TOML configuration and fail when the file is absent. + /// + /// # Errors + /// + /// Returns a configuration error when the file cannot be read, parsed, or + /// validated. + pub fn load_required_from_file(path: &Path) -> Result { + let contents = fs::read_to_string(path).map_err(|error| { + Error::with_source( + super::ErrorKind::Configuration, + format!("failed to read {}", path.display()), + error, + ) + })?; + Self::parse_file(path, &contents) + } + + fn parse_file(path: &Path, contents: &str) -> Result { + let config: Self = toml::from_str(contents).map_err(|error| { + Error::with_source( + super::ErrorKind::Configuration, + format!("failed to parse {}", path.display()), + error, + ) + })?; + config.validate()?; + Ok(config) + } + + /// Return whether a rule is disabled by either configuration form. + pub fn is_rule_disabled(&self, rule_id: &str) -> bool { + if self + .disabled_rules + .iter() + .any(|disabled| disabled == rule_id) + { + return true; + } + self.rules + .get(rule_id) + .and_then(|rule| rule.disabled) + .unwrap_or(false) + } + + /// Return a rule's effective Tier 1 threshold. + pub fn rule_tier1_threshold(&self, rule_id: &str) -> u64 { + self.rules + .get(rule_id) + .and_then(|rule| rule.tier1_threshold_rows) + .unwrap_or(self.tier1_threshold_rows) + } + + /// Return a rule's effective Tier 2 threshold. + pub fn rule_tier2_threshold(&self, rule_id: &str) -> u64 { + self.rules + .get(rule_id) + .and_then(|rule| rule.tier2_threshold_rows) + .unwrap_or(self.tier2_threshold_rows) + } + + /// Resolve a direct sync's schema scope. Explicit command input wins over + /// the shared configuration. + pub(crate) fn sync_schemas<'a>( + &'a self, + command_schemas: Option<&'a [String]>, + ) -> Result, Error> { + let schemas = command_schemas.or(self.schemas.as_deref()); + if schemas.is_some_and(|schemas| { + schemas.is_empty() || schemas.iter().any(|schema| schema.trim().is_empty()) + }) { + return Err(Error::configuration( + "schemas must not be empty and no schema name may be blank", + )); + } + Ok(schemas) + } + + pub(crate) fn validate_rule_ids<'a>( + &self, + primary_rule_ids: impl IntoIterator, + ) -> Result<(), Error> { + let valid: BTreeSet = primary_rule_ids.into_iter().map(str::to_owned).collect(); + let unknown: BTreeSet<&str> = self + .rules + .keys() + .map(String::as_str) + .chain(self.disabled_rules.iter().map(String::as_str)) + .filter(|rule_id| !valid.contains(*rule_id)) + .collect(); + + if unknown.is_empty() { + return Ok(()); + } + + Err(Error::configuration(format!( + "Unknown primary rule ID(s): {}. Valid primary rule IDs: {}", + unknown.into_iter().collect::>().join(", "), + valid.into_iter().collect::>().join(", ") + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn granular_rule_configuration_is_loaded() { + let mut file = NamedTempFile::new().expect("create temporary config"); + writeln!( + file, + r#" + tier1_threshold_rows = 500000 + + [rules.blocking-constraint] + tier1_threshold_rows = 50000 + + [rules.missing-idempotency] + disabled = true + "# + ) + .expect("write temporary config"); + + let config = Config::load_from_file(file.path()).expect("load valid config"); + assert_eq!(config.tier1_threshold_rows, 500_000); + assert_eq!(config.rule_tier1_threshold("blocking-constraint"), 50_000); + assert_eq!(config.rule_tier1_threshold("unspecified-rule"), 500_000); + assert!(!config.auto_sync); + assert!(!config.cache_encryption); + assert!(config.is_rule_disabled("missing-idempotency")); + assert!(!config.is_rule_disabled("blocking-constraint")); + } + + #[test] + fn command_schema_filter_takes_precedence() { + let config = Config { + schemas: Some(vec!["public".to_owned()]), + ..Config::default() + }; + let command_schemas = vec!["auth".to_owned()]; + + assert_eq!( + config.sync_schemas(None).unwrap(), + Some(["public".to_owned()].as_slice()) + ); + assert_eq!( + config.sync_schemas(Some(&command_schemas)).unwrap(), + Some(["auth".to_owned()].as_slice()) + ); + } + + #[test] + fn empty_schema_scope_is_rejected() { + let config = Config::default(); + assert!(config.sync_schemas(Some(&[])).is_err()); + assert!(config.sync_schemas(Some(&[String::new()])).is_err()); + } + + #[test] + fn optional_and_required_missing_config_have_distinct_behavior() { + let directory = tempfile::tempdir().unwrap(); + let missing = directory.path().join("missing.toml"); + + assert_eq!( + Config::load_from_file(&missing) + .unwrap() + .tier1_threshold_rows, + Config::default().tier1_threshold_rows + ); + assert!(Config::load_required_from_file(&missing).is_err()); + } + + #[test] + fn unknown_rule_ids_are_reported_together() { + let mut config = Config::default(); + config + .rules + .insert("typo-rule".to_owned(), RuleConfig::default()); + config.disabled_rules = vec!["known-rule".to_owned(), "other-typo".to_owned()]; + + let error = config + .validate_rule_ids(["known-rule"]) + .expect_err("unknown rule IDs must fail validation") + .to_string(); + assert!(error.contains("other-typo, typo-rule")); + assert!(error.contains("Valid primary rule IDs: known-rule")); + } + + #[test] + fn known_rule_ids_are_accepted() { + let mut config = Config::default(); + config + .rules + .insert("known-rule".to_owned(), RuleConfig::default()); + config.disabled_rules = vec!["known-rule".to_owned()]; + config.validate_rule_ids(["known-rule"]).unwrap(); + } + + #[test] + fn unknown_configuration_fields_are_rejected() { + let top_level = toml::from_str::("auto_syn = true") + .expect_err("unknown top-level settings must fail") + .to_string(); + assert!(top_level.contains("unknown field `auto_syn`")); + assert!(top_level.contains("auto_sync")); + + let per_rule = + toml::from_str::("[rules.blocking-constraint]\ntier1_threshold_row = 1") + .expect_err("unknown per-rule settings must fail") + .to_string(); + assert!(per_rule.contains("unknown field `tier1_threshold_row`")); + assert!(per_rule.contains("tier1_threshold_rows")); + } +} diff --git a/src/cli_init.rs b/src/cli_init.rs index 15d532f5..1f24e233 100644 --- a/src/cli_init.rs +++ b/src/cli_init.rs @@ -8,6 +8,7 @@ use std::fs; use std::io::{IsTerminal, Write}; use std::path::{Component, Path, PathBuf}; use std::process::{Command, Stdio}; +use zeroize::Zeroizing; const ANALYSIS_WORKFLOW: &str = "safe-migrate.yml"; const BASELINE_WORKFLOW: &str = "safe-migrate-baseline.yml"; @@ -64,8 +65,50 @@ fn yaml_single_quoted(value: &str) -> String { } fn validate_single_line(name: &str, value: &str) -> Result<()> { - if value.is_empty() || value.contains(['\r', '\n', '\0']) { - return Err(anyhow!("{name} must be a non-empty, single-line value")); + if value.is_empty() || value.chars().any(char::is_control) { + return Err(anyhow!( + "{name} must be a non-empty, single-line value without control characters" + )); + } + Ok(()) +} + +fn display_path(path: &Path) -> String { + let value = path.display().to_string(); + let mut output = String::with_capacity(value.len()); + for character in value.chars() { + if character.is_control() { + output.extend(character.escape_default()); + } else { + output.push(character); + } + } + output +} + +fn reject_symlink_components(path: &Path) -> Result<()> { + for component_path in path.ancestors().collect::>().into_iter().rev() { + if component_path.as_os_str().is_empty() { + continue; + } + match component_path.symlink_metadata() { + Ok(metadata) if metadata.is_symlink() => { + return Err(anyhow!( + "Refusing to write workflows through a symbolic link: {}", + component_path.display() + )); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!( + "Could not inspect workflow path {}", + component_path.display() + ) + }); + } + } } Ok(()) } @@ -258,19 +301,25 @@ fn set_github_secret(name: &str, value: Option<&str>, environment: Option<&str>) Ok(()) } -fn generate_cache_key() -> Result { +fn generate_cache_key() -> Result> { let key = Key::::try_generate() .context("Operating system could not generate a cache key")?; - Ok(key.iter().map(|byte| format!("{byte:02x}")).collect()) + let mut encoded = String::with_capacity(key.len() * 2); + const HEX: &[u8; 16] = b"0123456789abcdef"; + for byte in key.iter() { + encoded.push(HEX[(byte >> 4) as usize] as char); + encoded.push(HEX[(byte & 0x0f) as usize] as char); + } + Ok(Zeroizing::new(encoded)) } fn run_cache_key(store_github_secret: bool) -> Result<()> { let key = generate_cache_key()?; if store_github_secret { - set_github_secret("SAFE_MIGRATE_CACHE_KEY", Some(&key), None)?; + set_github_secret("SAFE_MIGRATE_CACHE_KEY", Some(key.as_str()), None)?; println!("Configured SAFE_MIGRATE_CACHE_KEY for the current GitHub repository."); } else { - println!("{key}"); + println!("{}", key.as_str()); } Ok(()) } @@ -308,15 +357,7 @@ fn run_github_actions( validate_single_line("migration path", migration_path)?; validate_single_line("branch", branch)?; - if output_dir - .symlink_metadata() - .is_ok_and(|metadata| metadata.is_symlink()) - { - return Err(anyhow!( - "Refusing to write workflows through a symbolic link: {}", - output_dir.display() - )); - } + reject_symlink_components(output_dir)?; if output_dir.exists() && !output_dir.is_dir() { return Err(anyhow!( "Workflow output is not a directory: {}", @@ -357,8 +398,8 @@ fn run_github_actions( fs::write(&baseline_output, baseline_workflow) .with_context(|| format!("Could not write {}", baseline_output.display()))?; - println!("Created {}", analysis_output.display()); - println!("Created {}", baseline_output.display()); + println!("Created {}", display_path(&analysis_output)); + println!("Created {}", display_path(&baseline_output)); if configure_secrets { warn_if_github_environment_is_unprotected(BASELINE_ENVIRONMENT); println!( @@ -370,7 +411,7 @@ fn run_github_actions( Some(BASELINE_ENVIRONMENT), )?; let key = generate_cache_key()?; - set_github_secret("SAFE_MIGRATE_CACHE_KEY", Some(&key), None)?; + set_github_secret("SAFE_MIGRATE_CACHE_KEY", Some(key.as_str()), None)?; println!( "Configured the database URL as an environment secret and the generated cache key as a repository secret." ); @@ -438,4 +479,35 @@ mod tests { .unwrap() ); } + + #[cfg(unix)] + #[test] + fn github_actions_rejects_a_symlinked_output_ancestor() { + use std::os::unix::fs::symlink; + + let workspace = tempfile::tempdir().unwrap(); + let workflow_target = workspace.path().join("workflow-target"); + let workflow_link = workspace.path().join("workflow-link"); + fs::create_dir_all(&workflow_target).unwrap(); + symlink(&workflow_target, &workflow_link).unwrap(); + + let error = reject_symlink_components(&workflow_link.join("nested")).unwrap_err(); + + assert!( + error.to_string().contains("symbolic link"), + "unexpected error: {error:#}" + ); + assert!(!workflow_target.join("nested").exists()); + } + + #[test] + fn github_actions_input_values_reject_terminal_controls() { + for value in ["branch\u{1b}[2J", "path\u{7f}", "line\nnext"] { + assert!(validate_single_line("input", value).is_err()); + } + assert_eq!( + display_path(Path::new("workflow\u{1b}[2J_日本")), + "workflow\\u{1b}[2J_日本" + ); + } } diff --git a/src/internal_tests.rs b/src/internal_tests.rs new file mode 100644 index 00000000..1ea5e10f --- /dev/null +++ b/src/internal_tests.rs @@ -0,0 +1,50 @@ +// Implementation tests are compiled inside the crate so they can exercise +// invariants without promoting the mutable engine model to public API. +#[path = "../tests/alter_schema_visitor.rs"] +mod alter_schema_visitor; +#[path = "../tests/architectural_gaps.rs"] +mod architectural_gaps; +#[path = "../tests/bug_fixes.rs"] +mod bug_fixes; +#[path = "../tests/chain_execution.rs"] +mod chain_execution; +#[path = "../tests/cli_tests.rs"] +mod cli_tests; +#[path = "../tests/destructive_rules.rs"] +mod destructive_rules; +#[path = "../tests/evidence_outcome.rs"] +mod evidence_outcome; +#[path = "../tests/exhaustive_fuzz.rs"] +mod exhaustive_fuzz; +#[path = "../tests/expression_parsing.rs"] +mod expression_parsing; +#[path = "../tests/identifier_casing.rs"] +mod identifier_casing; +#[path = "../tests/invariant_sequences.rs"] +mod invariant_sequences_file; +#[path = "../tests/live_auto_sync.rs"] +mod live_auto_sync; +#[path = "../tests/live_cache_encryption.rs"] +mod live_cache_encryption; +#[path = "../tests/live_catalog_sync.rs"] +mod live_catalog_sync; +#[path = "../tests/live_differential_harness.rs"] +mod live_differential_harness; +#[path = "../tests/performance_scenarios.rs"] +mod performance_scenarios_file; +#[path = "../tests/resolver_namespaces.rs"] +mod resolver_namespaces; +#[path = "../tests/reversibility.rs"] +mod reversibility; +#[path = "../tests/rule_evaluation.rs"] +mod rule_evaluation; +#[path = "../tests/state_machine_guards.rs"] +mod state_machine_guards; +#[path = "../tests/state_mutation.rs"] +mod state_mutation; +#[path = "../tests/transaction_lifecycle.rs"] +mod transaction_lifecycle; +#[path = "../tests/v045_state.rs"] +mod v045_state; +#[path = "../tests/v060_timeouts.rs"] +mod v060_timeouts; diff --git a/src/lib.rs b/src/lib.rs index 4f60301a..6cd6d0b9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,18 +1,41 @@ +//! PostgreSQL migration safety analysis backed by a synchronized catalog baseline. +//! +//! Supported integrations use the [`api`] module. Parser, schema-state, and +//! rule-engine implementation details are intentionally private. +#![warn(missing_docs)] +#![deny(unreachable_pub)] + +/// Supported Rust API for configuration, synchronization, and analysis. pub mod api; -#[doc(hidden)] -pub mod _internal { - pub mod analysis; - pub mod ast; - pub mod db; - pub mod engine; - pub mod model; - pub mod report; - pub mod rules; - pub mod sync; +// Lets crate-internal regression modules retain their historical fully +// qualified paths while compiling inside this crate's privacy boundary. +#[cfg(test)] +extern crate self as safe_migrate; + +#[cfg(test)] +#[path = "../tests/common/mod.rs"] +mod common; + +// The analyzer and catalog model are deliberately private. The supported +// contract is `safe_migrate::api`; keeping this crate-private prevents callers +// from coupling to mutable state-machine implementation details. +pub(crate) mod _internal { + pub(crate) mod analysis; + pub(crate) mod ast; + pub(crate) mod db; + pub(crate) mod engine; + pub(crate) mod model; + pub(crate) mod report; + pub(crate) mod rules; + pub(crate) mod sync; #[cfg(test)] - pub mod sync_tests; + pub(crate) mod sync_tests; #[cfg(test)] pub(crate) mod test_support; } + +#[cfg(test)] +#[path = "internal_tests.rs"] +mod internal_tests; diff --git a/src/main.rs b/src/main.rs index 6771654b..4367da77 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,25 +1,29 @@ use anyhow::{Context, Result, anyhow}; use clap::{Parser, Subcommand}; -use safe_migrate::_internal::analysis::evidence::{EvidenceCode, EvidenceRecord, EvidenceScope}; -use safe_migrate::_internal::analysis::outcome::AnalysisOutcome; -use safe_migrate::_internal::db::cache::{ - CACHE_FORMAT_VERSION, CACHE_V7_MAGIC, CacheMetadata, CatalogCoverage, DbCacheVersioned, +use safe_migrate::api::{ + self, AnalysisOutcome, AutoSyncStatus, Baseline, BaselineInspection, Config, Migration, Rule, }; -use safe_migrate::_internal::db::cache_file::{ - MAX_CACHE_DECODE_BYTES, is_encrypted_cache_bytes, read_cache_bytes, unprotect_cache_bytes, -}; -use safe_migrate::_internal::model::relation::RelationKind; -use safe_migrate::_internal::report::violations::{ReportFinding, Violation}; -use safe_migrate::_internal::rules::registry::{self, RuleDescriptor}; -use safe_migrate::_internal::sync; -use safe_migrate::api::{AnalysisState, Config, DbCache, Reporter, SafeMigrateEngine}; use std::fs; -use std::io::Read; use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; const EXIT_BLOCKING_FINDINGS: i32 = 2; +fn terminal_inline(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + for character in value.chars() { + if character.is_control() { + output.extend(character.escape_default()); + } else { + output.push(character); + } + } + output +} + +fn display_path(path: &Path) -> String { + terminal_inline(&path.display().to_string()) +} + mod cli_init; use cli_init::InitCommands; @@ -163,79 +167,9 @@ enum OutputMode { Interactive, } -#[derive(Clone, Copy)] -enum AutoSyncOutcome { - NotRequested, - Refreshed, - Failed, - Bypassed, -} - -impl AutoSyncOutcome { - fn label(self) -> &'static str { - match self { - Self::NotRequested => "not_requested", - Self::Refreshed => "refreshed", - Self::Failed => "failed", - Self::Bypassed => "bypassed", - } - } -} - struct PreparedCache { - cache: DbCache, - baseline_unknown: bool, - baseline_stale: bool, - auto_sync: AutoSyncOutcome, - metadata: CacheMetadata, -} - -#[derive(serde::Serialize)] -struct CacheInspection { - path: String, - format_version: u32, - encrypted: bool, - created_at_unix_secs: Option, - age_seconds: Option, - source_database: Option, - schemas: Option>, - coverage: CatalogCoverage, - search_path: Vec, - postgresql_version_num: Option, - observed_settings: ObservedSettings, - contents: CacheContentsSummary, -} - -#[derive(Clone, serde::Serialize)] -struct ObservedSettings { - lock_timeout_ms: Option, - statement_timeout_ms: Option, -} - -#[derive(serde::Serialize)] -struct CacheContentsSummary { - schemas: usize, - sequences: usize, - relations: usize, - tables: usize, - views: usize, - materialized_views: usize, - columns: usize, - indexes: usize, - foreign_keys: usize, - constraints: usize, - constraint_keys: usize, - triggers: usize, - functions: usize, - procedures: usize, - aggregates: usize, - window_functions: usize, - publications: usize, - subscriptions: usize, - types: usize, - roles: usize, - dependencies: usize, - inheritances: usize, + baseline: Baseline, + auto_sync: AutoSyncStatus, } impl OutputMode { @@ -252,9 +186,17 @@ impl OutputMode { } } -fn main() -> Result<()> { +fn main() { + if let Err(error) = run() { + eprintln!("error: {}", terminal_inline(&format!("{error:#}"))); + std::process::exit(1); + } +} + +fn run() -> Result<()> { let cli = Cli::parse(); if cli.no_color { + // CLI parsing happens before safe-migrate creates any worker threads. unsafe { std::env::set_var("NO_COLOR", "1"); } @@ -314,36 +256,22 @@ fn main() -> Result<()> { } } -fn rule_descriptor_json(descriptor: &RuleDescriptor, config: &Config) -> serde_json::Value { - use safe_migrate::_internal::rules::registry::RuleConfigurationField; - - let mut effective = serde_json::json!({ - "enabled": !config.is_rule_disabled(descriptor.id), - }); - if descriptor.supports(RuleConfigurationField::Tier1ThresholdRows) { - effective["tier1_threshold_rows"] = - serde_json::json!(config.rule_tier1_threshold(descriptor.id)); +fn rule_descriptor_json(rule: &Rule) -> serde_json::Value { + let mut effective = serde_json::json!({ "enabled": rule.enabled }); + if let Some(value) = rule.tier1_threshold_rows { + effective["tier1_threshold_rows"] = serde_json::json!(value); } - if descriptor.supports(RuleConfigurationField::Tier2ThresholdRows) { - effective["tier2_threshold_rows"] = - serde_json::json!(config.rule_tier2_threshold(descriptor.id)); + if let Some(value) = rule.tier2_threshold_rows { + effective["tier2_threshold_rows"] = serde_json::json!(value); } serde_json::json!({ - "id": descriptor.id, - "title": descriptor.title, - "summary": descriptor.summary, - "impact": descriptor.impact, - "default_tier": match descriptor.default_tier() { - safe_migrate::_internal::report::violations::ViolationTier::Tier1 => "Tier1", - safe_migrate::_internal::report::violations::ViolationTier::Tier2 => "Tier2", - safe_migrate::_internal::report::violations::ViolationTier::Tier3 => "Tier3", - }, - "remediation": descriptor.recipe(), - "supported_configuration_fields": descriptor - .supported_configuration_fields - .iter() - .map(|field| field.as_str()) - .collect::>(), + "id": rule.id, + "title": rule.title, + "summary": rule.summary, + "impact": rule.impact, + "default_tier": format!("{:?}", rule.default_tier), + "remediation": rule.remediation, + "supported_configuration_fields": rule.supported_configuration_fields, "effective": effective, }) } @@ -358,15 +286,26 @@ fn rules_separator() -> String { fn run_rules(rule_id: Option<&str>, json: bool, config_path: Option<&Path>) -> Result<()> { let config = load_config(config_path)?; - let descriptors: Vec<_> = match rule_id { - Some(id) => vec![registry::find_primary_rule(id).ok_or_else(|| { - anyhow!( - "Unknown primary rule ID '{}'. Valid primary rule IDs: {}", - id, - registry::primary_rule_ids().collect::>().join(", ") - ) - })?], - None => registry::PRIMARY_RULES.iter().collect(), + let all_rules = api::rules(&config).map_err(anyhow::Error::new)?; + let rules: Vec<_> = match rule_id { + Some(id) => vec![ + all_rules + .into_iter() + .find(|rule| rule.id == id) + .ok_or_else(|| { + anyhow!( + "Unknown primary rule ID '{}'. Valid primary rule IDs: {}", + id, + api::rules(&config) + .expect("validated configuration must list rules") + .iter() + .map(|rule| rule.id.as_str()) + .collect::>() + .join(", ") + ) + })?, + ], + None => all_rules, }; if json { @@ -374,48 +313,37 @@ fn run_rules(rule_id: Option<&str>, json: bool, config_path: Option<&Path>) -> R "{}", serde_json::to_string_pretty(&serde_json::json!({ "schema_version": 2, - "rules": descriptors.iter().map(|descriptor| rule_descriptor_json(descriptor, &config)).collect::>(), + "rules": rules.iter().map(rule_descriptor_json).collect::>(), }))? ); return Ok(()); } - for (index, descriptor) in descriptors.iter().enumerate() { + for (index, rule) in rules.iter().enumerate() { if index > 0 { println!(); println!("{}", rules_separator()); println!(); } - println!("{} ({})", descriptor.title, descriptor.id); - println!(" Summary: {}", descriptor.summary); - println!(" Impact: {}", descriptor.impact); - println!(" Default tier: {:?}", descriptor.default_tier()); - println!(" Remediation: {}", descriptor.recipe()); + println!("{} ({})", rule.title, rule.id); + println!(" Summary: {}", rule.summary); + println!(" Impact: {}", rule.impact); + println!(" Default tier: {:?}", rule.default_tier); + println!(" Remediation: {}", rule.remediation); println!( " Configuration: {}", - descriptor - .supported_configuration_fields + rule.supported_configuration_fields .iter() .map(|field| field.as_str()) .collect::>() .join(", ") ); - let mut effective = vec![format!( - "enabled={}", - !config.is_rule_disabled(descriptor.id) - )]; - use safe_migrate::_internal::rules::registry::RuleConfigurationField; - if descriptor.supports(RuleConfigurationField::Tier1ThresholdRows) { - effective.push(format!( - "tier1_threshold_rows={}", - config.rule_tier1_threshold(descriptor.id) - )); + let mut effective = vec![format!("enabled={}", rule.enabled)]; + if let Some(value) = rule.tier1_threshold_rows { + effective.push(format!("tier1_threshold_rows={value}")); } - if descriptor.supports(RuleConfigurationField::Tier2ThresholdRows) { - effective.push(format!( - "tier2_threshold_rows={}", - config.rule_tier2_threshold(descriptor.id) - )); + if let Some(value) = rule.tier2_threshold_rows { + effective.push(format!("tier2_threshold_rows={value}")); } println!(" Effective: {}", effective.join(", ")); } @@ -434,30 +362,16 @@ fn run_lint( .with_context(|| format!("Failed to read migration file: {}", file.display()))?; let config = load_config(config_path)?; let PreparedCache { - cache: db_cache, - baseline_unknown, - baseline_stale, + baseline, auto_sync, - metadata, } = prepare_cache(&config, cache, no_cache, no_auto_sync)?; - eprintln!("Analyzing migration: {}", file.display()); + eprintln!("Analyzing migration: {}", display_path(file)); - let engine = SafeMigrateEngine::new(config); - let mut state = AnalysisState::with_baseline(db_cache, !baseline_unknown); - let outcome = engine - .analyze_outcome_with_locations(file.display().to_string(), sql, &mut state) - .map_err(analysis_error)?; - let outcome = attach_baseline_evidence(outcome, baseline_unknown, baseline_stale); + let outcome = api::analyze(&config, file.display().to_string(), sql, &baseline) + .map_err(anyhow::Error::new)?; - finish_analysis( - outcome, - baseline_unknown, - baseline_stale, - auto_sync, - metadata, - output_mode, - ) + finish_analysis(outcome, auto_sync, output_mode) } fn run_lint_chain( @@ -485,7 +399,7 @@ fn run_lint_chain( files.sort_by_key(|entry| entry.file_name()); if files.is_empty() { - anyhow::bail!("No .sql migration files found in {}", dir.display()); + anyhow::bail!("No .sql migration files found in {}", display_path(dir)); } let mut migrations = Vec::new(); @@ -499,135 +413,64 @@ fn run_lint_chain( let config = load_config(config_path)?; let PreparedCache { - cache: db_cache, - baseline_unknown, - baseline_stale, + baseline, auto_sync, - metadata, } = prepare_cache(&config, cache, no_cache, no_auto_sync)?; - eprintln!("Analyzing migration chain in: {}", dir.display()); + eprintln!("Analyzing migration chain in: {}", display_path(dir)); - let engine = SafeMigrateEngine::new(config); - let mut state = AnalysisState::with_baseline(db_cache, !baseline_unknown); - let outcome = engine - .analyze_chain_outcome_with_locations(&migrations, &mut state) - .map_err(analysis_error)?; - let outcome = attach_baseline_evidence(outcome, baseline_unknown, baseline_stale); - - finish_analysis( - outcome, - baseline_unknown, - baseline_stale, - auto_sync, - metadata, - output_mode, + let outcome = api::analyze_chain( + &config, + migrations + .into_iter() + .map(|(filename, sql)| Migration::new(filename, sql)), + &baseline, ) + .map_err(anyhow::Error::new)?; + + finish_analysis(outcome, auto_sync, output_mode) } fn run_sync(out: &Path, config_path: Option<&Path>, schemas: Option<&[String]>) -> Result<()> { let config = load_config(config_path)?; - let schemas = config.sync_schemas(schemas)?; + let effective_schemas = schemas.or(config.schema_scope()); println!("Syncing PostgreSQL schema metadata and statistics..."); - if let Some(schemas) = schemas { - println!("Filtering to schemas: {}", schemas.join(", ")); + if let Some(schemas) = effective_schemas { + println!( + "Filtering to schemas: {}", + terminal_inline(&schemas.join(", ")) + ); } - sync::sync_cache(out, schemas, config.cache_encryption)?; - println!("[ SAFE ] Cache successfully written to {}", out.display()); + api::sync(out, &config, schemas).map_err(anyhow::Error::new)?; + println!( + "[ SAFE ] Cache successfully written to {}", + display_path(out) + ); Ok(()) } fn run_cache_inspect(cache_path: &Path, config_path: Option<&Path>, json: bool) -> Result<()> { let config = load_config(config_path)?; - let (cache, format_version, encrypted) = decode_cache(cache_path, config.cache_encryption)?; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let inspection = CacheInspection { - path: cache_path.display().to_string(), - format_version, - encrypted, - created_at_unix_secs: cache.metadata.created_at_unix_secs, - age_seconds: cache - .metadata - .created_at_unix_secs - .map(|created_at| now.saturating_sub(created_at)), - source_database: cache.metadata.source_database.clone(), - schemas: cache.metadata.schemas.clone(), - coverage: cache.coverage.clone(), - search_path: cache.search_path.clone(), - postgresql_version_num: cache.pg_version_num, - observed_settings: ObservedSettings { - lock_timeout_ms: Some(cache.metadata.source_lock_timeout_ms), - statement_timeout_ms: Some(cache.metadata.source_statement_timeout_ms), - }, - contents: summarize_cache(&cache), - }; + let inspection = Baseline::load(cache_path, &config) + .map_err(anyhow::Error::new)? + .inspect(); if json { println!("{}", serde_json::to_string_pretty(&inspection)?); } else { - print_cache_inspection(&inspection); + print_cache_inspection(cache_path, &inspection); } Ok(()) } - -fn summarize_cache(cache: &DbCache) -> CacheContentsSummary { - let mut tables = 0; - let mut views = 0; - let mut materialized_views = 0; - let mut columns = 0; - for relation in cache.relations.values() { - columns += relation.columns.len(); - match &relation.kind { - RelationKind::Table => tables += 1, - RelationKind::View => views += 1, - RelationKind::MaterializedView => materialized_views += 1, - } - } - let mut functions = 0; - let mut procedures = 0; - let mut aggregates = 0; - let mut window_functions = 0; - for routine in cache.functions.values() { - match routine.routine_kind { - safe_migrate::_internal::model::function::RoutineKind::Function => functions += 1, - safe_migrate::_internal::model::function::RoutineKind::Procedure => procedures += 1, - safe_migrate::_internal::model::function::RoutineKind::Aggregate => aggregates += 1, - safe_migrate::_internal::model::function::RoutineKind::Window => window_functions += 1, - } - } - CacheContentsSummary { - schemas: cache.schemas.len(), - sequences: cache.sequences.len(), - relations: cache.relations.len(), - tables, - views, - materialized_views, - columns, - indexes: cache.indexes.len(), - foreign_keys: cache.foreign_keys.len(), - constraints: cache.constraints.len(), - constraint_keys: cache.constraint_keys.len(), - triggers: cache.triggers.len(), - functions, - procedures, - aggregates, - window_functions, - publications: cache.publications.len(), - subscriptions: cache.subscriptions.len(), - types: cache.types.len(), - roles: cache.roles.len(), - dependencies: cache.dependencies.len(), - inheritances: cache.inheritances.len(), - } -} - -fn print_cache_inspection(inspection: &CacheInspection) { - println!("Cache: {}", inspection.path); - println!("Format version: {}", inspection.format_version); +fn print_cache_inspection(cache_path: &Path, inspection: &BaselineInspection) { + println!("Cache: {}", display_path(cache_path)); + println!( + "Format version: {}", + inspection + .format_version + .map_or_else(|| "unavailable".to_owned(), |version| version.to_string()) + ); println!( "Encryption: {}", if inspection.encrypted { @@ -651,25 +494,28 @@ fn print_cache_inspection(inspection: &CacheInspection) { ); println!( "Source database: {}", - inspection.source_database.as_deref().unwrap_or("unknown") - ); - println!( - "Schema scope: {}", - inspection - .schemas - .as_deref() - .map(|schemas| schemas.join(", ")) - .unwrap_or_else(|| "all non-system schemas".to_string()) + terminal_inline(inspection.source_database.as_deref().unwrap_or("unknown")) ); + let schema_scope = inspection + .schemas + .as_deref() + .map(|schemas| schemas.join(", ")) + .unwrap_or_else(|| "all non-system schemas".to_string()); + println!("Schema scope: {}", terminal_inline(&schema_scope)); println!( "Catalog coverage: {}", inspection .coverage - .family_names() + .families + .iter() + .map(String::as_str) .collect::>() .join(", ") ); - println!("Search path: {}", inspection.search_path.join(", ")); + println!( + "Search path: {}", + terminal_inline(&inspection.search_path.join(", ")) + ); println!( "PostgreSQL version: {}", inspection @@ -745,29 +591,14 @@ fn load_config(path: Option<&Path>) -> Result { }; let config = config .with_context(|| format!("Failed to load configuration: {}", loaded_path.display()))?; - let engine = SafeMigrateEngine::new(config.clone()); - config - .validate_rule_ids(engine.primary_rule_ids()) - .with_context(|| { - format!( - "Failed to validate configuration: {}", - loaded_path.display() - ) - })?; - registry::validate_rule_configuration(&config) - .map_err(anyhow::Error::msg) + api::validate_config(&config) + .map_err(anyhow::Error::new) .with_context(|| { format!( "Failed to validate configuration: {}", loaded_path.display() ) })?; - config.sync_schemas(None).with_context(|| { - format!( - "Failed to validate configuration: {}", - loaded_path.display() - ) - })?; Ok(config) } @@ -778,16 +609,25 @@ fn prepare_cache( no_auto_sync: bool, ) -> Result { let auto_sync = maybe_auto_sync(config, cache, no_cache, no_auto_sync); - let (cache, baseline_unknown) = load_cache(cache, no_cache, config.cache_encryption)?; - let baseline_stale = - warn_if_stale_cache(&cache.metadata, baseline_unknown, config.stale_stats_days); - let metadata = cache.metadata.clone(); + let baseline = if !no_cache && cache.exists() { + Baseline::load(cache, config).map_err(anyhow::Error::new)? + } else { + if no_cache { + eprintln!("[ INFO ] --no-cache passed. Running with default worst-case assumptions."); + } else { + eprintln!("[ INFO ] No cache found. Running with default worst-case assumptions."); + } + Baseline::unavailable() + }; + let baseline_stale = baseline.is_stale(config.stale_stats_days()); + if baseline_stale { + eprintln!( + "[ WARN ] Database cache is stale. Run `safe-migrate sync` before relying on baseline-aware results." + ); + } Ok(PreparedCache { - cache, - baseline_unknown, - baseline_stale, + baseline, auto_sync, - metadata, }) } @@ -796,34 +636,27 @@ fn maybe_auto_sync( cache: &Path, no_cache: bool, no_auto_sync: bool, -) -> AutoSyncOutcome { - if !config.auto_sync { - return AutoSyncOutcome::NotRequested; +) -> AutoSyncStatus { + if !config.auto_sync() { + return AutoSyncStatus::NotRequested; } if no_cache { eprintln!("[ INFO ] --no-cache bypasses configured automatic cache sync."); - return AutoSyncOutcome::Bypassed; + return AutoSyncStatus::Bypassed; } if no_auto_sync { eprintln!("[ INFO ] --no-auto-sync bypasses configured automatic cache sync."); - return AutoSyncOutcome::Bypassed; + return AutoSyncStatus::Bypassed; } eprintln!( "[ INFO ] Automatic cache sync enabled. Refreshing {}.", - cache.display() + display_path(cache) ); - let schemas = match config.sync_schemas(None) { - Ok(schemas) => schemas, - Err(error) => { - eprintln!("[ WARN ] Automatic cache sync configuration is invalid: {error}"); - return AutoSyncOutcome::Failed; - } - }; - match sync::sync_cache(cache, schemas, config.cache_encryption) { - Ok(()) => AutoSyncOutcome::Refreshed, + match api::sync(cache, config, None) { + Ok(()) => AutoSyncStatus::Refreshed, Err(error) => { eprintln!("[ WARN ] Automatic cache sync failed: {error}"); if cache.exists() { @@ -833,227 +666,30 @@ fn maybe_auto_sync( " No usable cache is available; continuing with uncertain analysis." ); } - AutoSyncOutcome::Failed + AutoSyncStatus::Failed } } } -fn warn_if_stale_cache(metadata: &CacheMetadata, baseline_unknown: bool, stale_days: u64) -> bool { - if baseline_unknown { - return false; - } - - let Some(created_at) = metadata.created_at_unix_secs else { - eprintln!( - "[ WARN ] Cache has no creation timestamp. Refresh it before relying on baseline-aware results." - ); - return true; - }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let age = now.saturating_sub(created_at); - if age > stale_days.saturating_mul(24 * 60 * 60) { - eprintln!( - "[ WARN ] Database cache is {} days old (configured limit: {} days).", - age / (24 * 60 * 60), - stale_days - ); - eprintln!(" Run `safe-migrate sync` to refresh lock evaluations."); - true - } else { - false - } -} - -fn load_cache(cache: &Path, no_cache: bool, cache_encryption: bool) -> Result<(DbCache, bool)> { - if !no_cache && cache.exists() { - let (cache, _, _) = decode_cache(cache, cache_encryption)?; - Ok((cache, false)) - } else { - if no_cache { - eprintln!("[ INFO ] --no-cache passed. Running with default worst-case assumptions."); - } else { - eprintln!("[ INFO ] No cache found. Running with default worst-case assumptions."); - } - Ok((DbCache::new(), true)) - } -} - -fn decode_cache(cache_path: &Path, cache_encryption: bool) -> Result<(DbCache, u32, bool)> { - let encoded = read_cache_bytes(cache_path)?; - let encrypted = is_encrypted_cache_bytes(&encoded); - let decrypted = unprotect_cache_bytes(encoded, cache_encryption)?; - let reader = std::io::Cursor::new(decrypted); - let decoder = zstd::stream::Decoder::new(reader).map_err(|error| { - anyhow!( - "Cache file '{}' is corrupted (zstd init): {}", - cache_path.display(), - error - ) - })?; - let mut decoder = decoder.take(MAX_CACHE_DECODE_BYTES as u64 + 1); - let mut header = vec![0; CACHE_V7_MAGIC.len()]; - let mut header_len = 0; - while header_len < header.len() { - let read = decoder.read(&mut header[header_len..]).map_err(|error| { - anyhow!( - "Cache file '{}' is corrupted while decompressing: {}", - cache_path.display(), - error - ) - })?; - if read == 0 { - break; - } - header_len += read; - } - if header_len != CACHE_V7_MAGIC.len() || header != CACHE_V7_MAGIC { - anyhow::bail!( - "Cache file '{}' uses an unsupported cache format. Run `safe-migrate sync` to rebuild it.", - cache_path.display() - ); - } - - let config = bincode::config::standard() - .with_variable_int_encoding() - .with_limit::(); - let versioned: DbCacheVersioned = - bincode::serde::decode_from_std_read(&mut decoder, config).map_err(|error| { - if matches!(&error, bincode::error::DecodeError::LimitExceeded) { - return anyhow!( - "Cache file '{}' exceeds the {} MiB decoded-size limit", - cache_path.display(), - MAX_CACHE_DECODE_BYTES / (1024 * 1024) - ); - } - anyhow!( - "Cache file '{}' is corrupted (bincode): {}. Run `safe-migrate sync` to rebuild it.", - cache_path.display(), - error - ) - })?; - let remaining_before_trailing = decoder.limit(); - std::io::copy(&mut decoder, &mut std::io::sink()).map_err(|error| { - anyhow!( - "Cache file '{}' is corrupted while decompressing: {}", - cache_path.display(), - error - ) - })?; - let decompressed_bytes = (MAX_CACHE_DECODE_BYTES as u64 + 1) - decoder.limit(); - if decompressed_bytes > MAX_CACHE_DECODE_BYTES as u64 { - anyhow::bail!( - "Cache file '{}' exceeds the {} MiB decoded-size limit", - cache_path.display(), - MAX_CACHE_DECODE_BYTES / (1024 * 1024) - ); - } - if decoder.limit() != remaining_before_trailing { - anyhow::bail!( - "Cache file '{}' is corrupted (trailing payload data). Run `safe-migrate sync` to rebuild it.", - cache_path.display() - ); - } - let header_version = CACHE_FORMAT_VERSION; - let format_version = versioned.format_version(); - if format_version != header_version { - anyhow::bail!( - "Cache file '{}' has a mismatched cache format header. Run `safe-migrate sync` to rebuild it.", - cache_path.display() - ); - } - let cache = versioned.into_cache().map_err(|error| { - anyhow!( - "Cache file '{}' is incompatible: {}", - cache_path.display(), - error - ) - })?; - Ok((cache, format_version, encrypted)) -} - -fn analysis_error(errors: Vec) -> anyhow::Error { - anyhow!( - "Failed to parse SQL migration:\n - {}", - errors.join("\n - ") - ) -} - fn finish_analysis( - outcome: AnalysisOutcome, - baseline_unknown: bool, - baseline_stale: bool, - auto_sync: AutoSyncOutcome, - metadata: CacheMetadata, + outcome: AnalysisOutcome, + auto_sync: AutoSyncStatus, output_mode: OutputMode, ) -> Result<()> { - let violations: Vec = outcome - .findings - .iter() - .map(|finding| finding.violation.clone()) - .collect(); - let should_halt = Reporter::should_halt(&violations); - let observed_settings = ObservedSettings { - lock_timeout_ms: (!baseline_unknown).then_some(metadata.source_lock_timeout_ms), - statement_timeout_ms: (!baseline_unknown).then_some(metadata.source_statement_timeout_ms), - }; - let baseline = serde_json::json!({ - "status": if baseline_unknown { "unavailable" } else if baseline_stale { "stale" } else { "available" }, - "created_at_unix_secs": metadata.created_at_unix_secs, - "source_database": metadata.source_database, - "schemas": metadata.schemas, - "auto_sync": auto_sync.label(), - "observed_settings": observed_settings, - }); + let outcome = outcome.with_auto_sync_status(auto_sync); + let should_halt = outcome.should_halt(); match output_mode { OutputMode::Human => { - Reporter::print_outcome(&outcome); + outcome.print_human(); } OutputMode::Json => { - let mut report = Reporter::json_outcome_with_locations(&outcome); - report["baseline"] = baseline.clone(); - println!("{}", serde_json::to_string_pretty(&report)?); + println!("{}", serde_json::to_string_pretty(&outcome.json())?); } OutputMode::Markdown => { - let mut report = Reporter::markdown_outcome(&outcome); - report.push_str("\n## Baseline\n\n"); - report.push_str(&format!( - "- **Status:** `{}`\n- **Automatic sync:** `{}`\n", - baseline["status"].as_str().unwrap_or("unknown"), - baseline["auto_sync"].as_str().unwrap_or("unknown") - )); - if let Some(source_database) = baseline["source_database"].as_str() { - report.push_str(&format!( - "- **Source database:** `{}`\n", - source_database.replace('`', "'") - )); - } - if let Some(schemas) = baseline["schemas"].as_array() { - let schemas = schemas - .iter() - .filter_map(serde_json::Value::as_str) - .collect::>() - .join(", "); - report.push_str(&format!("- **Schemas:** `{}`\n", schemas.replace('`', "'"))); - } - report.push_str(&format!( - "- **Observed lock timeout:** `{}`\n- **Observed statement timeout:** `{}`\n", - baseline["observed_settings"]["lock_timeout_ms"] - .as_u64() - .map_or_else(|| "unknown".to_string(), |value| format!("{value} ms")), - baseline["observed_settings"]["statement_timeout_ms"] - .as_u64() - .map_or_else(|| "unknown".to_string(), |value| format!("{value} ms")), - )); - println!("{report}"); + println!("{}", outcome.markdown()); } OutputMode::Interactive => { - safe_migrate::_internal::report::interactive::run_interactive( - &violations, - &outcome.confidence, - )?; + outcome.run_interactive().map_err(anyhow::Error::new)?; } } @@ -1064,22 +700,15 @@ fn finish_analysis( Ok(()) } -fn attach_baseline_evidence( - mut outcome: AnalysisOutcome, - baseline_unknown: bool, - baseline_stale: bool, -) -> AnalysisOutcome { - if baseline_unknown { - outcome = outcome.with_evidence(EvidenceRecord::new( - EvidenceCode::BaselineUnavailable, - EvidenceScope::Chain, - )); - } - if baseline_stale { - outcome = outcome.with_evidence(EvidenceRecord::new( - EvidenceCode::BaselineStale, - EvidenceScope::Chain, - )); +#[cfg(test)] +mod tests { + use super::{display_path, terminal_inline}; + use std::path::Path; + + #[test] + fn terminal_values_render_controls_inertly() { + assert_eq!(terminal_inline("cache\x1b[2J\r\n"), "cache\\u{1b}[2J\\r\\n"); + assert_eq!(terminal_inline("café_日本"), "café_日本"); + assert_eq!(display_path(Path::new("cache\x1b[2J")), "cache\\u{1b}[2J"); } - outcome } diff --git a/tests/alter_schema_visitor.rs b/tests/alter_schema_visitor.rs index 6f47fc43..4a0a069c 100644 --- a/tests/alter_schema_visitor.rs +++ b/tests/alter_schema_visitor.rs @@ -1,5 +1,3 @@ -mod common; - mod alter_schema_visitor_test { #[test] fn test_alter_schema_pipeline() { diff --git a/tests/api_facade.rs b/tests/api_facade.rs index 9733d148..2f3080c2 100644 --- a/tests/api_facade.rs +++ b/tests/api_facade.rs @@ -1,15 +1,333 @@ use safe_migrate::api; +use std::error::Error as _; #[test] -fn public_api_analyzes_with_only_supported_reexports() { - let outcome = api::analyze( - api::Config::default(), - "migration.sql", - "", - api::DbCache::new(), +fn public_api_analyzes_a_typed_migration_chain_with_an_opaque_baseline() { + let config = api::Config::default() + .with_cache_encryption(false) + .with_tier_thresholds(100_000, 10_000); + let outcome = api::analyze_chain( + &config, + [api::Migration::new("migration.sql", "")], + &api::Baseline::unavailable(), ) .expect("the supported API should accept a valid empty migration"); - assert!(outcome.findings.is_empty()); - assert!(outcome.evidence.is_empty()); + assert!(outcome.findings().is_empty()); + assert_eq!(outcome.confidence(), api::Confidence::Tainted); + assert_eq!(outcome.evidence().len(), 1); + assert_eq!( + outcome.evidence()[0].code, + api::EvidenceCode::BaselineUnavailable + ); + assert!(api::rule(&api::Config::default(), "missing-idempotency").is_ok()); +} + +#[test] +fn public_config_builders_cover_every_runtime_setting() { + let rule = api::RuleConfig::new() + .disabled(true) + .tier_thresholds(Some(25), Some(10)); + let config = api::Config::default() + .with_cache_encryption(true) + .with_auto_sync(true) + .with_stale_stats_days(3) + .with_tier_thresholds(500, 50) + .with_default_rows(250) + .with_toast_width_threshold_bytes(1024) + .with_assumed_postgres_version(170000) + .with_schema_scope(["public"]) + .with_rule("missing-idempotency", rule.clone()) + .enable_rule("missing-idempotency") + .disable_rule("require-lock-timeout"); + + assert!(config.cache_encryption()); + assert!(config.auto_sync()); + assert_eq!(config.stale_stats_days(), 3); + assert_eq!(config.tier1_threshold_rows(), 500); + assert_eq!(config.tier2_threshold_rows(), 50); + assert_eq!(config.default_rows(), 250); + assert_eq!(config.toast_width_threshold_bytes(), 1024); + assert_eq!(config.assumed_postgres_version(), 170000); + assert_eq!( + config.schema_scope(), + Some([String::from("public")].as_slice()) + ); + assert!(!config.is_rule_disabled("missing-idempotency")); + assert!(config.is_rule_disabled("require-lock-timeout")); + assert_eq!( + config + .rule_config("missing-idempotency") + .unwrap() + .disabled_override(), + Some(false) + ); + assert_eq!(rule.disabled_override(), Some(true)); + assert_eq!(rule.tier1_threshold_rows(), Some(25)); + assert_eq!(rule.tier2_threshold_rows(), Some(10)); +} + +#[test] +fn unavailable_baseline_is_honest_in_every_report_surface() { + let config = api::Config::default(); + let baseline = api::Baseline::unavailable(); + let inspection = baseline.inspect(); + assert!(!inspection.available); + assert_eq!(inspection.format_version, None); + assert_eq!(inspection.observed_settings.lock_timeout_ms, None); + + let outcome = api::analyze( + &config, + "001_create_users.sql", + "CREATE TABLE users (id bigint);", + &baseline, + ) + .expect("analyze with conservative defaults"); + assert_eq!(outcome.baseline().status, api::BaselineStatus::Unavailable); + assert_eq!(outcome.confidence(), api::Confidence::Tainted); + assert_eq!( + serde_json::to_value(outcome.confidence()).unwrap(), + "Tainted" + ); + + let json = outcome.json(); + assert_eq!(json["baseline"]["status"], "unavailable"); + assert_eq!(json["baseline"]["auto_sync"], "not_requested"); + assert_eq!( + json["baseline"], + serde_json::to_value(outcome.baseline()).unwrap() + ); + assert_eq!( + json["evidence"], + serde_json::to_value(outcome.evidence()).unwrap() + ); + assert!(outcome.markdown().contains("## Baseline")); +} + +#[test] +fn public_finding_serialization_matches_the_report_contract() { + let config = api::Config::default(); + let baseline = api::Baseline::unavailable(); + let outcome = api::analyze( + &config, + "001_create_users.sql", + "CREATE TABLE users (id bigint);", + &baseline, + ) + .unwrap(); + let finding = outcome + .findings() + .iter() + .find(|finding| finding.rule_id == "missing-idempotency") + .expect("CREATE TABLE without a guard should be reported"); + assert_eq!(finding.operation_kind, api::OperationKind::CreateTable); + assert_eq!(finding.object_kind, api::ObjectKind::Table); + let value = serde_json::to_value(finding).unwrap(); + + assert_eq!(value["fk_dependency_related"], false); + assert!(value.get("foreign_key_dependency_related").is_none()); + assert_eq!(value["rule_title"], "Missing idempotency"); + assert!(value.get("rule_summary").is_some()); + assert!(value.get("impact").is_some()); + assert!(value.get("dedup_key").is_some()); + let report_finding = outcome.json()["violations"] + .as_array() + .unwrap() + .iter() + .find(|candidate| candidate["rule_id"] == finding.rule_id) + .unwrap() + .clone(); + assert_eq!(value, report_finding); +} + +#[test] +fn outcome_exposes_the_same_verdict_and_summary_as_its_reports() { + let outcome = api::analyze( + &api::Config::default(), + "001_create_users.sql", + "CREATE TABLE users (id bigint);", + &api::Baseline::unavailable(), + ) + .unwrap(); + + assert_eq!(outcome.verdict(), api::Verdict::Safe); + assert_eq!(outcome.verdict().as_str(), "SAFE"); + assert_eq!( + outcome.recommendation(), + "no blocking finding, but baseline evidence is uncertain — review before deploying" + ); + let summary = outcome.summary(); + assert_eq!(summary.total, outcome.findings().len()); + assert_eq!(summary.total, summary.tier1 + summary.tier2 + summary.tier3); + assert_eq!(outcome.json()["verdict"], "SAFE"); + assert_eq!(outcome.json()["schema_version"], api::REPORT_SCHEMA_VERSION); + assert_eq!( + outcome.json()["summary"], + serde_json::to_value(summary).unwrap() + ); +} + +#[test] +fn optional_baseline_only_downgrades_a_missing_file() { + let directory = tempfile::tempdir().unwrap(); + let config = api::Config::default(); + let missing = directory.path().join("missing.cache"); + let baseline = api::Baseline::load_optional(&missing, &config).unwrap(); + + assert!(!baseline.is_available()); +} + +#[test] +fn configuration_errors_have_a_stable_kind_and_source() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("invalid.toml"); + std::fs::write(&path, "auto_syn = true").unwrap(); + let error = api::Config::load_required_from_file(&path).unwrap_err(); + + assert_eq!(error.kind(), api::ErrorKind::Configuration); + assert!(error.source().is_some()); + + let unknown = api::rule(&api::Config::default(), "not-a-rule").unwrap_err(); + assert_eq!(unknown.kind(), api::ErrorKind::UnknownRule); +} + +#[test] +fn public_io_and_validation_errors_retain_their_source_chain() { + let remote = api::DatabaseUrl::new("postgres://db.example.com/app").unwrap_err(); + assert_eq!(remote.kind(), api::ErrorKind::Configuration); + assert!(remote.source().is_some()); + + let directory = tempfile::tempdir().unwrap(); + let cache = directory.path().join("truncated.cache"); + std::fs::write(&cache, b"not-zstd").unwrap(); + let error = api::Baseline::load(&cache, &api::Config::default().with_cache_encryption(false)) + .unwrap_err(); + assert_eq!(error.kind(), api::ErrorKind::Cache); + assert!(error.source().is_some()); +} + +#[test] +fn config_and_baseline_can_be_reused_across_analyses() { + let config = api::Config::default(); + let baseline = api::Baseline::unavailable(); + + for filename in ["001.sql", "002.sql"] { + api::analyze(&config, filename, "", &baseline).unwrap(); + } +} + +#[test] +fn public_api_values_remain_send_and_sync() { + fn assert_send_sync() {} + + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); +} + +#[test] +fn public_secret_inputs_are_validated_and_redacted() { + let secret_url = "postgres://private-user:private-password@localhost/app"; + let database_url = api::DatabaseUrl::new(secret_url).unwrap(); + let database_debug = format!("{database_url:?}"); + assert!(!database_debug.contains("private-user")); + assert!(!database_debug.contains("private-password")); + + let secret_key = "42".repeat(32); + let cache_key = api::CacheKey::from_hex(&secret_key).unwrap(); + assert!(!format!("{cache_key:?}").contains(&secret_key)); + assert_eq!( + format!("{:?}", api::CacheKey::from_bytes([7; 32])), + "CacheKey([REDACTED])" + ); + + let remote = api::DatabaseUrl::new("postgres://db.example.com/app").unwrap_err(); + assert_eq!(remote.kind(), api::ErrorKind::Configuration); + assert!(api::CacheKey::from_hex("not-a-key").is_err()); +} + +#[test] +fn explicit_sync_secrets_must_match_encryption_configuration() { + let database_url = api::DatabaseUrl::new("postgres://localhost/app").unwrap(); + let cache_key = api::CacheKey::from_hex(&"42".repeat(32)).unwrap(); + let output = tempfile::tempdir().unwrap().path().join("baseline.cache"); + + let missing_key = api::sync_with_secrets( + &output, + &api::Config::default().with_cache_encryption(true), + None, + &database_url, + None, + ) + .unwrap_err(); + assert_eq!(missing_key.kind(), api::ErrorKind::Configuration); + + let unexpected_key = api::sync_with_secrets( + &output, + &api::Config::default().with_cache_encryption(false), + None, + &database_url, + Some(&cache_key), + ) + .unwrap_err(); + assert_eq!(unexpected_key.kind(), api::ErrorKind::Configuration); + + let missing_cache = tempfile::tempdir().unwrap().path().join("missing.cache"); + let invalid_optional_load = api::Baseline::load_optional_with_key( + &missing_cache, + &api::Config::default().with_cache_encryption(false), + &cache_key, + ) + .unwrap_err(); + assert_eq!(invalid_optional_load.kind(), api::ErrorKind::Configuration); + + let optional = api::Baseline::load_optional_with_key( + &missing_cache, + &api::Config::default().with_cache_encryption(true), + &cache_key, + ) + .unwrap(); + assert!(!optional.is_available()); +} + +#[test] +fn unsafe_conservative_defaults_are_rejected_at_the_api_boundary() { + let zero_rows = api::Config::default().with_default_rows(0); + assert_eq!( + zero_rows.validate().unwrap_err().kind(), + api::ErrorKind::Configuration + ); + + let invalid_width = api::Config::default().with_toast_width_threshold_bytes(0); + assert_eq!( + invalid_width.validate().unwrap_err().kind(), + api::ErrorKind::Configuration + ); + + assert!( + api::Config::default() + .with_assumed_postgres_version(140_000) + .validate() + .is_ok() + ); + assert!( + api::Config::default() + .with_assumed_postgres_version(180_999) + .validate() + .is_ok() + ); + for unsupported in [0, 130_999, 181_000, u32::MAX] { + assert_eq!( + api::Config::default() + .with_assumed_postgres_version(unsupported) + .validate() + .unwrap_err() + .kind(), + api::ErrorKind::Configuration, + "unsupported assumed PostgreSQL version {unsupported} was accepted" + ); + } } diff --git a/tests/architectural_gaps.rs b/tests/architectural_gaps.rs index e5e74357..ae7c619f 100644 --- a/tests/architectural_gaps.rs +++ b/tests/architectural_gaps.rs @@ -1,5 +1,3 @@ -mod common; - mod architectural_gap_tests { use crate::common::*; use safe_migrate::_internal::analysis::state::Confidence; @@ -571,6 +569,11 @@ mod architectural_gap_tests { avg_width: None, default_expr_text: None, type_modifier: Some(-1), + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, }); table.last_analyze = Some("2026-09-01 00:00:00+00".to_string()); cache.insert_baseline(table_id.clone(), table); @@ -750,7 +753,7 @@ mod architectural_gap_tests { ); } - // 24. ALTER TABLE typed actions produce opaque without crashing + // 24. ALTER TABLE typed actions preserve supported catalog state. #[test] fn test_alter_table_set_access_method_typed() { let engine = setup_engine(); @@ -760,8 +763,9 @@ mod architectural_gap_tests { .unwrap(); let result = engine.analyze("ALTER TABLE t SET ACCESS METHOD heap;", &mut state); assert!(result.is_ok(), "SetAccessMethod should not crash"); - assert!(state.evidence().iter().any(|record| { - record.code == safe_migrate::_internal::analysis::evidence::EvidenceCode::UnsupportedSemantics + assert!(!state.evidence().iter().any(|record| { + record.code + == safe_migrate::_internal::analysis::evidence::EvidenceCode::UnsupportedSemantics })); let mut state2 = setup_state(); @@ -773,12 +777,9 @@ mod architectural_gap_tests { &mut state2, ); assert!(result2.is_ok(), "SetStorage should not crash"); - assert!( - state2.evidence().iter().any(|record| { - record.code == safe_migrate::_internal::analysis::evidence::EvidenceCode::UnsupportedSemantics - }), - "SetStorage must produce UnsupportedSemantics evidence" - ); + assert!(!state2.evidence().iter().any(|record| { + record.code == safe_migrate::_internal::analysis::evidence::EvidenceCode::UnsupportedSemantics + })); } #[test] diff --git a/tests/bug_fixes.rs b/tests/bug_fixes.rs index e1bf9eee..5845dc3b 100644 --- a/tests/bug_fixes.rs +++ b/tests/bug_fixes.rs @@ -1,5 +1,3 @@ -mod common; - mod phase10_bug_fixes_and_sorting_tests { use crate::common::*; use safe_migrate::_internal::analysis::state::{AnalysisState, Confidence}; @@ -25,6 +23,7 @@ mod phase10_bug_fixes_and_sorting_tests { has_expression_keys: false, has_predicate: false, is_unique: false, + is_immediate: true, is_valid: true, is_ready: true, is_live: true, @@ -936,6 +935,11 @@ mod phase10_bug_fixes_and_sorting_tests { avg_width: None, default_expr_text: None, type_modifier: None, + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, }); rel.columns.push(Column { name: "b".into(), @@ -946,6 +950,11 @@ mod phase10_bug_fixes_and_sorting_tests { avg_width: None, default_expr_text: None, type_modifier: None, + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, }); // Rename "a" to "b" — "b" already exists, so rename should be a no-op @@ -1241,7 +1250,7 @@ mod phase10_bug_fixes_and_sorting_tests { // ───────────────────────────────────────────── #[test] fn test_bug012_partition_threshold_floor_at_one() { - let config = safe_migrate::_internal::engine::config::Config { + let config = safe_migrate::api::Config { tier1_threshold_rows: 1, tier2_threshold_rows: 1, ..Default::default() @@ -1275,7 +1284,7 @@ mod phase10_bug_fixes_and_sorting_tests { child.partition_type = Some("RANGE".to_string()); cache.insert_baseline(child_id, child); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let violations = engine .analyze( diff --git a/tests/chain_execution.rs b/tests/chain_execution.rs index 6611489b..35865066 100644 --- a/tests/chain_execution.rs +++ b/tests/chain_execution.rs @@ -1,5 +1,3 @@ -mod common; - mod chain_execution_tests { use crate::common::*; use safe_migrate::_internal::model::relation::RelationOverlay; diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 2697f29b..51e04a4a 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -7,7 +7,7 @@ use chacha20poly1305::{ aead::{Aead, KeyInit}, }; use safe_migrate::_internal::ast::identifiers::ObjectId; -use safe_migrate::_internal::db::cache::{CACHE_V7_MAGIC, DbCache, DbCacheVersioned}; +use safe_migrate::_internal::db::cache::{CACHE_V8_MAGIC, DbCache, DbCacheVersioned}; use safe_migrate::_internal::model::relation::{Persistence, RelationKind, RelationState}; use safe_migrate::_internal::model::schema::SchemaState; @@ -44,9 +44,9 @@ fn write_cache_with_timestamp(path: &std::path::Path, created_at_unix_secs: u64) let mut compressed = Vec::new(); let mut encoder = zstd::stream::Encoder::new(&mut compressed, 3).unwrap(); let config = bincode::config::standard().with_variable_int_encoding(); - encoder.write_all(CACHE_V7_MAGIC).unwrap(); + encoder.write_all(CACHE_V8_MAGIC).unwrap(); bincode::serde::encode_into_std_write( - DbCacheVersioned::V7(Box::new(cache)), + DbCacheVersioned::V8(Box::new(cache)), &mut encoder, config, ) @@ -57,7 +57,7 @@ fn write_cache_with_timestamp(path: &std::path::Path, created_at_unix_secs: u64) #[test] fn test_cli_help() { - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); cmd.arg("--help"); let output = cmd.output().unwrap(); assert!(output.status.success()); @@ -73,7 +73,7 @@ fn test_cli_help() { #[test] fn init_cache_key_generates_expected_secret_format() { - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let output = cmd.args(["init", "cache-key"]).output().unwrap(); assert!(output.status.success()); assert!(output.stderr.is_empty()); @@ -101,7 +101,7 @@ fn init_cache_key_sends_secret_to_github_cli_over_stdin() { fs::set_permissions(&fake_gh, permissions).unwrap(); let existing_path = std::env::var("PATH").unwrap_or_default(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); cmd.args(["init", "cache-key", "--set-github-secret"]) .env("CAPTURED_SECRET", &captured) .env( @@ -121,7 +121,7 @@ fn init_github_actions_creates_separate_analysis_and_baseline_workflows() { let project = tempfile::tempdir().unwrap(); fs::create_dir(project.path().join("migrations")).unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); cmd.current_dir(project.path()) .args(["init", "github-actions", "--path", "migrations"]) .assert() @@ -155,7 +155,7 @@ fn init_github_actions_creates_separate_analysis_and_baseline_workflows() { assert!(!baseline.contains("actions/checkout")); assert!(!baseline.contains("\n path:")); - let mut overwrite = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut overwrite = crate::common::safe_migrate_command(); let assertion = overwrite .current_dir(project.path()) .args(["init", "github-actions", "--path", "migrations"]) @@ -172,7 +172,7 @@ fn init_github_actions_supports_a_custom_default_branch_and_output_directory() { let project = tempfile::tempdir().unwrap(); fs::create_dir(project.path().join("db [migrations]")).unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); cmd.current_dir(project.path()) .args([ "init", @@ -199,7 +199,7 @@ fn init_github_actions_does_not_read_secrets_from_noninteractive_input() { let project = tempfile::tempdir().unwrap(); fs::create_dir(project.path().join("migrations")).unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assertion = cmd .current_dir(project.path()) .args([ @@ -231,13 +231,13 @@ fn init_github_actions_does_not_read_secrets_from_noninteractive_input() { #[test] fn rules_command_lists_registry_descriptors_in_json() { - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let output = cmd.arg("rules").arg("--json").output().unwrap(); assert!(output.status.success()); let report = parse_json_stdout(&output); assert_eq!(report["schema_version"], 2); let rules = report["rules"].as_array().expect("rules array"); - assert_eq!(rules.len(), 28); + assert_eq!(rules.len(), 29); assert_eq!(rules[0]["id"], "irreversible-migration"); assert_eq!(rules[0]["title"], "Irreversible migration"); assert!( @@ -264,7 +264,7 @@ fn rules_command_lists_registry_descriptors_in_json() { #[test] fn rules_command_separates_human_descriptors() { - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let output = cmd.arg("rules").arg("--no-color").output().unwrap(); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); @@ -275,13 +275,13 @@ fn rules_command_separates_human_descriptors() { .lines() .filter(|line| line.len() >= 40 && line.bytes().all(|byte| byte == b'-')) .count(), - 27 + 28 ); } #[test] fn rules_command_filters_one_rule_and_rejects_unknown_ids() { - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let output = cmd .arg("rules") .arg("--rule") @@ -300,7 +300,7 @@ fn rules_command_filters_one_rule_and_rejects_unknown_ids() { "[rules.require-concurrent-index]\ndisabled = true\ntier1_threshold_rows = 123\ntier2_threshold_rows = 45" ) .unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let output = cmd .arg("rules") .arg("--rule") @@ -322,7 +322,7 @@ fn rules_command_filters_one_rule_and_rejects_unknown_ids() { 45 ); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("rules") .arg("--rule") @@ -342,7 +342,7 @@ fn test_cli_rejects_unknown_configured_rule_id() { let mut config_file = tempfile::NamedTempFile::new().unwrap(); writeln!(config_file, "[rules.concurent-index]\ndisabled = true").unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("lint") .arg("--file") @@ -366,7 +366,7 @@ fn test_cli_rejects_thresholds_unsupported_by_a_rule() { ) .unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("rules") .arg("--json") @@ -385,7 +385,7 @@ fn test_cli_rejects_unknown_configuration_setting() { let mut config_file = tempfile::NamedTempFile::new().unwrap(); writeln!(config_file, "auto_syn = true").unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("lint") .arg("--file") @@ -402,11 +402,26 @@ fn test_cli_rejects_unknown_configuration_setting() { #[test] fn test_cli_lint_nonexistent_file() { - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); cmd.arg("lint").arg("--file").arg("nonexistent_file.sql"); cmd.assert().failure(); } +#[test] +fn test_cli_errors_render_control_characters_inertly() { + let mut cmd = crate::common::safe_migrate_command(); + let assert = cmd + .arg("lint") + .arg("--file") + .arg("missing\x1b[2J.sql") + .assert() + .failure(); + let stderr = String::from_utf8_lossy(&assert.get_output().stderr); + + assert!(!stderr.contains('\x1b')); + assert!(stderr.contains("\\u{1b}[2J"), "stderr was: {stderr}"); +} + #[test] fn test_cli_lint_invalid_cache() { let mut sql_file = tempfile::NamedTempFile::new().unwrap(); @@ -415,7 +430,7 @@ fn test_cli_lint_invalid_cache() { let mut corrupted_cache = tempfile::NamedTempFile::new().unwrap(); writeln!(corrupted_cache, "invalid json data").unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); cmd.arg("lint") .arg("--file") .arg(sql_file.path()) @@ -437,16 +452,16 @@ fn test_cli_rejects_semantically_contradictory_v7_cache() { ); let config = bincode::config::standard().with_variable_int_encoding(); let encoded = - bincode::serde::encode_to_vec(DbCacheVersioned::V7(Box::new(invalid)), config).unwrap(); + bincode::serde::encode_to_vec(DbCacheVersioned::V8(Box::new(invalid)), config).unwrap(); let mut compressed = Vec::new(); let mut encoder = zstd::stream::Encoder::new(&mut compressed, 3).unwrap(); - encoder.write_all(CACHE_V7_MAGIC).unwrap(); + encoder.write_all(CACHE_V8_MAGIC).unwrap(); encoder.write_all(&encoded).unwrap(); encoder.finish().unwrap(); let cache = tempfile::NamedTempFile::new().unwrap(); fs::write(cache.path(), compressed).unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("cache") .arg("inspect") @@ -474,10 +489,10 @@ fn test_cli_rejects_authenticated_semantically_contradictory_v7_cache() { ); let config = bincode::config::standard().with_variable_int_encoding(); let encoded = - bincode::serde::encode_to_vec(DbCacheVersioned::V7(Box::new(invalid)), config).unwrap(); + bincode::serde::encode_to_vec(DbCacheVersioned::V8(Box::new(invalid)), config).unwrap(); let mut compressed = Vec::new(); let mut encoder = zstd::stream::Encoder::new(&mut compressed, 3).unwrap(); - encoder.write_all(CACHE_V7_MAGIC).unwrap(); + encoder.write_all(CACHE_V8_MAGIC).unwrap(); encoder.write_all(&encoded).unwrap(); encoder.finish().unwrap(); @@ -495,7 +510,7 @@ fn test_cli_rejects_authenticated_semantically_contradictory_v7_cache() { let mut config_file = tempfile::NamedTempFile::new().unwrap(); writeln!(config_file, "cache_encryption = true").unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .env("SAFE_MIGRATE_CACHE_KEY", "2a".repeat(32)) .arg("cache") @@ -518,7 +533,7 @@ fn test_cli_rejects_authenticated_semantically_contradictory_v7_cache() { fn test_cli_rejects_malformed_cache_payload() { let config = bincode::config::standard().with_variable_int_encoding(); let encoded = - bincode::serde::encode_to_vec(DbCacheVersioned::V7(Box::default()), config).unwrap(); + bincode::serde::encode_to_vec(DbCacheVersioned::V8(Box::default()), config).unwrap(); // Preserve the current enum discriminant, then corrupt the payload. let mut malicious = encoded[..4].to_vec(); malicious.push(252); @@ -526,14 +541,14 @@ fn test_cli_rejects_malformed_cache_payload() { let mut compressed = Vec::new(); let mut encoder = zstd::stream::Encoder::new(&mut compressed, 3).unwrap(); - encoder.write_all(CACHE_V7_MAGIC).unwrap(); + encoder.write_all(CACHE_V8_MAGIC).unwrap(); encoder.write_all(&malicious).unwrap(); encoder.finish().unwrap(); let mut cache = tempfile::NamedTempFile::new().unwrap(); cache.write_all(&compressed).unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); cmd.arg("cache") .arg("inspect") .arg("--cache") @@ -551,11 +566,11 @@ fn test_cli_rejects_malformed_cache_payload() { fn test_cli_rejects_trailing_data_after_streamed_cache_decode() { let config = bincode::config::standard().with_variable_int_encoding(); let encoded = - bincode::serde::encode_to_vec(DbCacheVersioned::V7(Box::default()), config).unwrap(); + bincode::serde::encode_to_vec(DbCacheVersioned::V8(Box::default()), config).unwrap(); let mut compressed = Vec::new(); let mut encoder = zstd::stream::Encoder::new(&mut compressed, 3).unwrap(); - encoder.write_all(CACHE_V7_MAGIC).unwrap(); + encoder.write_all(CACHE_V8_MAGIC).unwrap(); encoder.write_all(&encoded).unwrap(); encoder.write_all(b"trailing-data").unwrap(); encoder.finish().unwrap(); @@ -563,7 +578,7 @@ fn test_cli_rejects_trailing_data_after_streamed_cache_decode() { let mut cache = tempfile::NamedTempFile::new().unwrap(); cache.write_all(&compressed).unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("cache") .arg("inspect") @@ -588,7 +603,7 @@ fn test_cache_inspect_rejects_unsupported_legacy_cache_without_exposing_its_vers let cache = tempfile::NamedTempFile::new().unwrap(); fs::write(cache.path(), compressed).unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("cache") .arg("inspect") @@ -617,7 +632,7 @@ fn test_cache_inspect_rejects_headered_legacy_caches() { let cache = tempfile::NamedTempFile::new().unwrap(); fs::write(cache.path(), compressed).unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("cache") .arg("inspect") @@ -643,7 +658,7 @@ fn test_cache_inspect_rejects_unknown_unheadered_cache_generically() { let cache = tempfile::NamedTempFile::new().unwrap(); fs::write(cache.path(), compressed).unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("cache") .arg("inspect") @@ -659,7 +674,7 @@ fn test_cache_inspect_rejects_unknown_unheadered_cache_generically() { #[test] fn test_cli_sync_no_db_url() { - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); cmd.arg("sync"); // Ensure DATABASE_URL is not set cmd.env_remove("DATABASE_URL"); @@ -672,7 +687,7 @@ fn test_cache_inspect_outputs_a_redacted_json_summary() { let cache_path = temp_dir.path().join("baseline.cache"); write_fresh_cache(&cache_path); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("cache") .arg("inspect") @@ -684,7 +699,7 @@ fn test_cache_inspect_outputs_a_redacted_json_summary() { let report = parse_json_stdout(assert.get_output()); assert_eq!(report["path"], cache_path.display().to_string()); - assert_eq!(report["format_version"], 7); + assert_eq!(report["format_version"], 8); assert_eq!(report["encrypted"], false); assert_eq!(report["coverage"]["schema_scope"], "all_non_system"); assert!(report["coverage"]["families"].is_array()); @@ -717,7 +732,7 @@ fn test_cache_inspect_human_summary_discloses_redaction() { let cache_path = temp_dir.path().join("baseline.cache"); write_fresh_cache(&cache_path); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("cache") .arg("inspect") @@ -747,7 +762,7 @@ fn test_cli_json_is_machine_clean_and_marks_missing_baseline_tainted() { let mut sql_file = tempfile::NamedTempFile::new().unwrap(); writeln!(sql_file, "CREATE TABLE widgets (id bigint PRIMARY KEY);").unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("lint") .arg("--file") @@ -774,7 +789,7 @@ fn test_cli_no_cache_does_not_invent_schema_drift() { let mut sql_file = tempfile::NamedTempFile::new().unwrap(); writeln!(sql_file, "ALTER TABLE widgets ADD COLUMN status text;").unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("lint") .arg("--file") @@ -802,7 +817,7 @@ fn test_cli_no_cache_bypasses_configured_auto_sync() { let mut config_file = tempfile::NamedTempFile::new().unwrap(); writeln!(config_file, "auto_sync = true").unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("lint") .arg("--file") @@ -828,7 +843,7 @@ fn test_cli_no_auto_sync_uses_cache_without_database_access() { let mut config_file = tempfile::NamedTempFile::new().unwrap(); writeln!(config_file, "auto_sync = true").unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("lint") .arg("--file") @@ -858,7 +873,7 @@ fn explicit_missing_config_is_an_error_for_lint_and_rules() { let missing = sql_file.path().with_extension("missing.toml"); for command in ["lint", "rules"] { - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); cmd.arg(command); if command == "lint" { cmd.arg("--file").arg(sql_file.path()).arg("--no-cache"); @@ -875,7 +890,7 @@ fn lint_chain_rejects_a_directory_without_sql_files() { let directory = tempfile::tempdir().unwrap(); fs::write(directory.path().join("README.txt"), "not a migration").unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let output = cmd .arg("lint-chain") .arg("--dir") @@ -894,7 +909,7 @@ fn empty_configured_schema_scope_fails_before_auto_sync() { let mut config_file = tempfile::NamedTempFile::new().unwrap(); writeln!(config_file, "auto_sync = true\nschemas = []").unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let output = cmd .arg("lint") .arg("--file") @@ -918,7 +933,7 @@ fn test_cli_auto_sync_failure_continues_without_a_cache() { let cache_dir = tempfile::tempdir().unwrap(); let cache_path = cache_dir.path().join("missing.cache"); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("lint") .arg("--file") @@ -951,7 +966,7 @@ fn test_cli_auto_sync_failure_uses_the_previous_cache() { let mut config_file = tempfile::NamedTempFile::new().unwrap(); writeln!(config_file, "auto_sync = true").unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("lint") .arg("--file") @@ -982,7 +997,7 @@ fn test_cli_auto_sync_failure_keeps_fresh_cache_confidence_exact() { let mut config_file = tempfile::NamedTempFile::new().unwrap(); writeln!(config_file, "auto_sync = true").unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("lint") .arg("--file") @@ -1017,7 +1032,7 @@ fn test_cli_json_halt_is_json_and_uses_blocking_exit_status() { let mut sql_file = tempfile::NamedTempFile::new().unwrap(); writeln!(sql_file, "DROP DATABASE production;").unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("lint") .arg("--file") @@ -1064,7 +1079,7 @@ fn test_cli_json_statement_index_counts_preceding_schema_neutral_statements() { writeln!(sql_file, "COMMENT ON TABLE widgets IS 'migration note';").unwrap(); writeln!(sql_file, "DROP DATABASE production;").unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("lint") .arg("--file") @@ -1089,7 +1104,7 @@ fn test_cli_markdown_report_is_machine_clean_and_includes_location() { let mut sql_file = tempfile::NamedTempFile::new().unwrap(); writeln!(sql_file, "DROP DATABASE production;").unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("lint") .arg("--file") @@ -1117,7 +1132,7 @@ fn test_cli_chain_json_reports_the_source_file_for_findings() { let migration = dir.path().join("002_drop_database.sql"); fs::write(&migration, "DROP DATABASE production;").unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("lint-chain") .arg("--dir") @@ -1143,7 +1158,7 @@ fn test_cli_json_locations_preserve_offsets_through_execute_normalization() { writeln!(sql_file, "-- generated migration").unwrap(); writeln!(sql_file, "EXECUTE 'DROP DATABASE production';").unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("lint") .arg("--file") @@ -1169,7 +1184,7 @@ fn test_cli_rejects_json_and_markdown_together() { let mut sql_file = tempfile::NamedTempFile::new().unwrap(); writeln!(sql_file, "CREATE TABLE widgets (id bigint PRIMARY KEY);").unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); cmd.arg("lint") .arg("--file") .arg(sql_file.path()) @@ -1184,7 +1199,7 @@ fn test_cli_human_halt_uses_blocking_exit_status() { let mut sql_file = tempfile::NamedTempFile::new().unwrap(); writeln!(sql_file, "DROP DATABASE production;").unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); cmd.arg("lint") .arg("--file") .arg(sql_file.path()) @@ -1202,7 +1217,7 @@ fn test_cli_chain_json_is_machine_clean() { ) .unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("lint-chain") .arg("--dir") @@ -1224,7 +1239,7 @@ fn test_cli_rejects_json_and_interactive_together() { let mut sql_file = tempfile::NamedTempFile::new().unwrap(); writeln!(sql_file, "CREATE TABLE widgets (id bigint PRIMARY KEY);").unwrap(); - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let mut cmd = crate::common::safe_migrate_command(); let assert = cmd .arg("lint") .arg("--file") diff --git a/tests/common/invariants.rs b/tests/common/invariants.rs index 5611dca6..cebf5073 100644 --- a/tests/common/invariants.rs +++ b/tests/common/invariants.rs @@ -12,7 +12,7 @@ use safe_migrate::_internal::model::trigger::TriggerOverlay; use safe_migrate::_internal::model::types::TypeOverlay; use std::collections::HashSet; -pub fn assert_cache_invariants(cache: &DbCache) { +pub(crate) fn assert_cache_invariants(cache: &DbCache) { for (id, relation) in &cache.relations { assert_eq!( id, &relation.id, @@ -94,7 +94,7 @@ pub fn assert_cache_invariants(cache: &DbCache) { } } -pub fn assert_state_invariants(state: &AnalysisState) { +pub(crate) fn assert_state_invariants(state: &AnalysisState) { let local = &state.local; assert!( local.graph.indexes_are_valid(), @@ -238,11 +238,19 @@ pub fn assert_state_invariants(state: &AnalysisState) { .contains_key(&(edge.dependent.clone(), constraint_name.clone())), "constraint key edge must have a matching constraint" ), + DependencyKind::ConstraintDependency { + constraint_name, .. + } => assert!( + local + .constraints + .contains_key(&(edge.dependent.clone(), constraint_name.clone())), + "constraint dependency edge must have a matching constraint" + ), DependencyKind::IndexOnRelation { .. } | DependencyKind::RenameTo | DependencyKind::InheritanceOf | DependencyKind::PartitionOf - | DependencyKind::ConstraintDependency { .. } + | DependencyKind::PartitionDetachPending | DependencyKind::ColumnGeneratedFrom { .. } | DependencyKind::ColumnDefaultOnSequence { .. } | DependencyKind::ForeignKey { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 05de5696..8bd361e9 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,19 +1,46 @@ #![allow(dead_code)] -pub mod invariants; +pub(crate) mod invariants; use safe_migrate::_internal::ast::identifiers::ObjectId; use safe_migrate::_internal::db::cache::DbCache; -use safe_migrate::_internal::engine::config::Config; use safe_migrate::_internal::engine::engine::SafeMigrateEngine; use safe_migrate::_internal::model::relation::{Persistence, RelationKind, RelationState}; +use safe_migrate::api::Config; +use std::path::PathBuf; +use std::process::Command; +use std::sync::OnceLock; -pub fn setup_engine() -> SafeMigrateEngine { +static SAFE_MIGRATE_BINARY: OnceLock = OnceLock::new(); + +/// Unit tests do not receive Cargo's integration-test binary environment +/// variable, so build the CLI once and invoke its deterministic target path. +pub(crate) fn safe_migrate_command() -> assert_cmd::Command { + let binary = SAFE_MIGRATE_BINARY.get_or_init(|| { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let status = Command::new(env!("CARGO")) + .args(["build", "--locked", "--bin", "safe-migrate"]) + .current_dir(&manifest_dir) + .status() + .expect("run cargo build for CLI integration tests"); + assert!( + status.success(), + "cargo build must produce the safe-migrate CLI" + ); + manifest_dir + .join("target") + .join("debug") + .join(format!("safe-migrate{}", std::env::consts::EXE_SUFFIX)) + }); + assert_cmd::Command::new(binary) +} + +pub(crate) fn setup_engine() -> SafeMigrateEngine { SafeMigrateEngine::new(Config::default()) } -pub fn setup_state() -> safe_migrate::api::AnalysisState { - safe_migrate::api::AnalysisState::new(cache_with_safe_timeouts()) +pub(crate) fn setup_state() -> crate::_internal::analysis::state::AnalysisState { + crate::_internal::analysis::state::AnalysisState::new(cache_with_safe_timeouts()) } fn cache_with_safe_timeouts() -> DbCache { @@ -23,11 +50,11 @@ fn cache_with_safe_timeouts() -> DbCache { cache } -pub fn object_id(schema: &str, name: &str) -> ObjectId { +pub(crate) fn object_id(schema: &str, name: &str) -> ObjectId { ObjectId::new(schema, name) } -pub fn database_hosts_are_local(config: &postgres::Config) -> bool { +pub(crate) fn database_hosts_are_local(config: &postgres::Config) -> bool { config .get_hostaddrs() .iter() @@ -44,7 +71,7 @@ pub fn database_hosts_are_local(config: &postgres::Config) -> bool { }) } -pub fn cache_with_table(schema: &str, name: &str, rows: Option) -> DbCache { +pub(crate) fn cache_with_table(schema: &str, name: &str, rows: Option) -> DbCache { let mut cache = cache_with_safe_timeouts(); let tid = object_id(schema, name); cache.insert_baseline( diff --git a/tests/destructive_rules.rs b/tests/destructive_rules.rs index c93a582a..d841a4e7 100644 --- a/tests/destructive_rules.rs +++ b/tests/destructive_rules.rs @@ -1,5 +1,3 @@ -mod common; - mod destructive_rule_tests { use crate::common::*; use safe_migrate::_internal::analysis::state::AnalysisState; @@ -85,6 +83,11 @@ mod destructive_rule_tests { avg_width: None, default_expr_text: None, type_modifier: Some(104), + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, }); cache.insert_baseline(object_id("public", "t"), relation); @@ -166,6 +169,11 @@ mod destructive_rule_tests { avg_width: None, default_expr_text: None, type_modifier: Some(259), + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, }); cache.insert_baseline(object_id("public", "t"), rel); @@ -206,6 +214,11 @@ mod destructive_rule_tests { avg_width: None, default_expr_text: None, type_modifier: Some(54), + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, }); cache2.insert_baseline(object_id("public", "t"), rel2); let mut state2 = AnalysisState::new(cache2); @@ -249,6 +262,11 @@ mod destructive_rule_tests { avg_width: None, default_expr_text: None, type_modifier: None, // text has no modifier + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, }); cache.insert_baseline(object_id("public", "t"), rel); diff --git a/tests/evidence_outcome.rs b/tests/evidence_outcome.rs index 59cae671..be267c21 100644 --- a/tests/evidence_outcome.rs +++ b/tests/evidence_outcome.rs @@ -1,5 +1,4 @@ -mod common; - +use crate::common; use safe_migrate::_internal::analysis::evidence::EvidenceCode; #[test] @@ -65,7 +64,7 @@ fn foreign_key_type_compatibility_gap_has_catalog_evidence_not_legacy_taint() { #[test] fn unknown_sequence_target_has_typed_object_state_evidence() { let engine = common::setup_engine(); - let mut state = safe_migrate::api::AnalysisState::with_baseline( + let mut state = crate::_internal::analysis::state::AnalysisState::with_baseline( safe_migrate::_internal::db::cache::DbCache::new(), false, ); @@ -88,7 +87,7 @@ fn unknown_sequence_target_has_typed_object_state_evidence() { #[test] fn unavailable_rule_capability_is_recorded_before_rule_evaluation() { let engine = common::setup_engine(); - let mut state = safe_migrate::api::AnalysisState::with_baseline( + let mut state = crate::_internal::analysis::state::AnalysisState::with_baseline( safe_migrate::_internal::db::cache::DbCache::new(), false, ); diff --git a/tests/exhaustive_fuzz.rs b/tests/exhaustive_fuzz.rs index 82726c09..4098e6d8 100644 --- a/tests/exhaustive_fuzz.rs +++ b/tests/exhaustive_fuzz.rs @@ -1,5 +1,3 @@ -mod common; - mod exhaustive_fuzz_tests { use crate::common::*; use safe_migrate::_internal::analysis::state::AnalysisState; diff --git a/tests/expression_parsing.rs b/tests/expression_parsing.rs index 0e518341..8b5f7f09 100644 --- a/tests/expression_parsing.rs +++ b/tests/expression_parsing.rs @@ -1,5 +1,3 @@ -mod common; - mod expression_parsing_tests { use crate::common::*; use safe_migrate::_internal::analysis::state::AnalysisState; diff --git a/tests/identifier_casing.rs b/tests/identifier_casing.rs index 56993397..4dc22712 100644 --- a/tests/identifier_casing.rs +++ b/tests/identifier_casing.rs @@ -1,5 +1,3 @@ -mod common; - mod identifier_casing_tests { use crate::common::*; use safe_migrate::_internal::model::relation::RelationOverlay; diff --git a/tests/invariant_sequences.rs b/tests/invariant_sequences.rs index 3d11e7f7..5c91bddb 100644 --- a/tests/invariant_sequences.rs +++ b/tests/invariant_sequences.rs @@ -1,5 +1,3 @@ -mod common; - mod invariant_sequences { use crate::common::invariants::{assert_cache_invariants, assert_state_invariants}; use crate::common::{cache_with_table, object_id, setup_engine, setup_state}; @@ -249,10 +247,12 @@ mod invariant_sequences { .expect("structure-aware statement should analyze"); assert_state_invariants(&state); reports.push( - serde_json::to_string(&safe_migrate::api::Reporter::json_report( - &findings, - &state.local.confidence, - )) + serde_json::to_string( + &crate::_internal::report::reporter::Reporter::json_report( + &findings, + &state.local.confidence, + ), + ) .expect("report should serialize"), ); } diff --git a/tests/live_auto_sync.rs b/tests/live_auto_sync.rs index 76920138..ace88649 100644 --- a/tests/live_auto_sync.rs +++ b/tests/live_auto_sync.rs @@ -2,7 +2,7 @@ use std::fs; use std::io::Read; use std::path::Path; -use safe_migrate::_internal::db::cache::{CACHE_V7_MAGIC, DbCacheVersioned}; +use safe_migrate::_internal::db::cache::{CACHE_V8_MAGIC, DbCacheVersioned}; fn run_auto_sync_case( database_url: &str, @@ -18,7 +18,7 @@ fn run_auto_sync_case( fs::write(&config_path, "auto_sync = true\nschemas = [\"public\"]\n") .expect("write live auto-sync config"); - let mut command = assert_cmd::Command::cargo_bin("safe-migrate").expect("safe-migrate binary"); + let mut command = crate::common::safe_migrate_command(); command .arg(mode) .arg("--config") @@ -84,14 +84,14 @@ fn run_auto_sync_case( .read_to_end(&mut payload) .expect("read decoded cache payload"); let v7_payload = payload - .strip_prefix(CACHE_V7_MAGIC) - .expect("auto-sync must write a V7 cache"); + .strip_prefix(CACHE_V8_MAGIC) + .expect("auto-sync must write a V8 cache"); let config = bincode::config::standard().with_variable_int_encoding(); let (versioned, bytes_read): (DbCacheVersioned, usize) = - bincode::serde::decode_from_slice(v7_payload, config).expect("decode V7 cache"); + bincode::serde::decode_from_slice(v7_payload, config).expect("decode V8 cache"); assert_eq!(bytes_read, v7_payload.len()); - let DbCacheVersioned::V7(cache) = versioned else { - panic!("auto-sync must encode the V7 cache variant"); + let DbCacheVersioned::V8(cache) = versioned else { + panic!("auto-sync must encode the V8 cache variant"); }; assert_eq!(cache.metadata.source_role.as_deref(), Some(expected_role)); assert_eq!( diff --git a/tests/live_cache_encryption.rs b/tests/live_cache_encryption.rs index 11e60b3d..64a9430b 100644 --- a/tests/live_cache_encryption.rs +++ b/tests/live_cache_encryption.rs @@ -30,7 +30,7 @@ fn live_encrypted_cache_round_trip_and_rejection_contract() { fs::write(&config_path, "cache_encryption = true\n").expect("write encryption config"); fs::write(&plain_config_path, "").expect("write plain config"); - let mut sync = assert_cmd::Command::cargo_bin("safe-migrate").expect("safe-migrate binary"); + let mut sync = crate::common::safe_migrate_command(); let sync_output = sync .arg("sync") .arg("--out") @@ -51,8 +51,7 @@ fn live_encrypted_cache_round_trip_and_rejection_contract() { assert!(!String::from_utf8_lossy(&sync_output.stdout).contains(TEST_KEY)); assert!(!String::from_utf8_lossy(&sync_output.stderr).contains(TEST_KEY)); - let mut plain_sync = - assert_cmd::Command::cargo_bin("safe-migrate").expect("safe-migrate binary"); + let mut plain_sync = crate::common::safe_migrate_command(); let plain_sync_output = plain_sync .arg("sync") .arg("--out") @@ -74,7 +73,7 @@ fn live_encrypted_cache_round_trip_and_rejection_contract() { .any(|bytes| bytes == TEST_KEY.as_bytes()) ); - let mut inspect = assert_cmd::Command::cargo_bin("safe-migrate").expect("safe-migrate binary"); + let mut inspect = crate::common::safe_migrate_command(); let inspect_output = inspect .arg("cache") .arg("inspect") @@ -89,12 +88,12 @@ fn live_encrypted_cache_round_trip_and_rejection_contract() { assert_success(&inspect_output, "encrypted cache inspect"); let inspection = parse_json(&inspect_output); assert_eq!(inspection["encrypted"], true); - assert_eq!(inspection["format_version"], 7); + assert_eq!(inspection["format_version"], 8); assert!(inspection["contents"]["roles"].is_number()); let migration_path = temp_dir.path().join("migration.sql"); fs::write(&migration_path, "SET search_path TO public;\n").expect("write lint migration"); - let mut lint = assert_cmd::Command::cargo_bin("safe-migrate").expect("safe-migrate binary"); + let mut lint = crate::common::safe_migrate_command(); let lint_output = lint .arg("lint") .arg("--file") @@ -119,8 +118,7 @@ fn live_encrypted_cache_round_trip_and_rejection_contract() { "auto_sync = true\ncache_encryption = true\nschemas = [\"public\"]\n", ) .expect("write encrypted auto-sync config"); - let mut encrypted_auto_sync = - assert_cmd::Command::cargo_bin("safe-migrate").expect("safe-migrate binary"); + let mut encrypted_auto_sync = crate::common::safe_migrate_command(); let auto_sync_output = encrypted_auto_sync .arg("lint") .arg("--file") @@ -156,8 +154,7 @@ fn live_encrypted_cache_round_trip_and_rejection_contract() { "SET search_path TO public;\n", ) .expect("write second chain migration"); - let mut lint_chain = - assert_cmd::Command::cargo_bin("safe-migrate").expect("safe-migrate binary"); + let mut lint_chain = crate::common::safe_migrate_command(); let chain_output = lint_chain .arg("lint-chain") .arg("--dir") @@ -205,8 +202,7 @@ fn live_encrypted_cache_round_trip_and_rejection_contract() { "key is incorrect or the file was modified", ), ] { - let mut rejected = - assert_cmd::Command::cargo_bin("safe-migrate").expect("safe-migrate binary"); + let mut rejected = crate::common::safe_migrate_command(); rejected .arg("cache") .arg("inspect") diff --git a/tests/live_catalog_sync.rs b/tests/live_catalog_sync.rs index a5ed1400..30cbe3f7 100644 --- a/tests/live_catalog_sync.rs +++ b/tests/live_catalog_sync.rs @@ -1,5 +1,3 @@ -mod common; - use crate::common::database_hosts_are_local; use std::fs; use std::io::Read; @@ -7,40 +5,55 @@ use std::io::Read; use safe_migrate::_internal::analysis::facts::{ ConnectionTarget, PublicationObjectFact, PublicationRowFilter, PublicationScope, }; +use safe_migrate::_internal::analysis::graph::DependencyKind; use safe_migrate::_internal::analysis::state::AnalysisState; use safe_migrate::_internal::ast::identifiers::ObjectId; -use safe_migrate::_internal::db::cache::{CACHE_V7_MAGIC, DbCacheVersioned}; -use safe_migrate::_internal::engine::config::Config; +use safe_migrate::_internal::db::cache::{CACHE_V8_MAGIC, DbCacheVersioned}; use safe_migrate::_internal::engine::engine::SafeMigrateEngine; +use safe_migrate::_internal::model::constraint::ConstraintKind; use safe_migrate::_internal::model::function::{ FunctionOverlay, RoutineKind, SecurityMode, Volatility, }; use safe_migrate::_internal::model::replication::{PublicationOverlay, SubscriptionOverlay}; use safe_migrate::_internal::sync::sync_cache; +use safe_migrate::api::Config; -const SCHEMA: &str = "sm_v6_catalog"; -const SECOND_SCHEMA: &str = "sm_v6_catalog_extra"; -const PUBLICATION: &str = "sm_v6_catalog_publication"; -const SCHEMA_PUBLICATION: &str = "sm_v6_schema_publication"; -const SUBSCRIPTION: &str = "sm_v6_catalog_subscription"; -const CONNECTION_SENTINEL: &str = "sm_v6_connection_secret_must_not_enter_cache"; +const SCHEMA: &str = "sm_v7_catalog"; +const SECOND_SCHEMA: &str = "sm_v7_catalog_extra"; +const PUBLICATION: &str = "sm_v7_catalog_publication"; +const SCHEMA_PUBLICATION: &str = "sm_v7_schema_publication"; +const SUBSCRIPTION: &str = "sm_v7_catalog_subscription"; +const CONNECTED_SUBSCRIPTION: &str = "sm_v7_connected_subscription"; +const CONNECTION_SENTINEL: &str = "sm_v7_connection_secret_must_not_enter_cache"; fn cleanup(client: &mut postgres::Client) { - let subscription_exists: bool = client - .query_one( - "SELECT EXISTS (SELECT 1 FROM pg_subscription WHERE subname = $1)", - &[&SUBSCRIPTION], - ) - .expect("check live catalog subscription") - .get(0); - if subscription_exists { - client - .batch_execute(&format!( - "ALTER SUBSCRIPTION {SUBSCRIPTION} DISABLE; - ALTER SUBSCRIPTION {SUBSCRIPTION} SET (slot_name = NONE); - DROP SUBSCRIPTION {SUBSCRIPTION};" - )) - .expect("remove live catalog subscription"); + for subscription in [CONNECTED_SUBSCRIPTION, SUBSCRIPTION] { + let exists: bool = client + .query_one( + "SELECT EXISTS (SELECT 1 FROM pg_subscription WHERE subname = $1)", + &[&subscription], + ) + .expect("check live catalog subscription") + .get(0); + if exists { + // DROP SUBSCRIPTION is one of PostgreSQL's commands that must be + // executed outside a transaction. Send each statement as its + // own simple query; combining it with ALTER in batch_execute can + // make the server treat the command string as a transaction block. + client + .batch_execute(&format!("ALTER SUBSCRIPTION {subscription} DISABLE;")) + .expect("disable live catalog subscription"); + if subscription != CONNECTED_SUBSCRIPTION { + client + .batch_execute(&format!( + "ALTER SUBSCRIPTION {subscription} SET (slot_name = NONE);" + )) + .expect("detach live catalog replication slot"); + } + client + .batch_execute(&format!("DROP SUBSCRIPTION {subscription};")) + .expect("remove live catalog subscription"); + } } client .batch_execute(&format!( @@ -62,7 +75,7 @@ impl Drop for CatalogCleanup { } } -fn decode_cache(path: &std::path::Path) -> (safe_migrate::api::DbCache, Vec) { +fn decode_cache(path: &std::path::Path) -> (crate::_internal::db::cache::DbCache, Vec) { let encoded = fs::read(path).expect("read synchronized cache"); let mut decoder = zstd::stream::Decoder::new(encoded.as_slice()).expect("decode cache zstd"); let mut payload = Vec::new(); @@ -70,14 +83,14 @@ fn decode_cache(path: &std::path::Path) -> (safe_migrate::api::DbCache, Vec) .read_to_end(&mut payload) .expect("read decoded cache payload"); let v7_payload = payload - .strip_prefix(CACHE_V7_MAGIC) - .expect("catalog sync must write a V7 cache"); + .strip_prefix(CACHE_V8_MAGIC) + .expect("catalog sync must write a V8 cache"); let config = bincode::config::standard().with_variable_int_encoding(); let (versioned, bytes_read): (DbCacheVersioned, usize) = - bincode::serde::decode_from_slice(v7_payload, config).expect("decode V7 cache"); + bincode::serde::decode_from_slice(v7_payload, config).expect("decode V8 cache"); assert_eq!(bytes_read, v7_payload.len()); - let DbCacheVersioned::V7(cache) = versioned else { - panic!("catalog sync must encode the V7 cache variant"); + let DbCacheVersioned::V8(cache) = versioned else { + panic!("catalog sync must encode the V8 cache variant"); }; (*cache, payload) } @@ -120,6 +133,8 @@ fn seed_catalog(client: &mut postgres::Client, version: i32) { CREATE TABLE {SCHEMA}.entries ( id integer PRIMARY KEY, note text, + score integer, + period int4range, CONSTRAINT entries_note_check CHECK (note IS NOT NULL) ); CREATE TABLE {SCHEMA}.entry_refs ( @@ -130,6 +145,9 @@ fn seed_catalog(client: &mut postgres::Client, version: i32) { WHERE note IS NOT NULL; CREATE INDEX entries_note_expression_idx ON {SCHEMA}.entries((lower(note))); CREATE INDEX entries_id_include_idx ON {SCHEMA}.entries(id) INCLUDE (note); + CREATE STATISTICS {SCHEMA}.entries_note_score_stats (dependencies, ndistinct) + ON note, score FROM {SCHEMA}.entries; + ALTER STATISTICS {SCHEMA}.entries_note_score_stats SET STATISTICS 250; CREATE TABLE {SCHEMA}.view_source (id integer, note text, unused text); CREATE TABLE {SCHEMA}.generated_source ( source integer, @@ -150,6 +168,10 @@ fn seed_catalog(client: &mut postgres::Client, version: i32) { CREATE TABLE {SECOND_SCHEMA}.audit_entries (id integer PRIMARY KEY); CREATE FUNCTION {SCHEMA}.with_out(value integer, OUT doubled integer) LANGUAGE sql IMMUTABLE AS 'SELECT value * 2'; + CREATE FUNCTION {SCHEMA}.partition_audit() RETURNS trigger + LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END'; + CREATE TRIGGER partition_audit AFTER INSERT ON {SCHEMA}.partition_root + FOR EACH ROW EXECUTE FUNCTION {SCHEMA}.partition_audit(); CREATE PROCEDURE {SCHEMA}.record_value(value integer) LANGUAGE sql AS 'SELECT value'; CREATE FUNCTION {SCHEMA}.add_values(state integer, value integer) @@ -223,7 +245,7 @@ fn seed_catalog(client: &mut postgres::Client, version: i32) { } fn inspect_cache(path: &std::path::Path) -> serde_json::Value { - let mut command = assert_cmd::Command::cargo_bin("safe-migrate").expect("safe-migrate binary"); + let mut command = crate::common::safe_migrate_command(); let output = command .arg("cache") .arg("inspect") @@ -249,7 +271,7 @@ fn attributes( fn assert_routine_matches( state: &AnalysisState, - cache: &safe_migrate::api::DbCache, + cache: &crate::_internal::db::cache::DbCache, id: &ObjectId, ) { let Some(FunctionOverlay::Present(simulated)) = state.local.functions.get(id) else { @@ -267,7 +289,7 @@ fn assert_routine_matches( fn assert_publication_matches( state: &AnalysisState, - cache: &safe_migrate::api::DbCache, + cache: &crate::_internal::db::cache::DbCache, name: &str, ) { let Some(PublicationOverlay::Present(simulated)) = state.local.publications.get(name) else { @@ -294,7 +316,7 @@ fn assert_publication_matches( fn assert_subscription_matches( state: &AnalysisState, - cache: &safe_migrate::api::DbCache, + cache: &crate::_internal::db::cache::DbCache, name: &str, ) { let Some(SubscriptionOverlay::Present(simulated)) = state.local.subscriptions.get(name) else { @@ -319,6 +341,15 @@ fn assert_subscription_matches( ); } +fn cleanup_publisher(client: &mut postgres::Client) { + client + .batch_execute(&format!( + "DROP PUBLICATION IF EXISTS {PUBLICATION}; + DROP SCHEMA IF EXISTS {SCHEMA} CASCADE;" + )) + .expect("remove publisher fixtures"); +} + #[test] fn live_catalog_database_guard_accepts_only_local_hosts() { for value in [ @@ -437,6 +468,31 @@ fn live_sync_preserves_routine_and_replication_catalogs_without_connection_secre index.index_id == ObjectId::new(SCHEMA, "entries_id_include_idx") && index.included_columns == ["note"] })); + let extended_statistics = cache + .relations + .get(&entry_id) + .and_then(|relation| { + relation + .extended_statistics + .get(&ObjectId::new(SCHEMA, "entries_note_score_stats")) + }) + .expect("synchronized extended statistics"); + assert_eq!(extended_statistics.kinds, ["d", "f"]); + assert_eq!(extended_statistics.columns, ["note", "score"]); + assert_eq!(extended_statistics.target, Some(250)); + let parent_trigger_id = ObjectId::new(SCHEMA, "partition_root\0partition_audit"); + assert!(cache.triggers.iter().any(|trigger| { + trigger.table_id == ObjectId::new(SCHEMA, "partition_root") + && trigger.trigger_id.name == "partition_audit" + && trigger.row_level + && trigger.parent_trigger_id.is_none() + })); + assert!(cache.triggers.iter().any(|trigger| { + trigger.table_id == ObjectId::new(SCHEMA, "partition_leaf") + && trigger.trigger_id.name == "partition_audit" + && trigger.row_level + && trigger.parent_trigger_id.as_ref() == Some(&parent_trigger_id) + })); for column in ["id", "note"] { assert!(cache.dependencies.iter().any(|dependency| { dependency.dependent == ObjectId::new(SCHEMA, "entry_projection") @@ -767,6 +823,9 @@ fn live_routine_and_replication_mutations_match_postgresql() { ALTER PROCEDURE {SCHEMA}.record_value(integer) RENAME TO record_value_renamed; ALTER AGGREGATE {SCHEMA}.total(integer) RENAME TO total_renamed; ALTER FUNCTION {SCHEMA}.win_rank() STABLE; + ALTER TABLE {SCHEMA}.entries + ADD CONSTRAINT entries_score_check CHECK (score >= 0), + ADD CONSTRAINT entries_period_excl EXCLUDE USING gist (period WITH &&); ALTER PUBLICATION {PUBLICATION} ADD TABLE ONLY {SECOND_SCHEMA}.audit_entries; ALTER PUBLICATION {PUBLICATION} SET (publish = 'insert'); ALTER SUBSCRIPTION {SUBSCRIPTION} @@ -800,6 +859,82 @@ fn live_routine_and_replication_mutations_match_postgresql() { assert_publication_matches(&state, &altered, PUBLICATION); assert_subscription_matches(&state, &altered, SUBSCRIPTION); + let entries = ObjectId::new(SCHEMA, "entries"); + for (name, kind, columns) in [ + ("entries_score_check", ConstraintKind::Check, &["score"][..]), + ( + "entries_period_excl", + ConstraintKind::Exclusion, + &["period"][..], + ), + ] { + let simulated = state + .local + .constraints + .get(&(entries.clone(), name.to_string())) + .unwrap_or_else(|| panic!("simulator omitted altered constraint {name}")); + let synchronized = altered + .constraints + .iter() + .find(|constraint| constraint.table_id == entries && constraint.name == name) + .unwrap_or_else(|| panic!("PostgreSQL omitted altered constraint {name}")); + assert_eq!(simulated.kind, kind, "simulator constraint kind for {name}"); + assert_eq!( + synchronized.kind, kind, + "catalog constraint kind for {name}" + ); + assert_eq!(simulated.validated, synchronized.validated); + assert!(state.local.graph.edges().iter().any(|edge| { + edge.dependent == entries + && matches!( + &edge.kind, + DependencyKind::ConstraintDependency { + constraint_name, + columns: dependency_columns, + } if constraint_name == name && dependency_columns == columns + ) + })); + assert!(altered.constraint_dependencies.iter().any(|dependency| { + dependency.table_id == entries + && dependency.constraint_name == name + && dependency.columns == columns + })); + } + + let constraint_drop_sql = format!( + "ALTER TABLE {SCHEMA}.entries + DROP COLUMN score CASCADE, + DROP COLUMN period CASCADE;" + ); + let violations = engine + .analyze(&constraint_drop_sql, &mut state) + .expect("analyze altered constraint cascades"); + assert!( + !violations + .iter() + .any(|violation| violation.rule_id == "chain-conflict"), + "simulator rejected PostgreSQL-valid constraint cascades: {violations:#?}" + ); + client + .batch_execute(&constraint_drop_sql) + .expect("apply altered constraint cascades to PostgreSQL"); + sync_cache(&cache_path, None, false).expect("resync cascaded constraints"); + let (after_constraint_drop, _) = decode_cache(&cache_path); + for name in ["entries_score_check", "entries_period_excl"] { + assert!( + !state + .local + .constraints + .contains_key(&(entries.clone(), name.to_string())) + ); + assert!( + !after_constraint_drop + .constraints + .iter() + .any(|constraint| constraint.table_id == entries && constraint.name == name) + ); + } + let drop_sql = format!( "DROP SUBSCRIPTION {SUBSCRIPTION}; DROP PUBLICATION {PUBLICATION}; @@ -857,3 +992,85 @@ fn live_routine_and_replication_mutations_match_postgresql() { cleanup(&mut client); } + +#[test] +#[ignore = "requires local subscriber and publisher databases"] +fn live_connected_subscription_round_trip_is_redacted_and_exact() { + let publisher_url = std::env::var("PUBLISHER_DATABASE_URL") + .expect("PUBLISHER_DATABASE_URL is required for connected subscription validation"); + let subscription_url = std::env::var("SUBSCRIPTION_DATABASE_URL") + .expect("SUBSCRIPTION_DATABASE_URL is required for connected subscription validation"); + assert!( + !publisher_url.trim().is_empty() && !subscription_url.trim().is_empty(), + "connected subscription database URLs must not be empty" + ); + let publisher_config: postgres::Config = publisher_url + .parse() + .expect("PUBLISHER_DATABASE_URL is invalid"); + assert!( + database_hosts_are_local(&publisher_config), + "connected subscription validation accepts only a local publisher" + ); + let mut publisher = publisher_config + .connect(postgres::NoTls) + .expect("connect to logical replication publisher"); + cleanup_publisher(&mut publisher); + publisher + .batch_execute(&format!( + "CREATE SCHEMA {SCHEMA}; + CREATE TABLE {SCHEMA}.entries (id integer PRIMARY KEY, note text); + CREATE PUBLICATION {PUBLICATION} FOR TABLE {SCHEMA}.entries;" + )) + .expect("seed logical replication publisher"); + + let (subscriber_config, _, subscriber_version) = live_database(); + let _cleanup = CatalogCleanup(subscriber_config.clone()); + let mut subscriber = subscriber_config + .connect(postgres::NoTls) + .expect("connect to logical replication subscriber"); + seed_catalog(&mut subscriber, subscriber_version); + let escaped_url = subscription_url.replace('\'', "''"); + subscriber + .batch_execute(&format!( + "CREATE SUBSCRIPTION {CONNECTED_SUBSCRIPTION} + CONNECTION '{escaped_url}' + PUBLICATION {PUBLICATION} + WITH (copy_data = false, enabled = false);" + )) + .expect("create connected subscription and remote replication slot"); + + let temp_dir = tempfile::tempdir().expect("create connected subscription directory"); + let cache_path = temp_dir.path().join("connected.cache"); + sync_cache(&cache_path, None, false).expect("sync connected subscription"); + let (cache, decoded_payload) = decode_cache(&cache_path); + let connected = cache + .subscriptions + .get(CONNECTED_SUBSCRIPTION) + .expect("synchronized connected subscription"); + assert_eq!(connected.connection, ConnectionTarget::Redacted); + assert_eq!(connected.publications, [PUBLICATION]); + assert!(!connected.enabled); + assert_eq!(connected.slot_name.as_deref(), Some(CONNECTED_SUBSCRIPTION)); + assert!( + !decoded_payload + .windows(subscription_url.len()) + .any(|bytes| bytes == subscription_url.as_bytes()), + "publisher credentials entered the decoded cache" + ); + + let mut state = AnalysisState::new(cache); + let engine = SafeMigrateEngine::new(Config::default()); + let alter_sql = format!("ALTER SUBSCRIPTION {CONNECTED_SUBSCRIPTION} ENABLE;"); + engine + .analyze(&alter_sql, &mut state) + .expect("analyze connected subscription enablement"); + subscriber + .batch_execute(&alter_sql) + .expect("enable connected subscription"); + sync_cache(&cache_path, None, false).expect("resync enabled subscription"); + let (enabled, _) = decode_cache(&cache_path); + assert_subscription_matches(&state, &enabled, CONNECTED_SUBSCRIPTION); + + cleanup(&mut subscriber); + cleanup_publisher(&mut publisher); +} diff --git a/tests/live_differential_harness.rs b/tests/live_differential_harness.rs index af51da50..4ade4dbb 100644 --- a/tests/live_differential_harness.rs +++ b/tests/live_differential_harness.rs @@ -1,11 +1,9 @@ -mod common; - use crate::common::database_hosts_are_local; use postgres::{Client, Config as PostgresConfig, NoTls}; use safe_migrate::_internal::analysis::graph::DependencyKind; use safe_migrate::_internal::analysis::state::AnalysisState; +use safe_migrate::_internal::ast::identifiers::ObjectId; use safe_migrate::_internal::db::cache::DbCache; -use safe_migrate::_internal::engine::config::Config; use safe_migrate::_internal::engine::engine::SafeMigrateEngine; use safe_migrate::_internal::model::constraint::ConstraintKind; use safe_migrate::_internal::model::function::{FunctionOverlay, Volatility}; @@ -15,7 +13,9 @@ use safe_migrate::_internal::model::sequence::{SequenceKind, SequenceOverlay}; use safe_migrate::_internal::model::trigger::TriggerOverlay; use safe_migrate::_internal::model::types::{TypeKind, TypeOverlay}; use safe_migrate::_internal::sync::{populate_cache, populate_cache_in_current_transaction}; +use safe_migrate::api::Config; use serde::Deserialize; +use squawk_syntax::ast::{self, AstNode}; use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Path, PathBuf}; @@ -44,6 +44,8 @@ struct RuleManifest { #[serde(default)] fixture_transactional: BTreeMap, #[serde(default)] + fixture_autocommit: BTreeSet, + #[serde(default)] fixture_min_pg_version: BTreeMap, #[serde(default)] excluded_fixtures: Vec, @@ -98,6 +100,28 @@ fn default_transactional() -> bool { true } +fn split_fixture_statements(sql: &str) -> Result, String> { + let parsed = ast::SourceFile::parse(sql); + if !parsed.errors().is_empty() { + return Err(format!("SQL parse errors: {:?}", parsed.errors())); + } + // Typed statement boundaries preserve semicolons inside bodies and literals. + Ok(parsed + .tree() + .stmts() + .map(|stmt| stmt.syntax().text().to_string()) + .collect()) +} + +#[test] +fn autocommit_fixture_split_preserves_bodies_and_literals() { + let statements = split_fixture_statements("-- leading ;\n CREATE FUNCTION f() RETURNS text LANGUAGE sql AS $$ SELECT ';'; $$; SELECT ';'; -- trailing ;").unwrap(); + assert_eq!(statements.len(), 2); + assert!(statements[0].contains("$$ SELECT ';'; $$")); + assert_eq!(statements[1], "SELECT ';';"); + assert!(split_fixture_statements("CREATE TABLE broken (").is_err()); +} + #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] enum ComparisonScope { @@ -118,6 +142,7 @@ enum ComparisonScope { Partitions, Publications, Subscriptions, + ExtendedStatistics, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -134,10 +159,11 @@ struct NormalizedState { privileges: BTreeSet, policies: BTreeSet, triggers: BTreeMap<(String, String), NormalizedTrigger>, - partition_edges: BTreeSet<(String, String)>, + partition_edges: BTreeSet<(String, String, bool, bool)>, view_dependencies: BTreeSet<(String, String)>, publications: BTreeMap, subscriptions: BTreeMap, + extended_statistics: BTreeMap, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] @@ -155,6 +181,14 @@ struct NormalizedSequence { owner: String, owned_by: Option, kind: SequenceKind, + data_type: String, + start_value: i64, + increment: i64, + min_value: i64, + max_value: i64, + cache_size: i64, + cycle: bool, + persistence: String, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] @@ -162,6 +196,19 @@ struct NormalizedRelation { kind: NormalizedRelationKind, owner: String, partition_strategy: Option, + partition_is_default: Option, + partition_bound: Option, + partition_constraint: Option, + persistence: String, + tablespace: Option, + access_method: Option, + cluster_index: Option, + row_security: Option, + force_row_security: Option, + replica_identity: Option, + table_options: BTreeMap, + of_type: Option, + rules: BTreeMap, columns: BTreeMap, } @@ -177,12 +224,42 @@ struct NormalizedColumn { data_type: String, is_nullable: bool, has_default: bool, + generated: Option, + generation: Option, + identity_generation: Option, + storage: Option, + compression: Option, + statistics_target: Option, + options: BTreeMap, +} + +fn normalize_statistics_target(target: Option) -> Option { + target.filter(|value| *value != -1) +} + +fn normalize_partition_constraint(constraint: Option<&str>) -> Option { + constraint + .filter(|value| !value.trim_start().starts_with("satisfies_hash_partition(")) + .map(str::to_owned) } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] struct NormalizedIndex { index: String, table: String, + using_method: Option, + key_columns: Vec, + included_columns: Vec, + has_expression_keys: bool, + has_predicate: bool, + is_unique: bool, + is_immediate: bool, + is_valid: bool, + is_ready: bool, + is_live: bool, + has_default_sort_order: bool, + has_default_opclasses: bool, + has_default_collations: bool, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] @@ -198,6 +275,8 @@ struct NormalizedConstraint { name: String, kind: String, validated: bool, + definition: Option, + backing_index: Option, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] @@ -210,6 +289,17 @@ struct NormalizedPolicy { struct NormalizedTrigger { function: String, enabled_mode: String, + row_level: bool, + parent_trigger: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NormalizedExtendedStatistics { + table: String, + kinds: Vec, + columns: Vec, + expressions: Option, + target: Option, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] @@ -258,6 +348,7 @@ enum MismatchCategory { RelationKindMismatch, RelationOwnerMismatch, PartitionStrategyMismatch, + RelationDefinitionMismatch, ColumnMismatch, MissingIndexInSimulator, ExtraIndexInSimulator, @@ -279,6 +370,10 @@ enum MismatchCategory { ExtraTriggerInSimulator, TriggerFunctionMismatch, TriggerEnableModeMismatch, + TriggerDefinitionMismatch, + MissingExtendedStatisticsInSimulator, + ExtraExtendedStatisticsInSimulator, + ExtendedStatisticsDefinitionMismatch, MissingPartitionEdgeInSimulator, ExtraPartitionEdgeInSimulator, MissingViewDependencyInSimulator, @@ -504,6 +599,16 @@ fn live_postgres_differential_harness() { let sql = fs::read_to_string(&fixture_path).unwrap_or_else(|error| { panic!("failed to read {}: {error}", fixture_path.display()) }); + let live_statements = if rule.fixture_autocommit.contains(fixture) { + split_fixture_statements(&sql).unwrap_or_else(|error| { + panic!( + "invalid autocommit fixture {}: {error}", + fixture_path.display() + ) + }) + } else { + vec![sql.clone()] + }; verbose( verbosity, 1, @@ -630,7 +735,10 @@ fn live_postgres_differential_harness() { ); } - if let Err(error) = client.batch_execute(&sql) { + if let Err(error) = live_statements + .iter() + .try_for_each(|statement| client.batch_execute(statement)) + { // Recover the shared harness connection even when a fixture // owns its transaction boundaries and leaves one aborted. let _ = client.batch_execute("ROLLBACK"); @@ -758,7 +866,7 @@ fn live_postgres_differential_harness() { fixture: fixture.clone(), category: MismatchCategory::LiveExecutionFailed, root_cause: RootCauseClassification::EnvironmentIssue, - note: format!("failed to snapshot live PostgreSQL state: {error}"), + note: format!("failed to snapshot live PostgreSQL state: {error:#}"), }); continue; } @@ -862,7 +970,7 @@ fn verbose(verbosity: u8, level: u8, message: impl std::fmt::Display) { fn state_counts(state: &NormalizedState) -> String { format!( - "schemas:{} sequences:{} relations:{} indexes:{} constraints:{} foreign_keys:{} functions:{} types:{} privileges:{} policies:{} triggers:{} partitions:{} view_dependencies:{} publications:{} subscriptions:{}", + "schemas:{} sequences:{} relations:{} indexes:{} constraints:{} foreign_keys:{} functions:{} types:{} privileges:{} policies:{} triggers:{} partitions:{} view_dependencies:{} publications:{} subscriptions:{} extended_statistics:{}", state.schemas.len(), state.sequences.len(), state.relations.len(), @@ -877,7 +985,8 @@ fn state_counts(state: &NormalizedState) -> String { state.partition_edges.len(), state.view_dependencies.len(), state.publications.len(), - state.subscriptions.len() + state.subscriptions.len(), + state.extended_statistics.len() ) } @@ -889,6 +998,146 @@ fn repo_path(relative: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join(relative) } +#[test] +#[ignore = "requires a disposable local PostgreSQL database via DATABASE_URL"] +fn live_interrupted_partition_detach_finalize() { + let config: PostgresConfig = std::env::var("DATABASE_URL") + .expect("DATABASE_URL") + .parse() + .expect("database configuration"); + assert!(database_hosts_are_local(&config)); + let mut client = config.connect(NoTls).expect("local PostgreSQL"); + let database: String = client + .query_one("SELECT current_database()", &[]) + .unwrap() + .get(0); + assert_eq!( + database, + std::env::var(DATABASE_NAME_ENV).unwrap_or_else(|_| "safe_migrate".into()) + ); + let schema = format!("sm_detach_interrupt_{}", std::process::id()); + client + .batch_execute(&format!("CREATE SCHEMA {schema}")) + .unwrap(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + for (strategy, bound) in [("RANGE", "FROM (0) TO (10)"), ("LIST", "IN (1, 2, 3)")] { + client + .batch_execute(&format!( + "CREATE TABLE {schema}.parent(id integer) PARTITION BY {strategy}(id); + CREATE TABLE {schema}.child PARTITION OF {schema}.parent FOR VALUES {bound};" + )) + .unwrap(); + let mut blocker = config.connect(NoTls).unwrap(); + blocker + .batch_execute(&format!( + "BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT * FROM {schema}.parent;" + )) + .unwrap(); + let mut detacher = config.connect(NoTls).unwrap(); + detacher + .batch_execute("SET statement_timeout = '15s'") + .unwrap(); + let cancel = detacher.cancel_token(); + let sql = + format!("ALTER TABLE {schema}.parent DETACH PARTITION {schema}.child CONCURRENTLY"); + let worker = std::thread::spawn(move || detacher.batch_execute(&sql)); + let deadline = Instant::now() + Duration::from_secs(10); + let mut pending = false; + while Instant::now() < deadline { + pending = client.query_one("SELECT EXISTS (SELECT 1 FROM pg_inherits WHERE inhrelid = to_regclass($1) AND inhdetachpending)", &[&format!("{schema}.child")]).unwrap().get(0); + if pending { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + let cancellation = cancel.cancel_query(NoTls); + let detached = worker.join().expect("detach worker"); + blocker.batch_execute("ROLLBACK").unwrap(); + assert!(pending, "detach never committed its pending state"); + cancellation.expect("cancel blocked detach"); + assert_eq!( + detached.unwrap_err().code(), + Some(&postgres::error::SqlState::QUERY_CANCELED) + ); + + let scopes = vec![schema.clone()]; + let cache = populate_cache(&mut client, Some(&scopes)).expect("sync interrupted state"); + cache.validate_semantics().expect("valid interrupted cache"); + let child = safe_migrate::_internal::ast::identifiers::ObjectId::new(&schema, "child"); + let mut state = AnalysisState::with_baseline(cache, true); + assert!( + state + .local + .graph + .edges() + .iter() + .any(|edge| edge.dependent == child + && matches!(edge.kind, DependencyKind::PartitionDetachPending)) + ); + let checks_before: Vec<_> = state + .local + .constraints + .values() + .filter(|check| check.table_id == child && check.kind == ConstraintKind::Check) + .cloned() + .collect(); + assert_eq!(checks_before.len(), 1); + let finalize = + format!("ALTER TABLE {schema}.parent DETACH PARTITION {schema}.child FINALIZE;"); + let findings = SafeMigrateEngine::new(Config::default()) + .analyze(&finalize, &mut state) + .unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "{findings:?}" + ); + client.batch_execute(&finalize).expect("live FINALIZE"); + let live = AnalysisState::with_baseline( + populate_cache(&mut client, Some(&scopes)).unwrap(), + true, + ); + for snapshot in [&state, &live] { + assert!( + !snapshot + .local + .graph + .edges() + .iter() + .any(|edge| edge.dependent == child + && matches!( + edge.kind, + DependencyKind::PartitionOf + | DependencyKind::PartitionDetachPending + )) + ); + let checks: Vec<_> = snapshot + .local + .constraints + .values() + .filter(|check| check.table_id == child && check.kind == ConstraintKind::Check) + .cloned() + .collect(); + assert_eq!(checks, checks_before); + } + client + .batch_execute(&format!( + "DROP TABLE {schema}.child; DROP TABLE {schema}.parent;" + )) + .unwrap(); + } + })); + client + .batch_execute(&format!( + "SET lock_timeout = '3s'; DROP SCHEMA {schema} CASCADE;" + )) + .expect("cleanup interruption schema"); + if let Err(panic) = result { + std::panic::resume_unwind(panic); + } +} + #[test] fn differential_database_guard_accepts_only_local_hosts() { for url in [ @@ -910,6 +1159,35 @@ fn differential_manifest_accounts_for_every_sql_fixture() { load_manifest(&repo_path("live_tests/differential_manifest.json")); } +#[test] +fn constraint_definition_boolean_operands_are_paren_insensitive() { + assert_eq!( + canonical_constraint_expression("(id > 0) AND (id < 100)"), + "id > 0 AND id < 100" + ); + assert_eq!( + canonical_constraint_expression("id > 0 AND id < 100"), + "id > 0 AND id < 100" + ); + assert_eq!( + canonical_constraint_expression("((id > 0)) AND (id < 100)"), + "id > 0 AND id < 100" + ); + assert_eq!( + canonical_constraint_expression("(a = 1 OR b = 2) AND c = 3"), + "(a = 1 OR b = 2) AND c = 3" + ); + assert_eq!( + canonical_constraint_expression("land = 1 AND id > 0"), + "land = 1 AND id > 0" + ); + assert_eq!(canonical_constraint_expression("(id > 0)"), "id > 0"); + assert_eq!( + canonical_constraint_expression("status IN ('on', 'off') AND (id > 0)"), + "status IN ('on', 'off') AND id > 0" + ); +} + #[test] fn publication_option_normalization_ignores_catalog_order() { let first = vec![ @@ -1020,7 +1298,7 @@ fn state_counts_include_every_replication_family() { }, ); let counts = state_counts(&state); - assert!(counts.ends_with("publications:0 subscriptions:1")); + assert!(counts.ends_with("publications:0 subscriptions:1 extended_statistics:0")); } fn load_manifest(path: &Path) -> DifferentialManifest { @@ -1097,6 +1375,24 @@ fn validate_manifest(manifest: &DifferentialManifest, path: &Path) { fixture ); } + for fixture in &rule.fixture_autocommit { + assert!( + included.contains(fixture), + "autocommit references a non-included fixture: {}/{}", + rule.rule_dir, + fixture + ); + assert!( + !rule + .fixture_transactional + .get(fixture) + .copied() + .unwrap_or(rule.transactional), + "autocommit fixture cannot use the rollback wrapper: {}/{}", + rule.rule_dir, + fixture + ); + } for fixture in rule.fixture_min_pg_version.keys() { assert!( included.contains(fixture), @@ -1182,6 +1478,7 @@ fn expected_live_error_must_reference_an_included_fixture() { fixtures: Vec::new(), transactional: true, fixture_transactional: BTreeMap::new(), + fixture_autocommit: BTreeSet::new(), fixture_min_pg_version: BTreeMap::new(), excluded_fixtures: Vec::new(), schemas: Vec::new(), @@ -1210,6 +1507,7 @@ fn expected_live_error_must_reference_a_known_simulator_rule() { fixtures: vec!["case.sql".to_string()], transactional: true, fixture_transactional: BTreeMap::new(), + fixture_autocommit: BTreeSet::new(), fixture_min_pg_version: BTreeMap::new(), excluded_fixtures: Vec::new(), schemas: Vec::new(), @@ -1514,6 +1812,7 @@ fn snapshot_live_state( // fixture that changes search_path cannot turn the same type into two // different textual representations. let resolved_cache_state = AnalysisState::with_baseline(cache.clone(), true); + let known_function_ids = cache.functions.keys().cloned().collect::>(); if scope.contains(&ComparisonScope::Schemas) { for (name, schema) in &cache.schemas { @@ -1574,6 +1873,14 @@ fn snapshot_live_state( format!("{}.{}", qualified_name(&table.schema, &table.name), column) }), kind: sequence.kind.clone(), + data_type: normalize_data_type(&sequence.parameters.data_type), + start_value: sequence.parameters.start_value, + increment: sequence.parameters.increment, + min_value: sequence.parameters.min_value, + max_value: sequence.parameters.max_value, + cache_size: sequence.parameters.cache_size, + cycle: sequence.parameters.cycle, + persistence: normalize_sequence_persistence(sequence.parameters.persistence), }, ); } @@ -1625,11 +1932,19 @@ fn snapshot_live_state( if scope.contains(&ComparisonScope::Constraints) { for constraint in &cache.constraints { + if constraint.kind == ConstraintKind::NotNull { + continue; + } state.constraints.insert(NormalizedConstraint { table: qualified_name(&constraint.table_id.schema, &constraint.table_id.name), name: constraint.name.clone(), kind: normalize_constraint_kind(constraint.kind), validated: constraint.validated, + definition: normalize_constraint_definition(constraint.definition.as_deref()), + backing_index: constraint + .backing_index + .as_ref() + .map(|id| qualified_name(&id.schema, &id.name)), }); } } @@ -1647,6 +1962,11 @@ fn snapshot_live_state( &trigger.function_id.name, ), enabled_mode: normalize_trigger_mode(trigger.enabled_mode), + row_level: trigger.row_level, + parent_trigger: trigger + .parent_trigger_id + .as_ref() + .map(|id| qualified_name(&id.schema, &id.name)), }, ); } @@ -1658,6 +1978,40 @@ fn snapshot_live_state( kind: normalize_relation_kind(relation.kind.clone()), owner: relation.owner.name.clone(), partition_strategy: relation.partition_type.clone(), + partition_is_default: relation + .partition_bound + .as_deref() + .map(|bound| bound.trim().eq_ignore_ascii_case("DEFAULT")), + partition_bound: relation.partition_bound.clone(), + partition_constraint: normalize_partition_constraint( + relation.partition_constraint.as_deref(), + ), + persistence: normalize_relation_persistence(relation.persistence.clone()), + tablespace: relation.tablespace.clone(), + access_method: normalize_access_method( + &relation.kind, + relation.access_method.as_deref(), + ), + cluster_index: relation.cluster_index.clone(), + row_security: normalize_table_flag(&relation.kind, relation.row_security), + force_row_security: normalize_table_flag( + &relation.kind, + relation.force_row_security, + ), + replica_identity: normalize_replica_identity( + &relation.kind, + relation.replica_identity.as_deref(), + ), + table_options: relation.table_options.clone(), + of_type: relation + .of_type + .as_ref() + .map(|id| qualified_name(&id.schema, &id.name)), + rules: relation + .rules + .iter() + .map(|(name, mode)| (name.clone(), format!("{mode:?}"))) + .collect(), columns: BTreeMap::new(), }; if scope.contains(&ComparisonScope::Columns) { @@ -1671,19 +2025,36 @@ fn snapshot_live_state( .and_then(|resolved_column| resolved_column.type_id.as_ref()), RelationOverlay::Dropped => None, }); + let data_type = normalize_data_type_with_identity( + &column + .data_type + .clone() + .unwrap_or_else(|| "".to_string()), + type_id, + ); normalized.columns.insert( column.name.clone(), NormalizedColumn { - data_type: normalize_data_type_with_identity( - &column - .data_type - .clone() - .unwrap_or_else(|| "".to_string()), - type_id, - ), + data_type: data_type.clone(), is_nullable: column.is_nullable, - has_default: column.default.is_some() - || column.default_expr_text.is_some(), + has_default: !relation.identity_columns.contains_key(&column.name) + && (column.default.is_some() || column.default_expr_text.is_some()), + generated: column.generated, + generation: normalize_generated_column( + relation.generated_columns.get(&column.name), + &known_function_ids, + &cache.search_path, + ), + identity_generation: relation + .identity_columns + .get(&column.name) + .map(|generation| format!("{generation:?}")), + storage: column.storage.clone(), + compression: column.compression.clone(), + statistics_target: normalize_statistics_target( + column.statistics_target, + ), + options: column.options.clone(), }, ); } @@ -1699,10 +2070,40 @@ fn snapshot_live_state( state.indexes.insert(NormalizedIndex { index: qualified_name(&index.index_id.schema, &index.index_id.name), table: qualified_name(&index.table_id.schema, &index.table_id.name), + using_method: Some(index.using_method.to_ascii_lowercase()), + key_columns: index.key_columns, + included_columns: index.included_columns, + has_expression_keys: index.has_expression_keys, + has_predicate: index.has_predicate, + is_unique: index.is_unique, + is_immediate: index.is_immediate, + is_valid: index.is_valid, + is_ready: index.is_ready, + is_live: index.is_live, + has_default_sort_order: index.has_default_sort_order, + has_default_opclasses: index.has_default_opclasses, + has_default_collations: index.has_default_collations, }); } } + if scope.contains(&ComparisonScope::ExtendedStatistics) { + for (table_id, relation) in &cache.relations { + for (stats_id, statistics) in &relation.extended_statistics { + state.extended_statistics.insert( + qualified_name(&stats_id.schema, &stats_id.name), + NormalizedExtendedStatistics { + table: qualified_name(&table_id.schema, &table_id.name), + kinds: statistics.kinds.clone(), + columns: statistics.columns.clone(), + expressions: statistics.expressions.clone(), + target: normalize_statistics_target(statistics.target), + }, + ); + } + } + } + if scope.contains(&ComparisonScope::ForeignKeys) { for fk in cache.foreign_keys { state.foreign_keys.insert(NormalizedForeignKey { @@ -1721,13 +2122,17 @@ fn snapshot_live_state( pn.nspname AS parent_schema, pc.relname AS parent_name, cn.nspname AS child_schema, - cc.relname AS child_name + cc.relname AS child_name, + cc.relispartition AS is_partition, + i.inhdetachpending AS detach_pending FROM pg_inherits i JOIN pg_class pc ON pc.oid = i.inhparent JOIN pg_namespace pn ON pn.oid = pc.relnamespace JOIN pg_class cc ON cc.oid = i.inhrelid JOIN pg_namespace cn ON cn.oid = cc.relnamespace WHERE pn.nspname = ANY($1) AND cn.nspname = ANY($1) + AND pc.relkind IN ('r', 'p') + AND cc.relkind IN ('r', 'p') ", &[&schema_names], )? { @@ -1738,6 +2143,8 @@ fn snapshot_live_state( state.partition_edges.insert(( qualified_name(&parent_schema, &parent_name), qualified_name(&child_schema, &child_name), + row.get("is_partition"), + row.get("detach_pending"), )); } } @@ -1788,6 +2195,14 @@ fn snapshot_simulator_state( scope: &[ComparisonScope], ) -> NormalizedState { let mut projection = NormalizedState::default(); + let known_function_ids = state + .local + .functions + .iter() + .filter_map(|(id, overlay)| { + matches!(overlay, FunctionOverlay::Present(_)).then_some(id.clone()) + }) + .collect::>(); if scope.contains(&ComparisonScope::Schemas) { for (name, overlay) in &state.local.schemas { @@ -1877,6 +2292,14 @@ fn snapshot_simulator_state( format!("{}.{}", qualified_name(&table.schema, &table.name), column) }), kind: sequence.kind.clone(), + data_type: normalize_data_type(&sequence.parameters.data_type), + start_value: sequence.parameters.start_value, + increment: sequence.parameters.increment, + min_value: sequence.parameters.min_value, + max_value: sequence.parameters.max_value, + cache_size: sequence.parameters.cache_size, + cycle: sequence.parameters.cycle, + persistence: normalize_sequence_persistence(sequence.parameters.persistence), }, ); } @@ -1940,11 +2363,19 @@ fn snapshot_simulator_state( if scope.contains(&ComparisonScope::Constraints) { for constraint in state.local.constraints.values() { + if constraint.kind == ConstraintKind::NotNull { + continue; + } projection.constraints.insert(NormalizedConstraint { table: qualified_name(&constraint.table_id.schema, &constraint.table_id.name), name: constraint.name.clone(), kind: normalize_constraint_kind(constraint.kind), validated: constraint.validated, + definition: normalize_constraint_definition(constraint.definition.as_deref()), + backing_index: constraint + .backing_index + .as_ref() + .map(|id| qualified_name(&id.schema, &id.name)), }); } } @@ -1971,6 +2402,11 @@ fn snapshot_simulator_state( NormalizedTrigger { function: qualified_name(&function_id.schema, &function_id.name), enabled_mode: normalize_trigger_mode(trigger.enabled_mode), + row_level: trigger.row_level, + parent_trigger: trigger + .parent_trigger_id + .as_ref() + .map(|id| qualified_name(&id.schema, &id.name)), }, ); } @@ -1985,23 +2421,78 @@ fn snapshot_simulator_state( kind: normalize_relation_kind(relation.kind.clone()), owner: relation.owner.name.clone(), partition_strategy: relation.partition_type.clone(), + partition_is_default: relation + .partition_bound + .as_deref() + .map(|bound| bound.trim().eq_ignore_ascii_case("DEFAULT")), + partition_bound: relation.partition_bound.clone(), + partition_constraint: normalize_partition_constraint( + relation.partition_constraint.as_deref(), + ), + persistence: normalize_relation_persistence(relation.persistence.clone()), + tablespace: relation.tablespace.clone(), + access_method: normalize_access_method( + &relation.kind, + relation.access_method.as_deref(), + ), + cluster_index: relation.cluster_index.clone(), + row_security: normalize_table_flag(&relation.kind, relation.row_security), + force_row_security: normalize_table_flag( + &relation.kind, + relation.force_row_security, + ), + replica_identity: normalize_replica_identity( + &relation.kind, + relation.replica_identity.as_deref(), + ), + table_options: relation.table_options.clone(), + of_type: relation + .of_type + .as_ref() + .map(|id| qualified_name(&id.schema, &id.name)), + rules: relation + .rules + .iter() + .map(|(name, mode)| (name.clone(), format!("{mode:?}"))) + .collect(), columns: BTreeMap::new(), }; if scope.contains(&ComparisonScope::Columns) { for column in &relation.columns { + let data_type = normalize_data_type_with_identity( + &column + .data_type + .clone() + .unwrap_or_else(|| "".to_string()), + column.type_id.as_ref(), + ); normalized.columns.insert( column.name.clone(), NormalizedColumn { - data_type: normalize_data_type_with_identity( - &column - .data_type - .clone() - .unwrap_or_else(|| "".to_string()), - column.type_id.as_ref(), - ), + data_type: data_type.clone(), is_nullable: column.is_nullable, - has_default: column.default.is_some() - || column.default_expr_text.is_some(), + has_default: !relation.identity_columns.contains_key(&column.name) + && (column.default.is_some() || column.default_expr_text.is_some()), + generated: column.generated, + generation: normalize_generated_column( + relation.generated_columns.get(&column.name), + &known_function_ids, + state.search_path(), + ), + identity_generation: relation + .identity_columns + .get(&column.name) + .map(|generation| format!("{generation:?}")), + storage: normalize_column_storage( + state, + &data_type, + column.storage.as_deref(), + ), + compression: column.compression.clone(), + statistics_target: normalize_statistics_target( + column.statistics_target, + ), + options: column.options.clone(), }, ); } @@ -2014,10 +2505,40 @@ fn snapshot_simulator_state( for edge in state.local.graph.edges() { match &edge.kind { - DependencyKind::IndexOnRelation { .. } if scope.contains(&ComparisonScope::Indexes) => { + DependencyKind::IndexOnRelation { + using_method, + key_columns, + included_columns, + has_expression_keys, + has_predicate, + is_unique, + is_immediate, + is_valid, + is_ready, + is_live, + has_default_sort_order, + has_default_opclasses, + has_default_collations, + .. + } if scope.contains(&ComparisonScope::Indexes) => { projection.indexes.insert(NormalizedIndex { index: qualified_name(&edge.dependent.schema, &edge.dependent.name), table: qualified_name(&edge.referenced.schema, &edge.referenced.name), + using_method: using_method + .as_ref() + .map(|method| method.to_ascii_lowercase()), + key_columns: key_columns.clone(), + included_columns: included_columns.clone(), + has_expression_keys: *has_expression_keys, + has_predicate: *has_predicate, + is_unique: *is_unique, + is_immediate: *is_immediate, + is_valid: *is_valid, + is_ready: *is_ready, + is_live: *is_live, + has_default_sort_order: *has_default_sort_order, + has_default_opclasses: *has_default_opclasses, + has_default_collations: *has_default_collations, }); } DependencyKind::ForeignKey { @@ -2031,10 +2552,16 @@ fn snapshot_simulator_state( .unwrap_or_else(|| "".to_string()), }); } - DependencyKind::PartitionOf if scope.contains(&ComparisonScope::Partitions) => { + DependencyKind::PartitionOf + | DependencyKind::PartitionDetachPending + | DependencyKind::InheritanceOf + if scope.contains(&ComparisonScope::Partitions) => + { projection.partition_edges.insert(( qualified_name(&edge.referenced.schema, &edge.referenced.name), qualified_name(&edge.dependent.schema, &edge.dependent.name), + !matches!(edge.kind, DependencyKind::InheritanceOf), + matches!(edge.kind, DependencyKind::PartitionDetachPending), )); } DependencyKind::ViewDependency { .. } @@ -2049,9 +2576,74 @@ fn snapshot_simulator_state( } } + if scope.contains(&ComparisonScope::ExtendedStatistics) { + for (table_id, overlay) in &state.local.relations { + let RelationOverlay::Present(relation) = overlay else { + continue; + }; + for (stats_id, statistics) in &relation.extended_statistics { + projection.extended_statistics.insert( + qualified_name(&stats_id.schema, &stats_id.name), + NormalizedExtendedStatistics { + table: qualified_name(&table_id.schema, &table_id.name), + kinds: statistics.kinds.clone(), + columns: statistics.columns.clone(), + expressions: statistics.expressions.clone(), + target: normalize_statistics_target(statistics.target), + }, + ); + } + } + } + projection } +#[test] +fn relation_comparison_detects_partition_metadata_differences() { + let relation = NormalizedRelation { + kind: NormalizedRelationKind::Table, + owner: "owner".into(), + partition_strategy: None, + partition_is_default: Some(false), + partition_bound: Some("FOR VALUES FROM (0) TO (10)".into()), + partition_constraint: Some("(id IS NOT NULL) AND (id >= 0) AND (id < 10)".into()), + persistence: "permanent".into(), + tablespace: None, + access_method: None, + cluster_index: None, + row_security: None, + force_row_security: None, + replica_identity: None, + table_options: BTreeMap::new(), + of_type: None, + rules: BTreeMap::new(), + columns: BTreeMap::new(), + }; + let mut live = NormalizedState::default(); + live.relations + .insert("public.child".into(), relation.clone()); + for field in 0..3 { + let mut changed = relation.clone(); + match field { + 0 => changed.partition_bound = Some("FOR VALUES FROM (0) TO (20)".into()), + 1 => changed.partition_constraint = None, + _ => changed.partition_is_default = Some(true), + } + let mut simulator = NormalizedState::default(); + simulator.relations.insert("public.child".into(), changed); + for scope in [ComparisonScope::Relations, ComparisonScope::Partitions] { + let mismatches = compare_states("regression", "partition", &[scope], &live, &simulator); + assert!( + mismatches + .iter() + .any(|mismatch| mismatch.category + == MismatchCategory::RelationDefinitionMismatch) + ); + } + } +} + fn compare_states( rule_dir: &str, fixture: &str, @@ -2246,6 +2838,29 @@ fn compare_states( } } + if scope.contains(&ComparisonScope::Relations) || scope.contains(&ComparisonScope::Partitions) { + for (name, live_relation) in &live.relations { + let Some(sim_relation) = simulator.relations.get(name) else { + continue; + }; + if sim_relation.partition_is_default != live_relation.partition_is_default + || sim_relation.partition_bound != live_relation.partition_bound + || normalize_constraint_definition(sim_relation.partition_constraint.as_deref()) + != normalize_constraint_definition( + live_relation.partition_constraint.as_deref(), + ) + { + mismatches.push(Mismatch { + rule_dir: rule_dir.to_string(), + fixture: fixture.to_string(), + category: MismatchCategory::RelationDefinitionMismatch, + root_cause: RootCauseClassification::SimulatorBug, + note: format!("partition metadata mismatch for {name}: live={live_relation:?}, simulator={sim_relation:?}"), + }); + } + } + } + if scope.contains(&ComparisonScope::Relations) { for (name, live_relation) in &live.relations { match simulator.relations.get(name) { @@ -2294,6 +2909,28 @@ fn compare_states( ), }); } + Some(sim_relation) + if sim_relation.persistence != live_relation.persistence + || sim_relation.tablespace != live_relation.tablespace + || sim_relation.access_method != live_relation.access_method + || sim_relation.cluster_index != live_relation.cluster_index + || sim_relation.row_security != live_relation.row_security + || sim_relation.force_row_security != live_relation.force_row_security + || sim_relation.replica_identity != live_relation.replica_identity + || sim_relation.table_options != live_relation.table_options + || sim_relation.of_type != live_relation.of_type + || sim_relation.rules != live_relation.rules => + { + mismatches.push(Mismatch { + rule_dir: rule_dir.to_string(), + fixture: fixture.to_string(), + category: MismatchCategory::RelationDefinitionMismatch, + root_cause: RootCauseClassification::SimulatorBug, + note: format!( + "relation metadata mismatch for {name}: live={live_relation:?}, simulator={sim_relation:?}" + ), + }); + } Some(sim_relation) if scope.contains(&ComparisonScope::Columns) && sim_relation.columns != live_relation.columns => @@ -2333,10 +2970,7 @@ fn compare_states( fixture: fixture.to_string(), category: MismatchCategory::MissingIndexInSimulator, root_cause: RootCauseClassification::SimulatorBug, - note: format!( - "live PostgreSQL kept index {} on {}", - index.index, index.table - ), + note: format!("live PostgreSQL kept index {index:?}"), }); } for index in simulator.indexes.difference(&live.indexes) { @@ -2345,7 +2979,7 @@ fn compare_states( fixture: fixture.to_string(), category: MismatchCategory::ExtraIndexInSimulator, root_cause: RootCauseClassification::SimulatorBug, - note: format!("simulator kept index {} on {}", index.index, index.table), + note: format!("simulator kept index {index:?}"), }); } } @@ -2384,10 +3018,7 @@ fn compare_states( fixture: fixture.to_string(), category: MismatchCategory::MissingConstraintInSimulator, root_cause: RootCauseClassification::SimulatorBug, - note: format!( - "live PostgreSQL kept {:?} constraint {} on {} (validated={})", - constraint.kind, constraint.name, constraint.table, constraint.validated - ), + note: format!("live PostgreSQL kept constraint {constraint:?}"), }); } for constraint in simulator.constraints.difference(&live.constraints) { @@ -2396,10 +3027,7 @@ fn compare_states( fixture: fixture.to_string(), category: MismatchCategory::ExtraConstraintInSimulator, root_cause: RootCauseClassification::SimulatorBug, - note: format!( - "simulator kept {:?} constraint {} on {} (validated={})", - constraint.kind, constraint.name, constraint.table, constraint.validated - ), + note: format!("simulator kept constraint {constraint:?}"), }); } } @@ -2571,6 +3199,21 @@ fn compare_states( ), }); } + Some(simulator_trigger) + if simulator_trigger.row_level != live_trigger.row_level + || simulator_trigger.parent_trigger != live_trigger.parent_trigger => + { + mismatches.push(Mismatch { + rule_dir: rule_dir.to_string(), + fixture: fixture.to_string(), + category: MismatchCategory::TriggerDefinitionMismatch, + root_cause: RootCauseClassification::SimulatorBug, + note: format!( + "trigger {} on {} definition mismatch: live={live_trigger:?}, simulator={simulator_trigger:?}", + key.1, key.0 + ), + }); + } Some(_) => {} } } @@ -2590,6 +3233,48 @@ fn compare_states( } } + if scope.contains(&ComparisonScope::ExtendedStatistics) { + for (name, live_statistics) in &live.extended_statistics { + match simulator.extended_statistics.get(name) { + None => mismatches.push(Mismatch { + rule_dir: rule_dir.to_string(), + fixture: fixture.to_string(), + category: MismatchCategory::MissingExtendedStatisticsInSimulator, + root_cause: RootCauseClassification::SimulatorBug, + note: format!( + "live PostgreSQL kept extended statistics {name} on {}", + live_statistics.table + ), + }), + Some(simulator_statistics) if simulator_statistics != live_statistics => { + mismatches.push(Mismatch { + rule_dir: rule_dir.to_string(), + fixture: fixture.to_string(), + category: MismatchCategory::ExtendedStatisticsDefinitionMismatch, + root_cause: RootCauseClassification::SimulatorBug, + note: format!( + "extended statistics {name} mismatch: live={live_statistics:?}, simulator={simulator_statistics:?}" + ), + }); + } + Some(_) => {} + } + } + for name in simulator.extended_statistics.keys() { + if !live.extended_statistics.contains_key(name) { + mismatches.push(Mismatch { + rule_dir: rule_dir.to_string(), + fixture: fixture.to_string(), + category: MismatchCategory::ExtraExtendedStatisticsInSimulator, + root_cause: RootCauseClassification::SimulatorBug, + note: format!( + "simulator kept extended statistics {name}, but live PostgreSQL removed it" + ), + }); + } + } + } + if scope.contains(&ComparisonScope::Partitions) { for edge in live.partition_edges.difference(&simulator.partition_edges) { mismatches.push(Mismatch { @@ -2655,6 +3340,222 @@ fn normalize_relation_kind(kind: RelationKind) -> NormalizedRelationKind { } } +fn normalize_access_method(kind: &RelationKind, method: Option<&str>) -> Option { + match kind { + RelationKind::Table | RelationKind::MaterializedView => { + Some(method.unwrap_or("heap").to_ascii_lowercase()) + } + RelationKind::View => None, + } +} + +fn normalize_table_flag(kind: &RelationKind, value: Option) -> Option { + matches!(kind, RelationKind::Table).then_some(value.unwrap_or(false)) +} + +fn normalize_replica_identity(kind: &RelationKind, value: Option<&str>) -> Option { + matches!(kind, RelationKind::Table).then(|| value.unwrap_or("DEFAULT").to_string()) +} + +#[test] +fn generated_expression_comparison_preserves_operators_and_literals() { + use safe_migrate::_internal::model::relation::{GeneratedColumnKind, GeneratedColumnState}; + let normalize = |expression: &str| { + normalize_generated_column( + Some(&GeneratedColumnState { + kind: GeneratedColumnKind::Stored, + expression: Some(expression.into()), + }), + &[], + &[], + ) + }; + assert_eq!(normalize("((value * 2))"), normalize("value*2")); + assert_ne!(normalize("-value"), normalize("value")); + assert_ne!(normalize("value * 2"), normalize("value * 3")); + assert_ne!(normalize("'a b'"), normalize("'ab'")); +} + +#[test] +fn generated_expression_comparison_resolves_only_unambiguous_known_functions() { + use safe_migrate::_internal::model::relation::{GeneratedColumnKind, GeneratedColumnState}; + let generated = |expression: &str| GeneratedColumnState { + kind: GeneratedColumnKind::Stored, + expression: Some(expression.into()), + }; + let one_overload = vec![ObjectId::new("app", "visible(integer)")]; + assert_eq!( + normalize_generated_column( + Some(&generated("app.visible(value)")), + &one_overload, + &["app".into()] + ), + normalize_generated_column( + Some(&generated("visible(value)")), + &one_overload, + &["app".into()] + ), + ); + + let overloaded = vec![ + ObjectId::new("app", "visible(integer)"), + ObjectId::new("app", "visible(text)"), + ]; + assert_ne!( + normalize_generated_column( + Some(&generated("app.visible(value)")), + &overloaded, + &["app".into()] + ), + normalize_generated_column( + Some(&generated("visible(value)")), + &overloaded, + &["app".into()] + ), + ); +} + +fn normalize_generated_column( + generated: Option<&safe_migrate::_internal::model::relation::GeneratedColumnState>, + known_function_ids: &[ObjectId], + search_path: &[String], +) -> Option { + let generated = generated?; + let Some(expression) = &generated.expression else { + return Some(format!("{:?}:", generated.kind)); + }; + let expression = normalize_constraint_definition(Some(expression)).unwrap(); + let parsed = ast::SourceFile::parse(&format!("SELECT {expression}")); + let callee_identities = parsed + .tree() + .syntax() + .descendants() + .filter_map(ast::CallExpr::cast) + .filter_map(|call| { + let callee = call.expr()?; + canonical_generated_function_identity(&callee, known_function_ids, search_path) + .map(|identity| (callee.syntax().text_range(), identity)) + }) + .collect::>(); + let normalized = parsed + .tree() + .syntax() + .descendants_with_tokens() + .filter_map(|element| element.into_token()) + .filter(|token| !token.kind().is_trivia()) + .filter_map(|token| { + if let Some((callee_range, identity)) = callee_identities + .iter() + .find(|(callee_range, _)| callee_range.contains_range(token.text_range())) + { + return (token.text_range().start() == callee_range.start()) + .then(|| format!("function:{identity}")); + } + token + .parent() + .and_then(ast::NameRef::cast) + .map(|name| format!("name:{}", name.text())) + .or_else(|| Some(token.text().to_string())) + }) + .collect::>(); + Some(format!("{:?}:{normalized:?}", generated.kind)) +} + +fn canonical_generated_function_identity( + callee: &ast::Expr, + known_function_ids: &[ObjectId], + search_path: &[String], +) -> Option { + let id = match callee { + ast::Expr::NameRef(name) => { + let name = name.text(); + search_path + .iter() + .find_map(|schema| known_generated_function(schema, &name, known_function_ids))? + } + ast::Expr::FieldExpr(field) => { + let ast::Expr::NameRef(schema) = field.base()? else { + return None; + }; + let name = field.field()?; + let schema = schema.text(); + let name = name.text(); + known_generated_function(&schema, &name, known_function_ids)? + } + _ => return None, + }; + Some(qualified_name(&id.schema, &id.name)) +} + +fn known_generated_function( + schema: &str, + name: &str, + known_function_ids: &[ObjectId], +) -> Option { + let prefix = format!("{name}("); + let mut candidates = known_function_ids + .iter() + .filter(|id| id.schema == schema && id.name.starts_with(&prefix)); + let candidate = candidates.next()?.clone(); + candidates.next().is_none().then_some(candidate) +} + +fn normalize_column_storage( + state: &AnalysisState, + data_type: &str, + storage: Option<&str>, +) -> Option { + if let Some(storage) = storage { + return Some(storage.to_ascii_uppercase()); + } + let mut data_type = data_type.to_string(); + let mut visited = BTreeSet::new(); + loop { + if data_type.ends_with("[]") { + return Some("EXTENDED".into()); + } + if !visited.insert(data_type.clone()) { + return None; + } + let Some((schema, name)) = data_type.split_once('.') else { + break; + }; + let id = safe_migrate::_internal::ast::identifiers::ObjectId::new(schema, name); + match state.local.types.get(&id) { + Some(TypeOverlay::Present(ty)) => match &ty.kind { + TypeKind::Domain { + base_type, + base_type_id, + } => { + data_type = normalize_data_type_with_identity(base_type, base_type_id.as_ref()); + continue; + } + TypeKind::Composite { .. } | TypeKind::Range => return Some("EXTENDED".into()), + TypeKind::Enum { .. } => return Some("PLAIN".into()), + TypeKind::Base => return None, + }, + _ if matches!( + state.local.relations.get(&id), + Some(RelationOverlay::Present(_)) + ) => + { + return Some("EXTENDED".into()); + } + _ => break, + } + } + let base = data_type.split('(').next()?.trim().to_ascii_lowercase(); + let inferred = match base.as_str() { + "text" | "bytea" | "json" | "jsonb" | "xml" | "character varying" | "varchar" + | "character" | "char" | "bpchar" | "bit" | "bit varying" | "varbit" | "jsonpath" + | "path" | "polygon" | "tsvector" | "refcursor" => "EXTENDED", + "numeric" | "decimal" | "inet" | "cidr" => "MAIN", + "" => return None, + _ => "PLAIN", + }; + Some(inferred.into()) +} + fn normalize_attributes( attributes: &[safe_migrate::_internal::analysis::facts::AttributeFact], ) -> String { @@ -2727,6 +3628,30 @@ fn normalize_trigger_mode( .to_string() } +fn normalize_sequence_persistence( + persistence: safe_migrate::_internal::model::sequence::SequencePersistence, +) -> String { + use safe_migrate::_internal::model::sequence::SequencePersistence; + match persistence { + SequencePersistence::Permanent => "permanent", + SequencePersistence::Temporary => "temporary", + SequencePersistence::Unlogged => "unlogged", + } + .to_string() +} + +fn normalize_relation_persistence( + persistence: safe_migrate::_internal::model::relation::Persistence, +) -> String { + use safe_migrate::_internal::model::relation::Persistence; + match persistence { + Persistence::Permanent => "permanent", + Persistence::Temporary => "temporary", + Persistence::Unlogged => "unlogged", + } + .to_string() +} + fn normalize_volatility(volatility: &Volatility) -> String { match volatility { Volatility::Volatile => "volatile", @@ -2745,7 +3670,7 @@ fn normalize_type_kind(kind: &TypeKind) -> NormalizedType { base_type: normalize_data_type(base_type), }, TypeKind::Base => NormalizedType::Base, - TypeKind::Composite => NormalizedType::Composite, + TypeKind::Composite { .. } => NormalizedType::Composite, TypeKind::Range => NormalizedType::Range, } } @@ -2777,6 +3702,177 @@ fn normalize_constraint_kind(kind: ConstraintKind) -> String { .to_string() } +fn normalize_constraint_definition(definition: Option<&str>) -> Option { + definition.map(canonical_constraint_expression) +} + +/// PostgreSQL's `pg_get_expr(conbin)` deparse wraps every operand of a +/// top-level `AND`/`OR` in parentheses regardless of how the author spelled +/// the constraint. Canonicalize both sides to a parenthesis-insensitive +/// boolean form: a parenthesized operand that is not itself a boolean +/// combination loses its parens; a parenthesized `AND`/`OR` group keeps them. +fn canonical_constraint_expression(expression: &str) -> String { + let trimmed = expression.trim(); + if trimmed.is_empty() { + return String::new(); + } + let mut current = trimmed; + while current.starts_with('(') + && current.ends_with(')') + && outer_parentheses_wrap_expression(current) + { + current = current[1..current.len() - 1].trim(); + } + let separators = top_level_boolean_separators(current); + if separators.is_empty() { + return current.to_string(); + } + let mut parts = Vec::new(); + let mut cursor = 0usize; + for separator in &separators { + parts.push(¤t[cursor..separator.start]); + cursor = separator.end; + } + parts.push(¤t[cursor..]); + let mut canonical = String::new(); + for (index, part) in parts.iter().enumerate() { + if index > 0 { + canonical.push(' '); + canonical.push_str(separators[index - 1].word); + canonical.push(' '); + } + canonical.push_str(&canonical_boolean_part(part)); + } + canonical +} + +fn canonical_boolean_part(part: &str) -> String { + let trimmed = part.trim(); + if trimmed.starts_with('(') + && trimmed.ends_with(')') + && outer_parentheses_wrap_expression(trimmed) + { + let inner = &trimmed[1..trimmed.len() - 1]; + if top_level_boolean_separators(inner.trim()).is_empty() { + canonical_constraint_expression(inner) + } else { + format!("({})", canonical_constraint_expression(inner)) + } + } else { + canonical_constraint_expression(trimmed) + } +} + +struct BooleanSeparator { + start: usize, + end: usize, + word: &'static str, +} + +/// Byte offsets of top-level `AND`/`OR` keywords (whitespace-delimited) in a +/// SQL expression, ignoring quoted strings. `'a'`/`'o'` inside identifiers +/// such as `land` are rejected by requiring surrounding whitespace. +fn top_level_boolean_separators(expression: &str) -> Vec { + let mut separators = Vec::new(); + let bytes = expression.as_bytes(); + let mut depth = 0usize; + let mut single_quoted = false; + let mut double_quoted = false; + let mut index = 0usize; + while index < bytes.len() { + let ch = bytes[index]; + if single_quoted { + if ch == b'\'' { + single_quoted = false; + } + index += 1; + continue; + } + if double_quoted { + if ch == b'"' { + double_quoted = false; + } + index += 1; + continue; + } + match ch { + b'\'' => single_quoted = true, + b'"' => double_quoted = true, + b'(' => depth += 1, + b')' => depth = depth.saturating_sub(1), + _ => {} + } + let len = + if depth == 0 && (ch.eq_ignore_ascii_case(&b'a') || ch.eq_ignore_ascii_case(&b'o')) { + bytes + .get(index..index + 3) + .is_some_and(|word| word.eq_ignore_ascii_case(b"and")) + .then_some(3) + .or_else(|| { + bytes + .get(index..index + 2) + .is_some_and(|word| word.eq_ignore_ascii_case(b"or")) + .then_some(2) + }) + } else { + None + }; + if let Some(len) = len { + let before = index == 0 || bytes[index - 1].is_ascii_whitespace(); + let after = index + len; + let after_whitespace = after >= bytes.len() || bytes[after].is_ascii_whitespace(); + if before && after_whitespace { + let word = if len == 3 { "AND" } else { "OR" }; + separators.push(BooleanSeparator { + start: index, + end: after, + word, + }); + index = after; + continue; + } + } + index += 1; + } + separators +} + +fn outer_parentheses_wrap_expression(expression: &str) -> bool { + let mut depth = 0usize; + let mut single_quoted = false; + let mut double_quoted = false; + let bytes = expression.as_bytes(); + let mut index = 0usize; + while index < bytes.len() { + match bytes[index] { + b'\'' if !double_quoted => { + if single_quoted && bytes.get(index + 1) == Some(&b'\'') { + index += 1; + } else { + single_quoted = !single_quoted; + } + } + b'"' if !single_quoted => { + if double_quoted && bytes.get(index + 1) == Some(&b'"') { + index += 1; + } else { + double_quoted = !double_quoted; + } + } + b'(' if !single_quoted && !double_quoted => depth += 1, + b')' if !single_quoted && !double_quoted => { + depth = depth.saturating_sub(1); + if depth == 0 && index + 1 != bytes.len() { + return false; + } + } + _ => {} + } + index += 1; + } + depth == 0 && !single_quoted && !double_quoted +} + fn normalize_data_type(data_type: &str) -> String { let normalized = data_type.trim().to_ascii_lowercase(); match normalized.as_str() { diff --git a/tests/performance_scenarios.rs b/tests/performance_scenarios.rs index cd1d10c7..de7d788a 100644 --- a/tests/performance_scenarios.rs +++ b/tests/performance_scenarios.rs @@ -1,5 +1,3 @@ -mod common; - use std::alloc::{GlobalAlloc, Layout, System}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -52,6 +50,7 @@ mod performance_scenarios { CACHE_KEY_ENV, protect_cache_bytes, unprotect_cache_bytes, }; use safe_migrate::_internal::model::relation::{Persistence, RelationKind, RelationState}; + use safe_migrate::_internal::test_support::EnvironmentValueGuard; use std::io::Cursor; use std::sync::atomic::Ordering; use std::time::Instant; @@ -106,7 +105,8 @@ mod performance_scenarios { #[test] #[ignore = "manual allocation scenario; run alone with --ignored --nocapture"] fn large_state_checkpoint_and_prestate_capture() { - let state = safe_migrate::api::AnalysisState::with_baseline(large_baseline(), true); + let state = + crate::_internal::analysis::state::AnalysisState::with_baseline(large_baseline(), true); let started = Instant::now(); let before = allocation_snapshot(); @@ -140,7 +140,8 @@ mod performance_scenarios { #[ignore = "manual allocation scenario; run alone with --ignored --nocapture"] fn large_baseline_short_chain_allocations() { let engine = setup_engine(); - let mut state = safe_migrate::api::AnalysisState::with_baseline(large_baseline(), true); + let mut state = + crate::_internal::analysis::state::AnalysisState::with_baseline(large_baseline(), true); let files = (0..50) .map(|index| { ( @@ -213,7 +214,8 @@ mod performance_scenarios { #[ignore = "manual performance scenario; run with --ignored --nocapture"] fn large_synchronized_baseline_hydration() { let started = Instant::now(); - let state = safe_migrate::api::AnalysisState::with_baseline(large_baseline(), true); + let state = + crate::_internal::analysis::state::AnalysisState::with_baseline(large_baseline(), true); let elapsed = started.elapsed(); assert!(state.baseline_available); @@ -232,22 +234,17 @@ mod performance_scenarios { let cache = large_baseline(); let started = Instant::now(); let config = bincode::config::standard().with_variable_int_encoding(); - let payload = bincode::serde::encode_to_vec(DbCacheVersioned::V7(Box::new(cache)), config) + let payload = bincode::serde::encode_to_vec(DbCacheVersioned::V8(Box::new(cache)), config) .expect("cache should encode"); let compressed = zstd::stream::encode_all(Cursor::new(payload), 3) .expect("cache payload should compress"); - unsafe { - std::env::set_var( - CACHE_KEY_ENV, - "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", - ); - } + let _cache_key = EnvironmentValueGuard::set( + CACHE_KEY_ENV, + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + ); let encrypted = protect_cache_bytes(compressed, true).expect("cache should encrypt"); let compressed = unprotect_cache_bytes(encrypted.clone(), true).expect("cache should decrypt"); - unsafe { - std::env::remove_var(CACHE_KEY_ENV); - } let payload = zstd::stream::decode_all(Cursor::new(compressed)) .expect("cache payload should decompress"); let decoded: DbCacheVersioned = bincode::serde::decode_from_slice(&payload, config) @@ -382,12 +379,14 @@ mod performance_scenarios { let findings = engine .analyze_with_locations("performance.sql".to_string(), sql, &mut state) .expect("report scenario should analyze"); - let json = safe_migrate::api::Reporter::json_report_with_locations( + let json = crate::_internal::report::reporter::Reporter::json_report_with_locations( + &findings, + &state.local.confidence, + ); + let markdown = crate::_internal::report::reporter::Reporter::markdown_report( &findings, &state.local.confidence, ); - let markdown = - safe_migrate::api::Reporter::markdown_report(&findings, &state.local.confidence); let elapsed = started.elapsed(); assert_eq!(findings.len(), REPORT_FINDINGS); diff --git a/tests/resolver_namespaces.rs b/tests/resolver_namespaces.rs index 47228173..67468973 100644 --- a/tests/resolver_namespaces.rs +++ b/tests/resolver_namespaces.rs @@ -1,8 +1,6 @@ -mod common; - #[cfg(test)] mod resolver_namespace_tests { - use super::common::{object_id, setup_engine, setup_state}; + use crate::common::{object_id, setup_engine, setup_state}; use safe_migrate::_internal::model::function::FunctionOverlay; use safe_migrate::_internal::model::relation::RelationOverlay; diff --git a/tests/reversibility.rs b/tests/reversibility.rs index 30321fe7..a195c5ce 100644 --- a/tests/reversibility.rs +++ b/tests/reversibility.rs @@ -1,5 +1,3 @@ -mod common; - mod reversibility_tests { use crate::common::*; use safe_migrate::_internal::analysis::state::AnalysisState; diff --git a/tests/rule_evaluation.rs b/tests/rule_evaluation.rs index 89f1e80f..2719cc92 100644 --- a/tests/rule_evaluation.rs +++ b/tests/rule_evaluation.rs @@ -1,10 +1,7 @@ -mod common; - mod rule_evaluation_tests { use crate::common::*; use safe_migrate::_internal::analysis::state::{AnalysisState, Confidence}; use safe_migrate::_internal::ast::identifiers::ObjectId; - use safe_migrate::_internal::engine::config::{Config, RuleConfig}; use safe_migrate::_internal::engine::engine::SafeMigrateEngine; use safe_migrate::_internal::model::column::Column; use safe_migrate::_internal::model::function::{ @@ -12,6 +9,7 @@ mod rule_evaluation_tests { }; use safe_migrate::_internal::model::relation::{Persistence, RelationKind, RelationState}; use safe_migrate::_internal::report::violations::ViolationTier; + use safe_migrate::api::{Config, RuleConfig}; #[test] fn test_rule_idempotency() { @@ -78,6 +76,11 @@ mod rule_evaluation_tests { avg_width: Some(3000), default_expr_text: None, type_modifier: None, + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, }); cache.insert_baseline(tid, rel); @@ -402,6 +405,11 @@ mod rule_evaluation_tests { avg_width: Some(4), default_expr_text: None, type_modifier: None, + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, }); cache.insert_baseline(table_id, relation); let mut state = AnalysisState::new(cache); diff --git a/tests/state_machine_guards.rs b/tests/state_machine_guards.rs index 0c6ab1d1..d6bb3e49 100644 --- a/tests/state_machine_guards.rs +++ b/tests/state_machine_guards.rs @@ -1,5 +1,3 @@ -mod common; - mod state_machine_guards_tests { use crate::common::*; use safe_migrate::_internal::analysis::state::AnalysisState; diff --git a/tests/state_mutation.rs b/tests/state_mutation.rs index 06874642..66657033 100644 --- a/tests/state_mutation.rs +++ b/tests/state_mutation.rs @@ -1,7 +1,6 @@ -mod common; - mod state_mutation_tests { use crate::common::*; + use safe_migrate::_internal::analysis::evidence::EvidenceCode; use safe_migrate::_internal::analysis::facts::FunctionSigFact; use safe_migrate::_internal::analysis::graph::{ DependencyEdge, DependencyGraph, DependencyKind, @@ -12,7 +11,8 @@ mod state_mutation_tests { use safe_migrate::_internal::analysis::state::{Confidence, MutationResult}; use safe_migrate::_internal::ast::identifiers::{Ident, ObjectId, QualifiedName}; use safe_migrate::_internal::db::cache::{ - ConstraintDependencyCache, DbCache, GeneratedColumnDependencyCache, ViewDependencyCache, + CatalogFamily, ConstraintDependencyCache, DbCache, GeneratedColumnDependencyCache, + ViewDependencyCache, }; use safe_migrate::_internal::model::column::Column; use safe_migrate::_internal::model::constraint::ConstraintKind; @@ -28,10 +28,393 @@ mod state_mutation_tests { use safe_migrate::_internal::model::types::{TypeKind, TypeOverlay, TypeState}; #[test] - fn test_topology_table_basic() { + fn key_index_renames_preserve_constraint_and_table_metadata() { + let engine = setup_engine(); + for rename in [ + "ALTER TABLE rename_keys RENAME CONSTRAINT original_key TO renamed_key;", + "ALTER INDEX original_key RENAME TO renamed_key;", + ] { + let mut state = setup_state(); + engine.analyze("CREATE TABLE rename_keys(id integer NOT NULL); ALTER TABLE rename_keys ADD CONSTRAINT original_key UNIQUE(id); ALTER TABLE rename_keys CLUSTER ON original_key; ALTER TABLE rename_keys REPLICA IDENTITY USING INDEX original_key;", &mut state).unwrap(); + let before_constraints = state.local.constraints.clone(); + let before_relations = state.local.relations.clone(); + let before_edges = state.local.graph.edges().to_vec(); + let findings = engine + .analyze(&format!("BEGIN; {rename}"), &mut state) + .unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "{findings:?}" + ); + let table = object_id("public", "rename_keys"); + assert!( + !state + .local + .constraints + .contains_key(&(table.clone(), "original_key".into())) + ); + assert_eq!( + state.local.constraints[&(table.clone(), "renamed_key".into())].backing_index, + Some(object_id("public", "renamed_key")) + ); + let RelationOverlay::Present(relation) = &state.local.relations[&table] else { + panic!("missing table") + }; + assert_eq!(relation.cluster_index.as_deref(), Some("renamed_key")); + assert_eq!( + relation.replica_identity.as_deref(), + Some("USING INDEX renamed_key") + ); + engine.analyze("ROLLBACK;", &mut state).unwrap(); + assert_eq!(state.local.constraints, before_constraints); + assert_eq!(state.local.relations, before_relations); + assert_eq!(state.local.graph.edges(), before_edges.as_slice()); + engine.analyze(rename, &mut state).unwrap(); + let findings = engine + .analyze( + "ALTER TABLE rename_keys DROP CONSTRAINT renamed_key;", + &mut state, + ) + .unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "{findings:?}" + ); + assert!( + !state + .local + .graph + .edges() + .iter() + .any(|edge| edge.referenced == table + && matches!(edge.kind, DependencyKind::IndexOnRelation { .. })) + ); + let RelationOverlay::Present(relation) = &state.local.relations[&table] else { + panic!("missing table") + }; + assert_eq!(relation.cluster_index, None); + assert_eq!(relation.replica_identity.as_deref(), Some("USING INDEX")); + } + } + + #[test] + fn key_index_rename_rejects_constraint_name_collision_without_changes() { + let engine = setup_engine(); + let mut state = setup_state(); + engine.analyze("CREATE TABLE rename_keys(id integer NOT NULL, CONSTRAINT occupied CHECK(id > 0)); ALTER TABLE rename_keys ADD CONSTRAINT original_key UNIQUE(id);", &mut state).unwrap(); + let before_constraints = state.local.constraints.clone(); + let before_edges = state.local.graph.edges().to_vec(); + let findings = engine + .analyze("ALTER INDEX original_key RENAME TO occupied;", &mut state) + .unwrap(); + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "{findings:?}" + ); + assert_eq!(state.local.constraints, before_constraints); + assert_eq!(state.local.graph.edges(), before_edges.as_slice()); + } + + #[test] + fn dropping_foreign_key_keeps_its_referenced_index() { + let engine = setup_engine(); + let mut state = setup_state(); + engine.analyze("CREATE TABLE referenced_key(id integer); ALTER TABLE referenced_key ADD CONSTRAINT referenced_unique UNIQUE(id); CREATE TABLE referencing_key(id integer); ALTER TABLE referencing_key ADD CONSTRAINT referencing_fk FOREIGN KEY(id) REFERENCES referenced_key(id);", &mut state).unwrap(); + let parent = object_id("public", "referenced_key"); + let index = state + .local + .constraints + .values() + .find(|constraint| { + constraint.table_id == parent && constraint.kind == ConstraintKind::Unique + }) + .unwrap() + .backing_index + .clone() + .unwrap(); + state + .local + .constraints + .get_mut(&( + object_id("public", "referencing_key"), + "referencing_fk".into(), + )) + .unwrap() + .backing_index = Some(index.clone()); + engine + .analyze( + "ALTER TABLE referencing_key DROP CONSTRAINT referencing_fk;", + &mut state, + ) + .unwrap(); + assert!( + state + .local + .graph + .edges() + .iter() + .any(|edge| edge.dependent == index + && matches!(edge.kind, DependencyKind::IndexOnRelation { .. })) + ); + } + + #[test] + fn dropping_identity_index_settings_is_transactional() { + let engine = setup_engine(); + let mut state = setup_state(); + engine.analyze("CREATE TABLE index_settings(id integer NOT NULL); CREATE UNIQUE INDEX identity_idx ON index_settings(id); ALTER TABLE index_settings CLUSTER ON identity_idx; ALTER TABLE index_settings REPLICA IDENTITY USING INDEX identity_idx;", &mut state).unwrap(); + let before = state.local.relations.clone(); + engine + .analyze("BEGIN; DROP INDEX identity_idx;", &mut state) + .unwrap(); + let RelationOverlay::Present(table) = + &state.local.relations[&object_id("public", "index_settings")] + else { + panic!("missing table") + }; + assert_eq!(table.cluster_index, None); + assert_eq!(table.replica_identity.as_deref(), Some("USING INDEX")); + engine.analyze("ROLLBACK;", &mut state).unwrap(); + assert_eq!(state.local.relations, before); + } + + #[test] + fn dropping_qualified_quoted_index_clears_index_settings() { + let engine = setup_engine(); + let mut state = setup_state(); + engine.analyze("CREATE TABLE quoted_index_settings(id integer NOT NULL); CREATE UNIQUE INDEX \"IdentityIndex\" ON quoted_index_settings(id); ALTER TABLE quoted_index_settings CLUSTER ON \"IdentityIndex\"; ALTER TABLE quoted_index_settings REPLICA IDENTITY USING INDEX \"IdentityIndex\"; DROP INDEX public.\"IdentityIndex\";", &mut state).unwrap(); + let table = object_id("public", "quoted_index_settings"); + assert!(!state.index_is_present(&object_id("public", "IdentityIndex"))); + let RelationOverlay::Present(relation) = &state.local.relations[&table] else { + panic!("missing table") + }; + assert_eq!(relation.cluster_index, None); + assert_eq!(relation.replica_identity.as_deref(), Some("USING INDEX")); + } + + #[test] + fn renamed_check_dependencies_follow_rollback_and_drop() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE TABLE check_rename(id integer, CONSTRAINT old_check CHECK (id > 0));", + &mut state, + ) + .unwrap(); + let table = object_id("public", "check_rename"); + let edges_before = state.local.graph.edges().to_vec(); + engine.analyze("BEGIN; ALTER TABLE check_rename RENAME CONSTRAINT old_check TO new_check; ROLLBACK;", &mut state).unwrap(); + assert_eq!(state.local.graph.edges(), edges_before.as_slice()); + assert!( + state + .local + .constraints + .contains_key(&(table.clone(), "old_check".into())) + ); + engine + .analyze( + "ALTER TABLE check_rename RENAME CONSTRAINT old_check TO new_check;", + &mut state, + ) + .unwrap(); + assert!(state.local.graph.edges().iter().any(|edge| edge.dependent == table && matches!(&edge.kind, DependencyKind::ConstraintDependency { constraint_name, .. } if constraint_name == "new_check"))); + let findings = engine.analyze("ALTER TABLE check_rename DROP CONSTRAINT new_check; ALTER TABLE check_rename DROP COLUMN id;", &mut state).unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "{findings:?}" + ); + assert!( + !state + .local + .graph + .edges() + .iter() + .any(|edge| edge.dependent == table + && matches!( + edge.kind, + DependencyKind::ConstraintDependency { .. } + | DependencyKind::ConstraintOnRelation { .. } + )) + ); + } + + #[test] + fn altered_key_constraints_own_indexes_and_rollback_restores_them() { + let engine = setup_engine(); + for kind in ["UNIQUE", "PRIMARY KEY"] { + let mut state = setup_state(); + engine.analyze(&format!("CREATE TABLE key_owner(id int); ALTER TABLE key_owner ADD CONSTRAINT key_owner_key {kind} (id);"), &mut state).unwrap(); + let table = object_id("public", "key_owner"); + let index = object_id("public", "key_owner_key"); + assert_eq!( + state.local.constraints[&(table.clone(), "key_owner_key".into())].backing_index, + Some(index.clone()) + ); + assert!( + state + .local + .graph + .edges() + .iter() + .any(|edge| edge.dependent == index + && matches!( + edge.kind, + DependencyKind::IndexOnRelation { + is_unique: true, + .. + } + )) + ); + engine + .analyze( + "BEGIN; ALTER TABLE key_owner DROP CONSTRAINT key_owner_key; ROLLBACK;", + &mut state, + ) + .unwrap(); + assert!( + state + .local + .graph + .edges() + .iter() + .any(|edge| edge.dependent == index) + ); + engine + .analyze( + "ALTER TABLE key_owner DROP CONSTRAINT key_owner_key;", + &mut state, + ) + .unwrap(); + assert!( + !state + .local + .graph + .edges() + .iter() + .any(|edge| edge.dependent == index) + ); + } + } + + #[test] + fn alter_column_type_resets_storage_and_compression_transactionally() { + let engine = setup_engine(); + let mut state = setup_state(); + engine.analyze("CREATE TABLE type_reset(value text); ALTER TABLE type_reset ALTER COLUMN value SET STORAGE MAIN; ALTER TABLE type_reset ALTER COLUMN value SET COMPRESSION pglz; BEGIN; ALTER TABLE type_reset ALTER COLUMN value TYPE varchar(80);", &mut state).unwrap(); + let Some(RelationOverlay::Present(relation)) = + state.get_relation(&object_id("public", "type_reset")) + else { + panic!("missing relation") + }; + let column = relation.get_column("value").unwrap(); + assert_eq!(column.storage, None); + assert_eq!(column.compression, None); + engine.analyze("ROLLBACK;", &mut state).unwrap(); + let Some(RelationOverlay::Present(relation)) = + state.get_relation(&object_id("public", "type_reset")) + else { + panic!("missing relation") + }; + let column = relation.get_column("value").unwrap(); + assert_eq!(column.storage.as_deref(), Some("MAIN")); + assert_eq!(column.compression.as_deref(), Some("pglz")); + } + + #[test] + fn unavailable_compression_method_stays_conservative_without_mutating_state() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze("CREATE TABLE compression_probe(value text);", &mut state) + .unwrap(); + let findings = engine + .analyze( + "ALTER TABLE compression_probe ALTER COLUMN value SET COMPRESSION lz4;", + &mut state, + ) + .unwrap(); + + assert!(findings.is_empty()); + let RelationOverlay::Present(relation) = state + .get_relation(&object_id("public", "compression_probe")) + .unwrap() + else { + panic!("missing relation") + }; + assert_eq!(relation.get_column("value").unwrap().compression, None); + assert_eq!(state.confidence(), &Confidence::Tainted); + assert!( + state + .evidence() + .iter() + .any(|evidence| evidence.code == EvidenceCode::UnsupportedSemantics) + ); + } + + #[test] + fn column_type_lookup_prefers_earlier_table_row_type_over_later_domain() { + let engine = setup_engine(); + let mut state = setup_state(); + engine.analyze("CREATE SCHEMA early; CREATE SCHEMA late; CREATE TABLE early.shared(id int); CREATE DOMAIN late.shared AS integer; SET search_path TO early, late, public; CREATE TABLE public.probe(value shared);", &mut state).unwrap(); + let Some(RelationOverlay::Present(relation)) = + state.get_relation(&object_id("public", "probe")) + else { + panic!("missing relation") + }; + assert_eq!( + relation.get_column("value").unwrap().type_id, + Some(object_id("early", "shared")) + ); + } + + #[test] + fn generated_expression_text_tracks_create_add_change_and_rollback() { let engine = setup_engine(); let mut state = setup_state(); + engine.analyze("CREATE TABLE generated_text(base integer, doubled integer GENERATED ALWAYS AS (base * 2) STORED); ALTER TABLE generated_text ADD COLUMN tripled integer GENERATED ALWAYS AS (base * 3) STORED;", &mut state).unwrap(); + let id = object_id("public", "generated_text"); + let Some(RelationOverlay::Present(relation)) = state.get_relation(&id) else { + panic!("missing relation") + }; + assert_eq!( + relation.generated_columns["doubled"].expression.as_deref(), + Some("base * 2") + ); + assert_eq!( + relation.generated_columns["tripled"].expression.as_deref(), + Some("base * 3") + ); + engine.analyze("BEGIN; ALTER TABLE generated_text ALTER COLUMN doubled SET EXPRESSION AS (base * 4);", &mut state).unwrap(); + let Some(RelationOverlay::Present(relation)) = state.get_relation(&id) else { + panic!("missing relation") + }; + assert_eq!( + relation.generated_columns["doubled"].expression.as_deref(), + Some("base * 4") + ); + engine.analyze("ROLLBACK;", &mut state).unwrap(); + let Some(RelationOverlay::Present(relation)) = state.get_relation(&id) else { + panic!("missing relation") + }; + assert_eq!( + relation.generated_columns["doubled"].expression.as_deref(), + Some("base * 2") + ); + } + #[test] + fn test_topology_table_basic() { + let engine = setup_engine(); + let mut state = setup_state(); engine .analyze( "CREATE TABLE t(id int); ALTER TABLE t ADD COLUMN name text; ALTER TABLE t RENAME COLUMN name TO full_name;", @@ -304,7 +687,7 @@ mod state_mutation_tests { #[test] fn unavailable_baseline_never_claims_schema_coverage() { let engine = setup_engine(); - let mut state = safe_migrate::api::AnalysisState::with_baseline( + let mut state = crate::_internal::analysis::state::AnalysisState::with_baseline( safe_migrate::_internal::db::cache::DbCache::new(), false, ); @@ -451,7 +834,7 @@ mod state_mutation_tests { cache.dependencies.push(dependency(&view_id)); cache.dependencies.push(dependency(&table_id)); - let state = safe_migrate::api::AnalysisState::new(cache); + let state = crate::_internal::analysis::state::AnalysisState::new(cache); assert!(!state.local.graph.edges().iter().any(|edge| { matches!(edge.kind, DependencyKind::ViewDependency { .. }) && edge.dependent == view_id @@ -506,7 +889,7 @@ mod state_mutation_tests { }); let engine = setup_engine(); - let mut state = safe_migrate::api::AnalysisState::new(cache.clone()); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache.clone()); let findings = engine .analyze("ALTER TABLE t DROP COLUMN unused;", &mut state) .unwrap(); @@ -517,7 +900,12 @@ mod state_mutation_tests { .any(|finding| finding.rule_id == "chain-conflict"), "unrelated column drop must not conflict: {findings:?}" ); - assert_eq!(state.local.confidence, Confidence::Exact); + assert_eq!( + state.local.confidence, + Confidence::Exact, + "unexpected evidence: {:?}", + state.evidence() + ); assert!( state .get_relation(&table_id) @@ -526,7 +914,8 @@ mod state_mutation_tests { RelationOverlay::Dropped => false, }) ); - let mut blocked_state = safe_migrate::api::AnalysisState::new(cache.clone()); + let mut blocked_state = + crate::_internal::analysis::state::AnalysisState::new(cache.clone()); let findings = engine .analyze("ALTER TABLE t DROP COLUMN id;", &mut blocked_state) .unwrap(); @@ -536,7 +925,7 @@ mod state_mutation_tests { .any(|finding| finding.rule_id == "chain-conflict") ); - let mut cascade_state = safe_migrate::api::AnalysisState::new(cache); + let mut cascade_state = crate::_internal::analysis::state::AnalysisState::new(cache); let findings = engine .analyze("ALTER TABLE t DROP COLUMN id CASCADE;", &mut cascade_state) .unwrap(); @@ -586,6 +975,7 @@ mod state_mutation_tests { has_expression_keys: true, has_predicate: false, is_unique: false, + is_immediate: true, is_valid: true, is_ready: true, is_live: true, @@ -595,7 +985,7 @@ mod state_mutation_tests { }); let engine = setup_engine(); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let unrelated = engine .analyze("ALTER TABLE t DROP COLUMN unused;", &mut state) .unwrap(); @@ -651,7 +1041,7 @@ mod state_mutation_tests { referenced_column: None, }); - let state = safe_migrate::api::AnalysisState::new(cache); + let state = crate::_internal::analysis::state::AnalysisState::new(cache); assert!(state.local.graph.edges().iter().any(|edge| { matches!(edge.kind, DependencyKind::ViewDependency { .. }) && edge.dependent == view_id @@ -683,7 +1073,7 @@ mod state_mutation_tests { referenced_column: None, }); - let state = safe_migrate::api::AnalysisState::new(cache); + let state = crate::_internal::analysis::state::AnalysisState::new(cache); assert!(state.local.graph.edges().iter().any(|edge| { matches!(edge.kind, DependencyKind::ViewDependency { .. }) && edge.dependent == omitted_view @@ -716,7 +1106,7 @@ mod state_mutation_tests { }); let engine = setup_engine(); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let violations = engine .analyze("DROP TABLE app.base CASCADE;", &mut state) .unwrap(); @@ -753,7 +1143,7 @@ mod state_mutation_tests { ), ); } - let state = safe_migrate::api::AnalysisState::new(cache); + let state = crate::_internal::analysis::state::AnalysisState::new(cache); assert!( !state .local @@ -997,93 +1387,1968 @@ mod state_mutation_tests { let engine = setup_engine(); let mut state = setup_state(); - engine + engine + .analyze( + "CREATE TABLE a(id int); ALTER TABLE a RENAME TO b;", + &mut state, + ) + .unwrap(); + + assert!(!state.relation_is_present(&object_id("public", "a"))); + assert!(state.relation_is_present(&object_id("public", "b"))); + assert!( + state + .local + .graph + .edges() + .iter() + .filter(|e| matches!( + e.kind, + safe_migrate::_internal::analysis::graph::DependencyKind::RenameTo + )) + .any(|e| e.dependent == object_id("public", "a") + && e.referenced == object_id("public", "b")) + ); + } + + #[test] + fn rename_back_to_original_name_does_not_loop_during_drop() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze( + "CREATE TABLE a(id int); ALTER TABLE a RENAME TO b; ALTER TABLE b RENAME TO a; DROP TABLE a;", + &mut state, + ) + .unwrap(); + + assert!(!state.relation_is_present(&object_id("public", "a"))); + } + + #[test] + fn malformed_partition_ancestry_is_rejected_without_looping() { + let a = object_id("public", "a"); + let b = object_id("public", "b"); + let child = object_id("public", "new_child"); + let mut graph = DependencyGraph::new(); + graph.add_edge(DependencyEdge::new( + a.clone(), + b.clone(), + DependencyKind::PartitionOf, + )); + graph.add_edge(DependencyEdge::new( + b, + a.clone(), + DependencyKind::PartitionOf, + )); + + assert!(graph.check_partition_cycle(&a, &child)); + } + + #[test] + fn partition_operations_reject_unpartitioned_or_unattached_targets() { + let engine = setup_engine(); + let mut state = setup_state(); + let violations = engine + .analyze( + "CREATE TABLE plain (id integer); + CREATE TABLE child (id integer); + CREATE TABLE invalid PARTITION OF plain FOR VALUES IN (1); + ALTER TABLE plain ATTACH PARTITION child FOR VALUES IN (1); + ALTER TABLE plain DETACH PARTITION child;", + &mut state, + ) + .unwrap(); + + assert!(!state.relation_is_present(&object_id("public", "invalid"))); + assert!(!state.local.graph.edges().iter().any(|edge| { + matches!(edge.kind, DependencyKind::PartitionOf) + && edge.dependent == object_id("public", "child") + })); + assert_eq!( + violations + .iter() + .filter(|violation| violation.rule_id == "chain-conflict") + .count(), + 3, + "every invalid partition operation should be rejected: {violations:?}" + ); + } + + #[test] + fn partition_bounds_follow_attach_detach_and_rollback() { + let engine = setup_engine(); + let mut state = setup_state(); + engine.analyze("CREATE TABLE bound_parent(id integer) PARTITION BY RANGE(id); CREATE TABLE bound_child PARTITION OF bound_parent DEFAULT;", &mut state).unwrap(); + let child = object_id("public", "bound_child"); + let bound = |state: &safe_migrate::_internal::analysis::state::AnalysisState| { + let RelationOverlay::Present(relation) = &state.local.relations[&child] else { + panic!("missing child") + }; + relation.partition_bound.clone() + }; + assert_eq!(bound(&state).as_deref(), Some("DEFAULT")); + engine + .analyze( + "BEGIN; ALTER TABLE bound_parent DETACH PARTITION bound_child;", + &mut state, + ) + .unwrap(); + assert_eq!(bound(&state), None); + engine.analyze("ROLLBACK;", &mut state).unwrap(); + assert_eq!(bound(&state).as_deref(), Some("DEFAULT")); + engine.analyze("ALTER TABLE bound_parent DETACH PARTITION bound_child; ALTER TABLE bound_parent ATTACH PARTITION bound_child FOR VALUES FROM (0) TO (10);", &mut state).unwrap(); + assert_eq!( + bound(&state).as_deref(), + Some("FOR VALUES FROM (0) TO (10)") + ); + } + + #[test] + fn partition_strategy_is_typed_and_invalid_values_leave_no_table() { + let engine = setup_engine(); + for strategy in ["range", "\"RANGE\"", "list", "hash"] { + let mut state = setup_state(); + let findings = engine.analyze(&format!("CREATE TABLE typed_parent(id integer) PARTITION /* comment */ BY {strategy} (id);"), &mut state).unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "{findings:?}" + ); + let RelationOverlay::Present(parent) = + &state.local.relations[&object_id("public", "typed_parent")] + else { + panic!("missing parent") + }; + assert_eq!( + parent.partition_type.as_deref(), + Some(strategy.trim_matches('"').to_uppercase().as_str()) + ); + } + let mut state = setup_state(); + let findings = engine + .analyze( + "CREATE TABLE invalid_strategy(id integer) PARTITION BY imaginary(id);", + &mut state, + ) + .unwrap(); + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict" + && finding + .reason + .contains("unrecognized partitioning strategy")), + "{findings:?}" + ); + assert!(!state.relation_is_present(&object_id("public", "invalid_strategy"))); + } + + #[test] + fn concurrent_detach_rejects_default_and_pending_siblings() { + let engine = setup_engine(); + for pending in [false, true] { + let mut state = setup_state(); + engine.analyze("CREATE TABLE bound_parent(id integer) PARTITION BY RANGE(id); CREATE TABLE bound_child PARTITION OF bound_parent FOR VALUES FROM (0) TO (10); CREATE TABLE bound_default PARTITION OF bound_parent DEFAULT;", &mut state).unwrap(); + if pending { + state + .local + .graph + .retain_edges(|edge| edge.dependent != object_id("public", "bound_default")); + state.local.graph.add_edge(DependencyEdge::new( + object_id("public", "bound_default"), + object_id("public", "bound_parent"), + DependencyKind::PartitionDetachPending, + )); + } + let findings = engine + .analyze( + "ALTER TABLE bound_parent DETACH PARTITION bound_child CONCURRENTLY;", + &mut state, + ) + .unwrap(); + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict" + && finding.reason.contains(if pending { + "pending detach" + } else { + "default partition" + })), + "{findings:?}" + ); + assert!( + state + .local + .graph + .edges() + .iter() + .any(|edge| edge.dependent == object_id("public", "bound_child") + && matches!(edge.kind, DependencyKind::PartitionOf)) + ); + } + } + + #[test] + fn concurrent_detach_in_transaction_preserves_partition_state() { + let engine = setup_engine(); + let mut state = setup_state(); + let findings = engine.analyze("CREATE TABLE detach_parent(id integer) PARTITION BY RANGE(id); CREATE TABLE detach_child PARTITION OF detach_parent FOR VALUES FROM (0) TO (10); BEGIN; ALTER TABLE detach_parent DETACH PARTITION detach_child CONCURRENTLY; ROLLBACK;", &mut state).unwrap(); + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict" + && finding.reason.contains("inside a transaction")) + ); + assert!( + state + .local + .graph + .edges() + .iter() + .any(|edge| edge.dependent == object_id("public", "detach_child") + && edge.referenced == object_id("public", "detach_parent") + && matches!(edge.kind, DependencyKind::PartitionOf)) + ); + assert!( + !state + .local + .graph + .edges() + .iter() + .any(|edge| matches!(edge.kind, DependencyKind::PartitionDetachPending)) + ); + } + + #[test] + fn concurrent_hash_partition_detach_completes_without_pending_or_check() { + let engine = setup_engine(); + let mut state = setup_state(); + let findings = engine.analyze("CREATE TABLE hash_parent(id integer PRIMARY KEY) PARTITION BY HASH(id); CREATE TABLE hash_child PARTITION OF hash_parent FOR VALUES WITH (MODULUS 2, REMAINDER 0); ALTER TABLE hash_parent DETACH PARTITION hash_child CONCURRENTLY;", &mut state).unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "{findings:?}" + ); + let child = object_id("public", "hash_child"); + assert!( + !state + .local + .graph + .edges() + .iter() + .any(|edge| edge.dependent == child + && matches!( + edge.kind, + DependencyKind::PartitionOf | DependencyKind::PartitionDetachPending + )) + ); + let RelationOverlay::Present(relation) = &state.local.relations[&child] else { + panic!("missing detached child") + }; + assert_eq!(relation.partition_bound, None); + assert!( + !state + .local + .constraints + .values() + .any(|constraint| constraint.table_id == child + && constraint.kind == ConstraintKind::Check) + ); + let findings = engine + .analyze( + "ALTER TABLE hash_parent DETACH PARTITION hash_child FINALIZE;", + &mut state, + ) + .unwrap(); + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict" + && finding.reason.contains("no pending")), + "{findings:?}" + ); + } + + #[test] + fn inherit_provenance_preserves_other_parents_and_local_status() { + let engine = setup_engine(); + let mut state = setup_state(); + engine.analyze("CREATE TABLE p1(id integer); CREATE TABLE p2(id integer); CREATE TABLE child() INHERITS(p1,p2);", &mut state).unwrap(); + for (sql, count, local) in [ + ("ALTER TABLE child NO INHERIT p1;", 1, false), + ("ALTER TABLE child NO INHERIT p2;", 0, true), + ("ALTER TABLE child INHERIT p1;", 1, true), + ] { + let findings = engine.analyze(sql, &mut state).unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "{findings:?}" + ); + let RelationOverlay::Present(relation) = + &state.local.relations[&object_id("public", "child")] + else { + panic!("missing child") + }; + let provenance = &relation.column_inheritance["id"]; + assert_eq!( + (provenance.parent_count, provenance.is_local), + (count, local), + "{sql}" + ); + } + } + + #[test] + fn partition_attachment_provenance_follows_detach_and_rollback() { + let engine = setup_engine(); + let mut state = setup_state(); + engine.analyze("CREATE TABLE parent(id integer) PARTITION BY RANGE(id); CREATE TABLE child(id integer);", &mut state).unwrap(); + let child = object_id("public", "child"); + let provenance = |state: &safe_migrate::_internal::analysis::state::AnalysisState| { + let RelationOverlay::Present(relation) = &state.local.relations[&child] else { + panic!("missing child") + }; + let value = &relation.column_inheritance["id"]; + (value.parent_count, value.is_local) + }; + assert_eq!(provenance(&state), (0, true)); + engine + .analyze( + "ALTER TABLE parent ATTACH PARTITION child FOR VALUES FROM (0) TO (10);", + &mut state, + ) + .unwrap(); + assert_eq!(provenance(&state), (1, false)); + engine + .analyze( + "BEGIN; ALTER TABLE parent DETACH PARTITION child;", + &mut state, + ) + .unwrap(); + assert_eq!(provenance(&state), (0, true)); + engine.analyze("ROLLBACK;", &mut state).unwrap(); + assert_eq!(provenance(&state), (1, false)); + } + + #[test] + fn create_table_records_column_inheritance_provenance() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE TABLE p1 (id integer); CREATE TABLE p2 (id integer); + CREATE TABLE inherited () INHERITS (p1, p2); + CREATE TABLE local_child (id integer) INHERITS (p1); + CREATE TABLE partitioned (id integer) PARTITION BY RANGE (id); + CREATE TABLE part PARTITION OF partitioned FOR VALUES FROM (0) TO (10);", + &mut state, + ) + .unwrap(); + for (name, parent_count, is_local) in [ + ("p1", 0, true), + ("inherited", 2, false), + ("local_child", 1, true), + ("part", 1, false), + ] { + let RelationOverlay::Present(relation) = + &state.local.relations[&object_id("public", name)] + else { + panic!("missing relation") + }; + let provenance = &relation.column_inheritance["id"]; + assert_eq!( + (provenance.parent_count, provenance.is_local), + (parent_count, is_local), + "{name}" + ); + } + } + + #[test] + fn ancestor_detach_invalidates_descendant_predicate_and_rollback_restores_it() { + let engine = setup_engine(); + let mut state = setup_state(); + engine.analyze( + "CREATE TABLE root (id integer) PARTITION BY RANGE (id); + CREATE TABLE middle PARTITION OF root FOR VALUES FROM (0) TO (100) PARTITION BY RANGE (id); + CREATE TABLE leaf PARTITION OF middle FOR VALUES FROM (0) TO (10);", + &mut state, + ).unwrap(); + let leaf = object_id("public", "leaf"); + let predicate = "(id IS NOT NULL) AND (id >= 0) AND (id < 10)"; + let Some(RelationOverlay::Present(relation)) = state.local.relations.get_mut(&leaf) else { + panic!("missing leaf"); + }; + relation.partition_constraint = Some(predicate.into()); + let findings = engine + .analyze( + "BEGIN; ALTER TABLE root DETACH PARTITION middle;", + &mut state, + ) + .unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "{findings:?}" + ); + let RelationOverlay::Present(relation) = &state.local.relations[&leaf] else { + panic!("missing leaf"); + }; + assert_eq!(relation.partition_constraint, None); + engine.analyze("ROLLBACK;", &mut state).unwrap(); + let RelationOverlay::Present(relation) = &state.local.relations[&leaf] else { + panic!("missing leaf"); + }; + assert_eq!(relation.partition_constraint.as_deref(), Some(predicate)); + } + + #[test] + fn recursive_column_rename_collision_preserves_every_relation_and_dependency() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE TABLE parent (id integer CHECK (id > 0)); + CREATE TABLE child (renamed integer) INHERITS (parent); + CREATE INDEX child_id_idx ON child (id);", + &mut state, + ) + .unwrap(); + let relations = state.local.relations.clone(); + let constraints = state.local.constraints.clone(); + let edges = state.local.graph.edges().to_vec(); + let findings = engine + .analyze( + "ALTER TABLE parent RENAME COLUMN id TO renamed;", + &mut state, + ) + .unwrap(); + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "{findings:?}" + ); + assert_eq!(state.local.relations, relations); + assert_eq!(state.local.constraints, constraints); + assert_eq!(state.local.graph.edges(), edges); + } + + #[test] + fn recursive_column_rename_updates_descendant_metadata_and_rolls_back() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE TABLE parent (id integer CHECK (id > 0)); + CREATE TABLE child () INHERITS (parent); + CREATE INDEX child_id_idx ON child (id); + BEGIN; ALTER TABLE parent RENAME COLUMN id TO renamed;", + &mut state, + ) + .unwrap(); + for name in ["parent", "child"] { + let RelationOverlay::Present(relation) = + &state.local.relations[&object_id("public", name)] + else { + panic!("missing {name}") + }; + assert!(relation.has_column("renamed")); + assert!(!relation.has_column("id")); + } + assert!( + state + .local + .constraints + .values() + .filter( + |constraint| constraint.table_id == object_id("public", "parent") + && constraint.kind == ConstraintKind::Check + ) + .all(|constraint| constraint + .definition + .as_deref() + .is_some_and(|definition| definition.contains("renamed"))) + ); + assert!(state.local.graph.edges().iter().any(|edge| edge.dependent == object_id("public", "child_id_idx") + && matches!(&edge.kind, DependencyKind::IndexOnRelation { key_columns, .. } if key_columns == &["renamed".to_string()]))); + engine.analyze("ROLLBACK;", &mut state).unwrap(); + for name in ["parent", "child"] { + let RelationOverlay::Present(relation) = + &state.local.relations[&object_id("public", name)] + else { + panic!("missing {name}") + }; + assert!(relation.has_column("id")); + assert!(!relation.has_column("renamed")); + } + } + + #[test] + fn only_column_rename_rejects_descendants_without_mutation() { + let engine = setup_engine(); + for child_sql in [ + "CREATE TABLE child () INHERITS (parent);", + "CREATE TABLE child PARTITION OF parent FOR VALUES FROM (0) TO (10);", + ] { + let mut state = setup_state(); + let parent_sql = if child_sql.contains("PARTITION") { + "CREATE TABLE parent (id integer) PARTITION BY RANGE (id);" + } else { + "CREATE TABLE parent (id integer);" + }; + engine + .analyze(&format!("{parent_sql} {child_sql}"), &mut state) + .unwrap(); + let findings = engine + .analyze( + "ALTER TABLE ONLY parent RENAME COLUMN id TO renamed;", + &mut state, + ) + .unwrap(); + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "{findings:?}" + ); + for name in ["parent", "child"] { + let RelationOverlay::Present(relation) = + &state.local.relations[&object_id("public", name)] + else { + panic!("missing relation") + }; + assert!(relation.has_column("id")); + assert!(!relation.has_column("renamed")); + } + } + } + + #[test] + fn partition_key_column_rename_preserves_expressions_and_rollback() { + let engine = setup_engine(); + let mut state = setup_state(); + engine.analyze( + "CREATE TABLE key_parent (id integer, other integer) PARTITION BY RANGE (id, (id + other));", + &mut state, + ).unwrap(); + let id = object_id("public", "key_parent"); + let RelationOverlay::Present(before) = &state.local.relations[&id] else { + panic!("missing parent") + }; + let original = before.partition_by.clone(); + let findings = engine + .analyze( + "BEGIN; ALTER TABLE key_parent RENAME COLUMN id TO \"NewKey\";", + &mut state, + ) + .unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "{findings:?}" + ); + let RelationOverlay::Present(relation) = &state.local.relations[&id] else { + panic!("missing parent") + }; + assert_eq!( + relation.partition_by.as_deref(), + Some("PARTITION BY RANGE (\"NewKey\", (\"NewKey\" + other))") + ); + engine.analyze("ROLLBACK;", &mut state).unwrap(); + let RelationOverlay::Present(relation) = &state.local.relations[&id] else { + panic!("missing parent") + }; + assert_eq!(relation.partition_by, original); + } + + #[test] + fn partition_predicate_column_rename_restores_on_rollback() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze("CREATE TABLE predicate_child (id integer);", &mut state) + .unwrap(); + let id = object_id("public", "predicate_child"); + let predicate = "id IS NOT NULL AND id >= 0 AND id < 10"; + let RelationOverlay::Present(relation) = state.local.relations.get_mut(&id).unwrap() else { + panic!("missing relation") + }; + relation.partition_constraint = Some(predicate.into()); + engine + .analyze( + "BEGIN; ALTER TABLE predicate_child RENAME COLUMN id TO renamed;", + &mut state, + ) + .unwrap(); + let RelationOverlay::Present(relation) = &state.local.relations[&id] else { + panic!("missing relation") + }; + assert_eq!( + relation.partition_constraint.as_deref(), + Some("renamed IS NOT NULL AND renamed >= 0 AND renamed < 10") + ); + engine.analyze("ROLLBACK;", &mut state).unwrap(); + let RelationOverlay::Present(relation) = &state.local.relations[&id] else { + panic!("missing relation") + }; + assert_eq!(relation.partition_constraint.as_deref(), Some(predicate)); + } + + #[test] + fn finalize_interrupted_detach_retains_existing_check_and_undo_state() { + let engine = setup_engine(); + let mut state = setup_state(); + engine.analyze( + "CREATE TABLE parent (id integer) PARTITION BY RANGE (id); + CREATE TABLE child PARTITION OF parent FOR VALUES FROM (0) TO (10); + ALTER TABLE child ADD CONSTRAINT retained_bound CHECK (id IS NOT NULL AND id >= 0 AND id < 10);", + &mut state, + ).unwrap(); + let child = object_id("public", "child"); + let parent = object_id("public", "parent"); + // Model the catalog after the first internal transaction was committed. + state.local.graph.retain_edges(|edge| { + !(edge.dependent == child + && edge.referenced == parent + && matches!(edge.kind, DependencyKind::PartitionOf)) + }); + state.local.graph.add_edge(DependencyEdge::new( + child.clone(), + parent.clone(), + DependencyKind::PartitionDetachPending, + )); + let check = state.local.constraints[&(child.clone(), "retained_bound".into())].clone(); + let findings = engine + .analyze( + "BEGIN; ALTER TABLE parent DETACH PARTITION child FINALIZE; ROLLBACK;", + &mut state, + ) + .unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "{findings:?}" + ); + assert!(state.local.graph.edges().iter().any(|edge| { + edge.dependent == child + && edge.referenced == parent + && matches!(edge.kind, DependencyKind::PartitionDetachPending) + })); + let findings = engine + .analyze( + "ALTER TABLE parent DETACH PARTITION child FINALIZE;", + &mut state, + ) + .unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "{findings:?}" + ); + assert!(!state.local.graph.edges().iter().any(|edge| { + edge.dependent == child + && edge.referenced == parent + && matches!( + edge.kind, + DependencyKind::PartitionDetachPending | DependencyKind::PartitionOf + ) + })); + assert_eq!( + state.local.constraints[&(child.clone(), "retained_bound".into())], + check + ); + assert_eq!( + state + .local + .constraints + .values() + .filter(|constraint| { + constraint.table_id == child && constraint.kind == ConstraintKind::Check + }) + .count(), + 1 + ); + } + + #[test] + fn successful_concurrent_partition_detach_completes_without_finalize() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze( + "CREATE TABLE parent (id integer) PARTITION BY RANGE (id); + CREATE TABLE child (id integer); + ALTER TABLE parent ATTACH PARTITION child FOR VALUES FROM (0) TO (10); + ALTER TABLE parent DETACH PARTITION child CONCURRENTLY;", + &mut state, + ) + .unwrap(); + + assert!(!state.local.graph.edges().iter().any(|edge| { + matches!( + edge.kind, + DependencyKind::PartitionOf | DependencyKind::PartitionDetachPending + ) && edge.dependent == object_id("public", "child") + && edge.referenced == object_id("public", "parent") + })); + + let RelationOverlay::Present(child) = &state.local.relations[&object_id("public", "child")] + else { + panic!("detached child must remain present") + }; + assert_eq!(child.partition_bound, None); + assert_eq!(child.partition_constraint, None); + // Concurrent detach retained a CHECK reproducing the partition + // predicate; the chain is now provable. + assert_eq!(state.local.confidence, Confidence::Exact); + let retained = + &state.local.constraints[&(object_id("public", "child"), "child_id_check".into())]; + assert_eq!(retained.kind, ConstraintKind::Check); + assert!(retained.validated); + assert_eq!( + retained.definition.as_deref(), + Some("((id IS NOT NULL) AND (id >= 0) AND (id < 10))") + ); + + let findings = engine + .analyze( + "ALTER TABLE parent DETACH PARTITION child FINALIZE;", + &mut state, + ) + .unwrap(); + assert!(findings.iter().any(|finding| { + finding.rule_id == "chain-conflict" && finding.reason.contains("no pending") + })); + } + + #[test] + fn retained_partition_check_preserves_quoted_key_identity() { + let engine = setup_engine(); + for name in ["MyKey", "key with space", "key\"quote"] { + let mut state = setup_state(); + let quoted = format!("\"{}\"", name.replace('"', "\"\"")); + let sql = format!( + "CREATE TABLE parent({quoted} integer) PARTITION BY RANGE ({quoted}); + CREATE TABLE child PARTITION OF parent FOR VALUES FROM (0) TO (10); + ALTER TABLE parent DETACH PARTITION child CONCURRENTLY;" + ); + engine.analyze(&sql, &mut state).unwrap(); + let child = object_id("public", "child"); + let check = state + .local + .constraints + .values() + .find(|check| check.table_id == child && check.kind == ConstraintKind::Check) + .expect("retained check"); + assert_eq!( + check.definition.as_deref(), + Some( + format!("(({quoted} IS NOT NULL) AND ({quoted} >= 0) AND ({quoted} < 10))") + .as_str() + ) + ); + assert!(state.local.graph.edges().iter().any(|edge| + edge.dependent == child && matches!(&edge.kind, DependencyKind::ConstraintDependency { columns, .. } if columns == &[name.to_string()]))); + } + } + + #[test] + fn retained_partition_check_tracks_all_cached_predicate_columns() { + let engine = setup_engine(); + let mut state = setup_state(); + engine.analyze("CREATE TABLE parent(id integer, ancestor_key integer) PARTITION BY RANGE(id); CREATE TABLE child PARTITION OF parent FOR VALUES FROM (0) TO (10);", &mut state).unwrap(); + let child = object_id("public", "child"); + let RelationOverlay::Present(relation) = state.local.relations.get_mut(&child).unwrap() + else { + panic!("child") + }; + relation.partition_constraint = + Some("id IS NOT NULL AND id >= 0 AND id < 10 AND ancestor_key > 5".into()); + engine + .analyze( + "ALTER TABLE parent DETACH PARTITION child CONCURRENTLY;", + &mut state, + ) + .unwrap(); + assert!( + state + .local + .constraints + .contains_key(&(child.clone(), "child_check".into())) + ); + assert!( + state + .local + .graph + .edges() + .iter() + .any(|edge| edge.dependent == child + && matches!(&edge.kind, DependencyKind::ConstraintDependency { columns, .. } + if columns == &["ancestor_key".to_string(), "id".to_string()])) + ); + } + + #[test] + fn concurrent_detach_uses_parent_strategy_for_subpartitioned_child() { + let engine = setup_engine(); + for (parent_strategy, child_strategy, bound, check_expected) in [ + ("RANGE", "HASH", "FROM (0) TO (10)", true), + ("HASH", "RANGE", "WITH (MODULUS 2, REMAINDER 0)", false), + ] { + let mut state = setup_state(); + let sql = format!("CREATE TABLE parent(id integer) PARTITION BY {parent_strategy}(id); + CREATE TABLE child PARTITION OF parent FOR VALUES {bound} PARTITION BY {child_strategy}(id); + ALTER TABLE parent DETACH PARTITION child CONCURRENTLY;"); + let findings = engine.analyze(&sql, &mut state).unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "{findings:?}" + ); + assert_eq!( + state + .local + .constraints + .values() + .any( + |constraint| constraint.table_id == object_id("public", "child") + && constraint.kind == ConstraintKind::Check + ), + check_expected + ); + } + } + + #[test] + fn concurrent_detach_retains_check_from_created_partition_bound() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE TABLE parent (id integer) PARTITION BY RANGE (id); + CREATE TABLE child PARTITION OF parent FOR VALUES FROM (0) TO (10); + ALTER TABLE parent DETACH PARTITION child CONCURRENTLY;", + &mut state, + ) + .unwrap(); + assert_eq!(state.local.confidence, Confidence::Exact); + let retained = + &state.local.constraints[&(object_id("public", "child"), "child_id_check".into())]; + assert_eq!( + retained.definition.as_deref(), + Some("((id IS NOT NULL) AND (id >= 0) AND (id < 10))") + ); + assert!(state.local.graph.edges().iter().any(|edge| { + edge.dependent == object_id("public", "child") + && edge.referenced == object_id("public", "child") + && matches!( + edge.kind, + DependencyKind::ConstraintDependency { ref columns, .. } + if columns == &vec!["id".to_string()] + ) + })); + } + + #[test] + fn concurrent_detach_keeps_list_bound_tainted_for_composite_key() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE TABLE parent (a integer, b integer) PARTITION BY RANGE (a, b); + CREATE TABLE child PARTITION OF parent FOR VALUES FROM (0, 0) TO (10, 10); + ALTER TABLE parent DETACH PARTITION child CONCURRENTLY;", + &mut state, + ) + .unwrap(); + assert_ne!(state.local.confidence, Confidence::Exact); + assert!(!state.local.constraints.values().any(|constraint| { + constraint.table_id == object_id("public", "child") + && constraint.kind == ConstraintKind::Check + })); + } + + #[test] + fn concurrent_detach_of_hash_partition_adds_no_check() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE TABLE parent (id integer) PARTITION BY HASH (id); + CREATE TABLE child PARTITION OF parent FOR VALUES WITH (MODULUS 2, REMAINDER 0); + ALTER TABLE parent DETACH PARTITION child CONCURRENTLY;", + &mut state, + ) + .unwrap(); + assert_eq!(state.local.confidence, Confidence::Exact); + assert!(!state.local.constraints.values().any(|constraint| { + constraint.table_id == object_id("public", "child") + && constraint.kind == ConstraintKind::Check + })); + } + + #[test] + fn concurrent_detach_folds_baseline_cached_list_predicate() { + use safe_migrate::_internal::db::cache::InheritanceCache; + + let engine = setup_engine(); + let mut cache = DbCache::new(); + let parent_id = object_id("public", "parent"); + let child_id = object_id("public", "child"); + let owner = object_id("public", "postgres"); + let mut parent = RelationState::new( + parent_id.clone(), + owner.clone(), + 0, + Some(1000), + RelationKind::Table, + Persistence::Permanent, + 0, + ); + parent.partition_type = Some("LIST".into()); + parent.partition_by = Some("PARTITION BY LIST (b)".into()); + parent.last_analyze = Some("2024-01-01 00:00:00+00".into()); + let mut child = RelationState::new( + child_id.clone(), + owner.clone(), + 0, + Some(1000), + RelationKind::Table, + Persistence::Permanent, + 0, + ); + child.partition_type = Some("LIST".into()); + child.partition_bound = Some("FOR VALUES IN (true, false)".into()); + child.partition_constraint = + Some("((b IS NOT NULL) AND (b = ANY (ARRAY[true, false])))".into()); + child.columns.push(Column { + name: "b".into(), + data_type: Some("boolean".into()), + type_id: None, + is_nullable: false, + default: None, + avg_width: None, + default_expr_text: None, + type_modifier: None, + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, + }); + cache.insert_baseline(parent_id.clone(), parent); + cache.insert_baseline(child_id.clone(), child); + cache.inheritances.push(InheritanceCache { + child: child_id.clone(), + parent: parent_id.clone(), + sequence: 0, + is_partition: true, + detach_pending: false, + }); + let mut state = safe_migrate::_internal::analysis::state::AnalysisState::new(cache); + engine + .analyze( + "ALTER TABLE parent DETACH PARTITION child CONCURRENTLY;", + &mut state, + ) + .unwrap(); + assert_eq!(state.local.confidence, Confidence::Exact); + let retained = &state.local.constraints[&(child_id, "child_b_check".into())]; + assert_eq!(retained.kind, ConstraintKind::Check); + assert!(retained.validated); + assert_eq!( + retained.definition.as_deref(), + Some("((b IS NOT NULL) AND (b = ANY ('{t,f}'::boolean[])))") + ); + } + + #[test] + fn concurrent_detach_folds_baseline_cached_boolean_single_predicate() { + use safe_migrate::_internal::db::cache::InheritanceCache; + + let engine = setup_engine(); + let mut cache = DbCache::new(); + let parent_id = object_id("public", "parent"); + let child_id = object_id("public", "child"); + let owner = object_id("public", "postgres"); + let mut parent = RelationState::new( + parent_id.clone(), + owner.clone(), + 0, + Some(1000), + RelationKind::Table, + Persistence::Permanent, + 0, + ); + parent.partition_type = Some("LIST".into()); + parent.partition_by = Some("PARTITION BY LIST (b)".into()); + parent.last_analyze = Some("2024-01-01 00:00:00+00".into()); + let mut child = RelationState::new( + child_id.clone(), + owner.clone(), + 0, + Some(1000), + RelationKind::Table, + Persistence::Permanent, + 0, + ); + child.partition_type = Some("LIST".into()); + child.partition_bound = Some("FOR VALUES IN (true)".into()); + child.last_analyze = Some("2024-01-01 00:00:00+00".into()); + child.partition_constraint = Some("((b IS NOT NULL) AND (b = true))".into()); + child.columns.push(Column { + name: "b".into(), + data_type: Some("boolean".into()), + type_id: None, + is_nullable: false, + default: None, + avg_width: None, + default_expr_text: None, + type_modifier: None, + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, + }); + cache.insert_baseline(parent_id.clone(), parent); + cache.insert_baseline(child_id.clone(), child); + cache.inheritances.push(InheritanceCache { + child: child_id.clone(), + parent: parent_id.clone(), + sequence: 0, + is_partition: true, + detach_pending: false, + }); + let mut state = safe_migrate::_internal::analysis::state::AnalysisState::new(cache); + engine + .analyze( + "ALTER TABLE parent DETACH PARTITION child CONCURRENTLY;", + &mut state, + ) + .unwrap(); + assert_eq!(state.local.confidence, Confidence::Exact); + let retained = &state.local.constraints[&(child_id, "child_b_check".into())]; + assert_eq!( + retained.definition.as_deref(), + Some("((b IS NOT NULL) AND b)") + ); + } + + #[test] + fn concurrent_detach_synthesizes_list_integer_array_from_bound() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE TABLE parent (a integer) PARTITION BY LIST (a); + CREATE TABLE child PARTITION OF parent FOR VALUES IN (1, 2, 3); + ALTER TABLE parent DETACH PARTITION child CONCURRENTLY;", + &mut state, + ) + .unwrap(); + assert_eq!(state.local.confidence, Confidence::Exact); + let retained = + &state.local.constraints[&(object_id("public", "child"), "child_a_check".into())]; + assert_eq!( + retained.definition.as_deref(), + Some("((a IS NOT NULL) AND (a = ANY ('{1,2,3}'::integer[])))") + ); + } + + #[test] + fn partition_attachment_validates_catalog_and_tracks_generated_objects() { + use safe_migrate::_internal::model::constraint::ConstraintKind; + use safe_migrate::_internal::model::trigger::TriggerOverlay; + + let engine = setup_engine(); + let mut state = setup_state(); + let findings = engine + .analyze( + "CREATE TABLE parent ( + id integer PRIMARY KEY, + value text, + CONSTRAINT positive CHECK (id > 0) + ) PARTITION BY RANGE (id); + CREATE INDEX parent_value_idx ON parent (value); + CREATE FUNCTION audit_row() RETURNS trigger LANGUAGE plpgsql + AS $$ BEGIN RETURN NEW; END; $$; + CREATE TRIGGER audit_row AFTER INSERT ON parent + FOR EACH ROW EXECUTE FUNCTION audit_row(); + CREATE TRIGGER audit_statement AFTER INSERT ON parent + FOR EACH STATEMENT EXECUTE FUNCTION audit_row(); + CREATE TABLE child ( + id integer NOT NULL, + value text, + CONSTRAINT positive CHECK (id > 0) + ); + ALTER TABLE parent ATTACH PARTITION child + FOR VALUES FROM (1) TO (100);", + &mut state, + ) + .unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "valid partition attachment conflicted: {findings:?}" + ); + + let parent = object_id("public", "parent"); + let child = object_id("public", "child"); + assert_eq!( + state + .local + .graph + .edges() + .iter() + .filter(|edge| { + edge.referenced == child + && matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) + }) + .count(), + 2, + "both parent indexes should have child counterparts" + ); + assert!(state.local.constraints.values().any(|constraint| { + constraint.table_id == child && constraint.kind == ConstraintKind::PrimaryKey + })); + let clone = state + .local + .triggers + .values() + .find_map(|overlay| match overlay { + TriggerOverlay::Present(trigger) + if trigger.table_id == child && trigger.name == "audit_row" => + { + Some(trigger) + } + _ => None, + }); + assert!(clone.is_some_and(|trigger| { + trigger.row_level + && trigger.parent_trigger_id.as_ref() + == Some(&object_id("public", "parent\0audit_row")) + })); + assert!(!state.local.triggers.values().any(|overlay| { + matches!(overlay, TriggerOverlay::Present(trigger) + if trigger.table_id == child && trigger.name == "audit_statement") + })); + + engine + .analyze( + "ALTER TABLE parent DETACH PARTITION child CONCURRENTLY;", + &mut state, + ) + .unwrap(); + assert!(!state.local.triggers.values().any(|overlay| { + matches!(overlay, TriggerOverlay::Present(trigger) + if trigger.table_id == child && trigger.parent_trigger_id.is_some()) + })); + assert!(state.local.constraints.values().any(|constraint| { + constraint.table_id == child && constraint.kind == ConstraintKind::PrimaryKey + })); + assert!(state.local.graph.edges().iter().any(|edge| { + edge.referenced == parent && matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) + })); + + engine + .analyze( + "CREATE TABLE rollback_child ( + id integer NOT NULL, + value text, + CONSTRAINT positive CHECK (id > 0) + ); + BEGIN; + ALTER TABLE parent ATTACH PARTITION rollback_child + FOR VALUES FROM (100) TO (200); + ROLLBACK;", + &mut state, + ) + .unwrap(); + let rollback_child = object_id("public", "rollback_child"); + assert!(!state.local.graph.edges().iter().any(|edge| { + edge.referenced == rollback_child + && matches!( + edge.kind, + DependencyKind::IndexOnRelation { .. } | DependencyKind::PartitionOf + ) + })); + assert!(!state.local.constraints.values().any(|constraint| { + constraint.table_id == rollback_child && constraint.kind == ConstraintKind::PrimaryKey + })); + assert!(!state.local.triggers.values().any(|overlay| { + matches!(overlay, TriggerOverlay::Present(trigger) + if trigger.table_id == rollback_child && trigger.parent_trigger_id.is_some()) + })); + } + + #[test] + fn partition_attachment_rejects_incompatible_columns_checks_and_triggers() { + let engine = setup_engine(); + let mut state = setup_state(); + let findings = engine + .analyze( + "CREATE TABLE parent (id integer NOT NULL, CONSTRAINT positive CHECK (id > 0)) + PARTITION BY RANGE (id); + CREATE FUNCTION audit_row() RETURNS trigger LANGUAGE plpgsql + AS $$ BEGIN RETURN NEW; END; $$; + CREATE TRIGGER audit AFTER INSERT ON parent + FOR EACH ROW EXECUTE FUNCTION audit_row(); + CREATE TABLE wrong_type (id bigint NOT NULL, CONSTRAINT positive CHECK (id > 0)); + CREATE TABLE wrong_check (id integer NOT NULL, CONSTRAINT positive CHECK (id >= 0)); + CREATE TABLE trigger_collision (id integer NOT NULL, CONSTRAINT positive CHECK (id > 0)); + CREATE TRIGGER audit AFTER INSERT ON trigger_collision + FOR EACH ROW EXECUTE FUNCTION audit_row(); + ALTER TABLE parent ATTACH PARTITION wrong_type FOR VALUES FROM (1) TO (10); + ALTER TABLE parent ATTACH PARTITION wrong_check FOR VALUES FROM (10) TO (20); + ALTER TABLE parent ATTACH PARTITION trigger_collision FOR VALUES FROM (20) TO (30);", + &mut state, + ) + .unwrap(); + assert_eq!( + findings + .iter() + .filter(|finding| finding.rule_id == "chain-conflict") + .count(), + 3, + "every incompatible attachment should conflict: {findings:?}" + ); + assert!(!state.local.graph.edges().iter().any(|edge| { + edge.referenced == object_id("public", "parent") + && matches!(edge.kind, DependencyKind::PartitionOf) + })); + } + + #[test] + fn create_table_inherits_copies_parent_columns_and_records_each_edge() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE TABLE parent_a (id integer); + CREATE TABLE parent_b (created_at timestamp); + CREATE TABLE child (local_value text) INHERITS (parent_a, parent_b);", + &mut state, + ) + .unwrap(); + + let child = object_id("public", "child"); + assert!(matches!( + state.get_relation(&child), + Some(RelationOverlay::Present(relation)) + if relation.has_column("id") + && relation.has_column("created_at") + && relation.has_column("local_value") + )); + for parent in ["parent_a", "parent_b"] { + assert!(state.local.graph.edges().iter().any(|edge| { + matches!(edge.kind, DependencyKind::InheritanceOf) + && edge.dependent == child + && edge.referenced == object_id("public", parent) + })); + } + } + + #[test] + fn create_table_inherits_merges_compatible_columns_and_rejects_conflicts() { + let engine = setup_engine(); + let mut state = setup_state(); + let findings = engine + .analyze( + "CREATE TABLE parent_a (id integer NOT NULL, value integer DEFAULT 7); + CREATE TABLE parent_b (id integer, value integer DEFAULT 7); + CREATE TABLE child (id integer, value integer DEFAULT 9) + INHERITS (parent_a, parent_b);", + &mut state, + ) + .unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "compatible inherited columns should merge: {findings:?}" + ); + let Some(RelationOverlay::Present(child)) = + state.get_relation(&object_id("public", "child")) + else { + panic!("merged child relation missing") + }; + assert!(!child.get_column("id").expect("id").is_nullable); + assert_eq!( + child.get_column("value").expect("value").default, + Some(safe_migrate::_internal::analysis::expr_ir::ExprIr::Literal( + "9".to_string() + )) + ); + + let mut conflicting = setup_state(); + let findings = engine + .analyze( + "CREATE TABLE left_parent (id integer); + CREATE TABLE right_parent (id text); + CREATE TABLE broken () INHERITS (left_parent, right_parent);", + &mut conflicting, + ) + .unwrap(); + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict") + ); + assert!(!conflicting.relation_is_present(&object_id("public", "broken"))); + } + + #[test] + fn inheritance_requires_and_merges_matching_check_definitions() { + let engine = setup_engine(); + let mut state = setup_state(); + let findings = engine + .analyze( + "CREATE TABLE parent_a (value integer, CONSTRAINT positive CHECK (value > 0)); + CREATE TABLE parent_b (value integer, CONSTRAINT positive CHECK (value > 0)); + CREATE TABLE child () INHERITS (parent_a, parent_b); + CREATE TABLE attached (value integer, CONSTRAINT positive CHECK (value > 0)); + ALTER TABLE attached INHERIT parent_a;", + &mut state, + ) + .unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "matching inherited CHECK definitions should merge: {findings:?}" + ); + assert_eq!( + state + .local + .constraints + .values() + .filter(|constraint| { + constraint.table_id == object_id("public", "child") + && constraint.name == "positive" + }) + .count(), + 1 + ); + + let mut conflict = setup_state(); + let findings = engine + .analyze( + "CREATE TABLE parent_a (value integer, CONSTRAINT positive CHECK (value > 0)); + CREATE TABLE parent_b (value integer, CONSTRAINT positive CHECK (value >= 0)); + CREATE TABLE broken () INHERITS (parent_a, parent_b);", + &mut conflict, + ) + .unwrap(); + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict") + ); + assert!(!conflict.relation_is_present(&object_id("public", "broken"))); + + let mut literal_case = setup_state(); + let findings = engine + .analyze( + "CREATE TABLE parent_a (value text, CONSTRAINT same_name CHECK (value = 'A')); + CREATE TABLE parent_b (value text, CONSTRAINT same_name CHECK (value = 'a')); + CREATE TABLE broken () INHERITS (parent_a, parent_b);", + &mut literal_case, + ) + .unwrap(); + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict") + ); + assert!(!literal_case.relation_is_present(&object_id("public", "broken"))); + } + + #[test] + fn create_table_like_copies_only_default_like_column_properties() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE TABLE source (id integer NOT NULL DEFAULT 42, note text); + CREATE TABLE copy (LIKE source);", + &mut state, + ) + .unwrap(); + + let relation = state + .get_relation(&object_id("public", "copy")) + .expect("LIKE target relation"); + let RelationOverlay::Present(relation) = relation else { + panic!("LIKE target should be present"); + }; + let id = relation.get_column("id").expect("copied id column"); + assert!(!id.is_nullable); + assert_eq!(id.data_type.as_deref(), Some("integer")); + assert!(id.default.is_none()); + assert!(id.default_expr_text.is_none()); + } + + #[test] + fn create_table_like_copies_each_supported_selected_property() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE TABLE source (id integer NOT NULL DEFAULT 42, computed integer GENERATED ALWAYS AS (id + 1) STORED); + ALTER TABLE source ALTER COLUMN id SET STORAGE PLAIN; + ALTER TABLE source ALTER COLUMN id SET STATISTICS 100; + ALTER TABLE source ALTER COLUMN id SET (n_distinct = -0.25); + CREATE TABLE copy (LIKE source INCLUDING DEFAULTS INCLUDING GENERATED INCLUDING STORAGE INCLUDING STATISTICS);", + &mut state, + ) + .unwrap(); + + let RelationOverlay::Present(relation) = state + .get_relation(&object_id("public", "copy")) + .expect("LIKE target relation") + else { + panic!("LIKE target should be present"); + }; + let id = relation.get_column("id").expect("copied id column"); + assert!(id.default.is_some()); + assert_eq!(id.storage.as_deref(), Some("PLAIN")); + assert_eq!(id.statistics_target, None); + assert!(id.options.is_empty()); + assert_eq!( + relation + .get_column("computed") + .and_then(|column| column.generated), + Some(true), + "INCLUDING GENERATED must preserve generated-column state" + ); + assert_eq!( + relation + .generated_columns + .get("computed") + .map(|state| state.kind), + Some(safe_migrate::_internal::model::relation::GeneratedColumnKind::Stored) + ); + assert!(state.local.graph.edges().iter().any(|edge| { + matches!( + &edge.kind, + DependencyKind::ColumnGeneratedFrom { column, depends_on_column } + if edge.dependent == object_id("public", "copy") + && column == "computed" + && depends_on_column == "id" + ) + })); + } + + #[test] + fn create_table_like_clones_constraints_indexes_and_identity_objects() { + let engine = setup_engine(); + let mut state = setup_state(); + let findings = engine + .analyze( + "CREATE TABLE source ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + code text UNIQUE, + amount integer CHECK (amount > 0) + ); + CREATE INDEX source_amount_idx ON source (amount); + CREATE TABLE copy ( + LIKE source INCLUDING CONSTRAINTS INCLUDING INDEXES INCLUDING IDENTITY + );", + &mut state, + ) + .unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "LIKE catalog-object cloning unexpectedly conflicted: {findings:?}" + ); + + let copy = object_id("public", "copy"); + let Some(RelationOverlay::Present(relation)) = state.local.relations.get(©) else { + panic!("LIKE target relation missing") + }; + assert_eq!( + relation.identity_columns.get("id"), + Some(&safe_migrate::_internal::model::relation::IdentityGeneration::Always) + ); + assert!(state.local.sequences.values().any(|overlay| matches!( + overlay, + SequenceOverlay::Present(sequence) + if sequence.kind == SequenceKind::Identity + && sequence.owned_by.as_ref() == Some(&(copy.clone(), "id".to_string())) + ))); + let cloned_constraints: Vec<_> = state + .local + .constraints + .values() + .filter(|constraint| constraint.table_id == copy) + .collect(); + assert!( + cloned_constraints + .iter() + .any(|constraint| constraint.kind == ConstraintKind::Check) + ); + assert!( + cloned_constraints + .iter() + .any(|constraint| constraint.kind == ConstraintKind::PrimaryKey) + ); + assert!( + cloned_constraints + .iter() + .any(|constraint| constraint.kind == ConstraintKind::Unique) + ); + assert_eq!( + state + .local + .graph + .edges() + .iter() + .filter(|edge| { + edge.referenced == copy + && matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) + }) + .count(), + 3 + ); + } + + #[test] + fn identity_sequence_options_round_trip_into_state_and_like_clone() { + let engine = setup_engine(); + let mut state = setup_state(); + let findings = engine + .analyze( + "CREATE TABLE source ( + id integer GENERATED BY DEFAULT AS IDENTITY + (SEQUENCE NAME source_custom_seq INCREMENT BY 5 START WITH 10 + MINVALUE 5 MAXVALUE 100 CACHE 4 CYCLE) + ); + CREATE TABLE copy (LIKE source INCLUDING IDENTITY);", + &mut state, + ) + .unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "identity sequence options should be valid: {findings:?}" + ); + for table in ["source", "copy"] { + let table_id = object_id("public", table); + let sequence = state + .local + .sequences + .values() + .find_map(|overlay| match overlay { + SequenceOverlay::Present(sequence) + if sequence.kind == SequenceKind::Identity + && sequence.owned_by.as_ref() + == Some(&(table_id.clone(), "id".to_string())) => + { + Some(sequence) + } + _ => None, + }) + .expect("owned identity sequence"); + assert_eq!(sequence.parameters.increment, 5); + assert_eq!(sequence.parameters.start_value, 10); + assert_eq!(sequence.parameters.min_value, 5); + assert_eq!(sequence.parameters.max_value, 100); + assert_eq!(sequence.parameters.cache_size, 4); + assert!(sequence.parameters.cycle); + } + assert!( + state + .local + .sequences + .contains_key(&object_id("public", "source_custom_seq")) + ); + } + + #[test] + fn alter_add_identity_validates_negative_ranges_names_and_rollback() { + let engine = setup_engine(); + let mut state = setup_state(); + let findings = engine + .analyze( + "CREATE TABLE events (name text); + ALTER TABLE events ADD COLUMN id integer GENERATED ALWAYS AS IDENTITY + (INCREMENT BY -2 NO MINVALUE NO MAXVALUE START WITH -1 CACHE 3 NO CYCLE); + BEGIN; + ALTER TABLE events ADD COLUMN rolled_back bigint GENERATED BY DEFAULT AS IDENTITY; + ROLLBACK;", + &mut state, + ) + .unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "valid ALTER ADD IDENTITY conflicted: {findings:?}" + ); + let events = object_id("public", "events"); + let Some(RelationOverlay::Present(relation)) = state.get_relation(&events) else { + panic!("events relation missing") + }; + assert!(relation.identity_columns.contains_key("id")); + assert!(!relation.has_column("rolled_back")); + let sequence = state + .local + .sequences + .values() + .find_map(|overlay| match overlay { + SequenceOverlay::Present(sequence) + if sequence.owned_by.as_ref() == Some(&(events.clone(), "id".to_string())) => + { + Some(sequence) + } + _ => None, + }) + .expect("identity sequence missing"); + assert_eq!(sequence.parameters.increment, -2); + assert_eq!(sequence.parameters.start_value, -1); + assert_eq!(sequence.parameters.min_value, i32::MIN as i64); + assert_eq!(sequence.parameters.max_value, -1); + assert_eq!(sequence.parameters.cache_size, 3); + assert!(!sequence.parameters.cycle); + assert!(!state.local.sequences.values().any(|overlay| { + matches!(overlay, SequenceOverlay::Present(sequence) + if sequence.owned_by.as_ref() + == Some(&(events.clone(), "rolled_back".to_string()))) + })); + + let invalid = engine + .analyze( + "CREATE SEQUENCE occupied; + CREATE TABLE collision ( + id integer GENERATED ALWAYS AS IDENTITY (SEQUENCE NAME occupied) + ); + CREATE TABLE invalid_range ( + id integer GENERATED ALWAYS AS IDENTITY (START WITH 20 MAXVALUE 10) + );", + &mut state, + ) + .unwrap(); + assert_eq!( + invalid + .iter() + .filter(|finding| finding.rule_id == "chain-conflict") + .count(), + 2, + "invalid identity definitions must conflict: {invalid:?}" + ); + assert!(!state.relation_is_present(&object_id("public", "collision"))); + assert!(!state.relation_is_present(&object_id("public", "invalid_range"))); + } + + #[test] + fn like_extended_statistics_follow_column_lifecycle_and_rollback() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze("CREATE TABLE source (a integer, b integer);", &mut state) + .unwrap(); + let source = object_id("public", "source"); + let statistics_id = object_id("public", "source_a_b_stat"); + let Some(RelationOverlay::Present(source_relation)) = + state.local.relations.get_mut(&source) + else { + panic!("source relation missing") + }; + source_relation.extended_statistics.insert( + statistics_id.clone(), + safe_migrate::_internal::model::relation::ExtendedStatisticsState { + id: statistics_id, + kinds: vec!["d".to_string(), "f".to_string()], + columns: vec!["a".to_string(), "b".to_string()], + expressions: Some("(a + b), (a * b)".to_string()), + target: Some(250), + }, + ); + + let findings = engine + .analyze( + "CREATE TABLE copy (LIKE source INCLUDING STATISTICS); + ALTER TABLE copy RENAME COLUMN a TO renamed; + BEGIN; + ALTER TABLE copy DROP COLUMN renamed CASCADE; + ROLLBACK;", + &mut state, + ) + .unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "extended-statistics lifecycle unexpectedly conflicted: {findings:?}" + ); + let copy = object_id("public", "copy"); + let Some(RelationOverlay::Present(copy_relation)) = state.local.relations.get(©) else { + panic!("copy relation missing") + }; + let cloned = copy_relation + .extended_statistics + .values() + .next() + .expect("cloned extended statistics"); + assert_eq!(cloned.columns, vec!["renamed", "b"]); + assert_eq!( + cloned.expressions.as_deref(), + Some("(renamed + b), (renamed * b)") + ); + assert_eq!(cloned.target, None); + + let findings = engine + .analyze("ALTER TABLE copy DROP COLUMN renamed RESTRICT;", &mut state) + .unwrap(); + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict") + ); + assert!(state + .get_relation(©) + .is_some_and(|overlay| matches!(overlay, RelationOverlay::Present(relation) if relation.has_column("renamed") && !relation.extended_statistics.is_empty()))); + } + + #[test] + fn temporary_table_on_commit_drop_is_removed_with_dependents() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "BEGIN; + CREATE TEMPORARY TABLE work (id integer) ON COMMIT DROP; + CREATE INDEX work_id_idx ON work (id); + COMMIT;", + &mut state, + ) + .unwrap(); + + assert!(!state.relation_is_present(&object_id("public", "work"))); + assert!(!state.index_is_present(&object_id("public", "work_id_idx"))); + } + + #[test] + fn autocommit_runs_on_commit_drop_before_the_next_statement() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE TEMPORARY TABLE work (id integer) ON COMMIT DROP; + CREATE INDEX work_id_idx ON work (id);", + &mut state, + ) + .unwrap(); + + assert!(!state.relation_is_present(&object_id("public", "work"))); + assert!(!state.index_is_present(&object_id("public", "work_id_idx"))); + } + + #[test] + fn temporary_table_on_commit_delete_rows_preserves_its_schema() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "BEGIN; + CREATE TEMPORARY TABLE work (id integer) ON COMMIT DELETE ROWS; + COMMIT;", + &mut state, + ) + .unwrap(); + + assert!(matches!( + state.get_relation(&object_id("public", "work")), + Some(RelationOverlay::Present(relation)) + if relation.has_column("id") && relation.estimated_rows == Some(0) + )); + } + + #[test] + fn alter_table_options_update_and_reset_relation_state() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE TABLE entries (id integer); + ALTER TABLE entries SET (fillfactor = 70); + ALTER TABLE entries RESET (fillfactor);", + &mut state, + ) + .unwrap(); + let Some(RelationOverlay::Present(relation)) = + state.get_relation(&object_id("public", "entries")) + else { + panic!("relation should be present"); + }; + assert!(!relation.table_options.contains_key("fillfactor")); + } + + #[test] + fn column_metadata_defaults_clear_prior_overrides() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE TABLE entries (payload text); + ALTER TABLE entries ALTER COLUMN payload SET STORAGE MAIN; + ALTER TABLE entries ALTER COLUMN payload SET STORAGE DEFAULT; + ALTER TABLE entries ALTER COLUMN payload SET STATISTICS 450; + ALTER TABLE entries ALTER COLUMN payload SET STATISTICS DEFAULT;", + &mut state, + ) + .unwrap(); + let Some(RelationOverlay::Present(relation)) = + state.get_relation(&object_id("public", "entries")) + else { + panic!("relation should be present"); + }; + let column = relation.get_column("payload").expect("payload column"); + assert_eq!(column.storage, None); + assert_eq!(column.statistics_target, None); + } + + #[test] + fn cluster_on_requires_an_index_owned_by_the_relation() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE TABLE entries (id integer); + CREATE TABLE other (id integer); + CREATE INDEX entries_id_idx ON entries (id); + CREATE INDEX other_id_idx ON other (id); + ALTER TABLE entries CLUSTER ON entries_id_idx;", + &mut state, + ) + .unwrap(); + assert!(matches!( + state.get_relation(&object_id("public", "entries")), + Some(RelationOverlay::Present(relation)) + if relation.cluster_index.as_deref() == Some("entries_id_idx") + )); + engine + .analyze("ALTER TABLE entries CLUSTER ON other_id_idx;", &mut state) + .unwrap(); + assert!(matches!( + state.get_relation(&object_id("public", "entries")), + Some(RelationOverlay::Present(relation)) + if relation.cluster_index.as_deref() == Some("entries_id_idx") + )); + } + + #[test] + fn replica_identity_using_index_requires_a_usable_owned_index() { + let engine = setup_engine(); + let mut state = setup_state(); + let findings = engine + .analyze( + "CREATE TABLE entries (id integer NOT NULL); + CREATE TABLE other (id integer NOT NULL); + CREATE UNIQUE INDEX entries_identity_idx ON entries (id); + CREATE UNIQUE INDEX other_identity_idx ON other (id); + ALTER TABLE entries REPLICA IDENTITY USING INDEX entries_identity_idx;", + &mut state, + ) + .unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "eligible replica identity index should be accepted: {findings:?}" + ); + assert!(matches!( + state.get_relation(&object_id("public", "entries")), + Some(RelationOverlay::Present(relation)) + if relation.replica_identity.as_deref() == Some("USING INDEX entries_identity_idx") + )); + + let findings = engine .analyze( - "CREATE TABLE a(id int); ALTER TABLE a RENAME TO b;", + "ALTER TABLE entries REPLICA IDENTITY USING INDEX other_identity_idx;", &mut state, ) .unwrap(); - - assert!(!state.relation_is_present(&object_id("public", "a"))); - assert!(state.relation_is_present(&object_id("public", "b"))); assert!( - state - .local - .graph - .edges() + findings .iter() - .filter(|e| matches!( - e.kind, - safe_migrate::_internal::analysis::graph::DependencyKind::RenameTo - )) - .any(|e| e.dependent == object_id("public", "a") - && e.referenced == object_id("public", "b")) + .any(|finding| finding.rule_id == "chain-conflict") ); + assert!(matches!( + state.get_relation(&object_id("public", "entries")), + Some(RelationOverlay::Present(relation)) + if relation.replica_identity.as_deref() == Some("USING INDEX entries_identity_idx") + )); } #[test] - fn rename_back_to_original_name_does_not_loop_during_drop() { + fn typed_table_uses_the_composite_type_column_layout() { let engine = setup_engine(); let mut state = setup_state(); - - engine + let findings = engine .analyze( - "CREATE TABLE a(id int); ALTER TABLE a RENAME TO b; ALTER TABLE b RENAME TO a; DROP TABLE a;", + "CREATE TYPE address AS (street text, zip integer); + CREATE TABLE addresses OF address;", &mut state, ) .unwrap(); - - assert!(!state.relation_is_present(&object_id("public", "a"))); - } - - #[test] - fn malformed_partition_ancestry_is_rejected_without_looping() { - let a = object_id("public", "a"); - let b = object_id("public", "b"); - let child = object_id("public", "new_child"); - let mut graph = DependencyGraph::new(); - graph.add_edge(DependencyEdge::new( - a.clone(), - b.clone(), - DependencyKind::PartitionOf, - )); - graph.add_edge(DependencyEdge::new( - b, - a.clone(), - DependencyKind::PartitionOf, + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "opaque-dynamic-sql"), + "typed table should not be opaque: {findings:?}" + ); + assert!(matches!( + state.get_relation(&object_id("public", "addresses")), + Some(RelationOverlay::Present(relation)) + if relation.of_type == Some(object_id("public", "address")) + && relation.columns.iter().map(|column| column.name.as_str()).collect::>() + == vec!["street", "zip"] )); - - assert!(graph.check_partition_cycle(&a, &child)); } #[test] - fn partition_operations_reject_unpartitioned_or_unattached_targets() { + fn alter_table_of_and_not_of_validate_the_composite_layout() { let engine = setup_engine(); let mut state = setup_state(); - let violations = engine + let findings = engine .analyze( - "CREATE TABLE plain (id integer); - CREATE TABLE child (id integer); - CREATE TABLE invalid PARTITION OF plain FOR VALUES IN (1); - ALTER TABLE plain ATTACH PARTITION child FOR VALUES IN (1); - ALTER TABLE plain DETACH PARTITION child;", + "CREATE TYPE address AS (street text, zip integer); + CREATE TABLE addresses (street text, zip integer); + ALTER TABLE addresses OF address; + ALTER TABLE addresses NOT OF;", &mut state, ) .unwrap(); - - assert!(!state.relation_is_present(&object_id("public", "invalid"))); - assert!(!state.local.graph.edges().iter().any(|edge| { - matches!(edge.kind, DependencyKind::PartitionOf) - && edge.dependent == object_id("public", "child") - })); - assert_eq!( - violations + assert!( + !findings .iter() - .filter(|violation| violation.rule_id == "chain-conflict") - .count(), - 3, - "every invalid partition operation should be rejected: {violations:?}" + .any(|finding| finding.rule_id == "opaque-dynamic-sql"), + "typed-table alterations should not be opaque: {findings:?}" ); + assert!(matches!( + state.get_relation(&object_id("public", "addresses")), + Some(RelationOverlay::Present(relation)) if relation.of_type.is_none() + )); } #[test] @@ -1442,7 +3707,7 @@ mod state_mutation_tests { generation: 0, }, ); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); engine .analyze( @@ -1498,6 +3763,44 @@ mod state_mutation_tests { assert!(state.relation_is_present(&object_id("public", "copied"))); } + #[test] + fn select_into_projects_simple_source_columns_exactly() { + let engine = setup_engine(); + let mut state = setup_state(); + + let findings = engine + .analyze( + "CREATE TABLE source (id integer NOT NULL, name varchar(40)); + SELECT id AS copied_id, name INTO snapshot FROM source; + ALTER TABLE snapshot DROP COLUMN name;", + &mut state, + ) + .unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "simple SELECT INTO should remain exact: {findings:?}" + ); + assert_eq!( + state.local.confidence, + Confidence::Exact, + "unexpected evidence: {:?}", + state.evidence() + ); + let Some(RelationOverlay::Present(snapshot)) = + state.get_relation(&object_id("public", "snapshot")) + else { + panic!("SELECT INTO relation missing") + }; + let copied_id = snapshot + .get_column("copied_id") + .expect("projected alias missing"); + assert_eq!(copied_id.data_type.as_deref(), Some("integer")); + assert!(copied_id.is_nullable, "SELECT INTO does not copy NOT NULL"); + assert!(!snapshot.has_column("name")); + } + #[test] fn test_topology_type_and_domain() { let engine = setup_engine(); @@ -1542,7 +3845,7 @@ mod state_mutation_tests { }, }, ); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); engine .analyze( @@ -1882,7 +4185,7 @@ mod state_mutation_tests { security: SecurityMode::Invoker, }, ); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); engine .analyze( @@ -2032,7 +4335,7 @@ mod state_mutation_tests { }, ); } - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); engine .analyze( @@ -2079,7 +4382,7 @@ mod state_mutation_tests { }, ); } - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); engine .analyze( @@ -2121,7 +4424,7 @@ mod state_mutation_tests { }, }, ); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); engine .analyze( @@ -2154,7 +4457,7 @@ mod state_mutation_tests { }, ); } - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let violations = engine .analyze( @@ -2215,7 +4518,7 @@ mod state_mutation_tests { }, }, ); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let violations = engine.analyze(sql, &mut state).unwrap(); assert!(violations.iter().any(|violation| { @@ -2627,7 +4930,7 @@ mod state_mutation_tests { let engine = setup_engine(); let mut cache = safe_migrate::_internal::db::cache::DbCache::new(); cache.search_path = vec!["tenant_app".to_string(), "shared".to_string()]; - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); engine .analyze("CREATE TABLE first(id int);", &mut state) @@ -2881,7 +5184,7 @@ mod state_mutation_tests { security: SecurityMode::Invoker, }, ); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); for sql in [ "CREATE FUNCTION work(integer) RETURNS integer LANGUAGE sql AS $$ SELECT 1 $$;", @@ -3015,7 +5318,7 @@ mod state_mutation_tests { }, ); } - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); engine .analyze( @@ -3083,7 +5386,7 @@ mod state_mutation_tests { security: SecurityMode::Invoker, }, ); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let violations = engine.analyze(sql, &mut state).unwrap(); assert!( @@ -3152,7 +5455,7 @@ mod state_mutation_tests { cache .functions .insert(object_id("public", "work(integer)"), routine(kind)); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); assert!(matches!( state.apply(&drop, None), MutationResult::Conflict { .. } @@ -3165,7 +5468,8 @@ mod state_mutation_tests { object_id("public", "work(integer)"), routine(RoutineKind::Function), ); - let mut wrong_kind_state = safe_migrate::api::AnalysisState::new(wrong_kind_cache); + let mut wrong_kind_state = + crate::_internal::analysis::state::AnalysisState::new(wrong_kind_cache); assert!(matches!( wrong_kind_state.apply(&aggregate_drop("public", true), None), MutationResult::Conflict { .. } @@ -3189,14 +5493,14 @@ mod state_mutation_tests { ] { let mut cache = DbCache::new(); cache.metadata.schemas = Some(vec!["public".into()]); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); assert_eq!(state.apply(&drop, None), MutationResult::Skipped); assert_eq!(state.local.confidence, Confidence::Tainted); } let mut cache = DbCache::new(); cache.metadata.schemas = Some(vec!["public".into()]); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); assert_eq!( state.apply(&function_drop("tenant", true), None), MutationResult::Skipped @@ -3276,7 +5580,7 @@ mod state_mutation_tests { } #[test] - fn exact_v6_baseline_rejects_an_alter_of_a_missing_publication() { + fn complete_v7_baseline_rejects_an_alter_of_a_missing_publication_exactly() { let engine = setup_engine(); let mut state = setup_state(); @@ -3296,11 +5600,7 @@ mod state_mutation_tests { && violation.reason.contains("missing_pub") && violation.reason.contains("does not exist") })); - assert_eq!( - state.local.confidence, - Confidence::Tainted, - "FOR ALL TABLES publication state depends on catalog-wide inheritance knowledge" - ); + assert_eq!(state.local.confidence, Confidence::Exact); } #[test] @@ -3322,7 +5622,7 @@ mod state_mutation_tests { let mut scoped_cache = DbCache::new(); scoped_cache.metadata.schemas = Some(vec!["public".into()]); - let mut scoped_state = safe_migrate::api::AnalysisState::new(scoped_cache); + let mut scoped_state = crate::_internal::analysis::state::AnalysisState::new(scoped_cache); let violations = engine .analyze( "CREATE PUBLICATION external_pub FOR TABLE tenant.entries;", @@ -3394,7 +5694,7 @@ mod state_mutation_tests { generation: 0, }, ); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let violations = engine .analyze( @@ -3477,7 +5777,7 @@ mod state_mutation_tests { generation: 0, }, ); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let initial_generation = state.local.generation_counter; for (mode, publications) in [ @@ -3543,7 +5843,7 @@ mod state_mutation_tests { generation: 0, }, ); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); engine.analyze("DROP TABLE entries;", &mut state).unwrap(); @@ -3564,6 +5864,7 @@ mod state_mutation_tests { fn cached_publication_parent_edits_are_tainted_without_inheritance_catalogs() { let engine = setup_engine(); let mut cache = cache_with_table("public", "parent", None); + cache.coverage.families.remove(&CatalogFamily::Inheritance); cache.publications.insert( "changes".into(), safe_migrate::_internal::model::replication::PublicationState { @@ -3588,7 +5889,8 @@ mod state_mutation_tests { }, ); - let mut inherited_state = safe_migrate::api::AnalysisState::new(cache.clone()); + let mut inherited_state = + crate::_internal::analysis::state::AnalysisState::new(cache.clone()); engine .analyze( "ALTER PUBLICATION changes DROP TABLE parent *;", @@ -3597,7 +5899,7 @@ mod state_mutation_tests { .unwrap(); assert_eq!(inherited_state.local.confidence, Confidence::Tainted); - let mut only_state = safe_migrate::api::AnalysisState::new(cache); + let mut only_state = crate::_internal::analysis::state::AnalysisState::new(cache); engine .analyze( "ALTER PUBLICATION changes DROP TABLE ONLY parent;", @@ -3607,6 +5909,41 @@ mod state_mutation_tests { assert_eq!(only_state.local.confidence, Confidence::Exact); } + #[test] + fn complete_inheritance_catalog_keeps_publication_parent_edits_exact() { + let engine = setup_engine(); + let mut cache = cache_with_table("public", "parent", None); + let child = object_id("public", "child"); + cache.insert_baseline( + child.clone(), + RelationState::new( + child.clone(), + object_id("public", "postgres"), + 0, + None, + RelationKind::Table, + Persistence::Permanent, + 0, + ), + ); + cache + .inheritances + .push(safe_migrate::_internal::db::cache::InheritanceCache { + child, + parent: object_id("public", "parent"), + sequence: 1, + is_partition: false, + detach_pending: false, + }); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); + + engine + .analyze("CREATE PUBLICATION changes FOR TABLE parent *;", &mut state) + .unwrap(); + + assert_eq!(state.local.confidence, Confidence::Exact); + } + #[test] fn subscription_publisher_operations_taint_and_slot_drops_obey_transaction_rules() { let engine = setup_engine(); @@ -3855,7 +6192,7 @@ mod state_mutation_tests { privileges.grant(reader.clone(), select.clone()); privileges.grant_options.insert(reader.clone(), select); } - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let _findings = engine .analyze( @@ -3904,7 +6241,7 @@ mod state_mutation_tests { [Privilege::Select].into_iter().collect(), ); cache.insert_baseline(table_id, relation); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let findings = engine .analyze( @@ -4005,7 +6342,7 @@ mod state_mutation_tests { .privileges .grant_with_option(parent, [Privilege::Select].into_iter().collect()); cache.insert_baseline(table_id, relation); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let violations = engine .analyze( @@ -4084,7 +6421,7 @@ mod state_mutation_tests { .privileges .grant_with_option(parent, [Privilege::Select].into_iter().collect()); cache.insert_baseline(table_id, relation); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let violations = engine .analyze( @@ -4152,7 +6489,7 @@ mod state_mutation_tests { .privileges .grant_with_option(parent, [Privilege::Select].into_iter().collect()); cache.insert_baseline(table_id, relation); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let findings = engine .analyze( @@ -4214,7 +6551,7 @@ mod state_mutation_tests { .privileges .grant_with_option(parent, [Privilege::Select].into_iter().collect()); cache.insert_baseline(table_id, relation); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let findings = engine .analyze( @@ -4271,7 +6608,7 @@ mod state_mutation_tests { }, ); } - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let findings = engine .analyze( @@ -4336,7 +6673,7 @@ mod state_mutation_tests { 0, ), ); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); engine .analyze( "GRANT SELECT ON cascade_grant_table TO grant_delegate WITH GRANT OPTION; SET ROLE grant_delegate; GRANT SELECT ON cascade_grant_table TO grant_reader WITH GRANT OPTION; SET ROLE grant_owner;", @@ -4373,7 +6710,7 @@ mod state_mutation_tests { use safe_migrate::_internal::model::role::RoleState; let engine = setup_engine(); - let mut cache = safe_migrate::api::DbCache::new(); + let mut cache = crate::_internal::db::cache::DbCache::new(); let table_id = object_id("public", "revoke_all_table"); let owner = object_id("", "owner"); let intermediate = object_id("", "intermediate"); @@ -4411,7 +6748,7 @@ mod state_mutation_tests { 0, ), ); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); engine .analyze( "GRANT SELECT, UPDATE ON revoke_all_table TO intermediate WITH GRANT OPTION; SET ROLE intermediate; GRANT SELECT ON revoke_all_table TO leaf; SET ROLE owner;", @@ -4472,9 +6809,11 @@ mod state_mutation_tests { trigger_id: object_id("public", "check_trigger"), table_id: object_id("public", "test_table"), function_id: object_id("public", "check_row()"), + row_level: true, + parent_trigger_id: None, enabled_mode: TriggerEnableMode::Origin, }); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); engine .analyze( @@ -4552,8 +6891,13 @@ mod state_mutation_tests { avg_width: None, default_expr_text: None, type_modifier: Some(-1), + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, }); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); engine .analyze( "ALTER TABLE t_large ADD CONSTRAINT positive_id CHECK (id > 0);", @@ -4599,6 +6943,11 @@ mod state_mutation_tests { avg_width: None, default_expr_text: None, type_modifier: Some(-1), + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, }, Column { name: "note".to_string(), @@ -4609,6 +6958,11 @@ mod state_mutation_tests { avg_width: None, default_expr_text: None, type_modifier: Some(-1), + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, }, ]); cache.constraints.push( @@ -4617,6 +6971,7 @@ mod state_mutation_tests { name: "accounts_note_check".to_string(), kind: ConstraintKind::Check, validated: true, + definition: Some("note IS NOT NULL".to_string()), backing_index: None, }, ); @@ -4628,7 +6983,8 @@ mod state_mutation_tests { columns: vec!["note".to_string()], }); - let mut drop_constraint_state = safe_migrate::api::AnalysisState::new(cache.clone()); + let mut drop_constraint_state = + crate::_internal::analysis::state::AnalysisState::new(cache.clone()); engine .analyze( "ALTER TABLE accounts DROP CONSTRAINT accounts_note_check;", @@ -4650,7 +7006,7 @@ mod state_mutation_tests { }) ); - let mut state = safe_migrate::api::AnalysisState::new(cache.clone()); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache.clone()); let findings = engine .analyze("ALTER TABLE accounts DROP COLUMN note;", &mut state) .unwrap(); @@ -4664,7 +7020,7 @@ mod state_mutation_tests { Some(RelationOverlay::Present(relation)) if relation.has_column("note") )); - let mut cascade_state = safe_migrate::api::AnalysisState::new(cache); + let mut cascade_state = crate::_internal::analysis::state::AnalysisState::new(cache); let findings = engine .analyze( "ALTER TABLE accounts DROP COLUMN note CASCADE;", @@ -4708,6 +7064,11 @@ mod state_mutation_tests { avg_width: None, default_expr_text: None, type_modifier: Some(-1), + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: Some(false), }, Column { name: "derived".to_string(), @@ -4718,6 +7079,11 @@ mod state_mutation_tests { avg_width: None, default_expr_text: None, type_modifier: Some(-1), + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: Some(true), }, Column { name: "derived_twice".to_string(), @@ -4728,6 +7094,11 @@ mod state_mutation_tests { avg_width: None, default_expr_text: None, type_modifier: Some(-1), + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: Some(true), }, ]); cache @@ -4750,6 +7121,7 @@ mod state_mutation_tests { name: "derived_twice_check".to_string(), kind: ConstraintKind::Check, validated: true, + definition: Some("derived_twice > 0".to_string()), backing_index: None, }, ); @@ -4774,6 +7146,7 @@ mod state_mutation_tests { has_expression_keys: false, has_predicate: false, is_unique: false, + is_immediate: true, is_valid: true, is_ready: true, is_live: true, @@ -4781,7 +7154,7 @@ mod state_mutation_tests { has_default_opclasses: true, has_default_collations: true, }); - let mut state = safe_migrate::api::AnalysisState::new(cache.clone()); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache.clone()); let findings = engine .analyze("ALTER TABLE metrics DROP COLUMN derived;", &mut state) @@ -4804,7 +7177,8 @@ mod state_mutation_tests { Some(RelationOverlay::Present(relation)) if relation.has_column("derived") )); - let mut derived_cascade_state = safe_migrate::api::AnalysisState::new(cache.clone()); + let mut derived_cascade_state = + crate::_internal::analysis::state::AnalysisState::new(cache.clone()); let findings = engine .analyze( "ALTER TABLE metrics DROP COLUMN derived CASCADE;", @@ -4828,7 +7202,7 @@ mod state_mutation_tests { .contains_key(&(table.clone(), "derived_twice_check".to_string())) ); - let mut source_state = safe_migrate::api::AnalysisState::new(cache.clone()); + let mut source_state = crate::_internal::analysis::state::AnalysisState::new(cache.clone()); let findings = engine .analyze("ALTER TABLE metrics DROP COLUMN source;", &mut source_state) .unwrap(); @@ -4837,7 +7211,7 @@ mod state_mutation_tests { .iter() .any(|finding| finding.rule_id == "chain-conflict") ); - let mut cascade_state = safe_migrate::api::AnalysisState::new(cache); + let mut cascade_state = crate::_internal::analysis::state::AnalysisState::new(cache); let findings = engine .analyze( "ALTER TABLE metrics DROP COLUMN source CASCADE;", @@ -4882,6 +7256,11 @@ mod state_mutation_tests { avg_width: None, default_expr_text: Some("nextval('public.event_seq'::regclass)".to_string()), type_modifier: Some(-1), + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, }); cache.sequences.insert( sequence.clone(), @@ -4890,6 +7269,7 @@ mod state_mutation_tests { owner: object_id("public", "postgres"), owned_by: None, kind: SequenceKind::Standalone, + parameters: Default::default(), generation: 0, }, ); @@ -4901,7 +7281,7 @@ mod state_mutation_tests { }, ); - let mut state = safe_migrate::api::AnalysisState::new(cache.clone()); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache.clone()); let findings = engine .analyze("DROP SEQUENCE event_seq;", &mut state) .unwrap(); @@ -4911,7 +7291,8 @@ mod state_mutation_tests { .any(|finding| finding.rule_id == "chain-conflict") ); - let mut cascade_state = safe_migrate::api::AnalysisState::new(cache.clone()); + let mut cascade_state = + crate::_internal::analysis::state::AnalysisState::new(cache.clone()); let findings = engine .analyze("DROP SEQUENCE event_seq CASCADE;", &mut cascade_state) .unwrap(); @@ -4933,7 +7314,8 @@ mod state_mutation_tests { .is_some_and(|column| column.default_expr_text.is_none()) )); - let mut table_drop_state = safe_migrate::api::AnalysisState::new(cache.clone()); + let mut table_drop_state = + crate::_internal::analysis::state::AnalysisState::new(cache.clone()); let findings = engine .analyze("DROP TABLE events;", &mut table_drop_state) .unwrap(); @@ -4963,6 +7345,7 @@ mod state_mutation_tests { owner: object_id("public", "postgres"), owned_by: None, kind: SequenceKind::Standalone, + parameters: Default::default(), generation: 0, }, ); @@ -4983,7 +7366,8 @@ mod state_mutation_tests { .find(|column| column.name == "event_id") .expect("baseline column"); column.default_expr_text = Some("nextval('event_seq'::regclass)".to_string()); - let mut same_name_state = safe_migrate::api::AnalysisState::new(same_name_cache); + let mut same_name_state = + crate::_internal::analysis::state::AnalysisState::new(same_name_cache); engine .analyze( "DROP SEQUENCE public.event_seq CASCADE;", @@ -4996,7 +7380,7 @@ mod state_mutation_tests { if relation.get_column("event_id").is_some_and(|column| column.default_expr_text.is_some()) )); - let mut rollback_state = safe_migrate::api::AnalysisState::new(cache); + let mut rollback_state = crate::_internal::analysis::state::AnalysisState::new(cache); engine .analyze("BEGIN; DROP TABLE events; ROLLBACK;", &mut rollback_state) .unwrap(); @@ -5058,13 +7442,43 @@ mod state_mutation_tests { })); } + #[test] + fn create_sequence_applies_all_catalog_parameters() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE UNLOGGED SEQUENCE public.parameterized_seq AS integer \ + INCREMENT BY -3 MINVALUE -99 MAXVALUE -3 START WITH -3 CACHE 7 CYCLE;", + &mut state, + ) + .unwrap(); + + let id = object_id("public", "parameterized_seq"); + let Some(SequenceOverlay::Present(sequence)) = state.local.sequences.get(&id) else { + panic!("sequence was not created"); + }; + assert_eq!(sequence.parameters.data_type, "integer"); + assert_eq!(sequence.parameters.increment, -3); + assert_eq!(sequence.parameters.min_value, -99); + assert_eq!(sequence.parameters.max_value, -3); + assert_eq!(sequence.parameters.start_value, -3); + assert_eq!(sequence.parameters.cache_size, 7); + assert!(sequence.parameters.cycle); + assert_eq!( + sequence.parameters.persistence, + safe_migrate::_internal::model::sequence::SequencePersistence::Unlogged + ); + } + #[test] fn test_state_adds_named_unique_constraint() { use safe_migrate::_internal::model::constraint::ConstraintKind; let engine = setup_engine(); - let mut state = - safe_migrate::api::AnalysisState::new(cache_with_table("public", "t_large", None)); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache_with_table( + "public", "t_large", None, + )); engine .analyze( "ALTER TABLE t_large ADD CONSTRAINT unique_id UNIQUE (id);", @@ -5178,7 +7592,7 @@ mod state_mutation_tests { security: SecurityMode::Invoker, }, ); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); engine .analyze("DROP FUNCTION f_safe(VARIADIC INT[]);", &mut state) .unwrap(); @@ -5370,6 +7784,76 @@ mod state_mutation_tests { } } + #[test] + fn generated_check_names_avoid_other_tables_in_same_schema() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE SCHEMA other; + CREATE TABLE other.holder (id integer + CONSTRAINT target_id_check CHECK (id > 0) + CONSTRAINT target_id_check1 CHECK (id < 100)); + CREATE TABLE public.holder (id integer CONSTRAINT target_id_check CHECK (id > 0)); + CREATE TABLE public.target (id integer CHECK (id > 0)); + CREATE TABLE other.target (id integer CHECK (id > 0));", + &mut state, + ) + .unwrap(); + for (schema, name) in [ + ("public", "target_id_check1"), + ("other", "target_id_check2"), + ] { + assert!( + state + .local + .constraints + .contains_key(&(object_id(schema, "target"), name.into())) + ); + } + } + + #[test] + fn check_names_use_one_distinct_column_for_create_and_alter() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE TABLE check_names (id integer, other integer, + CHECK (id > 0 AND id < 100), CHECK (id < other)); + ALTER TABLE check_names ADD CHECK (id > 1 AND id < 99);", + &mut state, + ) + .unwrap(); + let table = object_id("public", "check_names"); + for name in [ + "check_names_id_check", + "check_names_check", + "check_names_id_check1", + ] { + assert!( + state + .local + .constraints + .contains_key(&(table.clone(), name.into())), + "missing {name}" + ); + } + let findings = engine + .analyze( + "ALTER TABLE check_names DROP CONSTRAINT check_names_id_check1; + ALTER TABLE check_names RENAME CONSTRAINT check_names_id_check TO bounded_id;", + &mut state, + ) + .unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "{findings:?}" + ); + } + #[test] fn generated_constraint_names_follow_postgres_identifier_length_limit() { let engine = setup_engine(); @@ -5510,10 +7994,15 @@ mod state_mutation_tests { avg_width: None, default_expr_text: None, type_modifier: None, + storage: None, + compression: None, + statistics_target: None, + options: Default::default(), + generated: None, }); let mut cache = DbCache::new(); cache.insert_baseline(table_id, relation); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let violations = engine .analyze( "ALTER TABLE reservations ADD CONSTRAINT no_overlap @@ -5615,7 +8104,7 @@ mod state_mutation_tests { }, ); } - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); assert_eq!(state.local.current_role, "app_user"); assert_eq!(state.local.session_role, "app_user"); @@ -5849,7 +8338,7 @@ mod state_mutation_tests { 0, ), ); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); engine .analyze( @@ -5883,7 +8372,7 @@ mod state_mutation_tests { 0, ), ); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); engine .analyze( @@ -5923,10 +8412,11 @@ mod state_mutation_tests { owner: object_id("", "old_owner"), owned_by: Some((table.clone(), "id".into())), kind: SequenceKind::Owned, + parameters: Default::default(), generation: 0, }, ); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); engine .analyze( @@ -5963,7 +8453,7 @@ mod state_mutation_tests { can_set_role_to: Vec::new(), }, ); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let violations = engine .analyze("SET ROLE role_that_does_not_exist;", &mut state) @@ -6002,7 +8492,7 @@ mod state_mutation_tests { }, ); } - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let violations = engine .analyze( @@ -6046,7 +8536,7 @@ mod state_mutation_tests { }, ); } - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let violations = engine.analyze("SET ROLE target;", &mut state).unwrap(); @@ -6076,7 +8566,7 @@ mod state_mutation_tests { }, ); } - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let violations = engine .analyze("GRANT parent TO member; SET ROLE parent;", &mut state) @@ -6136,7 +8626,7 @@ mod state_mutation_tests { }, ); } - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let violations = engine .analyze( @@ -6213,9 +8703,12 @@ mod state_mutation_tests { member: member.clone(), role: parent.clone(), grantor: object_id("", "admin"), + admin: false, + inherit: true, + set: true, }, ); - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); engine .analyze( @@ -6252,7 +8745,7 @@ mod state_mutation_tests { }, ); } - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let result = engine.analyze("GRANT parent TO member, PUBLIC WITH SET TRUE;", &mut state); assert!(result.is_ok()); let role = state @@ -6287,7 +8780,7 @@ mod state_mutation_tests { }, ); } - let mut state = safe_migrate::api::AnalysisState::new(cache); + let mut state = crate::_internal::analysis::state::AnalysisState::new(cache); let _ = engine .analyze("GRANT role_a, role_b TO role_b, role_a;", &mut state) .unwrap(); @@ -6299,4 +8792,126 @@ mod state_mutation_tests { assert!(role.member_of.is_empty()); } } + + #[test] + fn rollback_restores_every_destructively_rewritten_graph_edge() { + let engine = setup_engine(); + let mut state = setup_state(); + state.pg_version_num = Some(180_000); + + let findings = engine + .analyze( + "CREATE TABLE items (id integer NOT NULL); + CREATE INDEX items_idx ON items (id); + ALTER TABLE items ADD CONSTRAINT items_check CHECK (id > 0) NOT VALID; + CREATE TABLE parent (id integer) PARTITION BY RANGE (id); + CREATE TABLE child PARTITION OF parent FOR VALUES FROM (0) TO (10); + BEGIN; + DROP INDEX items_idx; + ALTER TABLE items DROP CONSTRAINT items_check; + ALTER TABLE items ALTER COLUMN id DROP NOT NULL; + ALTER TABLE parent DETACH PARTITION child; + ROLLBACK;", + &mut state, + ) + .unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "rollback setup unexpectedly conflicted: {findings:?}" + ); + + let items = object_id("public", "items"); + let index = object_id("public", "items_idx"); + let parent = object_id("public", "parent"); + let child = object_id("public", "child"); + assert!(matches!( + state.get_relation(&child), + Some(RelationOverlay::Present(relation)) if relation.has_column("id") + )); + assert!(state.local.graph.edges().iter().any(|edge| { + edge.dependent == index + && edge.referenced == items + && matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) + })); + assert!(state.local.graph.edges().iter().any(|edge| { + edge.dependent == items + && matches!( + &edge.kind, + DependencyKind::ConstraintDependency { + constraint_name, + .. + } if constraint_name == "items_check" + ) + })); + assert!(state.local.graph.edges().iter().any(|edge| { + edge.dependent == items + && matches!( + &edge.kind, + DependencyKind::ConstraintOnRelation { + columns, + is_primary: false, + .. + } if columns == &["id"] + ) + })); + assert!(state.local.graph.edges().iter().any(|edge| { + edge.dependent == child + && edge.referenced == parent + && matches!(edge.kind, DependencyKind::PartitionOf) + })); + } + + #[test] + fn alter_rule_modes_validate_catalog_identity_and_rollback() { + let engine = setup_engine(); + let mut state = setup_state(); + let table_id = object_id("public", "events"); + engine + .analyze("CREATE TABLE events (id integer);", &mut state) + .unwrap(); + let Some(RelationOverlay::Present(relation)) = state.local.relations.get_mut(&table_id) + else { + panic!("created relation missing from state") + }; + relation.rules.insert( + "rewrite_rule".to_string(), + safe_migrate::_internal::model::relation::RuleEnableMode::Origin, + ); + + let findings = engine + .analyze( + "ALTER TABLE events ENABLE REPLICA RULE rewrite_rule; + ALTER TABLE events ENABLE ALWAYS RULE rewrite_rule; + BEGIN; + ALTER TABLE events DISABLE RULE rewrite_rule; + ROLLBACK;", + &mut state, + ) + .unwrap(); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "valid rule mode changes unexpectedly conflicted: {findings:?}" + ); + let Some(RelationOverlay::Present(relation)) = state.local.relations.get(&table_id) else { + panic!("relation missing after rule mode rollback") + }; + assert_eq!( + relation.rules.get("rewrite_rule"), + Some(&safe_migrate::_internal::model::relation::RuleEnableMode::Always) + ); + + let findings = engine + .analyze("ALTER TABLE events ENABLE RULE missing_rule;", &mut state) + .unwrap(); + assert!(findings.iter().any(|finding| { + finding.rule_id == "chain-conflict" + && finding + .reason + .contains("rule 'missing_rule' does not exist") + })); + } } diff --git a/tests/transaction_lifecycle.rs b/tests/transaction_lifecycle.rs index 26e78d0d..4bc669e1 100644 --- a/tests/transaction_lifecycle.rs +++ b/tests/transaction_lifecycle.rs @@ -1,5 +1,3 @@ -mod common; - mod transaction_lifecycle_tests { use crate::common::*; use safe_migrate::_internal::analysis::state::AnalysisState; diff --git a/tests/v045_state.rs b/tests/v045_state.rs index 5928812d..64106202 100644 --- a/tests/v045_state.rs +++ b/tests/v045_state.rs @@ -1,6 +1,5 @@ -mod common; - -use common::{object_id, setup_engine}; +use crate::_internal::analysis::state::AnalysisState; +use crate::common::{object_id, setup_engine}; use safe_migrate::_internal::analysis::facts::{PublicationObjectFact, PublicationScope}; use safe_migrate::_internal::analysis::graph::DependencyKind; use safe_migrate::_internal::db::cache::{DbCache, IndexCache}; @@ -9,7 +8,6 @@ use safe_migrate::_internal::model::role::RoleState; use safe_migrate::_internal::model::schema::{SchemaOverlay, SchemaState}; use safe_migrate::_internal::model::sequence::{SequenceKind, SequenceOverlay, SequenceState}; use safe_migrate::_internal::model::types::{TypeKind, TypeState}; -use safe_migrate::api::AnalysisState; fn cache_with_public_schema() -> DbCache { let mut cache = DbCache::new(); @@ -64,6 +62,7 @@ fn cache_v5_hydrates_schema_sequence_and_ownership_edge() { owner: object_id("", "owner"), owned_by: Some((table_id.clone(), "id".into())), kind: SequenceKind::SerialLike, + parameters: Default::default(), generation: 0, }, ); @@ -159,6 +158,7 @@ fn schema_rename_remaps_namespace_and_rolls_back_atomically() { owner, owned_by: Some((table.clone(), "id".into())), kind: SequenceKind::Owned, + parameters: Default::default(), generation: 0, }, ); @@ -308,6 +308,7 @@ fn table_set_schema_moves_the_relation_and_preserves_baseline_origin() { owner: object_id("", "owner"), owned_by: Some((old_id.clone(), "id".into())), kind: SequenceKind::SerialLike, + parameters: Default::default(), generation: 0, }, ); @@ -323,6 +324,7 @@ fn table_set_schema_moves_the_relation_and_preserves_baseline_origin() { has_expression_keys: false, has_predicate: false, is_unique: false, + is_immediate: true, is_valid: true, is_ready: true, is_live: true, diff --git a/tests/v060_timeouts.rs b/tests/v060_timeouts.rs index 14f40949..5cf2fca7 100644 --- a/tests/v060_timeouts.rs +++ b/tests/v060_timeouts.rs @@ -1,10 +1,10 @@ -use std::collections::HashMap; +use std::collections::BTreeMap; use safe_migrate::_internal::analysis::state::{AnalysisState, Confidence}; use safe_migrate::_internal::db::cache::DbCache; -use safe_migrate::_internal::engine::config::{Config, RuleConfig}; use safe_migrate::_internal::engine::engine::SafeMigrateEngine; use safe_migrate::_internal::report::violations::Violation; +use safe_migrate::api::{Config, RuleConfig}; fn cache_with_timeouts(lock_timeout_ms: u64, statement_timeout_ms: u64) -> DbCache { let mut cache = DbCache::new(); @@ -256,7 +256,7 @@ fn timeout_findings_deduplicate_once_per_file_and_can_be_disabled() { assert_eq!(timeout_findings(&violations).len(), 2); let config = Config { - rules: HashMap::from([ + rules: BTreeMap::from([ ( "require-lock-timeout".to_string(), RuleConfig {