diff --git a/.github/ISSUE_TEMPLATE/database-feedback.yml b/.github/ISSUE_TEMPLATE/database-feedback.yml index 54c4ab7..74fadd1 100644 --- a/.github/ISSUE_TEMPLATE/database-feedback.yml +++ b/.github/ISSUE_TEMPLATE/database-feedback.yml @@ -13,7 +13,7 @@ body: attributes: label: safe-migrate version description: Paste the output of `safe-migrate --version`. - placeholder: safe-migrate 0.6.2 + placeholder: safe-migrate 0.7.0 validations: required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdce373..c7ce0c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,9 +44,16 @@ jobs: - name: Build (locked) run: cargo build --locked --verbose + - name: Verify frozen-cache fixtures + working-directory: live_tests + run: ./run.sh + - name: Run tests (locked) run: cargo test --locked --verbose + - name: Verify crate package + run: cargo package --locked + - name: Run generated migration fuzz corpus run: scripts/fuzz @@ -59,6 +66,47 @@ jobs: test "$(cargo-audit --version)" = "cargo-audit 0.22.2" cargo audit + msrv: + name: Rust 1.94 MSRV + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust 1.94 + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable 2026-07-16 + with: + toolchain: 1.94.0 + + - name: Check all targets with the MSRV + run: cargo check --all-targets --locked + + platform-smoke: + name: Runtime smoke (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable 2026-07-16 + + - name: Build runtime + run: cargo build --locked + + - name: Exercise CLI runtime + run: cargo test --locked --test cli_tests test_cli_help + live-differential: name: PostgreSQL ${{ matrix.postgres }} differential harness runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 7b65033..5221057 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ graph.json AGENTS.md .git/ *.json +!tests/golden/*.json !live_tests/differential_baseline.sql !live_tests/differential_manifest.json !live_tests/**/*.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c19c59..6c84776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ 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.7.0 — 2026-08-30 + +- Added semantic Cache V6 validation and durable cache replacement; invalid + cache, configuration, rollback, and report state now produces diagnostics. +- Matched PostgreSQL's 63-byte identifier behavior, including UTF-8-safe + truncation, and added PostgreSQL 17 `MAINTAIN`/`GRANT ALL` support. +- Added inline foreign-key, CHECK, exclusion, and `NOT VALID` constraint state, + with validation tracking and generated-constraint name reservation. +- Fixed cascade cleanup for foreign keys, views, indexes, triggers, and + sequence-backed defaults, including cross-schema `DROP SCHEMA ... CASCADE`. +- Made scoped multi-target drops atomic and tightened validation for sequence + ownership, trigger targets, partition changes, and dependent routines. +- Improved view dependency extraction and catalog filtering, preventing casts, + function expressions, and unrelated catalog rows from creating false edges. +- Added conservative handling for incomplete baseline evidence, including + expression indexes, inherited/publication tables, and type rewrite safety. +- Routed unsupported `ALTER TABLE`/type/view/materialized-view actions, + copied-table forms, unmodeled role options, and incomplete domain/type forms + to explicit tainted analysis rather than recording an exact no-op. +- Preserved relevant safety findings when an operation is skipped because cache + evidence is incomplete, including irreversible drops and `WITH GRANT OPTION`. + ## v0.6.2 — 2026-08-27 - Added reproducible, ignored performance scenarios for large synchronized diff --git a/Cargo.lock b/Cargo.lock index 968af07..faa02ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,9 +217,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cipher", @@ -1020,7 +1020,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "safe-migrate" -version = "0.6.2" +version = "0.7.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 1c21858..ffce865 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "safe-migrate" -version = "0.6.2" +version = "0.7.0" edition = "2024" rust-version = "1.94" description = "Sync PostgreSQL metadata, then lint migrations offline" diff --git a/README.md b/README.md index 6870535..a626116 100644 --- a/README.md +++ b/README.md @@ -82,11 +82,9 @@ SQL alone cannot show the existing schema, table statistics, dependencies, role and search-path context, or inherited timeout settings. `sync` captures that baseline once, so later `lint` runs are offline and review the same state. -Cache V6 includes all routine kinds, publications, and redacted subscription -metadata as well as ordinary schema state. It never stores subscription -connection strings. Refresh publisher-side state, or a publication edit that -does not use `ONLY`, remains `Tainted` when PostgreSQL inheritance or remote -publisher state would decide the outcome. +The cache records schema, dependency, privilege, and statistics metadata, but +never connection credentials or password hashes. Treat it as sensitive and do +not publish it. Sync with the database, role, and defaults used by the migration runner; it only reads them. Refresh the cache when that baseline changes. If the runner @@ -240,8 +238,8 @@ no command-line flag to enable it. Use `--no-auto-sync` to suppress it for one lint run. If refresh fails, safe-migrate prints the cause and continues with the previous readable cache; the old cache is replaced only after a new cache has been written successfully. `--no-cache` also bypasses automatic sync. The -previous cache must already be V6; an unsupported V1–V5 cache cannot be reused -after a failed refresh. +existing cache must use a supported format; otherwise rerun `sync` once +database access is available. ### Cache encryption @@ -257,10 +255,6 @@ The key is accepted only through the environment. Encrypted mode rejects plaintext caches, and plaintext mode rejects encrypted caches. Changing modes requires a fresh `sync`. -Cache files contain schema and role names, dependencies, privileges, and -statistics. They do not contain connection credentials or password hashes. -Treat cache files as sensitive and do not publish them. - ### Cache compatibility When safe-migrate encounters an unsupported cache format, rebuild it from the @@ -270,9 +264,8 @@ database: safe-migrate sync ``` -v0.6.0 introduces Cache V6 for synchronized timeout provenance, the complete -routine namespace, publications, and redacted subscriptions. Every V1–V5 cache -requires resynchronization. +Cache formats are checked before use. If a format is unsupported, rebuild the +cache with `safe-migrate sync`; no migration SQL is changed. Use `safe-migrate cache inspect` to view cache provenance and redacted object and role counts without connecting to PostgreSQL. It never lists role names or @@ -292,14 +285,8 @@ Pull-request job GitHub Actions cache -> runner baseline file -> lint-chain -> reports ``` -The Action uses -`~/.cache/safe-migrate-action/baselines//baseline-v6.cache` on the -runner. After a successful sync, it saves that file in GitHub Actions cache -under the `default` baseline name. A pull-request run restores the file to the -same managed path, then runs `lint-chain` with it; it does not connect to -PostgreSQL or run `sync` again. GitHub-hosted runners are discarded after the -job; on self-hosted runners the Action clears the selected baseline before each -restore. +The trusted workflow refreshes the baseline. Pull-request workflows restore it +and run `lint-chain` without connecting to PostgreSQL. ### 1. Refresh the baseline @@ -310,7 +297,7 @@ and GitHub cache contents are not signed. Store a 64-character hexadecimal key as `SAFE_MIGRATE_CACHE_KEY` and pass it to both workflows. ```yaml -- uses: dsecurity49/safe-migrate@v0.6.2 +- uses: dsecurity49/safe-migrate@v0.7.0 env: DATABASE_URL: ${{ secrets.SAFE_MIGRATE_DATABASE_URL }} SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }} @@ -329,7 +316,7 @@ Replace `public` with the schemas that contain your migrations, or omit Add this after checkout in the pull-request workflow: ```yaml -- uses: dsecurity49/safe-migrate@v0.6.2 +- uses: dsecurity49/safe-migrate@v0.7.0 env: SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }} with: diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index c2ecd0a..4405b58 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -1,7 +1,7 @@ # Local benchmark baseline This document records reproducible, non-CI performance scenarios. The values -are comparison points for later `0.6.x` work, not performance guarantees. +are comparison points for the later `0.7.0` work, not performance guarantees. Run them with: ```sh @@ -36,3 +36,111 @@ intentionally avoid timing thresholds in CI. Allocation, peak-memory, checkpoint-capture, and isolated dependency-query measurements require a profiler or allocator instrumentation and are deliberately not inferred from these wall-clock samples. + +## Optimized-profile `v0.7.0` structural baseline + +Captured on 2026-08-28 from commit `b639b04` using Rust 1.98.0 on the same +aarch64 Android Linux environment. This run uses Cargo's optimized `release` +profile and is the comparison point for evidence-gated `v0.7.0` work; it is not +comparable to the debug timings above and is not a performance guarantee. + +| Scenario | Statements | Elapsed | +| --- | ---: | ---: | +| ordered thousand-statement chain | 1,000 | 6,652 ms | +| large synchronized-baseline hydration | 1,000 relations | 54 ms | +| cache encode/compress/encrypt/decrypt/decompress/decode | 1,000 relations | 24 ms | +| long transaction rollback | 503 | 2,673 ms | +| repeated savepoint rollback | 752 | 308 ms | +| failed multi-action statement rollback | 3 | 1 ms | +| rename and cascade dependency graph | 304 | 1,477 ms | +| location-rich reports with many findings | 250 | 922 ms | + +### Location-report parsing improvement + +On 2026-08-28, the location-report scenario was sampled five times after +reusing the parse already required to calculate statement ranges. The samples +were 250, 231, 238, 235, and 235 ms (median **235 ms**). This is a 74.5% +reduction from the structural baseline; both the CLI location test and the +scenario's state assertions remained green. This measurement is specific to +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 +``` + +The allocation scenarios use a process-global counting allocator. Run them +alone (or keep `--test-threads=1`) so allocations from another test cannot be +attributed to the scenario under measurement. + +## Phase 2 state-copying measurements + +Captured on 2026-08-28 in the same optimized profile and environment. The +pre-optimization samples were taken immediately before the Phase 2 changes; +the optimized samples include the statement undo checkpoint and incremental +`PreState` capture. All scenarios retain their exact final-state and rollback +assertions. + +| Scenario | Structural baseline | Phase 2 median | Change | +| --- | ---: | ---: | ---: | +| long transaction rollback | 2,673 ms | 1,052 ms | -60.6% | +| repeated savepoint rollback | 308 ms | 127 ms | -58.8% | +| ordered thousand-statement chain | 6,652 ms | 6,529 ms | -1.8% | + +The long-transaction samples were 1,124, 849, 767, 1,094, and 1,052 ms. The +savepoint samples were 127, 130, 97, 113, and 242 ms. The ordered-chain samples +were 6,371, 6,529, and 10,201 ms; the outlier illustrates why these local +timings are comparisons rather than release thresholds. Its median shows no +material small/ordinary-chain regression against the checked-in structural +baseline. + +The 50-statement chain over a 1,000-relation synchronized baseline provides a +less load-sensitive allocation comparison: + +| Measurement | Before Phase 2 | After Phase 2 | Change | +| --- | ---: | ---: | ---: | +| allocations | 1,027,883 | 724,358 | -29.5% | +| allocated bytes | 168,246,639 | 114,914,704 | -31.7% | + +Reserving and reusing the public `PreState` map storage also reduced a fresh +1,000-relation capture from 2,060,848 to 1,061,260 allocated bytes (-48.5%). +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 V6 decoding now 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 +enforced, and a regression test rejects trailing decompressed payload data. + +## Phase 3 dependency-graph measurements + +Captured on 2026-08-28 from the Phase 3 worktree using the debug test profile +on the same aarch64 Android Linux host. These samples are intentionally kept +separate from the optimized-profile baseline above. + +An initial eager index regressed the existing 304-statement rename/cascade +scenario from a five-sample median of 1,048 ms to 1,285 ms. A lazy index still +measured 1,093 ms. Both designs were rejected. The retained design preserves +canonical scans below 1,024 edges and lazily builds a referenced-object index +only for larger cascade graphs. It also omits derived indexes when cloning a +graph for a statement checkpoint. + +The unchanged-size rename/cascade scenario then measured 1,042, 1,015, and +1,013 ms (median **1,015 ms**, 3.1% below the 1,048 ms pre-change median). The +large isolated scenario includes initial index construction and compares the +same 1,000 lookups over 10,000 edges: + +| Lookup path | Elapsed | +| --- | ---: | +| lazy referenced-object index | 447,268 us | +| canonical full-edge scan | 2,834,576 us | + +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 +``` diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 55fa26d..eacafeb 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -1,6 +1,6 @@ # CLI and Report Contract -This document defines safe-migrate v0.6.2's CLI, report, cache, and GitHub +This document defines safe-migrate v0.7.0's CLI, report, cache, and GitHub Action behavior. If you are learning safe-migrate, start with the [README](../README.md). This @@ -181,6 +181,41 @@ an absent cache does not lower a finding by itself. A stale cache taints confidence and emits a warning on standard error. `stale_stats_days` uses the timestamp inside the cache, not file modification time. +Cache V6 does not carry foreign-key column-number lists or index eligibility +metadata. A transition whose correctness depends on either fact is skipped and +taints confidence rather than inventing state; syntax-level findings that are +independent of the skipped state update (for example `DROP COLUMN` being +irreversible or `WITH GRANT OPTION`) remain reportable. Multi-target view drops +with an unresolved target are likewise treated atomically, preserving known +targets in the simulated state. + +`GRANT` and `REVOKE` on `ALL TABLES IN SCHEMA` update the modeled relations but +are `Tainted`: the cache does not represent every PostgreSQL relation kind that +the server may include in that target set. + +Parser-valid DDL whose semantics are not represented by the state model is +handled as opaque and taints confidence. This includes copied or inherited +tables, CTAS transaction-lifecycle actions, unsupported role attributes, and +unmodeled type, view, or materialized-view alterations; these statements are never +silently recorded as exact no-ops. +The same rule applies to view options/check options, unpopulated materialized +views, and domain constraints or collations. CTAS `WITH NO DATA` and expression +indexes remain typed so their dedicated safety rules can report them; expression +index key/dependency metadata is not used to claim exact later constraint +adoption. Policy mutations remain available to security rules, but +policy role lists and expressions taint confidence because relation state does +not store them. +Aggregate creation retains its routine identity but is tainted because +transition-function and implementation-option dependencies are not modeled. +Composite, range, and base type creation is opaque because their attributes, +subtypes, and implementation dependencies are not represented. +Database create/alter/drop mutations remain available to their syntax rules but +taint confidence because database-level state is outside the current-database +schema model. +Unknown `RESET` parameters are opaque; only modeled timeout/search-path values +are exact, and explicitly schema-neutral settings such as `application_name` +remain no-ops. + Cache V6 synchronizes all `pg_proc.prokind` values in PostgreSQL's shared routine namespace. Function, procedure, aggregate, and window-function lifecycle operations use that baseline. Routine DDL without a typed Squawk @@ -200,6 +235,11 @@ publication names, and supported settings. It never selects or serializes Creating a connected subscription, refreshing publisher metadata, and dropping a subscription remain `Tainted` because their outcome depends on remote state. +On PostgreSQL 17 and newer, relation ACL synchronization and grant/revoke +analysis recognize the table `MAINTAIN` privilege. `GRANT ALL` expands to that +privilege only when the cache identifies PostgreSQL 17 or newer; older or +version-unknown baselines retain the pre-17 expansion conservatively. + ## Timeout evidence `require-lock-timeout` and `require-statement-timeout` are Tier 2 primary rules. diff --git a/docs/GITHUB_ACTIONS.md b/docs/GITHUB_ACTIONS.md index 775c1a6..af30711 100644 --- a/docs/GITHUB_ACTIONS.md +++ b/docs/GITHUB_ACTIONS.md @@ -54,7 +54,7 @@ jobs: with: persist-credentials: false - - uses: dsecurity49/safe-migrate@v0.6.2 + - uses: dsecurity49/safe-migrate@v0.7.0 env: SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }} with: @@ -83,7 +83,7 @@ explicitly. In a trusted branch job, pass the checked-out file directly: ```yaml - - uses: dsecurity49/safe-migrate@v0.6.2 + - uses: dsecurity49/safe-migrate@v0.7.0 with: path: migrations config: safe-migrate.toml @@ -107,7 +107,7 @@ separately: sparse-checkout-cone-mode: false persist-credentials: false - - uses: dsecurity49/safe-migrate@v0.6.2 + - uses: dsecurity49/safe-migrate@v0.7.0 with: path: migrations config: .safe-migrate-base/safe-migrate.toml @@ -153,7 +153,7 @@ jobs: with: persist-credentials: false - - uses: dsecurity49/safe-migrate@v0.6.2 + - uses: dsecurity49/safe-migrate@v0.7.0 env: DATABASE_URL: ${{ secrets.SAFE_MIGRATE_DATABASE_URL }} SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }} diff --git a/docs/assets/social-preview-v0.6.0.png b/docs/assets/social-preview-v0.6.0.png deleted file mode 100644 index 2933087..0000000 Binary files a/docs/assets/social-preview-v0.6.0.png and /dev/null differ diff --git a/live_tests/differential_manifest.json b/live_tests/differential_manifest.json index e299581..1938a66 100644 --- a/live_tests/differential_manifest.json +++ b/live_tests/differential_manifest.json @@ -96,10 +96,8 @@ "enabled": true, "fixtures": [ "001_drop_table.sql", - "002_drop_column.sql", "004_drop_table_if_exists.sql", "005_drop_cascade.sql", - "006_drop_column_if_exists.sql", "safe_001_rename_col.sql", "safe_002_add_col.sql", "safe_003_widen_varchar.sql", @@ -110,6 +108,14 @@ "safe_009_add_index.sql" ], "excluded_fixtures": [ + { + "fixture": "002_drop_column.sql", + "reason": "Cache V6 does not retain all foreign-key, index, view, and expression dependency column lists. The simulator conservatively skips a drop when those dependencies are unknown; exact column-aware replay is a 0.8.0 cache-evidence TODO." + }, + { + "fixture": "006_drop_column_if_exists.sql", + "reason": "IF EXISTS only suppresses an absent-column error; it does not make Cache V6's unknown foreign-key, index, view, and expression column dependencies exact. Exact column-aware replay is a 0.8.0 cache-evidence TODO." + }, { "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." @@ -1161,16 +1167,18 @@ "safe_002_drop_not_null.sql", "safe_003_create_table.sql", "safe_004_add_column.sql", - "safe_005_drop_column.sql", "safe_006_rename_column.sql", "safe_007_create_index.sql", "safe_008_drop_constraint.sql", "safe_009_alter_set_default.sql", "safe_011_foreign_key_not_valid.sql", - "safe_012_foreign_key_validate_later.sql", - "safe_013_unique_using_index.sql" + "safe_012_foreign_key_validate_later.sql" ], "excluded_fixtures": [ + { + "fixture": "safe_005_drop_column.sql", + "reason": "Cache V6 foreign-key edges do not retain conkey/confkey column lists. The simulator conservatively skips a drop when dependency columns are unknown; exact column-aware replay is a 0.8.0 cache-evidence TODO." + }, { "fixture": "006_set_storage.sql", "reason": "Column storage strategy is not synchronized or normalized by the differential harness." @@ -1182,6 +1190,10 @@ { "fixture": "safe_010_drop_index.sql", "reason": "DROP INDEX IF EXISTS idx is a no-op against the canonical baseline." + }, + { + "fixture": "safe_013_unique_using_index.sql", + "reason": "Cache V6 index edges do not retain uniqueness, predicate, or eligibility metadata. The simulator conservatively skips USING INDEX until the 0.8.0 cache-evidence TODO is proved." } ], "schemas": [ @@ -1222,10 +1234,6 @@ "relations", "columns" ], - "safe_005_drop_column.sql": [ - "relations", - "columns" - ], "safe_006_rename_column.sql": [ "relations", "columns" @@ -1247,9 +1255,6 @@ "safe_012_foreign_key_validate_later.sql": [ "constraints", "foreign_keys" - ], - "safe_013_unique_using_index.sql": [ - "constraints" ] }, "required_relations": [ @@ -1693,7 +1698,6 @@ "rule_dir": "rule_26_chain-conflict", "enabled": true, "fixtures": [ - "004_conflict.sql", "010_conflict.sql", "011_missing_fk_source_column.sql", "012_rename_enum_missing_label.sql", @@ -1702,6 +1706,7 @@ "015_rename_value_missing_type.sql", "016_missing_set_role.sql", "017_unauthorized_set_role.sql", + "018_identifier_truncation_collision.sql", "safe_001_chain.sql", "safe_001_create_table.sql", "safe_002_create_index.sql", @@ -1716,7 +1721,12 @@ "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_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" ], "fixture_transactional": { "safe_010_role_transaction_semantics.sql": false, @@ -1755,9 +1765,17 @@ "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": "004_conflict.sql", + "reason": "The chain drops a column whose dependency-column evidence is absent from Cache V6; the simulator conservatively retains it after a skipped transition. Exact column-aware replay is a 0.8.0 cache-evidence TODO." + }, { "fixture": "001_chain.sql", "reason": "PostgreSQL rejects the second incompatible duplicate column definition, leaving no committed resulting state." @@ -1821,17 +1839,19 @@ "sm_audit", "sm_analytics", "app_user", - "sm_role_quote" + "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" @@ -1873,6 +1893,30 @@ ], "safe_016_transitive_set_role_membership.sql": [ "relations" + ], + "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": [ diff --git a/live_tests/rule_26_chain-conflict/018_identifier_truncation_collision.sql b/live_tests/rule_26_chain-conflict/018_identifier_truncation_collision.sql new file mode 100644 index 0000000..ba3f00c --- /dev/null +++ b/live_tests/rule_26_chain-conflict/018_identifier_truncation_collision.sql @@ -0,0 +1,2 @@ +CREATE TABLE aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaax (id integer); +CREATE TABLE aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaay (id integer); diff --git a/live_tests/rule_26_chain-conflict/safe_017_relation_namespace_resolution.sql b/live_tests/rule_26_chain-conflict/safe_017_relation_namespace_resolution.sql new file mode 100644 index 0000000..7575093 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_017_relation_namespace_resolution.sql @@ -0,0 +1,12 @@ +CREATE SCHEMA sm_ns_early; +CREATE SCHEMA sm_ns_late; +CREATE TYPE sm_ns_early.shadowed_relation AS ENUM ('one'); +CREATE TABLE sm_ns_late.shadowed_relation (id integer); +CREATE TABLE sm_ns_early.dropped_relation (id integer); +CREATE TABLE sm_ns_late.dropped_relation (id integer); +DROP TABLE sm_ns_early.dropped_relation; +CREATE TABLE sm_ns_early."QuotedRelation" (id integer); +SET search_path TO sm_ns_early, sm_ns_late, public; +ALTER TABLE shadowed_relation ADD COLUMN selected_by_relation_namespace integer; +ALTER TABLE dropped_relation ADD COLUMN selected_after_tombstone integer; +ALTER TABLE "QuotedRelation" ADD COLUMN quoted_lookup integer; diff --git a/live_tests/rule_26_chain-conflict/safe_018_type_namespace_resolution.sql b/live_tests/rule_26_chain-conflict/safe_018_type_namespace_resolution.sql new file mode 100644 index 0000000..44d13d5 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_018_type_namespace_resolution.sql @@ -0,0 +1,12 @@ +CREATE SCHEMA sm_type_early; +CREATE SCHEMA sm_type_late; +CREATE TABLE sm_type_early.shadowed_type (id integer); +CREATE DOMAIN sm_type_late.shadowed_type AS integer; +CREATE DOMAIN sm_type_early.dropped_type AS integer; +CREATE DOMAIN sm_type_late.dropped_type AS bigint; +DROP DOMAIN sm_type_early.dropped_type; +SET search_path TO sm_type_early, sm_type_late, public; +CREATE TABLE public.type_namespace_probe ( + selected_type shadowed_type, + selected_after_tombstone dropped_type +); diff --git a/live_tests/rule_26_chain-conflict/safe_019_routine_namespace_resolution.sql b/live_tests/rule_26_chain-conflict/safe_019_routine_namespace_resolution.sql new file mode 100644 index 0000000..ffd27c4 --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_019_routine_namespace_resolution.sql @@ -0,0 +1,26 @@ +CREATE SCHEMA sm_routine_early; +CREATE SCHEMA sm_routine_late; +CREATE FUNCTION sm_routine_early.choose_value(value text) +RETURNS integer +LANGUAGE sql +IMMUTABLE +AS 'SELECT 1'; +CREATE FUNCTION sm_routine_late.choose_value(value integer) +RETURNS integer +LANGUAGE sql +IMMUTABLE +AS 'SELECT value'; +CREATE FUNCTION sm_routine_early.dropped_routine(value integer) +RETURNS integer +LANGUAGE sql +IMMUTABLE +AS 'SELECT value'; +CREATE FUNCTION sm_routine_late.dropped_routine(value integer) +RETURNS integer +LANGUAGE sql +IMMUTABLE +AS 'SELECT value'; +DROP FUNCTION sm_routine_early.dropped_routine(integer); +SET search_path TO sm_routine_early, sm_routine_late, public; +ALTER FUNCTION choose_value(integer) STABLE; +ALTER FUNCTION dropped_routine(integer) STABLE; diff --git a/live_tests/rule_26_chain-conflict/safe_020_generated_name_truncation.sql b/live_tests/rule_26_chain-conflict/safe_020_generated_name_truncation.sql new file mode 100644 index 0000000..598180e --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_020_generated_name_truncation.sql @@ -0,0 +1,5 @@ +CREATE TABLE sm_core.phase5_table_name_that_exceeds_the_postgresql_identifier_byte_limit_for_testing ( + phase5_column_name_that_also_exceeds_the_postgresql_identifier_byte_limit integer PRIMARY KEY, + phase5_unique_column_name_that_also_exceeds_the_postgresql_identifier_byte_limit integer, + UNIQUE (phase5_unique_column_name_that_also_exceeds_the_postgresql_identifier_byte_limit) +); diff --git a/live_tests/rule_26_chain-conflict/safe_021_routine_type_aliases.sql b/live_tests/rule_26_chain-conflict/safe_021_routine_type_aliases.sql new file mode 100644 index 0000000..ddce88f --- /dev/null +++ b/live_tests/rule_26_chain-conflict/safe_021_routine_type_aliases.sql @@ -0,0 +1,7 @@ +CREATE FUNCTION sm_core.phase5_alias(value int4) +RETURNS int4 +LANGUAGE SQL +IMMUTABLE +AS $$ SELECT value $$; +ALTER FUNCTION sm_core.phase5_alias(int) STABLE; +DROP FUNCTION sm_core.phase5_alias(integer); diff --git a/scripts/test-action-contract b/scripts/test-action-contract index d128bfd..2db8cbd 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.6.2 "$repo_root/Cargo.toml")" = v0.6.2 +test "$(/bin/sh "$resolver" v0.7.0 "$repo_root/Cargo.toml")" = v0.7.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 diff --git a/src/analysis/facts.rs b/src/analysis/facts.rs index 2256b9f..ae9ce46 100644 --- a/src/analysis/facts.rs +++ b/src/analysis/facts.rs @@ -152,6 +152,8 @@ pub enum StatementFact { table: QualifiedName, permissive: bool, command: PolicyCommand, + /// False when roles or policy expressions are present but not modeled. + semantics_complete: bool, }, DropPolicy { name: String, @@ -217,12 +219,12 @@ pub enum StatementFact { cascade: bool, }, DropTable { - name: QualifiedName, + names: Vec, if_exists: bool, cascade: bool, }, DropView { - name: QualifiedName, + names: Vec, if_exists: bool, cascade: bool, }, @@ -235,6 +237,7 @@ pub enum StatementFact { names: Vec, if_exists: bool, concurrently: bool, + cascade: bool, }, SetSearchPath { target: SearchPathTarget, @@ -681,6 +684,7 @@ pub enum PrivilegeFact { Truncate, References, Trigger, + Maintain, Execute, Create, Temporary, @@ -787,8 +791,12 @@ pub enum TableConstraintFact { constraint_name: Option, columns: Vec, }, - Check, - Exclude, + Check { + constraint_name: Option, + }, + Exclude { + constraint_name: Option, + }, } #[derive(Clone, Debug, PartialEq)] @@ -819,6 +827,7 @@ pub enum AlterTableActionFact { DropColumn { name: String, if_exists: bool, + cascade: bool, }, RenameColumn { from: Ident, @@ -844,6 +853,8 @@ pub enum AlterTableActionFact { }, DropConstraint { name: String, + if_exists: bool, + cascade: bool, }, AddCheckConstraint { constraint_name: Option, @@ -851,10 +862,12 @@ pub enum AlterTableActionFact { }, AddUniqueConstraint { constraint_name: Option, + columns: Vec, using_index: Option, }, AddPrimaryKeyConstraint { constraint_name: Option, + columns: Vec, using_index: Option, }, AddExcludeConstraint { diff --git a/src/analysis/graph.rs b/src/analysis/graph.rs index a7fa2c8..e072703 100644 --- a/src/analysis/graph.rs +++ b/src/analysis/graph.rs @@ -1,5 +1,6 @@ use crate::ast::identifiers::ObjectId; -use std::collections::HashSet; +use std::cell::OnceCell; +use std::collections::{HashMap, HashSet}; #[derive(Debug, Clone, PartialEq)] pub enum DependencyKind { @@ -19,6 +20,15 @@ pub enum DependencyKind { is_unique: bool, eligibility_known: bool, }, + /// A local primary-key/unique constraint whose key columns are known. + /// This edge is intentionally state-only (not a cache row): V6 caches + /// retain constraint identity but historically did not retain key + /// columns, so baseline callers must remain conservative. + ConstraintOnRelation { + constraint_name: String, + columns: Vec, + is_primary: bool, + }, RenameTo, PartitionOf, SequenceOwnedBy { @@ -54,16 +64,168 @@ impl DependencyEdge { } } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Default)] pub struct DependencyGraph { - pub edges: Vec, + edges: Vec, + indexes: OnceCell, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct GraphIndexes { + rename_by_source: HashMap, + by_resolved_referenced: HashMap>, +} + +impl Clone for DependencyGraph { + fn clone(&self) -> Self { + Self { + edges: self.edges.clone(), + // Indexes are derived state. Avoid duplicating them in statement + // checkpoints; the clone builds them only if a lookup needs them. + indexes: OnceCell::new(), + } + } } impl DependencyGraph { + const CASCADE_INDEX_MIN_EDGES: usize = 1_024; + pub fn new() -> Self { Self::default() } + pub fn edges(&self) -> &[DependencyEdge] { + &self.edges + } + + pub fn add_edge(&mut self, edge: DependencyEdge) { + self.edges.push(edge); + self.invalidate_indexes(); + } + + pub(crate) fn retain_edges(&mut self, mut keep: impl FnMut(&DependencyEdge) -> bool) { + self.edges.retain(|edge| keep(edge)); + self.invalidate_indexes(); + } + + pub(crate) fn edge_count(&self) -> usize { + self.edges.len() + } + + pub(crate) fn truncate(&mut self, len: usize) { + self.edges.truncate(len); + self.invalidate_indexes(); + } + + pub(crate) fn replace_edges(&mut self, edges: Vec) { + self.edges = edges; + self.invalidate_indexes(); + } + + pub(crate) fn mutate_edges(&mut self, mutate: impl FnOnce(&mut [DependencyEdge])) { + mutate(&mut self.edges); + self.invalidate_indexes(); + } + + /// 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 { + self.indexes() == &Self::build_indexes(&self.edges) + } + + fn invalidate_indexes(&mut self) { + self.indexes.take(); + } + + fn indexes(&self) -> &GraphIndexes { + self.indexes + .get_or_init(|| Self::build_indexes(&self.edges)) + } + + fn build_indexes(edges: &[DependencyEdge]) -> GraphIndexes { + let mut indexes = GraphIndexes::default(); + for (index, edge) in edges.iter().enumerate() { + if matches!(edge.kind, DependencyKind::RenameTo) { + indexes + .rename_by_source + .entry(edge.dependent.clone()) + .or_insert(index); + } + } + + for (index, edge) in edges.iter().enumerate() { + let referenced = Self::resolve_rename_with(edges, &indexes, &edge.referenced).clone(); + indexes + .by_resolved_referenced + .entry(referenced) + .or_default() + .push(index); + } + indexes + } + + fn resolve_rename_with<'a>( + edges: &'a [DependencyEdge], + indexes: &GraphIndexes, + id: &'a ObjectId, + ) -> &'a ObjectId { + let mut current = id; + let mut visited = HashSet::new(); + loop { + if !visited.insert(current.clone()) { + return id; + } + match indexes.rename_by_source.get(current) { + Some(index) => current = &edges[*index].referenced, + None => return current, + } + } + } + + fn resolved_referenced_edges(&self, id: &ObjectId) -> impl Iterator { + let target = self.resolve_rename(id); + self.indexes() + .by_resolved_referenced + .get(target) + .into_iter() + .flatten() + .map(|index| &self.edges[*index]) + } + + pub 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 + .edges + .iter() + .filter(|edge| { + matches!( + edge.kind, + DependencyKind::ViewDependency { .. } + | DependencyKind::IndexOnRelation { .. } + | DependencyKind::ForeignKey { .. } + | DependencyKind::PartitionOf + ) && self.resolve_rename(&edge.referenced) == target + }) + .collect(); + } + self.resolved_referenced_edges(id) + .filter(|edge| { + matches!( + edge.kind, + DependencyKind::ViewDependency { .. } + | DependencyKind::IndexOnRelation { .. } + | DependencyKind::ForeignKey { .. } + | DependencyKind::PartitionOf + ) + }) + .collect() + } + + pub(crate) fn cascade_index_is_worthwhile(&self) -> bool { + self.edges.len() >= Self::CASCADE_INDEX_MIN_EDGES + } + // Dependency lookups follow the current end of a rename chain. pub fn is_referenced_by_view(&self, id: &ObjectId) -> Vec<&ObjectId> { let target = self.resolve_rename(id); @@ -130,11 +292,9 @@ impl DependencyGraph { if !visited.insert(current.clone()) { return id; } - match self - .edges - .iter() - .find(|e| matches!(e.kind, DependencyKind::RenameTo) && &e.dependent == current) - { + match self.edges.iter().find(|edge| { + matches!(edge.kind, DependencyKind::RenameTo) && &edge.dependent == current + }) { Some(edge) => current = &edge.referenced, None => return current, } @@ -157,9 +317,9 @@ impl DependencyGraph { // attachment instead of looping or extending the cycle. return true; } - let maybe_edge = self.edges.iter().find(|e| { - matches!(e.kind, DependencyKind::PartitionOf) - && self.resolve_rename(&e.dependent) == current_parent + let maybe_edge = self.edges.iter().find(|edge| { + matches!(edge.kind, DependencyKind::PartitionOf) + && self.resolve_rename(&edge.dependent) == current_parent }); if let Some(edge) = maybe_edge { let p = self.resolve_rename(&edge.referenced); @@ -174,30 +334,112 @@ impl DependencyGraph { false } - pub fn propagate_rename(&mut self, old_id: &ObjectId, new_id: &ObjectId) { + /// Propagate a relation rename through relation-to-relation edges. + /// + /// Dependency endpoints are intentionally updated by edge kind rather than + /// by blindly comparing `ObjectId`s. `ObjectId` carries no catalog kind, + /// 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) { for edge in &mut self.edges { - if matches!(edge.kind, DependencyKind::RenameTo) { - continue; + match &mut edge.kind { + DependencyKind::RenameTo => {} + DependencyKind::ForeignKey { .. } + | DependencyKind::ViewDependency { .. } + | DependencyKind::PartitionOf + | DependencyKind::ColumnGeneratedFrom { .. } => { + if edge.dependent == *old_id { + edge.dependent = new_id.clone(); + } + if edge.referenced == *old_id { + edge.referenced = new_id.clone(); + } + } + DependencyKind::IndexOnRelation { .. } + | DependencyKind::SequenceOwnedBy { .. } + | DependencyKind::TriggerOnTable { .. } => { + if edge.referenced == *old_id { + edge.referenced = new_id.clone(); + } + } + DependencyKind::ConstraintOnRelation { .. } => { + if edge.dependent == *old_id { + edge.dependent = new_id.clone(); + } + if edge.referenced == *old_id { + edge.referenced = new_id.clone(); + } + } + DependencyKind::PublicationIncludes { .. } => { + if edge.dependent == *old_id { + edge.dependent = new_id.clone(); + } + } } - if edge.dependent == *old_id { + } + self.invalidate_indexes(); + } + + /// 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) { + for edge in &mut self.edges { + if matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) + && edge.dependent == *old_id + { edge.dependent = new_id.clone(); } - if edge.referenced == *old_id { - edge.referenced = new_id.clone(); + } + self.invalidate_indexes(); + } + + /// Propagate a sequence rename only through sequence ownership edges. + pub fn propagate_sequence_rename(&mut self, old_id: &ObjectId, new_id: &ObjectId) { + for edge in &mut self.edges { + if matches!(edge.kind, DependencyKind::SequenceOwnedBy { .. }) + && edge.dependent == *old_id + { + edge.dependent = new_id.clone(); } - if let DependencyKind::TriggerOnTable { - trigger_id, - function_id, - } = &mut edge.kind + } + self.invalidate_indexes(); + } + + /// Propagate a trigger rename through its trigger edge and payload. + pub 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 { - if *trigger_id == *old_id { - *trigger_id = new_id.clone(); - } - if *function_id == *old_id { - *function_id = new_id.clone(); + *trigger_id = new_id.clone(); + if edge.dependent == *old_id { + edge.dependent = new_id.clone(); } } } + self.invalidate_indexes(); + } + + /// Propagate a function rename through trigger dependency payloads. + pub 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 + { + *function_id = new_id.clone(); + } + } + self.invalidate_indexes(); + } + + /// Backwards-compatible relation rename entry point. + /// + /// 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) { + self.propagate_relation_rename(old_id, new_id); } pub fn triggers_on(&self, table_id: &ObjectId) -> Vec<&DependencyEdge> { @@ -238,3 +480,112 @@ impl DependencyGraph { .collect() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn id(name: &str) -> ObjectId { + ObjectId::new("public", name) + } + + fn view_edge(dependent: &str, referenced: &str) -> DependencyEdge { + DependencyEdge::new( + id(dependent), + id(referenced), + DependencyKind::ViewDependency { view_generation: 1 }, + ) + } + + fn canonical_views<'a>(graph: &'a DependencyGraph, target: &ObjectId) -> Vec<&'a ObjectId> { + let resolved_target = graph.resolve_rename(target); + graph + .edges() + .iter() + .filter(|edge| { + matches!(edge.kind, DependencyKind::ViewDependency { .. }) + && (graph.resolve_rename(&edge.referenced) == resolved_target + || &edge.referenced == target) + }) + .map(|edge| graph.resolve_rename(&edge.dependent)) + .collect() + } + + fn assert_indexed_views_match_scan(graph: &DependencyGraph, targets: &[ObjectId]) { + assert!(graph.indexes_are_valid()); + for target in targets { + let indexed = graph + .cascade_edges(target) + .into_iter() + .filter(|edge| matches!(edge.kind, DependencyKind::ViewDependency { .. })) + .map(|edge| graph.resolve_rename(&edge.dependent)) + .collect::>(); + assert_eq!(indexed, canonical_views(graph, target)); + } + } + + #[test] + fn indexes_track_every_graph_mutation_and_alias_cycle() { + let a = id("a"); + let b = id("b"); + let c = id("c"); + let d = id("d"); + let targets = [a.clone(), b.clone(), c.clone(), d.clone()]; + let mut graph = DependencyGraph::new(); + + graph.add_edge(view_edge("view_a", "a")); + graph.add_edge(view_edge("view_b", "b")); + for index in 0..DependencyGraph::CASCADE_INDEX_MIN_EDGES { + graph.add_edge(view_edge( + &format!("unrelated_view_{index}"), + &format!("unrelated_table_{index}"), + )); + } + assert_indexed_views_match_scan(&graph, &targets); + + graph.add_edge(DependencyEdge::new( + a.clone(), + b.clone(), + DependencyKind::RenameTo, + )); + assert_indexed_views_match_scan(&graph, &targets); + + graph.propagate_rename(&b, &c); + graph.add_edge(DependencyEdge::new( + b.clone(), + c.clone(), + DependencyKind::RenameTo, + )); + assert_indexed_views_match_scan(&graph, &targets); + + graph.mutate_edges(|edges| { + for edge in edges { + if edge.dependent == id("view_b") { + edge.dependent = id("view_c"); + } + } + }); + assert_indexed_views_match_scan(&graph, &targets); + + let snapshot = graph.edges().to_vec(); + graph.retain_edges(|edge| edge.dependent != id("view_a")); + assert_indexed_views_match_scan(&graph, &targets); + graph.replace_edges(snapshot); + assert_indexed_views_match_scan(&graph, &targets); + + let checkpoint = graph.edge_count(); + graph.add_edge(view_edge("temporary", "c")); + graph.truncate(checkpoint); + assert_indexed_views_match_scan(&graph, &targets); + + graph.add_edge(DependencyEdge::new( + c.clone(), + a.clone(), + DependencyKind::RenameTo, + )); + assert_eq!(graph.resolve_rename(&a), &a); + assert_eq!(graph.resolve_rename(&b), &b); + assert_eq!(graph.resolve_rename(&c), &c); + assert_indexed_views_match_scan(&graph, &targets); + } +} diff --git a/src/analysis/mutations.rs b/src/analysis/mutations.rs index f8c2a38..318a0e9 100644 --- a/src/analysis/mutations.rs +++ b/src/analysis/mutations.rs @@ -131,6 +131,7 @@ pub struct CreatePolicyMutation { pub table: ObjectId, pub permissive: bool, pub command: crate::analysis::facts::PolicyCommand, + pub semantics_complete: bool, } #[derive(Clone, Debug, PartialEq)] @@ -335,16 +336,17 @@ pub struct Rename { #[derive(Clone, Debug, PartialEq)] pub struct DropTable { - pub id: ObjectId, + pub ids: Vec, pub if_exists: bool, pub cascade: bool, } #[derive(Clone, Debug, PartialEq)] pub struct DropIndex { - pub id: ObjectId, + pub ids: Vec, pub if_exists: bool, pub concurrently: bool, + pub cascade: bool, } #[derive(Clone, Debug, PartialEq)] @@ -572,6 +574,7 @@ pub enum AlterTableActionMutation { DropColumn { name: String, if_exists: bool, + cascade: bool, }, RenameColumn { from: String, @@ -594,6 +597,8 @@ pub enum AlterTableActionMutation { }, DropConstraint { name: String, + if_exists: bool, + cascade: bool, }, AddCheckConstraint { constraint_name: Option, @@ -601,10 +606,12 @@ pub enum AlterTableActionMutation { }, AddUniqueConstraint { constraint_name: Option, + columns: Vec, using_index: Option, }, AddPrimaryKeyConstraint { constraint_name: Option, + columns: Vec, using_index: Option, }, AddExcludeConstraint { diff --git a/src/analysis/resolver.rs b/src/analysis/resolver.rs index 6852eaa..2befecd 100644 --- a/src/analysis/resolver.rs +++ b/src/analysis/resolver.rs @@ -1,27 +1,17 @@ -use crate::analysis::facts::{ - AlterIndexActionFact, AlterTableActionFact, PersistenceFact, StatementFact, TypeCreationKind, -}; -use crate::analysis::mutations::{ - AlterAggregateMutation, AlterDatabaseMutation, AlterDomainMutation, AlterFunctionMutation, - AlterProcedureMutation, AlterPublicationMutation, AlterRoleMutation, AlterSchemaMutation, - AlterSequenceActionMutation, AlterSequenceMutation, AlterSubscriptionMutation, AlterTable, - AlterTableActionMutation, AlterTypeActionMutation, AlterTypeMutation, ColumnMutation, - CreateAggregateMutation, CreateDatabaseMutation, CreateDomainMutation, CreateFunctionMutation, - CreateIndex, CreateMaterializedView, CreatePolicyMutation, CreateProcedureMutation, - CreatePublicationMutation, CreateRoleMutation, CreateSchemaMutation, CreateSequenceMutation, - CreateSubscriptionMutation, CreateTable, CreateTriggerMutation, CreateTypeMutation, CreateView, - DropAggregateMutation, DropDatabaseMutation, DropDomainMutation, DropFunctionMutation, - DropIndex, DropMaterializedViewMutation, DropPolicyMutation, DropProcedureMutation, - DropPublicationMutation, DropRoleMutation, DropSchemaMutation, DropSequenceMutation, - DropSubscriptionMutation, DropTable, DropTriggerMutation, DropTypeMutation, DropViewMutation, - FkMutation, GrantMutation, Mutation, OpaqueMutation, PersistenceMutation, - RefreshMaterializedViewMutation, ReleaseSavepointMutation, Rename, RenameTriggerMutation, - ResolvedGrantTarget, RevokeMutation, RollbackToSavepointMutation, SavepointMutation, - SearchPathChange, TimeoutSettingChange, -}; +use crate::analysis::facts::StatementFact; +use crate::analysis::mutations::{Mutation, OpaqueMutation}; use crate::analysis::state::AnalysisState; use crate::ast::identifiers::{ObjectId, QualifiedName}; -use crate::model::types::TypeKind; + +mod relation; +mod relation_aux; +mod replication; +mod routine; +mod schema; +mod security; +mod sequence; +mod session; +mod types; pub struct Resolver; @@ -44,24 +34,19 @@ impl Resolver { ObjectId::new(schema, name.name.resolve()) } - fn resolve_lookup_name(name: &QualifiedName, state: &AnalysisState) -> ObjectId { + fn resolve_in_namespace( + name: &QualifiedName, + object_name: String, + state: &AnalysisState, + present: impl Fn(&AnalysisState, &ObjectId) -> bool, + ) -> ObjectId { if let Some(schema_ident) = &name.schema { - return ObjectId::new(schema_ident.resolve(), name.name.resolve()); + return ObjectId::new(schema_ident.resolve(), object_name); } - let resolved_name = name.name.resolve(); - for schema in &state.local.search_path { - let mut candidate = ObjectId::new(schema.clone(), resolved_name.clone()); - if state.local.relations.contains_key(&candidate) - || state.local.types.contains_key(&candidate) - || state.local.sequences.contains_key(&candidate) - || state.local.functions.keys().any(|k| { - k.schema == candidate.schema - && (k.name == candidate.name - || k.name.starts_with(&format!("{}(", candidate.name))) - }) - { + let mut candidate = ObjectId::new(schema.clone(), object_name.clone()); + if present(state, &candidate) { candidate.inferred_schema = true; return candidate; } @@ -71,40 +56,43 @@ impl Resolver { .local .search_path .first() - .map(|s| s.as_str()) - .unwrap_or("public") - .to_string(); - let mut id = ObjectId::new(schema, resolved_name); + .cloned() + .unwrap_or_else(|| "public".to_string()); + let mut id = ObjectId::new(schema, object_name); id.inferred_schema = true; id } - fn resolve_type_lookup_name(name: &QualifiedName, state: &AnalysisState) -> ObjectId { - if let Some(schema_ident) = &name.schema { - return ObjectId::new(schema_ident.resolve(), name.name.resolve()); - } + fn resolve_relation_lookup_name(name: &QualifiedName, state: &AnalysisState) -> ObjectId { + Self::resolve_in_namespace( + name, + name.name.resolve(), + state, + AnalysisState::relation_namespace_object_is_present, + ) + } - let resolved_name = name.name.resolve(); - for schema in &state.local.search_path { - let mut candidate = ObjectId::new(schema.clone(), resolved_name.clone()); - if matches!( - state.local.types.get(&candidate), - Some(crate::model::types::TypeOverlay::Present(_)) - ) { - candidate.inferred_schema = true; - return candidate; - } - } + fn resolve_type_lookup_name(name: &QualifiedName, state: &AnalysisState) -> ObjectId { + Self::resolve_in_namespace( + name, + name.name.resolve(), + state, + AnalysisState::type_is_present, + ) + } - let schema = state - .local - .search_path - .first() - .cloned() - .unwrap_or_else(|| "public".to_string()); - let mut id = ObjectId::new(schema, resolved_name); - id.inferred_schema = true; - id + fn resolve_routine_lookup_name( + name: &QualifiedName, + params: &[String], + state: &AnalysisState, + ) -> ObjectId { + let signature = params + .iter() + .map(|param| Self::normalize_function_arg_type(param)) + .collect::>() + .join(","); + let object_name = format!("{}({signature})", name.name.resolve()); + Self::resolve_in_namespace(name, object_name, state, AnalysisState::routine_is_present) } fn resolve_constraint_index_name(name: &QualifiedName, table: &ObjectId) -> ObjectId { @@ -147,97 +135,6 @@ impl Resolver { id } - fn resolve_publication_object( - object: &crate::analysis::facts::PublicationObjectFact, - state: &AnalysisState, - ) -> crate::analysis::facts::PublicationObjectFact { - match object { - crate::analysis::facts::PublicationObjectFact::Table { - name, - only, - include_partitions, - columns, - row_filter, - } => { - let id = Self::resolve_lookup_name(name, state); - crate::analysis::facts::PublicationObjectFact::Table { - name: crate::ast::identifiers::QualifiedName::new( - Some(crate::ast::identifiers::Ident::new(id.schema, true)), - crate::ast::identifiers::Ident::new(id.name, true), - ), - only: *only, - include_partitions: *include_partitions, - columns: columns.clone(), - row_filter: row_filter.clone(), - } - } - crate::analysis::facts::PublicationObjectFact::CurrentSchemaShorthand => { - crate::analysis::facts::PublicationObjectFact::SchemaTables { - schema: state - .local - .search_path - .first() - .cloned() - .unwrap_or_else(|| "public".to_string()), - row_filter: None, - } - } - other => other.clone(), - } - } - - fn resolve_publication_scope( - scope: &crate::analysis::facts::PublicationScope, - state: &AnalysisState, - ) -> crate::analysis::facts::PublicationScope { - match scope { - crate::analysis::facts::PublicationScope::AllTables { except } => { - crate::analysis::facts::PublicationScope::AllTables { - except: except.clone(), - } - } - crate::analysis::facts::PublicationScope::Explicit(objects) => { - crate::analysis::facts::PublicationScope::Explicit( - objects - .iter() - .map(|object| Self::resolve_publication_object(object, state)) - .collect(), - ) - } - } - } - - fn resolve_alter_publication_action( - action: &crate::analysis::facts::AlterPublicationActionFact, - state: &AnalysisState, - ) -> crate::analysis::facts::AlterPublicationActionFact { - use crate::analysis::facts::AlterPublicationActionFact; - match action { - AlterPublicationActionFact::AddObjects(objects) => { - AlterPublicationActionFact::AddObjects( - objects - .iter() - .map(|object| Self::resolve_publication_object(object, state)) - .collect(), - ) - } - AlterPublicationActionFact::DropObjects(objects) => { - AlterPublicationActionFact::DropObjects( - objects - .iter() - .map(|object| Self::resolve_publication_object(object, state)) - .collect(), - ) - } - AlterPublicationActionFact::SetObjects(scope) => { - AlterPublicationActionFact::SetObjects(Self::resolve_publication_scope( - scope, state, - )) - } - other => other.clone(), - } - } - pub(crate) fn normalize_function_arg_type(raw: &str) -> String { let normalized = Self::fold_unquoted_identifier_case(raw.trim()); if let Some(element_type) = normalized.strip_suffix("[]") { @@ -282,23 +179,6 @@ impl Resolver { folded } - fn resolve_grant_target( - target: &crate::analysis::facts::GrantTarget, - state: &AnalysisState, - ) -> ResolvedGrantTarget { - match target { - crate::analysis::facts::GrantTarget::Tables(names) => ResolvedGrantTarget::Tables( - names - .iter() - .map(|n| Self::resolve_lookup_name(n, state)) - .collect(), - ), - crate::analysis::facts::GrantTarget::AllTablesInSchema(schemas) => { - ResolvedGrantTarget::AllTablesInSchema(schemas.clone()) - } - } - } - pub fn resolve(fact: &StatementFact, state: &AnalysisState) -> Vec { let mut mutations = Vec::new(); match fact { @@ -307,41 +187,22 @@ impl Resolver { if_not_exists, authorization, } => { - mutations.push(Mutation::CreateSchema(CreateSchemaMutation { - name: name.name.resolve(), - if_not_exists: *if_not_exists, - authorization: authorization.clone(), - })); + mutations.push(Self::resolve_create_schema( + name, + *if_not_exists, + authorization, + )); } StatementFact::SchemaNeutralNoop => {} StatementFact::AlterSchema { name, action } => { - let name = name.name.resolve(); - let action = match action { - crate::analysis::facts::AlterSchemaActionFact::RenameTo { new_name } => { - AlterSchemaMutation::Rename { - old_name: name, - new_name: new_name.resolve(), - } - } - crate::analysis::facts::AlterSchemaActionFact::OwnerTo { new_owner } => { - AlterSchemaMutation::OwnerTo { - name, - new_owner: new_owner.clone(), - } - } - }; - mutations.push(Mutation::AlterSchema(action)); + mutations.push(Self::resolve_alter_schema(name, action)); } StatementFact::DropSchema { names, if_exists, cascade, } => { - mutations.push(Mutation::DropSchema(DropSchemaMutation { - names: names.iter().map(|n| n.name.resolve()).collect(), - if_exists: *if_exists, - cascade: *cascade, - })); + mutations.push(Self::resolve_drop_schema(names, *if_exists, *cascade)); } StatementFact::CreateTable { name, @@ -355,133 +216,54 @@ impl Resolver { partition_of, partition_type, } => { - let id = Self::resolve_creation_name(name, state); - - let resolved_persistence = match persistence { - PersistenceFact::Permanent => PersistenceMutation::Permanent, - PersistenceFact::Temporary => PersistenceMutation::Temporary, - PersistenceFact::Unlogged => PersistenceMutation::Unlogged, - }; - - let col_mutations: Vec = columns - .iter() - .map(|c| ColumnMutation { - name: c.name.clone(), - ty: c.ty.clone(), - not_null: c.not_null, - is_primary_key: c.is_primary_key, - primary_key_constraint_name: c.primary_key_constraint_name.clone(), - is_unique: c.is_unique, - unique_constraint_name: c.unique_constraint_name.clone(), - default: c.default.clone(), - generation: c.generation, - }) - .collect(); - - let mut fk_mutations = Vec::new(); - for fk in foreign_keys { - let to_table = Self::resolve_lookup_name(&fk.references, state); - - fk_mutations.push(FkMutation { - constraint_name: fk.constraint_name.clone(), - to_table, - from_columns: fk.from_columns.clone(), - to_columns: fk.to_columns.clone(), - }); - } - - let partition_of_id = partition_of - .as_ref() - .map(|n| Self::resolve_lookup_name(n, state)); - - mutations.push(Mutation::CreateTable(CreateTable { - id, - if_not_exists: *if_not_exists, - as_select: *as_select, - persistence: resolved_persistence, - columns: col_mutations, - foreign_keys: fk_mutations, - table_constraints: table_constraints.clone(), - partition_by: partition_by.clone(), - partition_of: partition_of_id, - partition_type: partition_type.clone(), - })); + mutations.push(Self::resolve_create_table( + name, + *if_not_exists, + *as_select, + persistence, + columns, + foreign_keys, + table_constraints, + partition_by, + partition_of, + partition_type, + state, + )); } StatementFact::CreateView { name, or_replace, depends_on, } => { - let id = Self::resolve_creation_name(name, state); - - let resolved_depends = depends_on - .iter() - .map(|n| Self::resolve_lookup_name(n, state)) - .collect(); - - mutations.push(Mutation::CreateView(CreateView { - id, - or_replace: *or_replace, - depends_on: resolved_depends, - })); + mutations.push(Self::resolve_create_view( + name, + *or_replace, + depends_on, + state, + )); } StatementFact::AlterView { name, action } => { - match action { - crate::analysis::facts::AlterViewAction::RenameTo { new_name } => { - let id = Self::resolve_lookup_name(name, state); - let mut new_id = ObjectId::new(id.schema.clone(), new_name.resolve()); - new_id.inferred_schema = id.inferred_schema; - mutations.push(Mutation::Rename(Rename { old_id: id, new_id })); - } - crate::analysis::facts::AlterViewAction::SetSchema { new_schema } => { - let id = Self::resolve_lookup_name(name, state); - let new_id = ObjectId::new(new_schema, &id.name); - mutations.push(Mutation::Rename(Rename { old_id: id, new_id })); - } - crate::analysis::facts::AlterViewAction::OwnerTo { new_owner } => { - mutations.push(Mutation::ChangeRelationOwner { - id: Self::resolve_lookup_name(name, state), - new_owner: new_owner.clone(), - }); - } - crate::analysis::facts::AlterViewAction::SetDefault { .. } - | crate::analysis::facts::AlterViewAction::DropDefault { .. } - | crate::analysis::facts::AlterViewAction::RenameColumn { .. } - | crate::analysis::facts::AlterViewAction::SetOptions { .. } - | crate::analysis::facts::AlterViewAction::ResetOptions { .. } => { - // These are opaque from the state machine's perspective — - // they don't create or destroy objects, just modify metadata. - // No mutation emitted; rules can still check the StatementFact. - } + if let Some(mutation) = Self::resolve_alter_view(name, action, state) { + mutations.push(mutation); } } StatementFact::CreateMaterializedView { name, depends_on } => { - let id = Self::resolve_creation_name(name, state); - - let resolved_depends = depends_on - .iter() - .map(|n| Self::resolve_lookup_name(n, state)) - .collect(); - - mutations.push(Mutation::CreateMaterializedView(CreateMaterializedView { - id, - depends_on: resolved_depends, - })); + mutations.push(Self::resolve_create_materialized_view( + name, depends_on, state, + )); } StatementFact::AlterMaterializedView { name, new_name } => { - if let Some(new_name) = new_name { - let id = Self::resolve_lookup_name(name, state); - let mut new_id = ObjectId::new(id.schema.clone(), new_name.resolve()); - new_id.inferred_schema = id.inferred_schema; - mutations.push(Mutation::Rename(Rename { old_id: id, new_id })); + if let Some(mutation) = + Self::resolve_alter_materialized_view(name, new_name.as_ref(), state) + { + mutations.push(mutation); } } StatementFact::RefreshMaterializedView { name, concurrently } => { - mutations.push(Mutation::RefreshMaterializedView( - RefreshMaterializedViewMutation { - id: Self::resolve_lookup_name(name, state), - concurrently: *concurrently, - }, + mutations.push(Self::resolve_refresh_materialized_view( + name, + *concurrently, + state, )); } StatementFact::CreateIndex { @@ -493,565 +275,168 @@ impl Resolver { has_predicate, unique, } => { - let table = Self::resolve_lookup_name(relation, state); - // PostgreSQL places an unqualified index in the indexed - // relation's schema, not the first schema in search_path. - let id = if name.schema.is_some() { - Self::resolve_creation_name(name, state) - } else { - ObjectId::new(table.schema.clone(), name.name.resolve()) - }; - - mutations.push(Mutation::CreateIndex(CreateIndex { - id, - table, - if_not_exists: *if_not_exists, - concurrently: *concurrently, - using_method: using_method.clone(), - has_predicate: *has_predicate, - unique: *unique, - })); + mutations.push(Self::resolve_create_index( + name, + relation, + *if_not_exists, + *concurrently, + using_method, + *has_predicate, + *unique, + state, + )); } StatementFact::CreatePolicy { name, table, permissive, command, + semantics_complete, } => { - mutations.push(Mutation::CreatePolicy(CreatePolicyMutation { - name: name.clone(), - table: Self::resolve_lookup_name(table, state), - permissive: *permissive, - command: command.clone(), - })); + mutations.push(Self::resolve_create_policy( + name, + table, + *permissive, + command, + *semantics_complete, + state, + )); } StatementFact::DropPolicy { name, table, if_exists, } => { - mutations.push(Mutation::DropPolicy(DropPolicyMutation { - name: name.clone(), - table: Self::resolve_lookup_name(table, state), - if_exists: *if_exists, - })); + mutations.push(Self::resolve_drop_policy(name, table, *if_exists, state)); } StatementFact::CreateTrigger { name, table, function, } => { - // Function references in triggers are bare names (e.g., "notify_func") - // but functions are stored with signature (e.g., "notify_func()"). - // Use resolve_function_id_by_sig with empty params for consistent lookup. - let function_base = function - .as_ref() - .map(|f| Self::resolve_lookup_name(f, state)) - .unwrap_or_else(|| ObjectId::new("public", "unknown_function")); - let function_id = Self::resolve_function_id_by_sig(&function_base, ""); - mutations.push(Mutation::CreateTrigger(CreateTriggerMutation { - name: name.clone(), - table: Self::resolve_lookup_name(table, state), - function_id, - })); + mutations.push(Self::resolve_create_trigger(name, table, function, state)); } StatementFact::DropTrigger { name, table, if_exists, } => { - mutations.push(Mutation::DropTrigger(DropTriggerMutation { - name: name.clone(), - table: Self::resolve_lookup_name(table, state), - if_exists: *if_exists, - })); + mutations.push(Self::resolve_drop_trigger(name, table, *if_exists, state)); } StatementFact::AlterTrigger { name, table, new_name, - } => mutations.push(Mutation::RenameTrigger(RenameTriggerMutation { - name: name.clone(), - table: Self::resolve_lookup_name(table, state), - new_name: new_name.clone(), - })), + } => mutations.push(Self::resolve_alter_trigger(name, table, new_name, state)), StatementFact::AlterIndex { name, actions } => { - let id = Self::resolve_lookup_name(name, state); - for action in actions { - match action { - AlterIndexActionFact::RenameTo { new_name } => { - let mut new_id = ObjectId::new(id.schema.clone(), new_name.resolve()); - new_id.inferred_schema = id.inferred_schema; - mutations.push(Mutation::Rename(Rename { - old_id: id.clone(), - new_id, - })); - } - } - } + mutations.extend(Self::resolve_alter_index(name, actions, state)); } StatementFact::CreateType(create_type) => { - let id = Self::resolve_creation_name(&create_type.name, state); - - let mapped_kind = match &create_type.kind { - TypeCreationKind::Enum { variants } => TypeKind::Enum { - variants: variants.clone(), - }, - TypeCreationKind::Range => TypeKind::Range, - TypeCreationKind::Composite => TypeKind::Composite, - TypeCreationKind::Base => TypeKind::Base, - }; - - mutations.push(Mutation::CreateType(CreateTypeMutation { - id, - kind: mapped_kind, - })); + mutations.push(Self::resolve_create_type(create_type, state)); } StatementFact::AlterType(alter_type) => { - let id = Self::resolve_type_lookup_name(&alter_type.name, state); - for action_fact in &alter_type.actions { - match action_fact { - crate::analysis::facts::AlterTypeActionFact::RenameTo { new_name } => { - let mut new_id = ObjectId::new(id.schema.clone(), new_name.resolve()); - new_id.inferred_schema = id.inferred_schema; - mutations.push(Mutation::RenameType(Rename { - old_id: id.clone(), - new_id, - })); - } - crate::analysis::facts::AlterTypeActionFact::SetSchema { new_schema } => { - mutations.push(Mutation::RenameType(Rename { - old_id: id.clone(), - new_id: ObjectId::new(new_schema, &id.name), - })); - } - crate::analysis::facts::AlterTypeActionFact::AddValue { - new_value, - neighbor, - before, - } => { - mutations.push(Mutation::AlterType(AlterTypeMutation { - id: id.clone(), - action: AlterTypeActionMutation::AddValue { - new_value: new_value.clone(), - neighbor: neighbor.clone(), - before: *before, - }, - })); - } - crate::analysis::facts::AlterTypeActionFact::RenameValue { - old_value, - new_value, - } => { - mutations.push(Mutation::AlterType(AlterTypeMutation { - id: id.clone(), - action: AlterTypeActionMutation::RenameValue { - old_value: old_value.clone(), - new_value: new_value.clone(), - }, - })); - } - } - } + mutations.extend(Self::resolve_alter_type(alter_type, state)); } StatementFact::CreateDomain { name, base_type } => { - let id = Self::resolve_creation_name(name, state); - - mutations.push(Mutation::CreateDomain(CreateDomainMutation { - id, - base_type: base_type.clone(), - })); + mutations.push(Self::resolve_create_domain(name, base_type, state)); } StatementFact::AlterDomain { name, action } => { - mutations.push(Mutation::AlterDomain(AlterDomainMutation { - id: Self::resolve_lookup_name(name, state), - action: action.clone(), - })); + mutations.push(Self::resolve_alter_domain(name, action, state)); } StatementFact::DropDomain { names, if_exists, cascade, } => { - let ids = names - .iter() - .map(|n| Self::resolve_lookup_name(n, state)) - .collect(); - mutations.push(Mutation::DropDomain(DropDomainMutation { - ids, - if_exists: *if_exists, - cascade: *cascade, - })); + mutations.push(Self::resolve_drop_domain( + names, *if_exists, *cascade, state, + )); } StatementFact::DropType { names, if_exists, cascade, } => { - let ids = names - .iter() - .map(|n| Self::resolve_lookup_name(n, state)) - .collect(); - mutations.push(Mutation::DropType(DropTypeMutation { - ids, - if_exists: *if_exists, - cascade: *cascade, - })); + mutations.push(Self::resolve_drop_type(names, *if_exists, *cascade, state)); } StatementFact::CreateSequence { name, if_not_exists, owned_by, } => { - let id = Self::resolve_creation_name(name, state); - - let resolved_owned_by = owned_by.as_ref().map(|(table_name, col)| { - (Self::resolve_lookup_name(table_name, state), col.clone()) - }); - mutations.push(Mutation::CreateSequence(CreateSequenceMutation { - id, - if_not_exists: *if_not_exists, - owned_by: resolved_owned_by, - })); + mutations.push(Self::resolve_create_sequence( + name, + *if_not_exists, + owned_by, + state, + )); } StatementFact::AlterSequence { name, if_exists, action, } => { - let id = Self::resolve_lookup_name(name, state); - let action = match action { - crate::analysis::facts::AlterSequenceActionFact::OwnedBy(owned_by) => { - AlterSequenceActionMutation::OwnedBy(owned_by.as_ref().map( - |(table_name, col)| { - (Self::resolve_lookup_name(table_name, state), col.clone()) - }, - )) - } - crate::analysis::facts::AlterSequenceActionFact::OwnerTo(owner) => { - AlterSequenceActionMutation::OwnerTo(owner.clone()) - } - crate::analysis::facts::AlterSequenceActionFact::RenameTo(new_name) => { - AlterSequenceActionMutation::RenameTo(ObjectId::new( - &id.schema, - new_name.resolve(), - )) - } - crate::analysis::facts::AlterSequenceActionFact::SetSchema(schema) => { - AlterSequenceActionMutation::SetSchema(ObjectId::new(schema, &id.name)) - } - crate::analysis::facts::AlterSequenceActionFact::Other => { - AlterSequenceActionMutation::Other - } - }; - mutations.push(Mutation::AlterSequence(AlterSequenceMutation { - id, - if_exists: *if_exists, - action, - })); + mutations.push(Self::resolve_alter_sequence( + name, *if_exists, action, state, + )); } StatementFact::DropSequence { names, if_exists, cascade, } => { - let ids = names - .iter() - .map(|n| Self::resolve_lookup_name(n, state)) - .collect(); - mutations.push(Mutation::DropSequence(DropSequenceMutation { - ids, - if_exists: *if_exists, - cascade: *cascade, - })); + mutations.push(Self::resolve_drop_sequence( + names, *if_exists, *cascade, state, + )); } StatementFact::AlterTable { name, actions } => { - let id = Self::resolve_lookup_name(name, state); - for action_fact in actions { - let action = match action_fact { - AlterTableActionFact::AddColumn { - name: col_name, - ty, - if_not_exists, - not_null, - default, - generation, - } => AlterTableActionMutation::AddColumn { - name: col_name.clone(), - ty: ty.clone(), - if_not_exists: *if_not_exists, - not_null: *not_null, - default: default.clone(), - depends_on: None, // Logic for extraction can be added later if needed - generation: *generation, - }, - AlterTableActionFact::DropColumn { - name: col_name, - if_exists, - } => AlterTableActionMutation::DropColumn { - name: col_name.clone(), - if_exists: *if_exists, - }, - AlterTableActionFact::RenameColumn { from, to } => { - AlterTableActionMutation::RenameColumn { - from: from.resolve(), - to: to.resolve(), - } - } - AlterTableActionFact::RenameTo { new_name } => { - let mut new_id = ObjectId::new(id.schema.clone(), new_name.resolve()); - new_id.inferred_schema = id.inferred_schema; - mutations.push(Mutation::Rename(Rename { - old_id: id.clone(), - new_id, - })); - continue; - } - AlterTableActionFact::SetSchema { new_schema } => { - let new_id = ObjectId::new(new_schema, &id.name); - mutations.push(Mutation::Rename(Rename { - old_id: id.clone(), - new_id, - })); - continue; - } - AlterTableActionFact::AddForeignKey { - constraint_name, - references, - from_columns, - to_columns, - not_valid, - } => { - let to_table = Self::resolve_lookup_name(references, state); - if !state.relation_is_present(&to_table) { - return vec![Mutation::Opaque( - OpaqueMutation::UnresolvedReference { - object_kind: crate::report::violations::ObjectKind::Table, - object_name: to_table.to_string(), - }, - )]; - } - AlterTableActionMutation::AddForeignKey { - constraint_name: constraint_name.clone(), - to_table, - from_columns: from_columns.clone(), - to_columns: to_columns.clone(), - not_valid: *not_valid, - } - } - AlterTableActionFact::AlterConstraint { - name: c_name, - deferrable, - } => AlterTableActionMutation::AlterConstraint { - name: c_name.clone(), - deferrable: *deferrable, - }, - AlterTableActionFact::RenameConstraint { old_name, new_name } => { - AlterTableActionMutation::RenameConstraint { - old_name: old_name.clone(), - new_name: new_name.clone(), - } - } - AlterTableActionFact::DropConstraint { name: c_name } => { - AlterTableActionMutation::DropConstraint { - name: c_name.clone(), - } - } - AlterTableActionFact::AddCheckConstraint { - constraint_name, - not_valid, - } => AlterTableActionMutation::AddCheckConstraint { - constraint_name: constraint_name.clone(), - not_valid: *not_valid, - }, - AlterTableActionFact::AddUniqueConstraint { - constraint_name, - using_index, - } => AlterTableActionMutation::AddUniqueConstraint { - constraint_name: constraint_name.clone(), - using_index: using_index - .as_ref() - .map(|name| Self::resolve_constraint_index_name(name, &id)), - }, - AlterTableActionFact::AddPrimaryKeyConstraint { - constraint_name, - using_index, - } => AlterTableActionMutation::AddPrimaryKeyConstraint { - constraint_name: constraint_name.clone(), - using_index: using_index - .as_ref() - .map(|name| Self::resolve_constraint_index_name(name, &id)), - }, - AlterTableActionFact::AddExcludeConstraint { constraint_name } => { - AlterTableActionMutation::AddExcludeConstraint { - constraint_name: constraint_name.clone(), - } - } - AlterTableActionFact::SetNotNull { column } => { - AlterTableActionMutation::SetNotNull { - column: column.clone(), - } - } - AlterTableActionFact::DropNotNull { column } => { - AlterTableActionMutation::DropNotNull { - column: column.clone(), - } - } - AlterTableActionFact::SetType { - column, - ty, - has_using, - } => AlterTableActionMutation::SetType { - column: column.clone(), - ty: ty.clone(), - has_using: *has_using, - }, - AlterTableActionFact::SetDefault { column, default } => { - AlterTableActionMutation::SetDefault { - column: column.clone(), - default: default.clone(), - } - } - AlterTableActionFact::ValidateConstraint { constraint_name } => { - AlterTableActionMutation::ValidateConstraint { - constraint_name: constraint_name.clone(), - } - } - AlterTableActionFact::AttachPartition { child, strategy } => { - let child_id = Self::resolve_lookup_name(child, state); - - AlterTableActionMutation::AttachPartition { - child: child_id, - strategy: strategy.clone(), - } - } - AlterTableActionFact::DetachPartition { child } => { - AlterTableActionMutation::DetachPartition { - child: Self::resolve_lookup_name(child, state), - } - } - AlterTableActionFact::SetStorage { column } => { - AlterTableActionMutation::SetStorage { - column: column.clone(), - } - } - AlterTableActionFact::SetAccessMethod => { - AlterTableActionMutation::SetAccessMethod - } - AlterTableActionFact::DisableTrigger { trigger_name } => { - AlterTableActionMutation::DisableTrigger { - trigger_name: trigger_name.clone(), - } - } - AlterTableActionFact::EnableTrigger { trigger_name } => { - AlterTableActionMutation::EnableTrigger { - 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::OwnerTo { new_owner } => { - AlterTableActionMutation::OwnerTo { - new_owner: new_owner.clone(), - } - } - }; - mutations.push(Mutation::AlterTable(AlterTable { - id: id.clone(), - action, - })); - } + mutations.extend(Self::resolve_alter_table(name, actions, state)); } StatementFact::DropTable { - name, + names, if_exists, cascade, } => { - let id = Self::resolve_lookup_name(name, state); - - // Still emit a DropTable mutation for rule evaluation (e.g. DriftDetectionRule) - // even when the table is not present locally. The state machine will handle - // tainting confidence in apply(). - mutations.push(Mutation::DropTable(DropTable { - id, - if_exists: *if_exists, - cascade: *cascade, - })); + mutations.push(Self::resolve_drop_table(names, *if_exists, *cascade, state)); } StatementFact::DropView { - name, + names, if_exists, cascade, } => { - mutations.push(Mutation::DropView(DropViewMutation { - ids: vec![Self::resolve_lookup_name(name, state)], - if_exists: *if_exists, - cascade: *cascade, - })); + mutations.push(Self::resolve_drop_view(names, *if_exists, *cascade, state)); } StatementFact::DropMaterializedView { names, if_exists, cascade, } => { - let ids = names - .iter() - .map(|n| Self::resolve_lookup_name(n, state)) - .collect(); - mutations.push(Mutation::DropMaterializedView( - DropMaterializedViewMutation { - ids, - if_exists: *if_exists, - cascade: *cascade, - }, + mutations.push(Self::resolve_drop_materialized_view( + names, *if_exists, *cascade, state, )); } StatementFact::DropIndex { names, if_exists, concurrently, + cascade, } => { - for name in names { - mutations.push(Mutation::DropIndex(DropIndex { - id: Self::resolve_lookup_name(name, state), - if_exists: *if_exists, - concurrently: *concurrently, - })); - } + mutations.push(Self::resolve_drop_indexes( + names, + *if_exists, + *concurrently, + *cascade, + state, + )); } StatementFact::SetSearchPath { target, local } => { - mutations.push(Mutation::SearchPath(SearchPathChange { - target: target.clone(), - local: *local, - })) + mutations.push(Self::resolve_search_path(target, *local)) } StatementFact::SetTimeout { setting, value, local, - } => mutations.push(Mutation::TimeoutSetting(TimeoutSettingChange { - setting: *setting, - value: value.clone(), - local: *local, - })), + } => mutations.push(Self::resolve_timeout(*setting, value, *local)), StatementFact::ResetSettings { target } => { mutations.push(Mutation::ResetSettings(*target)) } @@ -1061,19 +446,11 @@ impl Resolver { StatementFact::RollbackTransaction => mutations.push(Mutation::RollbackTransaction), StatementFact::RollbackAndChain => mutations.push(Mutation::RollbackAndChain), StatementFact::RollbackToSavepoint { name } => { - mutations.push(Mutation::RollbackToSavepoint(RollbackToSavepointMutation { - name: name.clone(), - })) - } - StatementFact::Savepoint { name } => { - mutations.push(Mutation::Savepoint(SavepointMutation { - name: name.clone(), - })) + mutations.push(Self::resolve_rollback_to_savepoint(name)) } + StatementFact::Savepoint { name } => mutations.push(Self::resolve_savepoint(name)), StatementFact::ReleaseSavepoint { name } => { - mutations.push(Mutation::ReleaseSavepoint(ReleaseSavepointMutation { - name: name.clone(), - })) + mutations.push(Self::resolve_release_savepoint(name)) } StatementFact::PrepareTransaction { .. } => { mutations.push(Mutation::Opaque(OpaqueMutation::PrepareTransaction)) @@ -1087,243 +464,83 @@ impl Resolver { StatementFact::OpaqueBlock => mutations.push(Mutation::Opaque(OpaqueMutation::DoBlock)), StatementFact::Execute => mutations.push(Mutation::Opaque(OpaqueMutation::Execute)), StatementFact::Vacuum { relation, is_full } => { - let table_id = relation - .as_ref() - .map(|r| Self::resolve_lookup_name(r, state)); - mutations.push(Mutation::Vacuum { - table_id, - is_full: *is_full, - }) + mutations.push(Self::resolve_vacuum(relation.as_ref(), *is_full, state)) } StatementFact::CreateFunction(f) => { - let id = Self::resolve_function_id(&f.name, &f.params, state); - mutations.push(Mutation::CreateFunction(CreateFunctionMutation { - id, - or_replace: f.or_replace, - params: f.params.clone(), - return_type: f.return_type.clone(), - options: f.options.clone(), - })); + mutations.push(Self::resolve_create_function(f, state)); } StatementFact::AlterFunction(f) => { - let base_id = Self::resolve_lookup_name(&f.name, state); - let sig = f.params.join(","); - let id = Self::resolve_function_id_by_sig(&base_id, &sig); - mutations.push(Mutation::AlterFunction(AlterFunctionMutation { - id, - action: f.action.clone(), - })); + mutations.push(Self::resolve_alter_function(f, state)); } StatementFact::DropFunction(f) => { - let mut signatures = Vec::new(); - for sig in &f.signatures { - let mut normalized_sig = sig.clone(); - normalized_sig.params = normalized_sig - .params - .into_iter() - .map(|p| Self::normalize_function_arg_type(&p)) - .collect(); - signatures.push(normalized_sig); - } - mutations.push(Mutation::DropFunction(DropFunctionMutation { - signatures, - if_exists: f.if_exists, - cascade: f.cascade, - })); + mutations.push(Self::resolve_drop_function(f)); } StatementFact::CreateProcedure(p) => { - let id = Self::resolve_function_id(&p.name, &p.params, state); - mutations.push(Mutation::CreateProcedure(CreateProcedureMutation { - id, - or_replace: p.or_replace, - params: p.params.clone(), - options: p.options.clone(), - })); + mutations.push(Self::resolve_create_procedure(p, state)); } StatementFact::AlterProcedure(p) => { - let base_id = Self::resolve_lookup_name(&p.name, state); - let sig = p - .params - .iter() - .map(|p| p.to_string()) - .collect::>() - .join(","); - let id = Self::resolve_function_id_by_sig(&base_id, &sig); - mutations.push(Mutation::AlterProcedure(AlterProcedureMutation { - id, - action: p.action.clone(), - })); + mutations.push(Self::resolve_alter_procedure(p, state)); } StatementFact::DropProcedure(p) => { - let signatures = p - .signatures - .iter() - .cloned() - .map(|mut signature| { - signature.params = signature - .params - .into_iter() - .map(|param| Self::normalize_function_arg_type(¶m)) - .collect(); - signature - }) - .collect(); - mutations.push(Mutation::DropProcedure(DropProcedureMutation { - signatures, - if_exists: p.if_exists, - cascade: p.cascade, - })); + mutations.push(Self::resolve_drop_procedure(p)); } StatementFact::CreateAggregate(a) => { - let id = Self::resolve_function_id(&a.name, &a.params, state); - mutations.push(Mutation::CreateAggregate(CreateAggregateMutation { - id, - or_replace: a.or_replace, - params: a.params.clone(), - })); + mutations.push(Self::resolve_create_aggregate(a, state)); } StatementFact::AlterAggregate(a) => { - let base_id = Self::resolve_lookup_name(&a.name, state); - let signature = a - .params - .iter() - .map(|param| Self::normalize_function_arg_type(param)) - .collect::>() - .join(","); - let id = Self::resolve_function_id_by_sig(&base_id, &signature); - mutations.push(Mutation::AlterAggregate(AlterAggregateMutation { - id, - action: a.action.clone(), - })); + mutations.push(Self::resolve_alter_aggregate(a, state)); } StatementFact::DropAggregate(a) => { - let signatures = a - .signatures - .iter() - .cloned() - .map(|mut signature| { - signature.params = signature - .params - .into_iter() - .map(|param| Self::normalize_function_arg_type(¶m)) - .collect(); - signature - }) - .collect(); - mutations.push(Mutation::DropAggregate(DropAggregateMutation { - signatures, - if_exists: a.if_exists, - cascade: a.cascade, - })); + mutations.push(Self::resolve_drop_aggregate(a)); } StatementFact::CreatePublication(p) => { - mutations.push(Mutation::CreatePublication(CreatePublicationMutation { - name: p.name.clone(), - scope: Self::resolve_publication_scope(&p.scope, state), - params: p.params.clone(), - })); + mutations.push(Self::resolve_create_publication(p, state)); } StatementFact::AlterPublication(p) => { - mutations.push(Mutation::AlterPublication(AlterPublicationMutation { - name: p.name.clone(), - action: Self::resolve_alter_publication_action(&p.action, state), - })); + mutations.push(Self::resolve_alter_publication(p, state)); } StatementFact::DropPublication(p) => { - mutations.push(Mutation::DropPublication(DropPublicationMutation { - names: p.names.clone(), - if_exists: p.if_exists, - cascade: p.cascade, - })); + mutations.push(Self::resolve_drop_publication(p)); } StatementFact::CreateSubscription(s) => { - mutations.push(Mutation::CreateSubscription(CreateSubscriptionMutation { - name: s.name.clone(), - connection: s.connection.clone(), - publications: s.publications.clone(), - params: s.params.clone(), - })); + mutations.push(Self::resolve_create_subscription(s)); } StatementFact::AlterSubscription(s) => { - mutations.push(Mutation::AlterSubscription(AlterSubscriptionMutation { - name: s.name.clone(), - action: s.action.clone(), - })); + mutations.push(Self::resolve_alter_subscription(s)); } StatementFact::DropSubscription(s) => { - mutations.push(Mutation::DropSubscription(DropSubscriptionMutation { - name: s.name.clone(), - if_exists: s.if_exists, - })); + mutations.push(Self::resolve_drop_subscription(s)); } StatementFact::CreateRole(r) => { - mutations.push(Mutation::CreateRole(CreateRoleMutation { - name: r.name.clone(), - inherits: r.inherits, - can_login: r.can_login, - })); + mutations.push(Self::resolve_create_role(r)); } StatementFact::AlterRole(r) => { - mutations.push(Mutation::AlterRole(AlterRoleMutation { - name: r.name.clone(), - inherits: r.inherits, - })); + mutations.push(Self::resolve_alter_role(r)); } StatementFact::DropRole(r) => { - mutations.push(Mutation::DropRole(DropRoleMutation { - names: r.names.clone(), - if_exists: r.if_exists, - })); + mutations.push(Self::resolve_drop_role(r)); } StatementFact::Grant(g) => { - mutations.push(Mutation::Grant(GrantMutation { - privileges: g.privileges.clone(), - target: Self::resolve_grant_target(&g.target, state), - grantees: g.grantees.clone(), - with_grant_option: g.with_grant_option, - granted_by: g.granted_by.clone(), - })); + mutations.push(Self::resolve_grant(g, state)); } StatementFact::Revoke(r) => { - mutations.push(Mutation::Revoke(RevokeMutation { - grant_option_only: r.grant_option_only, - privileges: r.privileges.clone(), - target: Self::resolve_grant_target(&r.target, state), - revokees: r.revokees.clone(), - granted_by: r.granted_by.clone(), - cascade: r.cascade, - })); + mutations.push(Self::resolve_revoke(r, state)); } StatementFact::CreateDatabase(d) => { - mutations.push(Mutation::CreateDatabase(CreateDatabaseMutation { - name: d.name.clone(), - options: d.options.clone(), - })); + mutations.push(Self::resolve_create_database(d)); } StatementFact::AlterDatabase(d) => { - let id = Self::resolve_lookup_name(&d.name, state); - mutations.push(Mutation::AlterDatabase(AlterDatabaseMutation { - id, - action: d.action.clone(), - })); + mutations.push(Self::resolve_alter_database(d)); } StatementFact::DropDatabase(d) => { - let id = Self::resolve_lookup_name(&d.name, state); - mutations.push(Mutation::DropDatabase(DropDatabaseMutation { - id, - if_exists: d.if_exists, - })); + mutations.push(Self::resolve_drop_database(d)); } StatementFact::SetRole { role, local, is_session_auth, } => { - mutations.push(Mutation::SwitchRole { - role: role.clone(), - local: *local, - is_session_auth: *is_session_auth, - }); + mutations.push(Self::resolve_set_role(role, *local, *is_session_auth)); } } mutations diff --git a/src/analysis/resolver/relation.rs b/src/analysis/resolver/relation.rs new file mode 100644 index 0000000..649fb0c --- /dev/null +++ b/src/analysis/resolver/relation.rs @@ -0,0 +1,353 @@ +use super::Resolver; +use crate::analysis::facts::{ + AlterTableActionFact, ColumnFact, FkFact, PersistenceFact, TableConstraintFact, +}; +use crate::analysis::mutations::{ + AlterTable, AlterTableActionMutation, ColumnMutation, CreateTable, DropIndex, + DropMaterializedViewMutation, DropTable, DropViewMutation, FkMutation, Mutation, + PersistenceMutation, Rename, +}; +use crate::analysis::state::AnalysisState; +use crate::ast::identifiers::{ObjectId, QualifiedName}; + +impl Resolver { + #[allow(clippy::too_many_arguments)] + pub(super) fn resolve_create_table( + name: &QualifiedName, + if_not_exists: bool, + as_select: bool, + persistence: &PersistenceFact, + columns: &[ColumnFact], + foreign_keys: &[FkFact], + table_constraints: &[TableConstraintFact], + partition_by: &Option, + partition_of: &Option, + partition_type: &Option, + state: &AnalysisState, + ) -> Mutation { + let persistence = match persistence { + PersistenceFact::Permanent => PersistenceMutation::Permanent, + PersistenceFact::Temporary => PersistenceMutation::Temporary, + PersistenceFact::Unlogged => PersistenceMutation::Unlogged, + }; + let columns = columns + .iter() + .map(|column| ColumnMutation { + name: column.name.clone(), + ty: column.ty.clone(), + not_null: column.not_null, + is_primary_key: column.is_primary_key, + primary_key_constraint_name: column.primary_key_constraint_name.clone(), + is_unique: column.is_unique, + unique_constraint_name: column.unique_constraint_name.clone(), + default: column.default.clone(), + generation: column.generation, + }) + .collect(); + let foreign_keys = foreign_keys + .iter() + .map(|foreign_key| FkMutation { + constraint_name: foreign_key.constraint_name.clone(), + to_table: Self::resolve_relation_lookup_name(&foreign_key.references, state), + from_columns: foreign_key.from_columns.clone(), + to_columns: foreign_key.to_columns.clone(), + }) + .collect(); + Mutation::CreateTable(CreateTable { + id: Self::resolve_creation_name(name, state), + if_not_exists, + as_select, + persistence, + columns, + foreign_keys, + table_constraints: table_constraints.to_vec(), + partition_by: partition_by.clone(), + partition_of: partition_of + .as_ref() + .map(|parent| Self::resolve_relation_lookup_name(parent, state)), + partition_type: partition_type.clone(), + }) + } + + pub(super) fn resolve_alter_table( + name: &QualifiedName, + actions: &[AlterTableActionFact], + state: &AnalysisState, + ) -> Vec { + let id = Self::resolve_relation_lookup_name(name, state); + let mut mutations = Vec::with_capacity(actions.len()); + for action_fact in actions { + let action = match action_fact { + AlterTableActionFact::AddColumn { + name, + ty, + if_not_exists, + not_null, + default, + generation, + } => AlterTableActionMutation::AddColumn { + name: name.clone(), + ty: ty.clone(), + if_not_exists: *if_not_exists, + not_null: *not_null, + default: default.clone(), + depends_on: None, + generation: *generation, + }, + AlterTableActionFact::DropColumn { + name, + if_exists, + cascade, + } => AlterTableActionMutation::DropColumn { + name: name.clone(), + if_exists: *if_exists, + cascade: *cascade, + }, + AlterTableActionFact::RenameColumn { from, to } => { + AlterTableActionMutation::RenameColumn { + from: from.resolve(), + to: to.resolve(), + } + } + AlterTableActionFact::RenameTo { new_name } => { + let mut new_id = ObjectId::new(id.schema.clone(), new_name.resolve()); + new_id.inferred_schema = id.inferred_schema; + mutations.push(Mutation::Rename(Rename { + old_id: id.clone(), + new_id, + })); + continue; + } + AlterTableActionFact::SetSchema { new_schema } => { + mutations.push(Mutation::Rename(Rename { + old_id: id.clone(), + new_id: ObjectId::new(new_schema, &id.name), + })); + continue; + } + AlterTableActionFact::AddForeignKey { + constraint_name, + references, + from_columns, + to_columns, + not_valid, + } => { + let to_table = Self::resolve_relation_lookup_name(references, state); + AlterTableActionMutation::AddForeignKey { + constraint_name: constraint_name.clone(), + to_table, + from_columns: from_columns.clone(), + to_columns: to_columns.clone(), + not_valid: *not_valid, + } + } + AlterTableActionFact::AlterConstraint { name, deferrable } => { + AlterTableActionMutation::AlterConstraint { + name: name.clone(), + deferrable: *deferrable, + } + } + AlterTableActionFact::RenameConstraint { old_name, new_name } => { + AlterTableActionMutation::RenameConstraint { + old_name: old_name.clone(), + new_name: new_name.clone(), + } + } + AlterTableActionFact::DropConstraint { + name, + if_exists, + cascade, + } => AlterTableActionMutation::DropConstraint { + name: name.clone(), + if_exists: *if_exists, + cascade: *cascade, + }, + AlterTableActionFact::AddCheckConstraint { + constraint_name, + not_valid, + } => AlterTableActionMutation::AddCheckConstraint { + constraint_name: constraint_name.clone(), + not_valid: *not_valid, + }, + AlterTableActionFact::AddUniqueConstraint { + constraint_name, + columns, + using_index, + } => AlterTableActionMutation::AddUniqueConstraint { + constraint_name: constraint_name.clone(), + columns: columns.clone(), + using_index: using_index + .as_ref() + .map(|name| Self::resolve_constraint_index_name(name, &id)), + }, + AlterTableActionFact::AddPrimaryKeyConstraint { + constraint_name, + columns, + using_index, + } => AlterTableActionMutation::AddPrimaryKeyConstraint { + constraint_name: constraint_name.clone(), + columns: columns.clone(), + using_index: using_index + .as_ref() + .map(|name| Self::resolve_constraint_index_name(name, &id)), + }, + AlterTableActionFact::AddExcludeConstraint { constraint_name } => { + AlterTableActionMutation::AddExcludeConstraint { + constraint_name: constraint_name.clone(), + } + } + AlterTableActionFact::SetNotNull { column } => { + AlterTableActionMutation::SetNotNull { + column: column.clone(), + } + } + AlterTableActionFact::DropNotNull { column } => { + AlterTableActionMutation::DropNotNull { + column: column.clone(), + } + } + AlterTableActionFact::SetType { + column, + ty, + has_using, + } => AlterTableActionMutation::SetType { + column: column.clone(), + ty: ty.clone(), + has_using: *has_using, + }, + AlterTableActionFact::SetDefault { column, default } => { + AlterTableActionMutation::SetDefault { + column: column.clone(), + default: default.clone(), + } + } + AlterTableActionFact::ValidateConstraint { constraint_name } => { + AlterTableActionMutation::ValidateConstraint { + constraint_name: constraint_name.clone(), + } + } + AlterTableActionFact::AttachPartition { child, strategy } => { + AlterTableActionMutation::AttachPartition { + child: Self::resolve_relation_lookup_name(child, state), + strategy: strategy.clone(), + } + } + AlterTableActionFact::DetachPartition { child } => { + AlterTableActionMutation::DetachPartition { + child: Self::resolve_relation_lookup_name(child, state), + } + } + AlterTableActionFact::SetStorage { column } => { + AlterTableActionMutation::SetStorage { + column: column.clone(), + } + } + AlterTableActionFact::SetAccessMethod => AlterTableActionMutation::SetAccessMethod, + AlterTableActionFact::DisableTrigger { trigger_name } => { + AlterTableActionMutation::DisableTrigger { + trigger_name: trigger_name.clone(), + } + } + AlterTableActionFact::EnableTrigger { trigger_name } => { + AlterTableActionMutation::EnableTrigger { + 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::OwnerTo { new_owner } => AlterTableActionMutation::OwnerTo { + new_owner: new_owner.clone(), + }, + }; + mutations.push(Mutation::AlterTable(AlterTable { + id: id.clone(), + action, + })); + } + mutations + } + + pub(super) fn resolve_drop_table( + names: &[QualifiedName], + if_exists: bool, + cascade: bool, + state: &AnalysisState, + ) -> Mutation { + Mutation::DropTable(DropTable { + ids: names + .iter() + .map(|name| Self::resolve_relation_lookup_name(name, state)) + .collect(), + if_exists, + cascade, + }) + } + + pub(super) fn resolve_drop_view( + names: &[QualifiedName], + if_exists: bool, + cascade: bool, + state: &AnalysisState, + ) -> Mutation { + Mutation::DropView(DropViewMutation { + ids: names + .iter() + .map(|name| Self::resolve_relation_lookup_name(name, state)) + .collect(), + if_exists, + cascade, + }) + } + + pub(super) fn resolve_drop_materialized_view( + names: &[QualifiedName], + if_exists: bool, + cascade: bool, + state: &AnalysisState, + ) -> Mutation { + Mutation::DropMaterializedView(DropMaterializedViewMutation { + ids: names + .iter() + .map(|name| Self::resolve_relation_lookup_name(name, state)) + .collect(), + if_exists, + cascade, + }) + } + + pub(super) fn resolve_drop_indexes( + names: &[QualifiedName], + if_exists: bool, + concurrently: bool, + cascade: bool, + state: &AnalysisState, + ) -> Mutation { + Mutation::DropIndex(DropIndex { + ids: names + .iter() + .map(|name| Self::resolve_relation_lookup_name(name, state)) + .collect(), + if_exists, + concurrently, + cascade, + }) + } +} diff --git a/src/analysis/resolver/relation_aux.rs b/src/analysis/resolver/relation_aux.rs new file mode 100644 index 0000000..887070d --- /dev/null +++ b/src/analysis/resolver/relation_aux.rs @@ -0,0 +1,220 @@ +use super::Resolver; +use crate::analysis::facts::{AlterIndexActionFact, AlterViewAction, PolicyCommand}; +use crate::analysis::mutations::{ + CreateIndex, CreateMaterializedView, CreatePolicyMutation, CreateTriggerMutation, CreateView, + DropPolicyMutation, DropTriggerMutation, Mutation, OpaqueMutation, + RefreshMaterializedViewMutation, Rename, RenameTriggerMutation, +}; +use crate::analysis::state::AnalysisState; +use crate::ast::identifiers::{Ident, ObjectId, QualifiedName}; + +impl Resolver { + pub(super) fn resolve_create_view( + name: &QualifiedName, + or_replace: bool, + depends_on: &[QualifiedName], + state: &AnalysisState, + ) -> Mutation { + Mutation::CreateView(CreateView { + id: Self::resolve_creation_name(name, state), + or_replace, + depends_on: depends_on + .iter() + .map(|dependency| Self::resolve_relation_lookup_name(dependency, state)) + .collect(), + }) + } + + pub(super) fn resolve_alter_view( + name: &QualifiedName, + action: &AlterViewAction, + state: &AnalysisState, + ) -> Option { + match action { + AlterViewAction::RenameTo { new_name } => { + let id = Self::resolve_relation_lookup_name(name, state); + let mut new_id = ObjectId::new(id.schema.clone(), new_name.resolve()); + new_id.inferred_schema = id.inferred_schema; + Some(Mutation::Rename(Rename { old_id: id, new_id })) + } + AlterViewAction::SetSchema { new_schema } => { + let id = Self::resolve_relation_lookup_name(name, state); + let new_id = ObjectId::new(new_schema, &id.name); + Some(Mutation::Rename(Rename { old_id: id, new_id })) + } + AlterViewAction::OwnerTo { new_owner } => Some(Mutation::ChangeRelationOwner { + id: Self::resolve_relation_lookup_name(name, state), + new_owner: new_owner.clone(), + }), + AlterViewAction::RenameColumn { .. } => { + Some(Mutation::Opaque(OpaqueMutation::UnsupportedStatement)) + } + AlterViewAction::SetDefault { .. } + | AlterViewAction::DropDefault { .. } + | AlterViewAction::SetOptions { .. } + | AlterViewAction::ResetOptions { .. } => None, + } + } + + pub(super) fn resolve_create_materialized_view( + name: &QualifiedName, + depends_on: &[QualifiedName], + state: &AnalysisState, + ) -> Mutation { + Mutation::CreateMaterializedView(CreateMaterializedView { + id: Self::resolve_creation_name(name, state), + depends_on: depends_on + .iter() + .map(|dependency| Self::resolve_relation_lookup_name(dependency, state)) + .collect(), + }) + } + + pub(super) fn resolve_alter_materialized_view( + name: &QualifiedName, + new_name: Option<&Ident>, + state: &AnalysisState, + ) -> Option { + new_name.map(|new_name| { + let id = Self::resolve_relation_lookup_name(name, state); + let mut new_id = ObjectId::new(id.schema.clone(), new_name.resolve()); + new_id.inferred_schema = id.inferred_schema; + Mutation::Rename(Rename { old_id: id, new_id }) + }) + } + + pub(super) fn resolve_refresh_materialized_view( + name: &QualifiedName, + concurrently: bool, + state: &AnalysisState, + ) -> Mutation { + Mutation::RefreshMaterializedView(RefreshMaterializedViewMutation { + id: Self::resolve_relation_lookup_name(name, state), + concurrently, + }) + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn resolve_create_index( + name: &QualifiedName, + relation: &QualifiedName, + if_not_exists: bool, + concurrently: bool, + using_method: &Option, + has_predicate: bool, + unique: bool, + state: &AnalysisState, + ) -> Mutation { + let table = Self::resolve_relation_lookup_name(relation, state); + // PostgreSQL places an unqualified index in the indexed relation's + // schema, not the first schema in search_path. + let id = if name.schema.is_some() { + Self::resolve_creation_name(name, state) + } else { + ObjectId::new(table.schema.clone(), name.name.resolve()) + }; + Mutation::CreateIndex(CreateIndex { + id, + table, + if_not_exists, + concurrently, + using_method: using_method.clone(), + has_predicate, + unique, + }) + } + + pub(super) fn resolve_create_policy( + name: &str, + table: &QualifiedName, + permissive: bool, + command: &PolicyCommand, + semantics_complete: bool, + state: &AnalysisState, + ) -> Mutation { + Mutation::CreatePolicy(CreatePolicyMutation { + name: name.to_string(), + table: Self::resolve_relation_lookup_name(table, state), + permissive, + command: command.clone(), + semantics_complete, + }) + } + + pub(super) fn resolve_drop_policy( + name: &str, + table: &QualifiedName, + if_exists: bool, + state: &AnalysisState, + ) -> Mutation { + Mutation::DropPolicy(DropPolicyMutation { + name: name.to_string(), + table: Self::resolve_relation_lookup_name(table, state), + if_exists, + }) + } + + pub(super) fn resolve_create_trigger( + name: &str, + table: &QualifiedName, + function: &Option, + state: &AnalysisState, + ) -> Mutation { + let Some(function) = function else { + return Mutation::Opaque(OpaqueMutation::UnsupportedStatement); + }; + let function_id = Self::resolve_routine_lookup_name(function, &[], state); + Mutation::CreateTrigger(CreateTriggerMutation { + name: name.to_string(), + table: Self::resolve_relation_lookup_name(table, state), + function_id, + }) + } + + pub(super) fn resolve_drop_trigger( + name: &str, + table: &QualifiedName, + if_exists: bool, + state: &AnalysisState, + ) -> Mutation { + Mutation::DropTrigger(DropTriggerMutation { + name: name.to_string(), + table: Self::resolve_relation_lookup_name(table, state), + if_exists, + }) + } + + pub(super) fn resolve_alter_trigger( + name: &str, + table: &QualifiedName, + new_name: &str, + state: &AnalysisState, + ) -> Mutation { + Mutation::RenameTrigger(RenameTriggerMutation { + name: name.to_string(), + table: Self::resolve_relation_lookup_name(table, state), + new_name: new_name.to_string(), + }) + } + + pub(super) fn resolve_alter_index( + name: &QualifiedName, + actions: &[AlterIndexActionFact], + state: &AnalysisState, + ) -> Vec { + let id = Self::resolve_relation_lookup_name(name, state); + actions + .iter() + .map(|action| match action { + AlterIndexActionFact::RenameTo { new_name } => { + let mut new_id = ObjectId::new(id.schema.clone(), new_name.resolve()); + new_id.inferred_schema = id.inferred_schema; + Mutation::Rename(Rename { + old_id: id.clone(), + new_id, + }) + } + }) + .collect() + } +} diff --git a/src/analysis/resolver/replication.rs b/src/analysis/resolver/replication.rs new file mode 100644 index 0000000..b4b59c6 --- /dev/null +++ b/src/analysis/resolver/replication.rs @@ -0,0 +1,150 @@ +use super::Resolver; +use crate::analysis::facts::{ + AlterPublicationActionFact, AlterPublicationFact, AlterSubscriptionFact, CreatePublicationFact, + CreateSubscriptionFact, DropPublicationFact, DropSubscriptionFact, PublicationObjectFact, + PublicationScope, +}; +use crate::analysis::mutations::{ + AlterPublicationMutation, AlterSubscriptionMutation, CreatePublicationMutation, + CreateSubscriptionMutation, DropPublicationMutation, DropSubscriptionMutation, Mutation, +}; +use crate::analysis::state::AnalysisState; +use crate::ast::identifiers::{Ident, QualifiedName}; + +impl Resolver { + fn resolve_publication_object( + object: &PublicationObjectFact, + state: &AnalysisState, + ) -> PublicationObjectFact { + match object { + PublicationObjectFact::Table { + name, + only, + include_partitions, + columns, + row_filter, + } => { + let id = Self::resolve_relation_lookup_name(name, state); + PublicationObjectFact::Table { + name: QualifiedName::new( + Some(Ident::new(id.schema, true)), + Ident::new(id.name, true), + ), + only: *only, + include_partitions: *include_partitions, + columns: columns.clone(), + row_filter: row_filter.clone(), + } + } + PublicationObjectFact::CurrentSchemaShorthand => PublicationObjectFact::SchemaTables { + schema: state + .local + .search_path + .first() + .cloned() + .unwrap_or_else(|| "public".to_string()), + row_filter: None, + }, + other => other.clone(), + } + } + + fn resolve_publication_scope( + scope: &PublicationScope, + state: &AnalysisState, + ) -> PublicationScope { + match scope { + PublicationScope::AllTables { except } => PublicationScope::AllTables { + except: except.clone(), + }, + PublicationScope::Explicit(objects) => PublicationScope::Explicit( + objects + .iter() + .map(|object| Self::resolve_publication_object(object, state)) + .collect(), + ), + } + } + + fn resolve_alter_publication_action( + action: &AlterPublicationActionFact, + state: &AnalysisState, + ) -> AlterPublicationActionFact { + match action { + AlterPublicationActionFact::AddObjects(objects) => { + AlterPublicationActionFact::AddObjects( + objects + .iter() + .map(|object| Self::resolve_publication_object(object, state)) + .collect(), + ) + } + AlterPublicationActionFact::DropObjects(objects) => { + AlterPublicationActionFact::DropObjects( + objects + .iter() + .map(|object| Self::resolve_publication_object(object, state)) + .collect(), + ) + } + AlterPublicationActionFact::SetObjects(scope) => { + AlterPublicationActionFact::SetObjects(Self::resolve_publication_scope( + scope, state, + )) + } + other => other.clone(), + } + } + + pub(super) fn resolve_create_publication( + fact: &CreatePublicationFact, + state: &AnalysisState, + ) -> Mutation { + Mutation::CreatePublication(CreatePublicationMutation { + name: fact.name.clone(), + scope: Self::resolve_publication_scope(&fact.scope, state), + params: fact.params.clone(), + }) + } + + pub(super) fn resolve_alter_publication( + fact: &AlterPublicationFact, + state: &AnalysisState, + ) -> Mutation { + Mutation::AlterPublication(AlterPublicationMutation { + name: fact.name.clone(), + action: Self::resolve_alter_publication_action(&fact.action, state), + }) + } + + pub(super) fn resolve_drop_publication(fact: &DropPublicationFact) -> Mutation { + Mutation::DropPublication(DropPublicationMutation { + names: fact.names.clone(), + if_exists: fact.if_exists, + cascade: fact.cascade, + }) + } + + pub(super) fn resolve_create_subscription(fact: &CreateSubscriptionFact) -> Mutation { + Mutation::CreateSubscription(CreateSubscriptionMutation { + name: fact.name.clone(), + connection: fact.connection.clone(), + publications: fact.publications.clone(), + params: fact.params.clone(), + }) + } + + pub(super) fn resolve_alter_subscription(fact: &AlterSubscriptionFact) -> Mutation { + Mutation::AlterSubscription(AlterSubscriptionMutation { + name: fact.name.clone(), + action: fact.action.clone(), + }) + } + + pub(super) fn resolve_drop_subscription(fact: &DropSubscriptionFact) -> Mutation { + Mutation::DropSubscription(DropSubscriptionMutation { + name: fact.name.clone(), + if_exists: fact.if_exists, + }) + } +} diff --git a/src/analysis/resolver/routine.rs b/src/analysis/resolver/routine.rs new file mode 100644 index 0000000..aa37592 --- /dev/null +++ b/src/analysis/resolver/routine.rs @@ -0,0 +1,119 @@ +use super::Resolver; +use crate::analysis::facts::{ + AlterAggregateFact, AlterFunctionFact, AlterProcedureFact, CreateAggregateFact, + CreateFunctionFact, CreateProcedureFact, DropAggregateFact, DropFunctionFact, + DropProcedureFact, FunctionSigFact, +}; +use crate::analysis::mutations::{ + AlterAggregateMutation, AlterFunctionMutation, AlterProcedureMutation, CreateAggregateMutation, + CreateFunctionMutation, CreateProcedureMutation, DropAggregateMutation, DropFunctionMutation, + DropProcedureMutation, Mutation, +}; +use crate::analysis::state::AnalysisState; + +impl Resolver { + pub(super) fn resolve_create_function( + fact: &CreateFunctionFact, + state: &AnalysisState, + ) -> Mutation { + Mutation::CreateFunction(CreateFunctionMutation { + id: Self::resolve_function_id(&fact.name, &fact.params, state), + or_replace: fact.or_replace, + params: fact.params.clone(), + return_type: fact.return_type.clone(), + options: fact.options.clone(), + }) + } + + pub(super) fn resolve_alter_function( + fact: &AlterFunctionFact, + state: &AnalysisState, + ) -> Mutation { + Mutation::AlterFunction(AlterFunctionMutation { + id: Self::resolve_routine_lookup_name(&fact.name, &fact.params, state), + action: fact.action.clone(), + }) + } + + pub(super) fn resolve_drop_function(fact: &DropFunctionFact) -> Mutation { + Mutation::DropFunction(DropFunctionMutation { + signatures: Self::normalize_signatures(&fact.signatures), + if_exists: fact.if_exists, + cascade: fact.cascade, + }) + } + + pub(super) fn resolve_create_procedure( + fact: &CreateProcedureFact, + state: &AnalysisState, + ) -> Mutation { + Mutation::CreateProcedure(CreateProcedureMutation { + id: Self::resolve_function_id(&fact.name, &fact.params, state), + or_replace: fact.or_replace, + params: fact.params.clone(), + options: fact.options.clone(), + }) + } + + pub(super) fn resolve_alter_procedure( + fact: &AlterProcedureFact, + state: &AnalysisState, + ) -> Mutation { + Mutation::AlterProcedure(AlterProcedureMutation { + id: Self::resolve_routine_lookup_name(&fact.name, &fact.params, state), + action: fact.action.clone(), + }) + } + + pub(super) fn resolve_drop_procedure(fact: &DropProcedureFact) -> Mutation { + Mutation::DropProcedure(DropProcedureMutation { + signatures: Self::normalize_signatures(&fact.signatures), + if_exists: fact.if_exists, + cascade: fact.cascade, + }) + } + + pub(super) fn resolve_create_aggregate( + fact: &CreateAggregateFact, + state: &AnalysisState, + ) -> Mutation { + Mutation::CreateAggregate(CreateAggregateMutation { + id: Self::resolve_function_id(&fact.name, &fact.params, state), + or_replace: fact.or_replace, + params: fact.params.clone(), + }) + } + + pub(super) fn resolve_alter_aggregate( + fact: &AlterAggregateFact, + state: &AnalysisState, + ) -> Mutation { + Mutation::AlterAggregate(AlterAggregateMutation { + id: Self::resolve_routine_lookup_name(&fact.name, &fact.params, state), + action: fact.action.clone(), + }) + } + + pub(super) fn resolve_drop_aggregate(fact: &DropAggregateFact) -> Mutation { + Mutation::DropAggregate(DropAggregateMutation { + signatures: Self::normalize_signatures(&fact.signatures), + if_exists: fact.if_exists, + cascade: fact.cascade, + }) + } + + fn normalize_signatures(signatures: &[FunctionSigFact]) -> Vec { + signatures + .iter() + .cloned() + .map(|mut signature| { + signature.params = signature + .params + .into_iter() + .map(|param| Self::normalize_function_arg_type(¶m)) + .collect(); + signature + }) + .collect() + } +} diff --git a/src/analysis/resolver/schema.rs b/src/analysis/resolver/schema.rs new file mode 100644 index 0000000..ed5cb63 --- /dev/null +++ b/src/analysis/resolver/schema.rs @@ -0,0 +1,50 @@ +use super::Resolver; +use crate::analysis::facts::{AlterSchemaActionFact, RoleFact}; +use crate::analysis::mutations::{ + AlterSchemaMutation, CreateSchemaMutation, DropSchemaMutation, Mutation, +}; +use crate::ast::identifiers::QualifiedName; + +impl Resolver { + pub(super) fn resolve_create_schema( + name: &QualifiedName, + if_not_exists: bool, + authorization: &Option, + ) -> Mutation { + Mutation::CreateSchema(CreateSchemaMutation { + name: name.name.resolve(), + if_not_exists, + authorization: authorization.clone(), + }) + } + + pub(super) fn resolve_alter_schema( + name: &QualifiedName, + action: &AlterSchemaActionFact, + ) -> Mutation { + let name = name.name.resolve(); + let action = match action { + AlterSchemaActionFact::RenameTo { new_name } => AlterSchemaMutation::Rename { + old_name: name, + new_name: new_name.resolve(), + }, + AlterSchemaActionFact::OwnerTo { new_owner } => AlterSchemaMutation::OwnerTo { + name, + new_owner: new_owner.clone(), + }, + }; + Mutation::AlterSchema(action) + } + + pub(super) fn resolve_drop_schema( + names: &[QualifiedName], + if_exists: bool, + cascade: bool, + ) -> Mutation { + Mutation::DropSchema(DropSchemaMutation { + names: names.iter().map(|name| name.name.resolve()).collect(), + if_exists, + cascade, + }) + } +} diff --git a/src/analysis/resolver/security.rs b/src/analysis/resolver/security.rs new file mode 100644 index 0000000..c5e57de --- /dev/null +++ b/src/analysis/resolver/security.rs @@ -0,0 +1,104 @@ +use super::Resolver; +use crate::analysis::facts::{ + AlterDatabaseFact, AlterRoleFact, CreateDatabaseFact, CreateRoleFact, DropDatabaseFact, + DropRoleFact, GrantFact, GrantTarget, RevokeFact, RoleFact, +}; +use crate::analysis::mutations::{ + AlterDatabaseMutation, AlterRoleMutation, CreateDatabaseMutation, CreateRoleMutation, + DropDatabaseMutation, DropRoleMutation, GrantMutation, Mutation, ResolvedGrantTarget, + RevokeMutation, +}; +use crate::analysis::state::AnalysisState; +use crate::ast::identifiers::ObjectId; + +impl Resolver { + fn resolve_grant_target(target: &GrantTarget, state: &AnalysisState) -> ResolvedGrantTarget { + match target { + GrantTarget::Tables(names) => ResolvedGrantTarget::Tables( + names + .iter() + .map(|name| Self::resolve_relation_lookup_name(name, state)) + .collect(), + ), + GrantTarget::AllTablesInSchema(schemas) => { + ResolvedGrantTarget::AllTablesInSchema(schemas.clone()) + } + } + } + + pub(super) fn resolve_create_role(fact: &CreateRoleFact) -> Mutation { + Mutation::CreateRole(CreateRoleMutation { + name: fact.name.clone(), + inherits: fact.inherits, + can_login: fact.can_login, + }) + } + + pub(super) fn resolve_alter_role(fact: &AlterRoleFact) -> Mutation { + Mutation::AlterRole(AlterRoleMutation { + name: fact.name.clone(), + inherits: fact.inherits, + }) + } + + pub(super) fn resolve_drop_role(fact: &DropRoleFact) -> Mutation { + Mutation::DropRole(DropRoleMutation { + names: fact.names.clone(), + if_exists: fact.if_exists, + }) + } + + pub(super) fn resolve_grant(fact: &GrantFact, state: &AnalysisState) -> Mutation { + Mutation::Grant(GrantMutation { + privileges: fact.privileges.clone(), + target: Self::resolve_grant_target(&fact.target, state), + grantees: fact.grantees.clone(), + with_grant_option: fact.with_grant_option, + granted_by: fact.granted_by.clone(), + }) + } + + pub(super) fn resolve_revoke(fact: &RevokeFact, state: &AnalysisState) -> Mutation { + Mutation::Revoke(RevokeMutation { + grant_option_only: fact.grant_option_only, + privileges: fact.privileges.clone(), + target: Self::resolve_grant_target(&fact.target, state), + revokees: fact.revokees.clone(), + granted_by: fact.granted_by.clone(), + cascade: fact.cascade, + }) + } + + pub(super) fn resolve_create_database(fact: &CreateDatabaseFact) -> Mutation { + Mutation::CreateDatabase(CreateDatabaseMutation { + name: fact.name.clone(), + options: fact.options.clone(), + }) + } + + pub(super) fn resolve_alter_database(fact: &AlterDatabaseFact) -> Mutation { + Mutation::AlterDatabase(AlterDatabaseMutation { + id: ObjectId::new("", fact.name.name.resolve()), + action: fact.action.clone(), + }) + } + + pub(super) fn resolve_drop_database(fact: &DropDatabaseFact) -> Mutation { + Mutation::DropDatabase(DropDatabaseMutation { + id: ObjectId::new("", fact.name.name.resolve()), + if_exists: fact.if_exists, + }) + } + + pub(super) fn resolve_set_role( + role: &Option, + local: bool, + is_session_auth: bool, + ) -> Mutation { + Mutation::SwitchRole { + role: role.clone(), + local, + is_session_auth, + } + } +} diff --git a/src/analysis/resolver/sequence.rs b/src/analysis/resolver/sequence.rs new file mode 100644 index 0000000..f41831b --- /dev/null +++ b/src/analysis/resolver/sequence.rs @@ -0,0 +1,82 @@ +use super::Resolver; +use crate::analysis::facts::AlterSequenceActionFact; +use crate::analysis::mutations::{ + AlterSequenceActionMutation, AlterSequenceMutation, CreateSequenceMutation, + DropSequenceMutation, Mutation, +}; +use crate::analysis::state::AnalysisState; +use crate::ast::identifiers::{ObjectId, QualifiedName}; + +impl Resolver { + fn resolve_owned_by( + owned_by: &Option<(QualifiedName, String)>, + state: &AnalysisState, + ) -> Option<(ObjectId, String)> { + owned_by.as_ref().map(|(table_name, column)| { + ( + Self::resolve_relation_lookup_name(table_name, state), + column.clone(), + ) + }) + } + + pub(super) fn resolve_create_sequence( + name: &QualifiedName, + if_not_exists: bool, + owned_by: &Option<(QualifiedName, String)>, + 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), + }) + } + + pub(super) fn resolve_alter_sequence( + name: &QualifiedName, + if_exists: bool, + action: &AlterSequenceActionFact, + state: &AnalysisState, + ) -> Mutation { + let id = Self::resolve_relation_lookup_name(name, state); + let action = match action { + AlterSequenceActionFact::OwnedBy(owned_by) => { + AlterSequenceActionMutation::OwnedBy(Self::resolve_owned_by(owned_by, state)) + } + AlterSequenceActionFact::OwnerTo(owner) => { + AlterSequenceActionMutation::OwnerTo(owner.clone()) + } + AlterSequenceActionFact::RenameTo(new_name) => { + let mut new_id = ObjectId::new(&id.schema, new_name.resolve()); + new_id.inferred_schema = id.inferred_schema; + AlterSequenceActionMutation::RenameTo(new_id) + } + AlterSequenceActionFact::SetSchema(schema) => { + AlterSequenceActionMutation::SetSchema(ObjectId::new(schema, &id.name)) + } + AlterSequenceActionFact::Other => AlterSequenceActionMutation::Other, + }; + Mutation::AlterSequence(AlterSequenceMutation { + id, + if_exists, + action, + }) + } + + pub(super) fn resolve_drop_sequence( + names: &[QualifiedName], + if_exists: bool, + cascade: bool, + state: &AnalysisState, + ) -> Mutation { + Mutation::DropSequence(DropSequenceMutation { + ids: names + .iter() + .map(|name| Self::resolve_relation_lookup_name(name, state)) + .collect(), + if_exists, + cascade, + }) + } +} diff --git a/src/analysis/resolver/session.rs b/src/analysis/resolver/session.rs new file mode 100644 index 0000000..2396a62 --- /dev/null +++ b/src/analysis/resolver/session.rs @@ -0,0 +1,58 @@ +use super::Resolver; +use crate::analysis::facts::{SearchPathTarget, TimeoutSetting, TimeoutSettingValue}; +use crate::analysis::mutations::{ + Mutation, ReleaseSavepointMutation, RollbackToSavepointMutation, SavepointMutation, + SearchPathChange, TimeoutSettingChange, +}; +use crate::analysis::state::AnalysisState; +use crate::ast::identifiers::QualifiedName; + +impl Resolver { + pub(super) fn resolve_search_path(target: &SearchPathTarget, local: bool) -> Mutation { + Mutation::SearchPath(SearchPathChange { + target: target.clone(), + local, + }) + } + + pub(super) fn resolve_timeout( + setting: TimeoutSetting, + value: &TimeoutSettingValue, + local: bool, + ) -> Mutation { + Mutation::TimeoutSetting(TimeoutSettingChange { + setting, + value: value.clone(), + local, + }) + } + + pub(super) fn resolve_rollback_to_savepoint(name: &str) -> Mutation { + Mutation::RollbackToSavepoint(RollbackToSavepointMutation { + name: name.to_string(), + }) + } + + pub(super) fn resolve_savepoint(name: &str) -> Mutation { + Mutation::Savepoint(SavepointMutation { + name: name.to_string(), + }) + } + + pub(super) fn resolve_release_savepoint(name: &str) -> Mutation { + Mutation::ReleaseSavepoint(ReleaseSavepointMutation { + name: name.to_string(), + }) + } + + pub(super) fn resolve_vacuum( + relation: Option<&QualifiedName>, + is_full: bool, + state: &AnalysisState, + ) -> Mutation { + Mutation::Vacuum { + table_id: relation.map(|relation| Self::resolve_relation_lookup_name(relation, state)), + is_full, + } + } +} diff --git a/src/analysis/resolver/types.rs b/src/analysis/resolver/types.rs new file mode 100644 index 0000000..dde3c2e --- /dev/null +++ b/src/analysis/resolver/types.rs @@ -0,0 +1,125 @@ +use super::Resolver; +use crate::analysis::facts::{ + AlterDomainActionFact, AlterTypeActionFact, AlterTypeFact, CreateTypeFact, TypeCreationKind, +}; +use crate::analysis::mutations::{ + AlterDomainMutation, AlterTypeActionMutation, AlterTypeMutation, CreateDomainMutation, + CreateTypeMutation, DropDomainMutation, DropTypeMutation, Mutation, Rename, +}; +use crate::analysis::state::AnalysisState; +use crate::ast::identifiers::{ObjectId, QualifiedName}; +use crate::model::types::TypeKind; + +impl Resolver { + pub(super) fn resolve_create_type(fact: &CreateTypeFact, state: &AnalysisState) -> Mutation { + let kind = match &fact.kind { + TypeCreationKind::Enum { variants } => TypeKind::Enum { + variants: variants.clone(), + }, + TypeCreationKind::Range => TypeKind::Range, + TypeCreationKind::Composite => TypeKind::Composite, + TypeCreationKind::Base => TypeKind::Base, + }; + Mutation::CreateType(CreateTypeMutation { + id: Self::resolve_creation_name(&fact.name, state), + kind, + }) + } + + pub(super) fn resolve_alter_type(fact: &AlterTypeFact, state: &AnalysisState) -> Vec { + let id = Self::resolve_type_lookup_name(&fact.name, state); + fact.actions + .iter() + .map(|action| match action { + AlterTypeActionFact::RenameTo { new_name } => { + let mut new_id = ObjectId::new(id.schema.clone(), new_name.resolve()); + new_id.inferred_schema = id.inferred_schema; + Mutation::RenameType(Rename { + old_id: id.clone(), + new_id, + }) + } + AlterTypeActionFact::SetSchema { new_schema } => Mutation::RenameType(Rename { + old_id: id.clone(), + new_id: ObjectId::new(new_schema, &id.name), + }), + AlterTypeActionFact::AddValue { + new_value, + neighbor, + before, + } => Mutation::AlterType(AlterTypeMutation { + id: id.clone(), + action: AlterTypeActionMutation::AddValue { + new_value: new_value.clone(), + neighbor: neighbor.clone(), + before: *before, + }, + }), + AlterTypeActionFact::RenameValue { + old_value, + new_value, + } => Mutation::AlterType(AlterTypeMutation { + id: id.clone(), + action: AlterTypeActionMutation::RenameValue { + old_value: old_value.clone(), + new_value: new_value.clone(), + }, + }), + }) + .collect() + } + + pub(super) fn resolve_create_domain( + name: &QualifiedName, + base_type: &str, + state: &AnalysisState, + ) -> Mutation { + Mutation::CreateDomain(CreateDomainMutation { + id: Self::resolve_creation_name(name, state), + base_type: base_type.to_string(), + }) + } + + pub(super) fn resolve_alter_domain( + name: &QualifiedName, + action: &Option, + state: &AnalysisState, + ) -> Mutation { + Mutation::AlterDomain(AlterDomainMutation { + id: Self::resolve_type_lookup_name(name, state), + action: action.clone(), + }) + } + + pub(super) fn resolve_drop_domain( + names: &[QualifiedName], + if_exists: bool, + cascade: bool, + state: &AnalysisState, + ) -> Mutation { + Mutation::DropDomain(DropDomainMutation { + ids: names + .iter() + .map(|name| Self::resolve_type_lookup_name(name, state)) + .collect(), + if_exists, + cascade, + }) + } + + pub(super) fn resolve_drop_type( + names: &[QualifiedName], + if_exists: bool, + cascade: bool, + state: &AnalysisState, + ) -> Mutation { + Mutation::DropType(DropTypeMutation { + ids: names + .iter() + .map(|name| Self::resolve_type_lookup_name(name, state)) + .collect(), + if_exists, + cascade, + }) + } +} diff --git a/src/analysis/state.rs b/src/analysis/state.rs index 4931231..9c0c39a 100644 --- a/src/analysis/state.rs +++ b/src/analysis/state.rs @@ -1,22 +1,32 @@ -use crate::analysis::facts::{ - ResetSettingTarget, SearchPathTarget, TableConstraintFact, TimeoutSetting, TimeoutSettingValue, -}; use crate::analysis::graph::{DependencyEdge, DependencyGraph, DependencyKind}; -use crate::analysis::mutations::{ - AlterTableActionMutation, AlterTypeActionMutation, Mutation, PersistenceMutation, -}; +use crate::analysis::mutations::Mutation; use crate::analysis::settings::ScopedSetting; use crate::analysis::transaction::{NamespaceSnapshot, StateChange, TransactionFrame}; use crate::ast::identifiers::ObjectId; use crate::db::cache::DbCache; -use crate::model::constraint::{ConstraintKind, ConstraintState}; +use crate::model::constraint::ConstraintState; +use crate::model::function::FunctionOverlay; pub use crate::model::relation::RelationOverlay; -use crate::model::relation::{ColumnAction, Persistence, Privilege, RelationKind, RelationState}; +use crate::model::relation::{Persistence, Privilege, RelationKind}; use crate::model::schema::SchemaOverlay; -use crate::model::sequence::{SequenceKind, SequenceOverlay, SequenceState}; +use crate::model::sequence::SequenceOverlay; use crate::model::trigger::TriggerOverlay; use crate::model::types::{TypeKind, TypeOverlay, TypeState}; use std::collections::{HashMap, HashSet}; +use std::hash::Hash; + +mod apply_misc; +mod apply_policy_trigger; +mod apply_relation; +mod apply_replication; +mod apply_role; +mod apply_routine; +mod apply_schema; +mod apply_sequence; +mod apply_settings; +mod apply_transaction; +mod apply_type; +mod apply_view_index; #[derive(Debug, Clone, PartialEq, Eq)] pub enum Confidence { @@ -36,6 +46,40 @@ pub enum MutationResult { }, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ObjectLookup { + Present, + WrongKind, + Tombstone, + AuthoritativelyAbsent, + Unknown, +} + +fn sync_present_map(target: &mut HashMap, source: &HashMap, present: F) +where + K: Clone + Eq + Hash, + V: Clone + PartialEq, + F: for<'a> Fn(&'a O) -> Option<&'a V> + Copy, +{ + target.retain(|key, value| { + let Some(current) = source.get(key).and_then(present) else { + return false; + }; + if value != current { + value.clone_from(current); + } + true + }); + target.reserve(source.len().saturating_sub(target.len())); + for (key, overlay) in source { + if !target.contains_key(key) + && let Some(value) = present(overlay) + { + target.insert(key.clone(), value.clone()); + } + } +} + #[derive(Debug, Default, Clone)] pub struct CascadeResult { pub dropped_relations: HashSet, @@ -95,7 +139,7 @@ pub struct LocalState { pub generation_counter: u64, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct PreState { pub relations: HashMap, pub functions: HashMap, @@ -105,6 +149,66 @@ pub struct PreState { pub sequences: HashMap, pub types: HashMap, pub indexes: Vec, + pub baseline_foreign_keys: HashSet<(ObjectId, String)>, +} + +#[cfg(test)] +mod pre_state_tests { + use super::*; + + #[test] + fn incremental_capture_matches_a_fresh_public_pre_state() { + let mut state = AnalysisState::new(DbCache::new()); + let table_id = ObjectId::new("public", "capture_table"); + state.local.relations.insert( + table_id.clone(), + RelationOverlay::Present(crate::model::relation::RelationState::new( + table_id.clone(), + ObjectId::new("", "postgres"), + 1, + Some(1), + RelationKind::Table, + Persistence::Permanent, + 0, + )), + ); + + let mut reused = PreState::default(); + state.capture_pre_state_into(&mut reused); + assert_eq!(reused, state.capture_pre_state()); + + let Some(RelationOverlay::Present(relation)) = state.local.relations.get_mut(&table_id) + else { + panic!("test relation must remain present"); + }; + relation.estimated_rows = Some(2); + let index_id = ObjectId::new("public", "capture_table_idx"); + state.local.graph.add_edge(DependencyEdge::new( + index_id, + table_id.clone(), + DependencyKind::IndexOnRelation { + using_method: Some("btree".into()), + has_predicate: false, + is_concurrent: false, + is_unique: false, + eligibility_known: true, + }, + )); + state.capture_pre_state_into(&mut reused); + assert_eq!(reused, state.capture_pre_state()); + assert_eq!(reused.relations[&table_id].estimated_rows, Some(2)); + assert_eq!(reused.indexes.len(), 1); + + state + .local + .relations + .insert(table_id.clone(), RelationOverlay::Dropped); + state.local.graph.replace_edges(Vec::new()); + state.capture_pre_state_into(&mut reused); + assert_eq!(reused, state.capture_pre_state()); + assert!(!reused.relations.contains_key(&table_id)); + assert!(reused.indexes.is_empty()); + } } #[derive(Clone)] @@ -165,7 +269,7 @@ impl AnalysisState { scope: &crate::analysis::facts::PublicationScope, ) { self.snapshot_graph_full(); - self.local.graph.edges.retain(|edge| { + self.local.graph.retain_edges(|edge| { !matches!( &edge.kind, DependencyKind::PublicationIncludes { publication_name: name } @@ -175,7 +279,7 @@ impl AnalysisState { if let crate::analysis::facts::PublicationScope::Explicit(objects) = scope { for object in objects { if let crate::analysis::facts::PublicationObjectFact::Table { name, .. } = object { - self.local.graph.edges.push(DependencyEdge::new( + self.local.graph.add_edge(DependencyEdge::new( self.resolve_relation_id(name), ObjectId::new("public", publication_name), DependencyKind::PublicationIncludes { @@ -266,28 +370,37 @@ impl AnalysisState { &self, scope: &crate::analysis::facts::PublicationScope, ) -> bool { - let crate::analysis::facts::PublicationScope::Explicit(objects) = scope else { - return false; - }; - objects.iter().any(|object| match object { - crate::analysis::facts::PublicationObjectFact::Table { - name, - only, - include_partitions, - .. - } if !only || *include_partitions => { - let id = self.resolve_relation_id(name); - !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) + match scope { + // FOR ALL TABLES necessarily depends on the complete catalog and + // future table/inheritance state, neither of which Cache V6 stores. + crate::analysis::facts::PublicationScope::AllTables { .. } => true, + crate::analysis::facts::PublicationScope::Explicit(objects) => { + objects.iter().any(|object| match object { + crate::analysis::facts::PublicationObjectFact::Table { + name, + only, + include_partitions, + .. + } if !only || *include_partitions => { + let id = self.resolve_relation_id(name); + !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) + }) + } + // Schema-wide and current-schema shorthand scopes also + // include inherited/partitioned descendants. + crate::analysis::facts::PublicationObjectFact::SchemaTables { .. } + | crate::analysis::facts::PublicationObjectFact::CurrentSchemaShorthand => true, + crate::analysis::facts::PublicationObjectFact::Unknown => false, + _ => false, }) } - _ => false, - }) + } } fn taint_inheritance_sensitive_publication_scope( @@ -415,7 +528,7 @@ impl AnalysisState { let persistent_session_role = session_role.clone(); let persistent_session_role_known = session_role_known; let roles_known = cache.metadata.source_session_role.is_some(); - let baseline_schemas = cache + let baseline_schemas: Option> = cache .metadata .schemas .as_ref() @@ -480,7 +593,7 @@ impl AnalysisState { let baseline_sequences = cache.sequences.keys().cloned().collect(); for sequence in cache.sequences.values() { if let Some((table, column)) = &sequence.owned_by { - graph.edges.push(DependencyEdge::new( + graph.add_edge(DependencyEdge::new( sequence.id.clone(), table.clone(), DependencyKind::SequenceOwnedBy { @@ -534,7 +647,7 @@ impl AnalysisState { } for fk in cache.foreign_keys { baseline_foreign_keys.insert((fk.from_table.clone(), fk.constraint_name.clone())); - graph.edges.push(DependencyEdge::new( + graph.add_edge(DependencyEdge::new( fk.from_table, fk.to_table, DependencyKind::ForeignKey { @@ -549,7 +662,7 @@ impl AnalysisState { for idx in cache.indexes { // Index identities are tracked separately from relation identities. baseline_indexes.insert(idx.index_id.clone()); - graph.edges.push(DependencyEdge::new( + graph.add_edge(DependencyEdge::new( idx.index_id, idx.table_id, DependencyKind::IndexOnRelation { @@ -591,11 +704,20 @@ impl AnalysisState { state.kind, crate::model::relation::RelationKind::View | crate::model::relation::RelationKind::MaterializedView - ) + ) ) }); - if is_view && relations.contains_key(&referenced) { - graph.edges.push(DependencyEdge::new( + let dependent_schema_is_omitted = baseline_schemas + .as_ref() + .is_some_and(|schemas| !schemas.contains(&dependent.schema)); + if is_view || dependent_schema_is_omitted { + // Scoped caches may intentionally omit the referenced schema. + // The dependency query can also return a view outside the + // selected scope when it depends on an in-scope relation. + // Preserve either direction so a later migration cannot + // mistake an omitted dependent or referenced object for a + // safe drop target. + graph.add_edge(DependencyEdge::new( dependent, referenced, DependencyKind::ViewDependency { view_generation: 0 }, @@ -622,7 +744,7 @@ impl AnalysisState { generation: 0, }), ); - graph.edges.push(DependencyEdge::new( + graph.add_edge(DependencyEdge::new( trigger_key.clone(), t.table_id, DependencyKind::TriggerOnTable { @@ -682,7 +804,7 @@ impl AnalysisState { .unwrap_or_else(|| "public".to_string()), relation.name.resolve(), ); - graph.edges.push(DependencyEdge::new( + graph.add_edge(DependencyEdge::new( table_id, ObjectId::new("public", &name), DependencyKind::PublicationIncludes { @@ -778,7 +900,7 @@ impl AnalysisState { } for schema in &self.local.search_path { let candidate = ObjectId::new(schema.clone(), sig_str.to_string()); - if self.local.functions.contains_key(&candidate) { + if self.routine_is_present(&candidate) { return schema.clone(); } } @@ -796,7 +918,7 @@ impl AnalysisState { let resolved_name = name.name.resolve(); for schema in &self.local.search_path { let mut candidate = ObjectId::new(schema.clone(), resolved_name.clone()); - if self.local.relations.contains_key(&candidate) { + if self.relation_namespace_object_is_present(&candidate) { candidate.inferred_schema = true; return candidate; } @@ -828,6 +950,181 @@ impl AnalysisState { .is_none_or(|schemas| schemas.contains(&id.schema)) } + fn relation_lookup( + &self, + id: &ObjectId, + expected: impl FnOnce(&RelationKind) -> bool, + ) -> ObjectLookup { + match self.local.relations.get(id) { + Some(RelationOverlay::Present(relation)) if expected(&relation.kind) => { + ObjectLookup::Present + } + Some(RelationOverlay::Present(_)) => ObjectLookup::WrongKind, + Some(RelationOverlay::Dropped) => ObjectLookup::Tombstone, + None if self.sequence_is_present(id) || self.index_is_present(id) => { + ObjectLookup::WrongKind + } + None if self.baseline_available && self.baseline_covers_object(id) => { + ObjectLookup::AuthoritativelyAbsent + } + None => ObjectLookup::Unknown, + } + } + + /// Validate a relation reference before a mutation creates a dependent + /// object. A scoped baseline cannot prove anything about an omitted + /// schema, so leave the state conservative instead of inventing an edge. + pub(super) fn ensure_relation_target( + &mut self, + id: &ObjectId, + expected: F, + missing_reason: String, + wrong_kind_reason: String, + ) -> Result<(), MutationResult> + where + F: FnOnce(&RelationKind) -> bool, + { + match self.relation_lookup(id, expected) { + ObjectLookup::Present => Ok(()), + ObjectLookup::WrongKind => Err(MutationResult::Conflict { + reason: wrong_kind_reason, + }), + ObjectLookup::AuthoritativelyAbsent | ObjectLookup::Tombstone => { + Err(MutationResult::Conflict { + reason: missing_reason, + }) + } + ObjectLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + Err(MutationResult::Skipped) + } + } + } + + fn type_lookup(&self, id: &ObjectId, expected: impl FnOnce(&TypeKind) -> bool) -> ObjectLookup { + match self.local.types.get(id) { + Some(TypeOverlay::Present(state)) if expected(&state.kind) => ObjectLookup::Present, + Some(TypeOverlay::Present(_)) => ObjectLookup::WrongKind, + Some(TypeOverlay::Dropped) => ObjectLookup::Tombstone, + None if self.baseline_available && self.baseline_covers_object(id) => { + ObjectLookup::AuthoritativelyAbsent + } + None => ObjectLookup::Unknown, + } + } + + pub(super) fn ensure_routine_target( + &mut self, + id: &ObjectId, + expected: crate::model::function::RoutineKind, + missing_reason: String, + wrong_kind_reason: String, + ) -> Result<(), MutationResult> { + match self.local.functions.get(id) { + Some(FunctionOverlay::Present(function)) if function.routine_kind == expected => Ok(()), + Some(FunctionOverlay::Present(_)) => Err(MutationResult::Conflict { + reason: wrong_kind_reason, + }), + Some(FunctionOverlay::Dropped) => Err(MutationResult::Conflict { + reason: missing_reason, + }), + None if self.baseline_available && self.baseline_covers_object(id) => { + Err(MutationResult::Conflict { + reason: missing_reason, + }) + } + None => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + Err(MutationResult::Skipped) + } + } + } + + /// Validate the namespace in which a newly-created object will live. + /// + /// A full baseline (or an explicit schema creation earlier in the chain) + /// can prove that a schema exists. An omitted scoped schema is unknown; + /// do not manufacture an object there while claiming an exact result. + pub(super) fn ensure_schema_target(&mut self, schema: &str) -> Result<(), MutationResult> { + match self.schema_lookup(schema) { + ObjectLookup::Present => Ok(()), + ObjectLookup::Tombstone | ObjectLookup::AuthoritativelyAbsent => { + Err(MutationResult::Conflict { + reason: format!("schema '{}' does not exist", schema), + }) + } + ObjectLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + Err(MutationResult::Skipped) + } + ObjectLookup::WrongKind => { + unreachable!("schemas do not share an overlay with other object kinds") + } + } + } + + fn snapshot_baseline_foreign_keys(&mut self) { + if let Some(frame) = self.local.transactions.last_mut() { + frame + .undo_log + .push(StateChange::BaselineForeignKeysSnapshot { + previous: self.baseline_foreign_keys.clone(), + }); + } + } + + /// Remove constraint metadata and baseline FK identities for objects that + /// PostgreSQL drops as part of a relation dependency operation. + pub(super) fn remove_dropped_constraints( + &mut self, + dropped_relations: &HashSet, + dropped_constraints: &HashSet<(ObjectId, String)>, + ) { + let resolution_graph = self.local.graph.clone(); + let should_remove = |table_id: &ObjectId, name: &str| { + let resolved_table = resolution_graph.resolve_rename(table_id); + dropped_relations.contains(resolved_table) + || dropped_constraints.contains(&(resolved_table.clone(), name.to_string())) + }; + + let constraint_keys: Vec<(ObjectId, String)> = self + .local + .constraints + .keys() + .filter(|(table_id, name)| should_remove(table_id, name)) + .cloned() + .collect(); + for (table_id, name) in constraint_keys { + self.snapshot_constraint(&table_id, &name); + self.local.constraints.remove(&(table_id, name)); + } + + let pending_changed = self + .local + .pending_validation + .iter() + .any(|(table_id, name)| should_remove(table_id, name)); + if pending_changed { + self.snapshot_pending_validation(); + self.local + .pending_validation + .retain(|(table_id, name)| !should_remove(table_id, name)); + } + + if self + .baseline_foreign_keys + .iter() + .any(|(table_id, name)| should_remove(table_id, name)) + { + self.snapshot_baseline_foreign_keys(); + self.baseline_foreign_keys + .retain(|(table_id, name)| !should_remove(table_id, name)); + } + } + pub fn baseline_scope_omits_displayed_object<'a>( &self, object_name: &'a str, @@ -837,17 +1134,24 @@ impl AnalysisState { (!schemas.contains(schema)).then_some(schema) } - fn sequence_is_present(&self, id: &ObjectId) -> bool { + pub(crate) fn sequence_is_present(&self, id: &ObjectId) -> bool { matches!( self.local.sequences.get(id), Some(SequenceOverlay::Present(_)) ) } - fn type_is_present(&self, id: &ObjectId) -> bool { + pub(crate) fn type_is_present(&self, id: &ObjectId) -> bool { matches!(self.local.types.get(id), Some(TypeOverlay::Present(_))) } + pub(crate) fn routine_is_present(&self, id: &ObjectId) -> bool { + matches!( + self.local.functions.get(id), + Some(FunctionOverlay::Present(_)) + ) + } + fn resolve_type_reference_from_catalog( raw: &str, types: &HashMap, @@ -960,18 +1264,27 @@ impl AnalysisState { ) } - fn index_is_present(&self, id: &ObjectId) -> bool { - self.local.graph.edges.iter().any(|edge| { + pub(crate) fn index_is_present(&self, id: &ObjectId) -> bool { + self.local.graph.edges().iter().any(|edge| { matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) && edge.dependent == *id }) } - fn next_generated_constraint_name( + pub(crate) fn relation_namespace_object_is_present(&self, id: &ObjectId) -> bool { + self.relation_is_present(id) || self.sequence_is_present(id) || self.index_is_present(id) + } + + /// Pick a generated name while also avoiding names reserved by other + /// constraints in the same CREATE TABLE statement. The ordinary helper + /// only sees already-applied state, which is not enough for a batch of + /// inline constraints that becomes visible at the end of the statement. + pub(super) fn next_generated_constraint_name_avoiding( &self, table: &ObjectId, name1: &str, name2: Option<&str>, label: &str, + reserved: &HashSet, ) -> String { (0..) .map(|suffix| { @@ -983,10 +1296,11 @@ impl AnalysisState { Self::postgres_object_name(name1, name2, &label) }) .find(|candidate| { - !self - .local - .constraints - .contains_key(&(table.clone(), candidate.clone())) + !reserved.contains(candidate) + && !self + .local + .constraints + .contains_key(&(table.clone(), candidate.clone())) }) .expect("constraint suffix space is unbounded") } @@ -1022,10 +1336,7 @@ impl AnalysisState { } fn relation_namespace_is_taken(&self, id: &ObjectId) -> bool { - self.relation_is_present(id) - || self.sequence_is_present(id) - || self.index_is_present(id) - || self.type_is_present(id) + self.relation_namespace_object_is_present(id) || self.type_is_present(id) } fn next_implicit_sequence_id( @@ -1089,65 +1400,13 @@ impl AnalysisState { } pub fn capture_pre_state(&self) -> PreState { - let mut relations = HashMap::new(); - for (id, overlay) in &self.local.relations { - if let RelationOverlay::Present(s) = overlay { - relations.insert(id.clone(), s.clone()); - } - } - - let mut functions = HashMap::new(); - for (id, overlay) in &self.local.functions { - if let crate::model::function::FunctionOverlay::Present(s) = overlay { - functions.insert(id.clone(), s.clone()); - } - } - - let mut roles = HashMap::new(); - for (name, overlay) in &self.local.roles { - if let crate::model::role::RoleOverlay::Present(s) = overlay { - roles.insert(name.clone(), s.clone()); - } - } - - let mut publications = HashMap::new(); - for (name, overlay) in &self.local.publications { - if let crate::model::replication::PublicationOverlay::Present(s) = overlay { - publications.insert(name.clone(), s.clone()); - } - } - - let mut subscriptions = HashMap::new(); - for (name, overlay) in &self.local.subscriptions { - if let crate::model::replication::SubscriptionOverlay::Present(s) = overlay { - subscriptions.insert(name.clone(), s.clone()); - } - } - - let mut sequences = HashMap::new(); - for (id, overlay) in &self.local.sequences { - if let SequenceOverlay::Present(s) = overlay { - sequences.insert(id.clone(), s.clone()); - } - } - - let mut types = HashMap::new(); - for (id, overlay) in &self.local.types { - if let TypeOverlay::Present(s) = overlay { - types.insert(id.clone(), s.clone()); - } - } - - let indexes = self - .local - .graph - .edges - .iter() - .filter(|e| matches!(e.kind, DependencyKind::IndexOnRelation { .. })) - .cloned() - .collect(); + let mut pre_state = PreState::default(); + self.capture_pre_state_into(&mut pre_state); + pre_state + } - PreState { + pub(crate) fn capture_pre_state_into(&self, pre_state: &mut PreState) { + let PreState { relations, functions, roles, @@ -1156,7 +1415,65 @@ impl AnalysisState { sequences, types, indexes, + baseline_foreign_keys, + } = pre_state; + + sync_present_map(relations, &self.local.relations, |overlay| match overlay { + RelationOverlay::Present(state) => Some(state), + RelationOverlay::Dropped => None, + }); + sync_present_map(functions, &self.local.functions, |overlay| match overlay { + crate::model::function::FunctionOverlay::Present(state) => Some(state), + crate::model::function::FunctionOverlay::Dropped => None, + }); + sync_present_map(roles, &self.local.roles, |overlay| match overlay { + crate::model::role::RoleOverlay::Present(state) => Some(state), + crate::model::role::RoleOverlay::Dropped => None, + }); + sync_present_map( + publications, + &self.local.publications, + |overlay| match overlay { + crate::model::replication::PublicationOverlay::Present(state) => Some(state), + crate::model::replication::PublicationOverlay::Dropped => None, + }, + ); + sync_present_map( + subscriptions, + &self.local.subscriptions, + |overlay| match overlay { + crate::model::replication::SubscriptionOverlay::Present(state) => Some(state), + crate::model::replication::SubscriptionOverlay::Dropped => None, + }, + ); + sync_present_map(sequences, &self.local.sequences, |overlay| match overlay { + SequenceOverlay::Present(state) => Some(state), + SequenceOverlay::Dropped => None, + }); + sync_present_map(types, &self.local.types, |overlay| match overlay { + TypeOverlay::Present(state) => Some(state), + TypeOverlay::Dropped => None, + }); + + let mut index = 0; + for edge in self + .local + .graph + .edges() + .iter() + .filter(|edge| matches!(edge.kind, DependencyKind::IndexOnRelation { .. })) + { + if let Some(existing) = indexes.get_mut(index) { + if existing != edge { + existing.clone_from(edge); + } + } else { + indexes.push(edge.clone()); + } + index += 1; } + indexes.truncate(index); + baseline_foreign_keys.clone_from(&self.baseline_foreign_keys); } pub fn get_cascade_closure(&self, target_oid: &ObjectId) -> CascadeResult { @@ -1180,64 +1497,89 @@ impl AnalysisState { result.dropped_relations.insert(resolved_current.clone()); - for edge in &self.local.graph.edges { - match &edge.kind { - DependencyKind::ViewDependency { .. } => { - if self.local.graph.resolve_rename(&edge.referenced) == &resolved_current { - let resolved_view_id = - self.local.graph.resolve_rename(&edge.dependent).clone(); - if !visited.contains(&resolved_view_id) { - self.walk_cascade(&resolved_view_id, visited, result); - } - } - } - DependencyKind::IndexOnRelation { .. } => { - if self.local.graph.resolve_rename(&edge.referenced) == &resolved_current { - result - .dropped_indexes - .insert(self.local.graph.resolve_rename(&edge.dependent).clone()); + if self.local.graph.cascade_index_is_worthwhile() { + for edge in self.local.graph.cascade_edges(&resolved_current) { + self.walk_cascade_edge(edge, &resolved_current, visited, result); + } + } else { + for edge in self.local.graph.edges() { + self.walk_cascade_edge(edge, &resolved_current, visited, result); + } + } + } + + fn walk_cascade_edge( + &self, + edge: &DependencyEdge, + resolved_current: &ObjectId, + visited: &mut HashSet, + result: &mut CascadeResult, + ) { + match &edge.kind { + DependencyKind::ViewDependency { .. } => { + if self.local.graph.resolve_rename(&edge.referenced) == resolved_current { + let resolved_view_id = self.local.graph.resolve_rename(&edge.dependent).clone(); + if !visited.contains(&resolved_view_id) { + self.walk_cascade(&resolved_view_id, visited, result); } } - DependencyKind::ForeignKey { - constraint_name, .. - } => { - if self.local.graph.resolve_rename(&edge.referenced) == &resolved_current - && let Some(cname) = constraint_name - { - result.dropped_constraints.insert(( - self.local.graph.resolve_rename(&edge.dependent).clone(), - cname.clone(), - )); - } + } + DependencyKind::IndexOnRelation { .. } => { + if self.local.graph.resolve_rename(&edge.referenced) == resolved_current { + result + .dropped_indexes + .insert(self.local.graph.resolve_rename(&edge.dependent).clone()); } - DependencyKind::PartitionOf - if self.local.graph.resolve_rename(&edge.referenced) == &resolved_current => + } + DependencyKind::ForeignKey { + constraint_name, .. + } => { + if self.local.graph.resolve_rename(&edge.referenced) == resolved_current + && let Some(cname) = constraint_name { - let resolved_child = self.local.graph.resolve_rename(&edge.dependent).clone(); - if !visited.contains(&resolved_child) { - self.walk_cascade(&resolved_child, visited, result); - } + result.dropped_constraints.insert(( + self.local.graph.resolve_rename(&edge.dependent).clone(), + cname.clone(), + )); + } + } + DependencyKind::PartitionOf + if self.local.graph.resolve_rename(&edge.referenced) == resolved_current => + { + let resolved_child = self.local.graph.resolve_rename(&edge.dependent).clone(); + if !visited.contains(&resolved_child) { + self.walk_cascade(&resolved_child, visited, result); } - _ => {} } + _ => {} } } fn resolve_grant_privileges( + &self, spec: &crate::analysis::facts::PrivilegeSpec, ) -> HashSet { + let supports_maintain = self + .pg_version_num + .is_some_and(|version| version >= 170_000); match spec { - crate::analysis::facts::PrivilegeSpec::All => vec![ - Privilege::Select, - Privilege::Insert, - Privilege::Update, - Privilege::Delete, - Privilege::Truncate, - Privilege::References, - Privilege::Trigger, - ] - .into_iter() - .collect(), + crate::analysis::facts::PrivilegeSpec::All => { + let mut privileges = [ + Privilege::Select, + Privilege::Insert, + Privilege::Update, + Privilege::Delete, + Privilege::Truncate, + Privilege::References, + Privilege::Trigger, + ] + .into_iter() + .collect::>(); + if supports_maintain { + privileges.insert(Privilege::Maintain); + } + privileges + } crate::analysis::facts::PrivilegeSpec::List(list) => list .iter() .filter_map(|p| match p { @@ -1250,6 +1592,9 @@ impl AnalysisState { Some(Privilege::References) } crate::analysis::facts::PrivilegeFact::Trigger => Some(Privilege::Trigger), + crate::analysis::facts::PrivilegeFact::Maintain if supports_maintain => { + Some(Privilege::Maintain) + } _ => None, }) .collect(), @@ -1351,6 +1696,17 @@ impl AnalysisState { ) } + fn schema_lookup(&self, name: &str) -> ObjectLookup { + match self.local.schemas.get(name) { + Some(SchemaOverlay::Present(_)) => ObjectLookup::Present, + Some(SchemaOverlay::Dropped) => ObjectLookup::Tombstone, + None if self.schema_absence_is_authoritative(name) => { + ObjectLookup::AuthoritativelyAbsent + } + None => ObjectLookup::Unknown, + } + } + fn schema_absence_is_authoritative(&self, name: &str) -> bool { if matches!(self.local.schemas.get(name), Some(SchemaOverlay::Dropped)) { return true; @@ -1519,20 +1875,32 @@ impl AnalysisState { }) .collect(); - for edge in &mut self.local.graph.edges { - Self::remap_schema_id(&mut edge.dependent, old_name, new_name); - Self::remap_schema_id(&mut edge.referenced, old_name, new_name); - if let DependencyKind::TriggerOnTable { - trigger_id, - function_id, - } = &mut edge.kind - { - Self::remap_schema_id(trigger_id, old_name, new_name); - Self::remap_schema_id(function_id, old_name, new_name); + self.local.graph.mutate_edges(|edges| { + for edge in edges { + match &mut edge.kind { + // Publication nodes are synthetic `public/` IDs; + // only the included relation is schema-qualified. + DependencyKind::PublicationIncludes { .. } => { + Self::remap_schema_id(&mut edge.dependent, old_name, new_name); + } + DependencyKind::TriggerOnTable { + trigger_id, + function_id, + } => { + Self::remap_schema_id(&mut edge.dependent, old_name, new_name); + Self::remap_schema_id(&mut edge.referenced, old_name, new_name); + Self::remap_schema_id(trigger_id, old_name, new_name); + Self::remap_schema_id(function_id, old_name, new_name); + } + _ => { + Self::remap_schema_id(&mut edge.dependent, old_name, new_name); + Self::remap_schema_id(&mut edge.referenced, old_name, new_name); + } + } } - } + }); for (old_id, new_id) in aliases { - self.local.graph.edges.push(DependencyEdge::new( + self.local.graph.add_edge(DependencyEdge::new( old_id, new_id, DependencyKind::RenameTo, @@ -1559,6 +1927,11 @@ impl AnalysisState { (table, name) }) .collect(); + if let Some(schemas) = &mut self.baseline_schemas + && schemas.remove(old_name) + { + schemas.insert(new_name.to_string()); + } if let Some(SchemaOverlay::Present(mut schema)) = self.local.schemas.remove(old_name) { schema.name = new_name.to_string(); @@ -1584,18 +1957,12 @@ impl AnalysisState { &mut self, id: &ObjectId, privileges: &HashSet, - grantees: &[crate::analysis::facts::RoleFact], + grantees: &[ObjectId], ) { self.snapshot_relation(id); if let Some(RelationOverlay::Present(rel)) = self.local.relations.get_mut(id) { for grantee in grantees { - if let Some(role_id) = Self::resolve_role_name( - grantee, - &self.local.current_role, - &self.local.session_role, - ) { - rel.privileges.grant(role_id, privileges.clone()); - } + rel.privileges.grant(grantee.clone(), privileges.clone()); } } } @@ -1604,18 +1971,12 @@ impl AnalysisState { &mut self, id: &ObjectId, privileges: &HashSet, - revokees: &[crate::analysis::facts::RoleFact], + revokees: &[ObjectId], ) { self.snapshot_relation(id); if let Some(RelationOverlay::Present(rel)) = self.local.relations.get_mut(id) { for revokee in revokees { - if let Some(role_id) = Self::resolve_role_name( - revokee, - &self.local.current_role, - &self.local.session_role, - ) { - rel.privileges.revoke(&role_id, privileges); - } + rel.privileges.revoke(revokee, privileges); } } } @@ -1652,4462 +2013,88 @@ impl AnalysisState { precomputed_cascade: Option<&CascadeResult>, ) -> MutationResult { match mutation { - Mutation::CreateSchema(create_schema) => { - if self.schema_is_present(&create_schema.name) { - return if create_schema.if_not_exists { - MutationResult::Skipped - } else { - MutationResult::Conflict { - reason: format!("schema '{}' already exists", create_schema.name), - } - }; - } - let (owner_name, owner_known) = match &create_schema.authorization { - Some(role) => match self.role_fact_identity(role) { - Some(identity) => identity, - None => { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - (self.local.current_role.clone(), false) - } - }, - None => ( - self.local.current_role.clone(), - self.local.current_role_known, - ), - }; - if owner_known && self.local.roles_known && self.present_role(&owner_name).is_none() - { - return MutationResult::Conflict { - reason: format!("role '{}' does not exist", owner_name), - }; - } - if !owner_known || !self.local.roles_known { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - self.snapshot_generation_counter(); - self.local.generation_counter += 1; - let generation = self.local.generation_counter; - self.snapshot_schema(&create_schema.name); - self.local.schemas.insert( - create_schema.name.clone(), - SchemaOverlay::Present(crate::model::schema::SchemaState { - name: create_schema.name.clone(), - owner: ObjectId::new("", owner_name), - generation, - }), - ); - self.snapshot_search_path(); - self.refresh_role_sensitive_search_path(); - MutationResult::Applied + Mutation::CreateSchema(create_schema) => self.apply_create_schema(create_schema), + Mutation::AlterSchema(alter_schema) => self.apply_alter_schema(alter_schema), + Mutation::DropSchema(drop_schema) => self.apply_drop_schema(drop_schema), + Mutation::DropTable(drop) => self.apply_drop_table(drop, precomputed_cascade), + Mutation::CreateTable(create) => self.apply_create_table(create), + Mutation::CreateView(create) => self.apply_create_view(create), + Mutation::CreateMaterializedView(create) => self.apply_create_materialized_view(create), + Mutation::RefreshMaterializedView(refresh) => { + self.apply_refresh_materialized_view(refresh) + } + Mutation::CreateIndex(create) => self.apply_create_index(create), + Mutation::CreatePolicy(create_policy) => self.apply_create_policy(create_policy), + Mutation::DropPolicy(drop_policy) => self.apply_drop_policy(drop_policy), + Mutation::CreateTrigger(create_trigger) => self.apply_create_trigger(create_trigger), + Mutation::DropTrigger(drop_trigger) => self.apply_drop_trigger(drop_trigger), + Mutation::RenameTrigger(rename_trigger) => self.apply_rename_trigger(rename_trigger), + Mutation::AlterTable(alter) => self.apply_alter_table(alter), + Mutation::CreateType(create) => self.apply_create_type(create), + Mutation::RenameType(rename) => self.apply_rename_type(rename), + Mutation::AlterType(alter) => self.apply_alter_type(alter), + Mutation::CreateDomain(create) => self.apply_create_domain(create), + Mutation::AlterDomain(alter) => self.apply_alter_domain(alter), + Mutation::DropDomain(drop) => self.apply_drop_domain(drop), + Mutation::DropType(drop) => self.apply_drop_type(drop), + Mutation::CreateSequence(create) => self.apply_create_sequence(create), + Mutation::AlterSequence(alter) => self.apply_alter_sequence(alter), + Mutation::DropSequence(drop) => self.apply_drop_sequence(drop), + Mutation::Rename(rename) => self.apply_rename_relation(rename), + 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::ChangeRelationOwner { id, new_owner } => { + self.apply_change_relation_owner(id, new_owner) } - Mutation::AlterSchema(alter_schema) => match alter_schema { - crate::analysis::mutations::AlterSchemaMutation::OwnerTo { name, new_owner } => { - if !self.schema_is_present(name) { - if self.schema_absence_is_authoritative(name) { - return MutationResult::Conflict { - reason: format!("schema '{}' does not exist", name), - }; - } - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - } - let Some((owner_name, owner_known)) = self.role_fact_identity(new_owner) else { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - }; - if owner_known - && self.local.roles_known - && self.present_role(&owner_name).is_none() - { - return MutationResult::Conflict { - reason: format!("role '{}' does not exist", owner_name), - }; - } - if !owner_known || !self.local.roles_known { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - self.snapshot_schema(name); - if let Some(SchemaOverlay::Present(schema)) = self.local.schemas.get_mut(name) { - schema.owner = ObjectId::new("", owner_name); - } - MutationResult::Applied - } - crate::analysis::mutations::AlterSchemaMutation::Rename { old_name, new_name } => { - if !self.schema_is_present(old_name) { - if !self.schema_absence_is_authoritative(old_name) { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - } - return MutationResult::Conflict { - reason: format!("schema '{}' does not exist", old_name), - }; - } - if self.schema_is_present(new_name) { - return MutationResult::Conflict { - reason: format!("schema '{}' already exists", new_name), - }; - } - if !self.schema_absence_is_authoritative(new_name) { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - self.snapshot_search_path(); - self.rename_schema_namespace(old_name, new_name); - MutationResult::Applied - } - }, - Mutation::DropSchema(drop_schema) => { - for name in &drop_schema.names { - if !self.schema_is_present(name) && self.schema_absence_is_authoritative(name) { - if !drop_schema.if_exists { - return MutationResult::Conflict { - reason: format!("schema '{}' does not exist", name), - }; - } - } else if !self.schema_is_present(name) { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - } - let present_names: Vec = drop_schema - .names - .iter() - .filter(|name| self.schema_is_present(name)) - .cloned() - .collect(); - if present_names.is_empty() { - return MutationResult::Skipped; - } - if drop_schema.cascade { - self.snapshot_namespace(); - let mut relations_to_drop = Vec::new(); - for id in self.local.relations.keys() { - if drop_schema.names.contains(&id.schema) { - relations_to_drop.push(id.clone()); - } - } - for id in relations_to_drop { - self.snapshot_relation(&id); - self.local.relations.insert(id, RelationOverlay::Dropped); - } - - let constraints_to_drop: Vec<(ObjectId, String)> = self - .local - .constraints - .keys() - .filter(|(table_id, _)| drop_schema.names.contains(&table_id.schema)) - .cloned() - .collect(); - for (table_id, name) in constraints_to_drop { - self.snapshot_constraint(&table_id, &name); - self.local.constraints.remove(&(table_id, name)); - } - - let mut types_to_drop = Vec::new(); - for id in self.local.types.keys() { - if drop_schema.names.contains(&id.schema) { - types_to_drop.push(id.clone()); - } - } - for id in types_to_drop { - self.snapshot_type(&id); - self.local.types.insert(id, TypeOverlay::Dropped); - } - - let mut seqs_to_drop = Vec::new(); - for id in self.local.sequences.keys() { - if drop_schema.names.contains(&id.schema) { - seqs_to_drop.push(id.clone()); - } - } - for id in seqs_to_drop { - self.snapshot_sequence(&id); - self.local.sequences.insert(id, SequenceOverlay::Dropped); - } - - let functions_to_drop: Vec = self - .local - .functions - .keys() - .filter(|id| drop_schema.names.contains(&id.schema)) - .cloned() - .collect(); - for id in functions_to_drop { - self.snapshot_function(&id); - self.local - .functions - .insert(id, crate::model::function::FunctionOverlay::Dropped); - } - - let triggers_to_drop: Vec = self - .local - .triggers - .keys() - .filter(|id| drop_schema.names.contains(&id.schema)) - .cloned() - .collect(); - for id in triggers_to_drop { - self.snapshot_trigger(&id); - self.local.triggers.insert(id, TriggerOverlay::Dropped); - } - - self.local - .pending_validation - .retain(|(table, _)| !drop_schema.names.contains(&table.schema)); - let publication_names: Vec = - self.local.publications.keys().cloned().collect(); - for publication_name in publication_names { - self.snapshot_publication(&publication_name); - } - for overlay in self.local.publications.values_mut() { - let crate::model::replication::PublicationOverlay::Present(publication) = - overlay - else { - continue; - }; - let crate::analysis::facts::PublicationScope::Explicit(objects) = - &mut publication.scope - else { - continue; - }; - objects.retain(|object| match object { - crate::analysis::facts::PublicationObjectFact::Table { - name, .. - } => name.schema.as_ref().is_none_or(|schema| { - !drop_schema.names.contains(&schema.resolve()) - }), - crate::analysis::facts::PublicationObjectFact::SchemaTables { - schema, - .. - } => !drop_schema.names.contains(schema), - _ => true, - }); - } - - self.snapshot_graph_full(); - - let g = &mut self.local.graph; - g.edges.retain(|e| { - !drop_schema.names.contains(&e.dependent.schema) - && !drop_schema.names.contains(&e.referenced.schema) - && match &e.kind { - DependencyKind::TriggerOnTable { function_id, .. } => { - !drop_schema.names.contains(&function_id.schema) - } - _ => true, - } - }); - } else { - // Non-cascade: fail if any objects in the schema still exist - let has_relation = self.local.relations.iter().any(|(id, ov)| { - drop_schema.names.contains(&id.schema) - && !matches!(ov, RelationOverlay::Dropped) - }); - let has_type = self.local.types.iter().any(|(id, ov)| { - drop_schema.names.contains(&id.schema) - && !matches!(ov, TypeOverlay::Dropped) - }); - let has_sequence = self.local.sequences.iter().any(|(id, ov)| { - drop_schema.names.contains(&id.schema) - && !matches!(ov, SequenceOverlay::Dropped) - }); - let has_function = self.local.functions.iter().any(|(id, ov)| { - drop_schema.names.contains(&id.schema) - && !matches!(ov, crate::model::function::FunctionOverlay::Dropped) - }); - let has_trigger = self.local.triggers.iter().any(|(id, ov)| { - drop_schema.names.contains(&id.schema) - && !matches!(ov, TriggerOverlay::Dropped) - }); - if has_relation || has_type || has_sequence || has_function || has_trigger { - return MutationResult::Conflict { - reason: format!( - "schema(s) {:?} still contain objects; use CASCADE to drop them", - drop_schema.names - ), - }; - } - } - for name in present_names { - self.snapshot_schema(&name); - self.local.schemas.insert(name, SchemaOverlay::Dropped); - } - self.snapshot_search_path(); - self.refresh_role_sensitive_search_path(); - MutationResult::Applied - } - Mutation::DropTable(drop_table) => { - if !self.relation_is_present(&drop_table.id) { - if drop_table.if_exists { - return MutationResult::Skipped; - } - if self.baseline_available && self.baseline_covers_object(&drop_table.id) { - return MutationResult::Conflict { - reason: format!("table '{}' does not exist", drop_table.id), - }; - } - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - } - if !matches!( - self.local.relations.get(&drop_table.id), - Some(RelationOverlay::Present(relation)) - if relation.kind == RelationKind::Table - ) { - return MutationResult::Conflict { - reason: format!("'{}' is not a table", drop_table.id), - }; - } - - 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 resolved_drop = resolve(&drop_table.id); - let mut dropped_relations = HashSet::from([resolved_drop.clone()]); - - if drop_table.cascade { - let local_closure; - let closure = match precomputed_cascade { - Some(c) => c, - None => { - local_closure = self.get_cascade_closure(&drop_table.id); - &local_closure - } - }; - dropped_relations = closure.dropped_relations.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.edges.retain(|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| { - matches!(e.kind, DependencyKind::ViewDependency { .. }) - && resolve(&e.referenced) == resolved_drop - }); - let has_fk_deps = self.local.graph.edges.iter().any(|e| { - matches!(e.kind, DependencyKind::ForeignKey { .. }) - && resolve(&e.referenced) == resolved_drop - && resolve(&e.dependent) != resolved_drop - }); - let has_partition_deps = self.local.graph.edges.iter().any(|e| { - matches!(e.kind, DependencyKind::PartitionOf) - && resolve(&e.referenced) == resolved_drop - }); - - if has_view_deps || has_fk_deps || has_partition_deps { - return MutationResult::Conflict { - reason: format!( - "relation '{}' still has dependent objects; use CASCADE", - drop_table.id - ), - }; - } - - self.snapshot_relation(&drop_table.id); - self.local - .relations - .insert(drop_table.id.clone(), RelationOverlay::Dropped); - - self.snapshot_graph_full(); - self.local.graph.edges.retain(|e| { - !(matches!(e.kind, DependencyKind::SequenceOwnedBy { .. }) - && resolve(&e.referenced) == resolved_drop) - }); - } - - 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); - } - - let constraints_to_drop: Vec<(ObjectId, String)> = self - .local - .constraints - .keys() - .filter(|(table_id, _)| dropped_relations.contains(&resolve(table_id))) - .cloned() - .collect(); - for (table_id, name) in constraints_to_drop { - self.snapshot_constraint(&table_id, &name); - self.local.constraints.remove(&(table_id, name)); - } - - 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.edges.retain(|e| { - !(matches!(e.kind, DependencyKind::TriggerOnTable { .. }) - && dropped_relations.contains(&resolve(&e.referenced))) - }); - - self.snapshot_graph_full(); - self.local.graph.edges.retain(|e| { - if let DependencyKind::PartitionOf = e.kind { - resolve(&e.referenced) != resolved_drop - && resolve(&e.dependent) != resolved_drop - } else { - true - } - }); - - let publication_updates: Vec<(String, Vec<_>)> = self - .local - .publications - .iter() - .filter_map(|(name, overlay)| { - let crate::model::replication::PublicationOverlay::Present(publication) = - overlay - else { - return None; - }; - let crate::analysis::facts::PublicationScope::Explicit(objects) = - &publication.scope - else { - return None; - }; - let retained = objects - .iter() - .filter(|object| { - let crate::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::model::replication::PublicationOverlay::Present(publication)) = - self.local.publications.get_mut(&publication_name) - && let crate::analysis::facts::PublicationScope::Explicit(objects) = - &mut publication.scope - { - *objects = retained; - } - } - self.snapshot_graph_full(); - self.local.graph.edges.retain(|edge| { - !matches!(edge.kind, DependencyKind::PublicationIncludes { .. }) - || !dropped_relations.contains(&resolve(&edge.dependent)) - }); - - MutationResult::Applied - } - Mutation::CreateTable(create) => { - 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), - }; - } - - // 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. - let mut reserved_sequences = HashSet::new(); - let mut implicit_sequences = Vec::new(); - for column in &create.columns { - let kind = match column.generation { - crate::analysis::facts::ColumnGeneration::Serial => { - Some(SequenceKind::SerialLike) - } - crate::analysis::facts::ColumnGeneration::Identity => { - Some(SequenceKind::Identity) - } - crate::analysis::facts::ColumnGeneration::Ordinary => None, - }; - if let Some(kind) = kind { - let sequence_id = self.next_implicit_sequence_id( - &create.id, - &column.name, - &reserved_sequences, - ); - reserved_sequences.insert(sequence_id.clone()); - implicit_sequences.push((sequence_id, column.name.clone(), kind)); - } - } - - self.snapshot_relation(&create.id); - - self.snapshot_generation_counter(); - self.local.generation_counter += 1; - let generation = self.local.generation_counter; - - let resolved_persistence = match create.persistence { - PersistenceMutation::Permanent => { - crate::model::relation::Persistence::Permanent - } - PersistenceMutation::Temporary => { - crate::model::relation::Persistence::Temporary - } - PersistenceMutation::Unlogged => crate::model::relation::Persistence::Unlogged, - }; - - let mut rel_state = RelationState::new( - create.id.clone(), - ObjectId::new("", &self.local.current_role), - generation, - if create.as_select { None } else { Some(0) }, - RelationKind::Table, - resolved_persistence, - self.local.transactions.len(), - ); - - // Store partition strategy information - rel_state.partition_type = create - .partition_by - .as_ref() - .and_then(|pb| pb.split_whitespace().nth(2).map(|s| s.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_by = create.partition_by.clone(); - - let pk_columns: HashSet<&str> = create - .table_constraints - .iter() - .filter_map(|tc| { - if let TableConstraintFact::PrimaryKey { columns, .. } = tc { - Some(columns.iter().map(|s| s.as_str())) - } else { - None - } - }) - .flatten() - .collect(); - - for col in &create.columns { - let is_pk = col.is_primary_key || pk_columns.contains(col.name.as_str()); - rel_state.apply_column_action(&ColumnAction::Add { - name: col.name.clone(), - data_type: col.ty.clone(), - 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.type_id = column - .data_type - .as_deref() - .and_then(|raw| self.resolve_type_reference(raw)); - } - } - - for (sequence_id, column_name, _) in &implicit_sequences { - if let Some(column) = rel_state - .columns - .iter_mut() - .find(|column| column.name == *column_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; - } - } - - self.local - .relations - .insert(create.id.clone(), RelationOverlay::Present(rel_state)); - - for (sequence_id, column_name, kind) in implicit_sequences { - 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: ObjectId::new("", &self.local.current_role), - owned_by: Some((create.id.clone(), column_name.clone())), - kind, - generation: self.local.generation_counter, - }), - ); - self.snapshot_graph(); - self.local.graph.edges.push(DependencyEdge::new( - sequence_id, - create.id.clone(), - DependencyKind::SequenceOwnedBy { - column: column_name, - }, - )); - } - - let primary_key_name = create - .columns - .iter() - .find(|column| column.is_primary_key) - .map(|column| column.primary_key_constraint_name.clone()) - .or_else(|| { - create.table_constraints.iter().find_map(|constraint| { - if let TableConstraintFact::PrimaryKey { - constraint_name, .. - } = constraint - { - Some(constraint_name.clone()) - } else { - None - } - }) - }); - if let Some(explicit_name) = primary_key_name { - let name = explicit_name.unwrap_or_else(|| { - self.next_generated_constraint_name( - &create.id, - &create.id.name, - None, - "pkey", - ) - }); - self.snapshot_constraint(&create.id, &name); - self.local.constraints.insert( - (create.id.clone(), name.clone()), - ConstraintState { - table_id: create.id.clone(), - name, - kind: ConstraintKind::PrimaryKey, - validated: true, - }, - ); - } - - let unique_constraints = create - .columns - .iter() - .filter(|column| column.is_unique) - .map(|column| { - ( - column.unique_constraint_name.as_ref(), - vec![column.name.as_str()], - ) - }) - .chain(create.table_constraints.iter().filter_map(|constraint| { - if let TableConstraintFact::Unique { - constraint_name, - columns, - } = constraint - { - Some(( - constraint_name.as_ref(), - columns.iter().map(String::as_str).collect(), - )) - } else { - None - } - })) - .collect::>(); - for (explicit_name, columns) in unique_constraints { - let name = explicit_name.cloned().unwrap_or_else(|| { - self.next_generated_constraint_name( - &create.id, - &create.id.name, - Some(&columns.join("_")), - "key", - ) - }); - self.snapshot_constraint(&create.id, &name); - self.local.constraints.insert( - (create.id.clone(), name.clone()), - ConstraintState { - table_id: create.id.clone(), - name, - kind: ConstraintKind::Unique, - validated: true, - }, - ); - } - - if let Some(parent_id) = &create.partition_of { - self.snapshot_graph(); - self.local.graph.edges.push(DependencyEdge::new( - create.id.clone(), - parent_id.clone(), - DependencyKind::PartitionOf, - )); - } - - if !create.foreign_keys.is_empty() { - self.snapshot_graph(); - } - - for fk in &create.foreign_keys { - self.local.graph.edges.push(DependencyEdge::new( - create.id.clone(), - fk.to_table.clone(), - DependencyKind::ForeignKey { - constraint_name: fk.constraint_name.clone(), - from_columns: fk.from_columns.clone(), - to_columns: fk.to_columns.clone(), - from_generation: generation, - }, - )); - } - MutationResult::Applied - } - Mutation::CreateView(create_view) => { - if self.relation_namespace_is_taken(&create_view.id) { - let is_replaceable_view = matches!( - self.local.relations.get(&create_view.id), - Some(RelationOverlay::Present(relation)) - if relation.kind == RelationKind::View - ); - if !create_view.or_replace || !is_replaceable_view { - return MutationResult::Conflict { - reason: format!("relation '{}' already exists", create_view.id), - }; - } - } - self.snapshot_relation(&create_view.id); - self.snapshot_generation_counter(); - self.local.generation_counter += 1; - let generation = self.local.generation_counter; - - self.local.relations.insert( - create_view.id.clone(), - RelationOverlay::Present(RelationState::new( - create_view.id.clone(), - ObjectId::new("", &self.local.current_role), - generation, - None, - RelationKind::View, - Persistence::Permanent, - self.local.transactions.len(), - )), - ); - - self.snapshot_graph(); - for dep in &create_view.depends_on { - self.local.graph.edges.push(DependencyEdge::new( - create_view.id.clone(), - dep.clone(), - DependencyKind::ViewDependency { - view_generation: generation, - }, - )); - } - MutationResult::Applied - } - Mutation::CreateMaterializedView(create_mv) => { - if self.relation_namespace_is_taken(&create_mv.id) { - return MutationResult::Conflict { - reason: format!("relation '{}' already exists", create_mv.id), - }; - } - self.snapshot_relation(&create_mv.id); - self.snapshot_generation_counter(); - self.local.generation_counter += 1; - let generation = self.local.generation_counter; - - self.local.relations.insert( - create_mv.id.clone(), - RelationOverlay::Present(RelationState::new( - create_mv.id.clone(), - ObjectId::new("", &self.local.current_role), - generation, - None, - RelationKind::MaterializedView, - Persistence::Permanent, - self.local.transactions.len(), - )), - ); - - self.snapshot_graph(); - for dep in &create_mv.depends_on { - self.local.graph.edges.push(DependencyEdge::new( - create_mv.id.clone(), - dep.clone(), - DependencyKind::ViewDependency { - view_generation: generation, - }, - )); - } - MutationResult::Applied - } - Mutation::RefreshMaterializedView(_) => MutationResult::Applied, - Mutation::CreateIndex(create_idx) => { - let exists = self.index_is_present(&create_idx.id); - if create_idx.if_not_exists && exists { - return MutationResult::Skipped; - } - if self.relation_namespace_is_taken(&create_idx.id) { - return MutationResult::Conflict { - reason: format!("relation '{}' already exists", create_idx.id), - }; - } - self.snapshot_graph(); - self.local.graph.edges.push(DependencyEdge::new( - create_idx.id.clone(), - create_idx.table.clone(), - DependencyKind::IndexOnRelation { - using_method: create_idx.using_method.clone(), - has_predicate: create_idx.has_predicate, - is_concurrent: create_idx.concurrently, - is_unique: create_idx.unique, - eligibility_known: true, - }, - )); - MutationResult::Applied - } - Mutation::CreatePolicy(create_policy) => { - self.snapshot_relation(&create_policy.table); - if let Some(RelationOverlay::Present(rel)) = - self.local.relations.get_mut(&create_policy.table) - { - if rel.policies.contains(&create_policy.name) { - return MutationResult::Conflict { - reason: format!( - "policy '{}' already exists on relation '{}'", - create_policy.name, create_policy.table - ), - }; - } - rel.policies.insert(create_policy.name.clone()); - } else { - return MutationResult::Conflict { - reason: format!("relation '{}' does not exist", create_policy.table), - }; - } - MutationResult::Applied - } - Mutation::DropPolicy(drop_policy) => { - self.snapshot_relation(&drop_policy.table); - if let Some(RelationOverlay::Present(rel)) = - self.local.relations.get_mut(&drop_policy.table) - { - if !rel.policies.contains(&drop_policy.name) { - return if drop_policy.if_exists { - MutationResult::Skipped - } else { - MutationResult::Conflict { - reason: format!( - "policy '{}' does not exist on relation '{}'", - drop_policy.name, drop_policy.table - ), - } - }; - } - rel.policies.remove(&drop_policy.name); - } else { - return MutationResult::Conflict { - reason: format!("relation '{}' does not exist", drop_policy.table), - }; - } - MutationResult::Applied - } - Mutation::CreateTrigger(create_trigger) => { - let trigger_id = Self::trigger_key(&create_trigger.table, &create_trigger.name); - if matches!( - self.local.triggers.get(&trigger_id), - Some(TriggerOverlay::Present(_)) - ) { - return MutationResult::Conflict { - reason: format!( - "trigger '{}' already exists on relation '{}'", - create_trigger.name, create_trigger.table - ), - }; - } - self.snapshot_trigger(&trigger_id); - self.local.triggers.insert( - trigger_id.clone(), - TriggerOverlay::Present(crate::model::trigger::TriggerState { - name: create_trigger.name.clone(), - id: trigger_id.clone(), - table_id: create_trigger.table.clone(), - enabled_mode: crate::model::trigger::TriggerEnableMode::Origin, - generation: self.local.generation_counter, - }), - ); - - self.snapshot_relation(&create_trigger.table); - if let Some(RelationOverlay::Present(rel)) = - self.local.relations.get_mut(&create_trigger.table) - { - rel.triggers.insert(create_trigger.name.clone()); - } - - self.snapshot_graph_full(); - self.local.graph.edges.push(DependencyEdge::new( - trigger_id.clone(), - create_trigger.table.clone(), - DependencyKind::TriggerOnTable { - trigger_id: trigger_id.clone(), - function_id: create_trigger.function_id.clone(), - }, - )); - - MutationResult::Applied - } - Mutation::DropTrigger(drop_trigger) => { - let trigger_id = Self::trigger_key(&drop_trigger.table, &drop_trigger.name); - if !matches!( - self.local.triggers.get(&trigger_id), - Some(TriggerOverlay::Present(_)) - ) { - return if drop_trigger.if_exists { - MutationResult::Skipped - } else { - MutationResult::Conflict { - reason: format!( - "trigger '{}' does not exist on relation '{}'", - drop_trigger.name, drop_trigger.table - ), - } - }; - } - self.snapshot_trigger(&trigger_id); - self.local - .triggers - .insert(trigger_id.clone(), TriggerOverlay::Dropped); - - self.snapshot_relation(&drop_trigger.table); - if let Some(RelationOverlay::Present(rel)) = - self.local.relations.get_mut(&drop_trigger.table) - { - rel.triggers.remove(&drop_trigger.name); - } - - self.snapshot_graph_full(); - self.local.graph.edges.retain(|e| { - !(matches!(e.kind, DependencyKind::TriggerOnTable { .. }) - && e.dependent == trigger_id) - }); - - MutationResult::Applied - } - Mutation::RenameTrigger(rename_trigger) => { - let old_id = Self::trigger_key(&rename_trigger.table, &rename_trigger.name); - let new_id = Self::trigger_key(&rename_trigger.table, &rename_trigger.new_name); - let Some(TriggerOverlay::Present(mut trigger)) = - self.local.triggers.get(&old_id).cloned() - else { - return MutationResult::Conflict { - reason: format!( - "trigger '{}' does not exist on relation '{}'", - rename_trigger.name, rename_trigger.table - ), - }; - }; - if old_id != new_id - && matches!( - self.local.triggers.get(&new_id), - Some(TriggerOverlay::Present(_)) - ) - { - return MutationResult::Conflict { - reason: format!( - "trigger '{}' already exists on relation '{}'", - rename_trigger.new_name, rename_trigger.table - ), - }; - } - self.snapshot_trigger(&old_id); - self.snapshot_trigger(&new_id); - self.snapshot_relation(&rename_trigger.table); - self.snapshot_graph_full(); - self.local.triggers.remove(&old_id); - trigger.id = new_id.clone(); - trigger.name = rename_trigger.new_name.clone(); - self.local - .triggers - .insert(new_id.clone(), TriggerOverlay::Present(trigger)); - if let Some(RelationOverlay::Present(relation)) = - self.local.relations.get_mut(&rename_trigger.table) - { - relation.triggers.remove(&rename_trigger.name); - relation.triggers.insert(rename_trigger.new_name.clone()); - } - self.local.graph.propagate_rename(&old_id, &new_id); - self.local.graph.edges.push(DependencyEdge::new( - old_id, - new_id, - DependencyKind::RenameTo, - )); - MutationResult::Applied - } - Mutation::AlterTable(alter) => { - if let AlterTableActionMutation::OwnerTo { new_owner } = &alter.action { - let Some((owner, known)) = self.role_fact_identity(new_owner) else { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - }; - if !known { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - self.snapshot_relation(&alter.id); - return match self.local.relations.get_mut(&alter.id) { - Some(RelationOverlay::Present(relation)) => { - relation.owner = ObjectId::new("", owner); - MutationResult::Applied - } - _ => MutationResult::Conflict { - reason: format!("relation '{}' does not exist", alter.id), - }, - }; - } - - let trigger_mode = match &alter.action { - AlterTableActionMutation::DisableTrigger { trigger_name } => Some(( - trigger_name.as_deref(), - crate::model::trigger::TriggerEnableMode::Disabled, - )), - AlterTableActionMutation::EnableTrigger { trigger_name } => Some(( - trigger_name.as_deref(), - crate::model::trigger::TriggerEnableMode::Origin, - )), - _ => 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 trigger_ids: Vec = self - .local - .triggers - .iter() - .filter_map(|(id, overlay)| { - let TriggerOverlay::Present(trigger) = overlay else { - return None; - }; - (trigger.table_id == alter.id - && (all || trigger_name == Some(trigger.name.as_str()))) - .then(|| id.clone()) - }) - .collect(); - for trigger_id in trigger_ids { - self.snapshot_trigger(&trigger_id); - if let Some(TriggerOverlay::Present(trigger)) = - self.local.triggers.get_mut(&trigger_id) - { - trigger.enabled_mode = enabled_mode; - } - } - return MutationResult::Applied; - } - - if let AlterTableActionMutation::AddForeignKey { - to_table, - from_columns, - to_columns, - .. - } = &alter.action - { - if let Some(RelationOverlay::Present(child)) = - self.local.relations.get(&alter.id) - && let Some(column) = - from_columns.iter().find(|column| !child.has_column(column)) - { - return MutationResult::Conflict { - reason: format!( - "foreign key column '{}' does not exist on relation '{}'", - column, alter.id - ), - }; - } - - let Some(RelationOverlay::Present(parent)) = self.local.relations.get(to_table) - else { - return MutationResult::Conflict { - reason: format!( - "foreign key references relation '{}' which does not exist", - to_table - ), - }; - }; - if let Some(column) = - to_columns.iter().find(|column| !parent.has_column(column)) - { - return MutationResult::Conflict { - reason: format!( - "foreign key references column '{}.{}' which does not exist", - to_table, column - ), - }; - } - } - - let implicit_add = match &alter.action { - AlterTableActionMutation::AddColumn { - name, generation, .. - } => match generation { - crate::analysis::facts::ColumnGeneration::Serial => Some(( - self.next_implicit_sequence_id(&alter.id, name, &HashSet::new()), - name.clone(), - SequenceKind::SerialLike, - )), - crate::analysis::facts::ColumnGeneration::Identity => Some(( - self.next_implicit_sequence_id(&alter.id, name, &HashSet::new()), - name.clone(), - SequenceKind::Identity, - )), - crate::analysis::facts::ColumnGeneration::Ordinary => None, - }, - _ => None, - }; - let owned_sequences_for_column: Vec = match &alter.action { - AlterTableActionMutation::DropColumn { name, .. } - | AlterTableActionMutation::RenameColumn { from: name, .. } => self - .local - .sequences - .iter() - .filter_map(|(id, overlay)| match overlay { - SequenceOverlay::Present(sequence) - if sequence.owned_by.as_ref() - == Some(&(alter.id.clone(), name.clone())) => - { - Some(id.clone()) - } - _ => None, - }) - .collect(), - _ => Vec::new(), - }; - - let using_index = match &alter.action { - AlterTableActionMutation::AddUniqueConstraint { using_index, .. } - | AlterTableActionMutation::AddPrimaryKeyConstraint { using_index, .. } => { - using_index.as_ref() - } - _ => None, - }; - if let Some(index) = using_index { - let Some(edge) = self.local.graph.edges.iter().find(|edge| { - matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) - && edge.dependent == *index - }) else { - return MutationResult::Conflict { - reason: format!( - "constraint references index '{}' which does not exist", - index - ), - }; - }; - if edge.referenced != alter.id { - return MutationResult::Conflict { - reason: format!( - "constraint index '{}' belongs to relation '{}', not '{}'", - index, edge.referenced, alter.id - ), - }; - } - if let DependencyKind::IndexOnRelation { - has_predicate, - is_unique, - eligibility_known, - .. - } = &edge.kind - && *eligibility_known - && (!is_unique || *has_predicate) - { - return MutationResult::Conflict { - reason: format!( - "constraint index '{}' must be unique and non-partial", - index - ), - }; - } - } - - self.snapshot_relation(&alter.id); - let action_type_id = match &alter.action { - AlterTableActionMutation::AddColumn { ty, .. } => ty - .as_deref() - .and_then(|raw| self.resolve_type_reference(raw)), - AlterTableActionMutation::SetType { ty, .. } => self.resolve_type_reference(ty), - _ => None, - }; - let rel_overlay = self.local.relations.get_mut(&alter.id); - if let Some(RelationOverlay::Present(rel)) = rel_overlay { - let generation = rel.generation; - match &alter.action { - AlterTableActionMutation::AddColumn { - name, - ty, - if_not_exists, - not_null, - default, - depends_on, - generation: _, - } => { - 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 let Some(column) = - rel.columns.iter_mut().find(|column| column.name == *name) - { - column.type_id = action_type_id.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) - { - 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.edges.push(DependencyEdge::new( - alter.id.clone(), - source_table.clone(), - DependencyKind::ColumnGeneratedFrom { - column: name.clone(), - depends_on_column: source_col.clone(), - }, - )); - } - } - AlterTableActionMutation::DropColumn { name, if_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 '{}' does not exist on relation '{}'", - name, alter.id - ), - }; - } - rel.apply_column_action(&ColumnAction::Drop { name: name.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(), - }); - } - AlterTableActionMutation::DropNotNull { column } => { - rel.apply_column_action(&ColumnAction::DropNotNull { - name: column.clone(), - }); - } - AlterTableActionMutation::SetType { column, ty, .. } => { - if !rel.has_column(column) { - self.local.confidence = Confidence::Tainted; - } - 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::SetDefault { column, default } => { - if !rel.has_column(column) { - self.local.confidence = Confidence::Tainted; - } - rel.apply_column_action(&ColumnAction::SetDefault { - name: column.clone(), - default: default.clone(), - }); - } - AlterTableActionMutation::AddForeignKey { - constraint_name, - to_table, - from_columns, - to_columns, - not_valid, - } => { - let constraint_name = constraint_name.clone().unwrap_or_else(|| { - format!("{}_{}_fkey", alter.id.name, from_columns.join("_")) - }); - 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, - }, - ); - self.snapshot_graph(); - self.local.graph.edges.push(DependencyEdge::new( - alter.id.clone(), - to_table.clone(), - DependencyKind::ForeignKey { - constraint_name: Some(constraint_name), - from_columns: from_columns.clone(), - to_columns: to_columns.clone(), - from_generation: generation, - }, - )); - } - AlterTableActionMutation::DropConstraint { name } => { - self.snapshot_constraint(&alter.id, name); - self.local - .constraints - .remove(&(alter.id.clone(), name.clone())); - self.snapshot_graph(); - self.local.graph.edges.retain(|e| { - if let DependencyKind::ForeignKey { - constraint_name, .. - } = &e.kind - { - !(e.dependent == alter.id - && constraint_name.as_ref() == Some(name)) - } else { - 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); - } - self.snapshot_graph_full(); - for edge in &mut self.local.graph.edges { - if edge.dependent == alter.id - && let DependencyKind::ForeignKey { - constraint_name, .. - } = &mut edge.kind - && constraint_name.as_deref() == Some(old_name) - { - *constraint_name = Some(new_name.clone()); - } - } - } - AlterTableActionMutation::AddCheckConstraint { - constraint_name, - not_valid, - } => { - let constraint_name = constraint_name - .clone() - .unwrap_or_else(|| format!("{}_check", alter.id.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, - kind: ConstraintKind::Check, - validated: !not_valid, - }, - ); - } - AlterTableActionMutation::AddUniqueConstraint { - constraint_name, - using_index, - } => { - let constraint_name = constraint_name - .clone() - .or_else(|| using_index.as_ref().map(|index| index.name.clone())) - .unwrap_or_else(|| format!("{}_key", alter.id.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, - kind: ConstraintKind::Unique, - validated: true, - }, - ); - } - AlterTableActionMutation::AddPrimaryKeyConstraint { - constraint_name, - using_index, - } => { - let constraint_name = constraint_name - .clone() - .or_else(|| using_index.as_ref().map(|index| index.name.clone())) - .unwrap_or_else(|| format!("{}_pkey", alter.id.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, - kind: ConstraintKind::PrimaryKey, - validated: true, - }, - ); - } - AlterTableActionMutation::AddExcludeConstraint { constraint_name } => { - let constraint_name = constraint_name - .clone() - .unwrap_or_else(|| format!("{}_excl", alter.id.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, - kind: ConstraintKind::Exclusion, - validated: true, - }, - ); - } - AlterTableActionMutation::ValidateConstraint { constraint_name } => { - self.snapshot_constraint(&alter.id, constraint_name); - if let Some(constraint) = self - .local - .constraints - .get_mut(&(alter.id.clone(), constraint_name.clone())) - { - constraint.validated = true; - } - } - AlterTableActionMutation::AttachPartition { child, .. } => { - // Reject attachments that would make partition ancestry cyclic. - if self.local.graph.check_partition_cycle(&alter.id, child) { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } else { - self.snapshot_graph(); - self.local.graph.edges.push(DependencyEdge::new( - child.clone(), - alter.id.clone(), - DependencyKind::PartitionOf, - )); - } - } - AlterTableActionMutation::DetachPartition { child } => { - self.snapshot_graph(); - self.local.graph.edges.retain(|e| { - !(matches!(e.kind, DependencyKind::PartitionOf) - && e.dependent == *child - && e.referenced == alter.id) - }); - } - _ => {} - } - } - 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(); - self.local.graph.edges.push(DependencyEdge::new( - sequence_id, - alter.id.clone(), - DependencyKind::SequenceOwnedBy { - column: column_name, - }, - )); - } - match &alter.action { - AlterTableActionMutation::DropColumn { .. } => { - 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.edges.retain(|edge| { - !(matches!(edge.kind, DependencyKind::SequenceOwnedBy { .. }) - && edge.dependent == sequence_id) - }); - } - } - AlterTableActionMutation::RenameColumn { 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(); - } - self.snapshot_graph_full(); - for edge in &mut self.local.graph.edges { - if edge.dependent == sequence_id - && let DependencyKind::SequenceOwnedBy { column } = - &mut edge.kind - { - *column = to.clone(); - } - } - } - } - _ => {} - } - MutationResult::Applied - } - Mutation::CreateType(create_type) => { - if self.relation_namespace_is_taken(&create_type.id) { - return MutationResult::Conflict { - reason: format!("type '{}' already exists", create_type.id), - }; - } - self.snapshot_type(&create_type.id); - self.snapshot_generation_counter(); - self.local.generation_counter += 1; - let generation = self.local.generation_counter; - - self.local.types.insert( - create_type.id.clone(), - TypeOverlay::Present(TypeState { - id: create_type.id.clone(), - generation, - kind: create_type.kind.clone(), - }), - ); - MutationResult::Applied - } - Mutation::RenameType(rename) => { - if !self.type_is_present(&rename.old_id) { - if self.baseline_covers_object(&rename.old_id) { - return MutationResult::Conflict { - reason: format!("type '{}' does not exist", rename.old_id), - }; - } - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - } - if rename.old_id != rename.new_id - && self.relation_namespace_is_taken(&rename.new_id) - { - return MutationResult::Conflict { - reason: format!("type '{}' 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.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - } - - let mut remapped_functions = Vec::new(); - for (function_id, overlay) in &self.local.functions { - let crate::model::function::FunctionOverlay::Present(function) = overlay else { - continue; - }; - let new_arg_types = function - .arg_types - .iter() - .enumerate() - .map(|(index, raw)| { - if function.arg_type_ids.get(index) - == Some(&Some(rename.old_id.clone())) - { - Self::remapped_type_display( - raw, - &rename.new_id, - rename.old_id.schema != rename.new_id.schema, - ) - } else { - raw.clone() - } - }) - .collect::>(); - let new_return_type = if function.return_type_id == Some(rename.old_id.clone()) - { - Self::remapped_type_display( - &function.return_type, - &rename.new_id, - rename.old_id.schema != rename.new_id.schema, - ) - } else { - function.return_type.clone() - }; - let base_name = function_id - .name - .split_once('(') - .map(|(name, _)| name) - .unwrap_or(&function_id.name); - let mut new_function_id = ObjectId::new( - &function_id.schema, - format!("{}({})", base_name, new_arg_types.join(",")), - ); - new_function_id.inferred_schema = function_id.inferred_schema; - if new_function_id != *function_id - || new_arg_types != function.arg_types - || new_return_type != function.return_type - { - remapped_functions.push(( - function_id.clone(), - new_function_id, - new_arg_types, - new_return_type, - )); - } - } - let moved_function_ids = remapped_functions - .iter() - .map(|(old_id, _, _, _)| old_id) - .collect::>(); - let mut destinations = HashSet::new(); - for (_, new_id, _, _) in &remapped_functions { - if !destinations.insert(new_id) - || (self.local.functions.contains_key(new_id) - && !moved_function_ids.contains(new_id)) - { - return MutationResult::Conflict { - reason: format!( - "routine '{}' already exists after renaming type '{}'", - new_id, rename.old_id - ), - }; - } - } - - self.snapshot_namespace(); - if let Some(TypeOverlay::Present(mut state)) = - self.local.types.remove(&rename.old_id) - { - state.id = rename.new_id.clone(); - self.local - .types - .insert(rename.new_id.clone(), TypeOverlay::Present(state)); - } - for overlay in self.local.relations.values_mut() { - if let RelationOverlay::Present(relation) = overlay { - for column in &mut relation.columns { - if column.type_id == Some(rename.old_id.clone()) { - column.data_type = Some(Self::remapped_type_display( - column.data_type.as_deref().unwrap_or_default(), - &rename.new_id, - rename.old_id.schema != rename.new_id.schema, - )); - column.type_id = Some(rename.new_id.clone()); - } - } - } - } - for overlay in self.local.types.values_mut() { - if let TypeOverlay::Present(TypeState { - kind: - TypeKind::Domain { - base_type, - base_type_id, - }, - .. - }) = overlay - && *base_type_id == Some(rename.old_id.clone()) - { - *base_type = Self::remapped_type_display( - base_type, - &rename.new_id, - rename.old_id.schema != rename.new_id.schema, - ); - *base_type_id = Some(rename.new_id.clone()); - } - } - for (old_id, new_id, arg_types, return_type) in remapped_functions { - if let Some(crate::model::function::FunctionOverlay::Present(mut function)) = - self.local.functions.remove(&old_id) - { - function.id = new_id.clone(); - function.arg_types = arg_types; - for type_id in &mut function.arg_type_ids { - if *type_id == Some(rename.old_id.clone()) { - *type_id = Some(rename.new_id.clone()); - } - } - function.return_type = return_type; - if function.return_type_id == Some(rename.old_id.clone()) { - function.return_type_id = Some(rename.new_id.clone()); - } - self.local.functions.insert( - new_id.clone(), - crate::model::function::FunctionOverlay::Present(function), - ); - if old_id != new_id { - self.local.graph.propagate_rename(&old_id, &new_id); - self.local.graph.edges.push(DependencyEdge::new( - old_id, - new_id, - DependencyKind::RenameTo, - )); - } - } - } - MutationResult::Applied - } - Mutation::AlterType(alter_type) => { - self.snapshot_type(&alter_type.id); - if let Some(TypeOverlay::Present(t)) = self.local.types.get_mut(&alter_type.id) { - match &alter_type.action { - AlterTypeActionMutation::AddValue { - new_value, - neighbor, - before, - } => { - if let TypeKind::Enum { variants } = &mut t.kind { - if variants.contains(new_value) { - return MutationResult::Skipped; - } - let insertion_index = neighbor - .as_ref() - .and_then(|neighbor| { - variants.iter().position(|value| value == neighbor) - }) - .map(|index| if *before { index } else { index + 1 }) - .unwrap_or(variants.len()); - variants.insert(insertion_index, new_value.clone()); - } - } - AlterTypeActionMutation::RenameValue { - old_value, - new_value, - } => { - let TypeKind::Enum { variants } = &mut t.kind else { - return MutationResult::Conflict { - reason: format!("type '{}' is not an enum", alter_type.id), - }; - }; - let Some(old_index) = - variants.iter().position(|value| value == old_value) - else { - return MutationResult::Conflict { - reason: format!( - "'{}' is not an existing label of enum '{}'", - old_value, alter_type.id - ), - }; - }; - if variants.iter().any(|value| value == new_value) { - return MutationResult::Conflict { - reason: format!( - "enum label '{}' already exists on type '{}'", - new_value, alter_type.id - ), - }; - } - variants[old_index] = new_value.clone(); - } - } - } else if matches!( - alter_type.action, - AlterTypeActionMutation::RenameValue { .. } - ) { - return MutationResult::Conflict { - reason: format!("type '{}' does not exist", alter_type.id), - }; - } - MutationResult::Applied - } - Mutation::CreateDomain(create_domain) => { - if self.relation_namespace_is_taken(&create_domain.id) { - return MutationResult::Conflict { - reason: format!("type '{}' already exists", create_domain.id), - }; - } - self.snapshot_type(&create_domain.id); - self.snapshot_generation_counter(); - self.local.generation_counter += 1; - let generation = self.local.generation_counter; - - self.local.types.insert( - create_domain.id.clone(), - TypeOverlay::Present(TypeState { - id: create_domain.id.clone(), - generation, - kind: TypeKind::Domain { - base_type: create_domain.base_type.clone(), - base_type_id: self.resolve_type_reference(&create_domain.base_type), - }, - }), - ); - MutationResult::Applied - } - Mutation::AlterDomain(_) => MutationResult::Applied, - Mutation::DropDomain(drop_domain) => { - for id in &drop_domain.ids { - self.snapshot_type(id); - self.local.types.insert(id.clone(), TypeOverlay::Dropped); - } - MutationResult::Applied - } - Mutation::DropType(drop_type) => { - for id in &drop_type.ids { - self.snapshot_type(id); - self.local.types.insert(id.clone(), TypeOverlay::Dropped); - } - MutationResult::Applied - } - Mutation::CreateSequence(create_seq) => { - if create_seq.if_not_exists && self.relation_namespace_is_taken(&create_seq.id) { - return MutationResult::Skipped; - } - if self.relation_namespace_is_taken(&create_seq.id) { - return MutationResult::Conflict { - reason: format!("relation '{}' already exists", create_seq.id), - }; - } - if let Some((table_id, column)) = &create_seq.owned_by { - if table_id.schema != create_seq.id.schema { - return MutationResult::Conflict { - reason: "sequence must be in the same schema as its owning table" - .to_string(), - }; - } - match self.local.relations.get(table_id) { - Some(RelationOverlay::Present(table)) => { - if !table.has_column(column) { - return MutationResult::Conflict { - reason: format!( - "column '{}.{}' does not exist", - table_id, column - ), - }; - } - if self.local.current_role_known - && table.owner.name != self.local.current_role - { - return MutationResult::Conflict { - reason: "sequence and table must have the same owner" - .to_string(), - }; - } - } - _ if self.baseline_covers_object(table_id) && self.baseline_available => { - return MutationResult::Conflict { - reason: format!("relation '{}' does not exist", table_id), - }; - } - _ => { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - } - } - self.snapshot_sequence(&create_seq.id); - self.snapshot_generation_counter(); - self.local.generation_counter += 1; - let generation = self.local.generation_counter; - - self.local.sequences.insert( - create_seq.id.clone(), - SequenceOverlay::Present(SequenceState { - id: create_seq.id.clone(), - owner: ObjectId::new("", self.local.current_role.clone()), - owned_by: create_seq.owned_by.clone(), - kind: if create_seq.owned_by.is_some() { - SequenceKind::Owned - } else { - SequenceKind::Standalone - }, - generation, - }), - ); - - if let Some((table_id, col)) = &create_seq.owned_by { - self.snapshot_graph(); - self.local.graph.edges.push(DependencyEdge::new( - create_seq.id.clone(), - table_id.clone(), - DependencyKind::SequenceOwnedBy { - column: col.clone(), - }, - )); - } - MutationResult::Applied - } - Mutation::AlterSequence(alter_seq) => { - if !self.sequence_is_present(&alter_seq.id) { - if alter_seq.if_exists { - return MutationResult::Skipped; - } - if self.baseline_covers_object(&alter_seq.id) && self.baseline_available { - return MutationResult::Conflict { - reason: format!("sequence '{}' does not exist", alter_seq.id), - }; - } - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - } - let current = match self.local.sequences.get(&alter_seq.id) { - Some(SequenceOverlay::Present(sequence)) => sequence.clone(), - _ => unreachable!("presence checked above"), - }; - match &alter_seq.action { - crate::analysis::mutations::AlterSequenceActionMutation::OwnedBy(owned_by) => { - if current.kind == SequenceKind::Identity { - return MutationResult::Conflict { - reason: "cannot change ownership of an identity sequence" - .to_string(), - }; - } - if let Some((table_id, column)) = owned_by { - if table_id.schema != alter_seq.id.schema { - return MutationResult::Conflict { - reason: - "sequence must be in the same schema as its owning table" - .to_string(), - }; - } - let Some(RelationOverlay::Present(table)) = - self.local.relations.get(table_id) - else { - return MutationResult::Conflict { - reason: format!("relation '{}' does not exist", table_id), - }; - }; - if !table.has_column(column) { - return MutationResult::Conflict { - reason: format!( - "column '{}.{}' does not exist", - table_id, column - ), - }; - } - if table.owner != current.owner { - return MutationResult::Conflict { - reason: "sequence and table must have the same owner" - .to_string(), - }; - } - } - self.snapshot_sequence(&alter_seq.id); - self.snapshot_graph(); - self.local.graph.edges.retain(|edge| { - !(matches!(edge.kind, DependencyKind::SequenceOwnedBy { .. }) - && edge.dependent == alter_seq.id) - }); - if let Some(SequenceOverlay::Present(sequence)) = - self.local.sequences.get_mut(&alter_seq.id) - { - sequence.owned_by = owned_by.clone(); - sequence.kind = if owned_by.is_some() { - SequenceKind::Owned - } else { - SequenceKind::Standalone - }; - } - if let Some((table_id, column)) = owned_by { - self.local.graph.edges.push(DependencyEdge::new( - alter_seq.id.clone(), - table_id.clone(), - DependencyKind::SequenceOwnedBy { - column: column.clone(), - }, - )); - } - MutationResult::Applied - } - crate::analysis::mutations::AlterSequenceActionMutation::OwnerTo(owner) => { - if current.kind == SequenceKind::Identity { - return MutationResult::Conflict { - reason: "cannot alter an identity sequence independently" - .to_string(), - }; - } - let Some((owner_name, known)) = self.role_fact_identity(owner) else { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - }; - if known - && self.local.roles_known - && self.present_role(&owner_name).is_none() - { - return MutationResult::Conflict { - reason: format!("role '{}' does not exist", owner_name), - }; - } - if let Some((table_id, _)) = ¤t.owned_by - && let Some(RelationOverlay::Present(table)) = - self.local.relations.get(table_id) - && table.owner.name != owner_name - { - return MutationResult::Conflict { - reason: "sequence and table must have the same owner".to_string(), - }; - } - self.snapshot_sequence(&alter_seq.id); - if let Some(SequenceOverlay::Present(sequence)) = - self.local.sequences.get_mut(&alter_seq.id) - { - sequence.owner = ObjectId::new("", owner_name); - } - MutationResult::Applied - } - crate::analysis::mutations::AlterSequenceActionMutation::RenameTo(new_id) - | crate::analysis::mutations::AlterSequenceActionMutation::SetSchema(new_id) => { - if current.kind == SequenceKind::Identity { - return MutationResult::Conflict { - reason: "cannot alter an identity sequence independently" - .to_string(), - }; - } - if self.relation_namespace_is_taken(new_id) { - return MutationResult::Conflict { - reason: format!("relation '{}' already exists", new_id), - }; - } - if let Some((table_id, _)) = ¤t.owned_by - && table_id.schema != new_id.schema - { - return MutationResult::Conflict { - reason: "sequence must be in the same schema as its owning table" - .to_string(), - }; - } - self.snapshot_namespace(); - let mut moved = current; - moved.id = new_id.clone(); - self.local.sequences.remove(&alter_seq.id); - self.local - .sequences - .insert(new_id.clone(), SequenceOverlay::Present(moved)); - self.local.graph.propagate_rename(&alter_seq.id, new_id); - self.local.graph.edges.push(DependencyEdge::new( - alter_seq.id.clone(), - new_id.clone(), - DependencyKind::RenameTo, - )); - if self.baseline_sequences.remove(&alter_seq.id) { - self.baseline_sequences.insert(new_id.clone()); - } - MutationResult::Applied - } - crate::analysis::mutations::AlterSequenceActionMutation::Other => { - MutationResult::Applied - } - } - } - Mutation::DropSequence(drop_seq) => { - if !drop_seq.if_exists { - let missing: Vec = drop_seq - .ids - .iter() - .filter(|id| !self.sequence_is_present(id)) - .cloned() - .collect(); - for id in &missing { - if self.baseline_covers_object(id) && self.baseline_available { - return MutationResult::Conflict { - reason: format!("sequence '{}' does not exist", id), - }; - } - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - } - let present: Vec = drop_seq - .ids - .iter() - .filter(|id| self.sequence_is_present(id)) - .cloned() - .collect(); - if present.is_empty() { - return MutationResult::Skipped; - } - for id in &present { - let Some(SequenceOverlay::Present(sequence)) = self.local.sequences.get(id) - else { - continue; - }; - if sequence.kind == SequenceKind::Identity { - return MutationResult::Conflict { - reason: format!("cannot drop identity sequence '{}' independently", id), - }; - } - if sequence.kind == SequenceKind::SerialLike && !drop_seq.cascade { - return MutationResult::Conflict { - reason: format!("sequence '{}' still has dependent defaults", id), - }; - } - } - if drop_seq.cascade { - let serial_owners: Vec<(ObjectId, String)> = present - .iter() - .filter_map(|id| match self.local.sequences.get(id) { - Some(SequenceOverlay::Present(sequence)) - if sequence.kind == SequenceKind::SerialLike => - { - sequence.owned_by.clone() - } - _ => None, - }) - .collect(); - for (table_id, column) in serial_owners { - self.snapshot_relation(&table_id); - if let Some(RelationOverlay::Present(table)) = - self.local.relations.get_mut(&table_id) - && let Some(column) = - table.columns.iter_mut().find(|item| item.name == column) - { - column.default = None; - column.default_expr_text = None; - } - } - } - for id in &present { - self.snapshot_sequence(id); - self.local - .sequences - .insert(id.clone(), SequenceOverlay::Dropped); - } - self.snapshot_graph_full(); - self.local.graph.edges.retain(|e| { - !(matches!(e.kind, DependencyKind::SequenceOwnedBy { .. }) - && present.contains(&e.dependent)) - }); - MutationResult::Applied - } - Mutation::Rename(rename) => { - let renames_relation = self.relation_is_present(&rename.old_id); - let renames_index = self.index_is_present(&rename.old_id); - if !renames_relation && !renames_index { - if self.baseline_covers_object(&rename.old_id) { - return MutationResult::Conflict { - reason: format!("relation '{}' does not exist", rename.old_id), - }; - } - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - } - 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.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - } - - 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) => - { - 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(); - } - } - let triggers_to_move: Vec<(ObjectId, crate::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_rename(&old_trigger_id, &new_trigger_id); - self.local.graph.edges.push(DependencyEdge::new( - old_trigger_id, - new_trigger_id, - DependencyKind::RenameTo, - )); - } - 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); - } - 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.edges.push(DependencyEdge::new( - rename.old_id.clone(), - rename.new_id.clone(), - DependencyKind::RenameTo, - )); - self.local - .graph - .propagate_rename(&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()); - } - 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 { - (table, name) - } - }) - .collect(); - } - if renames_index && self.baseline_indexes.remove(&rename.old_id) { - self.baseline_indexes.insert(rename.new_id.clone()); - } - - MutationResult::Applied - } - Mutation::DropView(drop_view) => { - let mut present = Vec::new(); - for id in &drop_view.ids { - match self.local.relations.get(id) { - Some(RelationOverlay::Present(relation)) - if relation.kind == RelationKind::View => - { - present.push(id.clone()); - } - Some(RelationOverlay::Present(_)) => { - return MutationResult::Conflict { - reason: format!("'{}' is not a view", id), - }; - } - _ if drop_view.if_exists => {} - _ if self.baseline_available && self.baseline_covers_object(id) => { - return MutationResult::Conflict { - reason: format!("view '{}' does not exist", id), - }; - } - _ => { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - continue; - } - } - } - if present.is_empty() { - return MutationResult::Skipped; - } - for id in &present { - self.snapshot_relation(id); - self.local - .relations - .insert(id.clone(), RelationOverlay::Dropped); - } - self.snapshot_graph_full(); - self.local.graph.edges.retain(|e| { - !(matches!(e.kind, DependencyKind::ViewDependency { .. }) - && present.contains(&e.dependent)) - }); - MutationResult::Applied - } - Mutation::DropMaterializedView(drop_mv) => { - let mut present = Vec::new(); - for id in &drop_mv.ids { - match self.local.relations.get(id) { - Some(RelationOverlay::Present(relation)) - if relation.kind == RelationKind::MaterializedView => - { - present.push(id.clone()); - } - Some(RelationOverlay::Present(_)) => { - return MutationResult::Conflict { - reason: format!("'{}' is not a materialized view", id), - }; - } - _ if drop_mv.if_exists => {} - _ if self.baseline_available && self.baseline_covers_object(id) => { - return MutationResult::Conflict { - reason: format!("materialized view '{}' does not exist", id), - }; - } - _ => { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - continue; - } - } - } - if present.is_empty() { - return MutationResult::Skipped; - } - for id in &present { - self.snapshot_relation(id); - self.local - .relations - .insert(id.clone(), RelationOverlay::Dropped); - } - self.snapshot_graph_full(); - self.local.graph.edges.retain(|e| { - !((matches!(e.kind, DependencyKind::ViewDependency { .. }) - && present.contains(&e.dependent)) - || (matches!(e.kind, DependencyKind::IndexOnRelation { .. }) - && present.contains(&e.referenced))) - }); - MutationResult::Applied - } - Mutation::DropIndex(drop_idx) => { - let present = self.local.graph.edges.iter().any(|edge| { - matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) - && edge.dependent == drop_idx.id - }); - if !present { - if drop_idx.if_exists { - return MutationResult::Skipped; - } - if self.baseline_available && self.baseline_covers_object(&drop_idx.id) { - return MutationResult::Conflict { - reason: format!("index '{}' does not exist", drop_idx.id), - }; - } - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - } - self.snapshot_graph(); - self.local.graph.edges.retain(|e| { - !(matches!(e.kind, DependencyKind::IndexOnRelation { .. }) - && e.dependent == drop_idx.id) - }); - MutationResult::Applied - } - Mutation::ChangeRelationOwner { id, new_owner } => { - let Some((owner, known)) = self.role_fact_identity(new_owner) else { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - }; - if !known { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - self.snapshot_relation(id); - if let Some(RelationOverlay::Present(relation)) = self.local.relations.get_mut(id) { - relation.owner = ObjectId::new("", owner); - MutationResult::Applied - } else { - MutationResult::Conflict { - reason: format!("relation '{}' does not exist", id), - } - } - } - Mutation::SearchPath(sp) => { - if sp.local && self.local.transactions.is_empty() { - // PostgreSQL warns and leaves SET LOCAL unchanged outside - // an explicit transaction block. - return MutationResult::Skipped; - } - self.snapshot_search_path(); - self.snapshot_confidence(); - let template = match &sp.target { - SearchPathTarget::Default => self.local.default_search_path_template.clone(), - SearchPathTarget::Schemas(schemas) => schemas.clone(), - }; - self.local.search_path_template = template.clone(); - if !sp.local { - self.local.session_search_path_template = template; - } - self.refresh_role_sensitive_search_path(); - MutationResult::Applied - } - Mutation::TimeoutSetting(change) => { - if change.local && self.local.transactions.is_empty() { - return MutationResult::Skipped; - } - let next = match &change.value { - TimeoutSettingValue::Default => match change.setting { - TimeoutSetting::Lock => self.local.lock_timeout.default, - TimeoutSetting::Statement => self.local.statement_timeout.default, - }, - TimeoutSettingValue::Milliseconds(milliseconds) => Some(*milliseconds), - TimeoutSettingValue::Current => match change.setting { - TimeoutSetting::Lock => self.local.lock_timeout.effective, - TimeoutSetting::Statement => self.local.statement_timeout.effective, - }, - TimeoutSettingValue::Invalid(reason) => { - return MutationResult::Conflict { - reason: reason.clone(), - }; - } - }; - self.snapshot_timeout_settings(); - let setting = match change.setting { - TimeoutSetting::Lock => &mut self.local.lock_timeout, - TimeoutSetting::Statement => &mut self.local.statement_timeout, - }; - setting.effective = next; - if !change.local { - setting.session = next; - } - MutationResult::Applied - } - Mutation::ResetSettings(target) => { - if matches!( - target, - ResetSettingTarget::All | ResetSettingTarget::SearchPath - ) { - self.snapshot_search_path(); - self.snapshot_confidence(); - let template = self.local.default_search_path_template.clone(); - self.local.session_search_path_template = template.clone(); - self.local.search_path_template = template; - self.refresh_role_sensitive_search_path(); - } - if matches!( - target, - ResetSettingTarget::All - | ResetSettingTarget::LockTimeout - | ResetSettingTarget::StatementTimeout - ) { - self.snapshot_timeout_settings(); - if matches!( - target, - ResetSettingTarget::All | ResetSettingTarget::LockTimeout - ) { - self.local.lock_timeout.session = self.local.lock_timeout.default; - self.local.lock_timeout.effective = self.local.lock_timeout.default; - } - if matches!( - target, - ResetSettingTarget::All | ResetSettingTarget::StatementTimeout - ) { - self.local.statement_timeout.session = self.local.statement_timeout.default; - self.local.statement_timeout.effective = - self.local.statement_timeout.default; - } - } - MutationResult::Applied - } - Mutation::CheckTimeouts => MutationResult::Applied, - Mutation::SwitchRole { - role, - local, - is_session_auth, - } => { - if *local && self.local.transactions.is_empty() { - // PostgreSQL warns and leaves the setting unchanged. - return MutationResult::Skipped; - } - - let target = if let Some(role) = role { - let Some(identity) = self.role_fact_identity(role) else { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - }; - Some(identity) - } else if *is_session_auth { - Some(( - self.local.authenticated_role.clone(), - self.local.authenticated_role_known, - )) - } else { - Some(( - self.local.session_role.clone(), - self.local.session_role_known, - )) - }; - let (target_name, target_known) = target.expect("role reset always has a target"); - let persistent_role_reset_target = if role.is_none() && !*is_session_auth { - Some(( - self.local.persistent_session_role.clone(), - self.local.persistent_session_role_known, - )) - } else { - None - }; - - let authorized = if role.is_none() { - Some(true) - } else if *is_session_auth { - self.can_set_session_authorization_to(&target_name) - } else { - self.can_set_role_to(&target_name) - }; - match authorized { - Some(false) => { - return MutationResult::Conflict { - reason: if self.present_role(&target_name).is_none() { - format!("role '{}' does not exist", target_name) - } else { - format!("permission denied to set role '{}'", target_name) - }, - }; - } - None => { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - Some(true) => {} - } - - self.snapshot_role_context(); - self.snapshot_search_path(); - self.snapshot_confidence(); - if *is_session_auth { - self.local.session_role = target_name.clone(); - self.local.session_role_known = target_known; - self.local.current_role = target_name.clone(); - self.local.current_role_known = target_known; - if !local { - self.local.persistent_session_role = target_name.clone(); - self.local.persistent_session_role_known = target_known; - self.local.persistent_current_role = target_name; - self.local.persistent_current_role_known = target_known; - } - } else { - self.local.current_role = target_name.clone(); - self.local.current_role_known = target_known; - if !local { - let (persistent_name, persistent_known) = - persistent_role_reset_target.unwrap_or((target_name, target_known)); - self.local.persistent_current_role = persistent_name; - self.local.persistent_current_role_known = persistent_known; - } - } - self.refresh_role_sensitive_search_path(); - MutationResult::Applied - } - Mutation::BeginTransaction => { - if self.local.transactions.is_empty() { - self.local.transactions.push(TransactionFrame::root()); - MutationResult::Applied - } else { - // PostgreSQL emits a warning and leaves the current - // transaction active for a nested BEGIN. - MutationResult::Skipped - } - } - Mutation::CommitTransaction => { - if self.local.transaction_aborted { - while let Some(frame) = self.local.transactions.pop() { - self.rollback_frame(frame); - } - } else { - while self.local.transactions.pop().is_some() {} - self.restore_persistent_role_context(); - } - self.local.transaction_aborted = false; - MutationResult::Applied - } - Mutation::CommitAndChain => { - if self.local.transactions.is_empty() { - self.local.confidence = Confidence::Tainted; - return MutationResult::Conflict { - reason: "COMMIT AND CHAIN can only be used in transaction blocks" - .to_string(), - }; - } - if self.local.transaction_aborted { - while let Some(frame) = self.local.transactions.pop() { - self.rollback_frame(frame); - } - } else { - while self.local.transactions.pop().is_some() {} - self.restore_persistent_role_context(); - } - self.local.transaction_aborted = false; - self.local.transactions.push(TransactionFrame::root()); - MutationResult::Applied - } - Mutation::RollbackTransaction => { - while let Some(frame) = self.local.transactions.pop() { - self.rollback_frame(frame); - } - self.local.transaction_aborted = false; - MutationResult::Applied - } - Mutation::RollbackAndChain => { - if self.local.transactions.is_empty() { - self.local.confidence = Confidence::Tainted; - return MutationResult::Conflict { - reason: "ROLLBACK AND CHAIN can only be used in transaction blocks" - .to_string(), - }; - } - while let Some(frame) = self.local.transactions.pop() { - self.rollback_frame(frame); - } - self.local.transaction_aborted = false; - self.local.transactions.push(TransactionFrame::root()); - MutationResult::Applied - } - Mutation::RollbackToSavepoint(rts) => { - let Some(position) = self - .local - .transactions - .iter() - .rposition(|frame| frame.is_named_savepoint(&rts.name)) - else { - self.local.confidence = Confidence::Tainted; - if !self.local.transactions.is_empty() { - self.local.transaction_aborted = true; - } - return MutationResult::Conflict { - reason: format!("savepoint '{}' does not exist", rts.name), - }; - }; - let rolled_back = self.local.transactions.split_off(position + 1); - // Frames are popped newest-first. Restore them in that same - // order before restoring changes made after the target - // savepoint itself; undo logs are chronological. - for frame in rolled_back.into_iter().rev() { - self.rollback_frame(frame); - } - let undo_log = std::mem::take(&mut self.local.transactions[position].undo_log); - self.rollback_undo_log(undo_log); - self.local.transaction_aborted = false; - MutationResult::Applied - } - Mutation::Savepoint(sp) => { - if self.local.transactions.is_empty() { - self.local.confidence = Confidence::Tainted; - return MutationResult::Conflict { - reason: "SAVEPOINT can only be used in transaction blocks".to_string(), - }; - } - self.local - .transactions - .push(TransactionFrame::savepoint(sp.name.clone())); - MutationResult::Applied - } - Mutation::ReleaseSavepoint(rsp) => { - let Some(position) = self - .local - .transactions - .iter() - .rposition(|frame| frame.is_named_savepoint(&rsp.name)) - else { - self.local.confidence = Confidence::Tainted; - if !self.local.transactions.is_empty() { - self.local.transaction_aborted = true; - } - return MutationResult::Conflict { - reason: format!("savepoint '{}' does not exist", rsp.name), - }; - }; - if position == 0 { - self.local.confidence = Confidence::Tainted; - return MutationResult::Conflict { - reason: format!("savepoint '{}' is not inside a transaction", rsp.name), - }; - } - - let released = self.local.transactions.split_off(position); - let outer = self - .local - .transactions - .last_mut() - .expect("a released savepoint always has an outer transaction frame"); - for frame in released { - outer.undo_log.extend(frame.undo_log); - } - MutationResult::Applied - } - Mutation::Opaque(_) => { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - MutationResult::Applied - } - Mutation::CreateFunction(f) => { - let routine_kind = - if f.options.iter().any(|option| { - matches!(option, crate::analysis::facts::FuncOptionFact::Window) - }) { - crate::model::function::RoutineKind::Window - } else { - crate::model::function::RoutineKind::Function - }; - match self.local.functions.get(&f.id) { - Some(crate::model::function::FunctionOverlay::Present(existing)) - if existing.routine_kind != routine_kind || !f.or_replace => - { - return MutationResult::Conflict { - reason: format!("routine '{}' already exists", f.id), - }; - } - None if !self.baseline_available || !self.baseline_covers_object(&f.id) => { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - _ => {} - } - self.snapshot_function(&f.id); - self.snapshot_generation_counter(); - self.local.generation_counter += 1; - let _generation = self.local.generation_counter; - - let volatility = f - .options - .iter() - .find_map(|opt| { - if let crate::analysis::facts::FuncOptionFact::Volatility(v) = opt { - Some(match v { - crate::analysis::facts::VolatilityKind::Volatile => { - crate::model::function::Volatility::Volatile - } - crate::analysis::facts::VolatilityKind::Stable => { - crate::model::function::Volatility::Stable - } - crate::analysis::facts::VolatilityKind::Immutable => { - crate::model::function::Volatility::Immutable - } - }) - } else { - None - } - }) - .unwrap_or(crate::model::function::Volatility::Volatile); - - let security = f - .options - .iter() - .find_map(|opt| { - if let crate::analysis::facts::FuncOptionFact::Security(s) = opt { - Some(match s { - crate::analysis::facts::SecurityKind::Invoker => { - crate::model::function::SecurityMode::Invoker - } - crate::analysis::facts::SecurityKind::Definer => { - crate::model::function::SecurityMode::Definer - } - }) - } else { - None - } - }) - .unwrap_or(crate::model::function::SecurityMode::Invoker); - - let language = f - .options - .iter() - .find_map(|opt| { - if let crate::analysis::facts::FuncOptionFact::Language(l) = opt { - Some(l.clone()) - } else { - None - } - }) - .unwrap_or_else(|| "sql".to_string()); - - self.local.functions.insert( - f.id.clone(), - crate::model::function::FunctionOverlay::Present( - crate::model::function::FunctionState { - id: f.id.clone(), - routine_kind, - arg_types: f - .params - .iter() - .filter(|p| { - !matches!(&p.mode, crate::analysis::facts::ParamModeFact::Out) - }) - .map(|p| p.ty.clone()) - .collect(), - arg_type_ids: f - .params - .iter() - .filter(|p| { - !matches!(&p.mode, crate::analysis::facts::ParamModeFact::Out) - }) - .map(|parameter| self.resolve_type_reference(¶meter.ty)) - .collect(), - return_type: f - .return_type - .as_ref() - .map(|rt| match rt { - crate::analysis::facts::RetTypeFact::Scalar(ty) => ty.clone(), - crate::analysis::facts::RetTypeFact::Table(columns) => columns - .iter() - .map(|column| { - format!( - "{} {}", - column.name, - column.ty.as_deref().unwrap_or("unknown") - ) - }) - .collect::>() - .join(", "), - }) - .unwrap_or_default(), - return_type_id: f.return_type.as_ref().and_then(|return_type| { - match return_type { - crate::analysis::facts::RetTypeFact::Scalar(ty) => { - self.resolve_type_reference(ty) - } - crate::analysis::facts::RetTypeFact::Table(_) => None, - } - }), - volatility, - language, - security, - }, - ), - ); - MutationResult::Applied - } - Mutation::AlterFunction(f) => { - use crate::analysis::facts::{AlterFunctionAction, FuncOptionFact}; - use crate::model::function::{ - FunctionOverlay, RoutineKind, SecurityMode, Volatility, - }; - - match self.local.functions.get(&f.id) { - Some(FunctionOverlay::Present(function)) - if matches!( - function.routine_kind, - RoutineKind::Function | RoutineKind::Window - ) => {} - Some(FunctionOverlay::Present(_)) => { - return MutationResult::Conflict { - reason: format!("'{}' is not a function", f.id), - }; - } - Some(FunctionOverlay::Dropped) => { - return MutationResult::Conflict { - reason: format!("function '{}' does not exist", f.id), - }; - } - _ if self.baseline_available && self.baseline_covers_object(&f.id) => { - return MutationResult::Conflict { - reason: format!("function '{}' does not exist", f.id), - }; - } - _ => { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - } - } - - match &f.action { - AlterFunctionAction::OptionsChange(options) => { - self.snapshot_function(&f.id); - if let Some(FunctionOverlay::Present(function)) = - self.local.functions.get_mut(&f.id) - { - for option in options { - match option { - FuncOptionFact::Volatility(volatility) => { - function.volatility = match volatility { - crate::analysis::facts::VolatilityKind::Volatile => { - Volatility::Volatile - } - crate::analysis::facts::VolatilityKind::Stable => { - Volatility::Stable - } - crate::analysis::facts::VolatilityKind::Immutable => { - Volatility::Immutable - } - }; - } - FuncOptionFact::Security(security) => { - function.security = match security { - crate::analysis::facts::SecurityKind::Invoker => { - SecurityMode::Invoker - } - crate::analysis::facts::SecurityKind::Definer => { - SecurityMode::Definer - } - }; - } - FuncOptionFact::Language(language) => { - function.language = language.clone(); - } - _ => {} - } - } - } - } - AlterFunctionAction::Rename { to, .. } => { - let signature = - f.id.name - .find('(') - .map(|index| &f.id.name[index..]) - .unwrap_or(""); - let new_id = ObjectId::new(f.id.schema.clone(), format!("{to}{signature}")); - self.move_function(&f.id, &new_id); - } - AlterFunctionAction::SchemaChange { new_schema } => { - let new_id = ObjectId::new(new_schema.clone(), f.id.name.clone()); - self.move_function(&f.id, &new_id); - } - AlterFunctionAction::OwnerChange(_) - | AlterFunctionAction::DependsOnExtension { .. } - | AlterFunctionAction::NoDependsOnExtension { .. } => { - self.snapshot_function(&f.id); - } - } - MutationResult::Applied - } - Mutation::DropFunction(f) => { - let mut any_applied = false; - for sig in &f.signatures { - let sig_str = format!("{}({})", sig.name.name.resolve(), sig.params.join(",")); - let schema = self.resolve_function_schema(&sig.name, &sig_str); - let id = ObjectId::new(schema, sig_str); - let is_function = matches!( - self.local.functions.get(&id), - Some(crate::model::function::FunctionOverlay::Present(function)) - if matches!( - function.routine_kind, - crate::model::function::RoutineKind::Function - | crate::model::function::RoutineKind::Window - ) - ); - if !is_function { - let routine_exists = self.local.functions.contains_key(&id); - let absence_is_exact = - self.baseline_available && self.baseline_covers_object(&id); - if routine_exists || (!f.if_exists && absence_is_exact) { - return MutationResult::Conflict { - reason: format!("function '{}' does not exist", id), - }; - } else if !f.if_exists && !absence_is_exact { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - } else { - let dependent_triggers: Vec<(ObjectId, ObjectId)> = self - .local - .graph - .edges - .iter() - .filter_map(|edge| { - let DependencyKind::TriggerOnTable { function_id, .. } = &edge.kind - else { - return None; - }; - (function_id == &id) - .then(|| (edge.dependent.clone(), edge.referenced.clone())) - }) - .collect(); - if !dependent_triggers.is_empty() && !f.cascade { - return MutationResult::Conflict { - reason: format!( - "function '{}' still has dependent triggers; use CASCADE", - id - ), - }; - } - - any_applied = true; - self.snapshot_function(&id); - self.local - .functions - .insert(id.clone(), crate::model::function::FunctionOverlay::Dropped); - - if f.cascade { - for (trigger_id, table_id) in &dependent_triggers { - let trigger_name = - self.local.triggers.get(trigger_id).and_then(|overlay| { - match overlay { - TriggerOverlay::Present(trigger) => { - Some(trigger.name.clone()) - } - TriggerOverlay::Dropped => None, - } - }); - self.snapshot_trigger(trigger_id); - self.local - .triggers - .insert(trigger_id.clone(), TriggerOverlay::Dropped); - self.snapshot_relation(table_id); - if let Some(RelationOverlay::Present(relation)) = - self.local.relations.get_mut(table_id) - && let Some(trigger_name) = trigger_name - { - relation.triggers.remove(&trigger_name); - } - } - if !dependent_triggers.is_empty() { - self.snapshot_graph_full(); - self.local.graph.edges.retain(|edge| { - !dependent_triggers - .iter() - .any(|(trigger_id, _)| edge.dependent == *trigger_id) - }); - } - } - } - } - if any_applied { - MutationResult::Applied - } else { - MutationResult::Skipped - } - } - Mutation::CreateProcedure(p) => { - match self.local.functions.get(&p.id) { - Some(crate::model::function::FunctionOverlay::Present(existing)) - if existing.routine_kind - == crate::model::function::RoutineKind::Procedure - && p.or_replace => {} - Some(crate::model::function::FunctionOverlay::Present(_)) => { - return MutationResult::Conflict { - reason: format!("routine '{}' already exists", p.id), - }; - } - None if !self.baseline_available || !self.baseline_covers_object(&p.id) => { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - None => {} - Some(crate::model::function::FunctionOverlay::Dropped) => {} - } - self.snapshot_function(&p.id); - self.snapshot_generation_counter(); - self.local.generation_counter += 1; - let _generation = self.local.generation_counter; - - self.local.functions.insert( - p.id.clone(), - crate::model::function::FunctionOverlay::Present( - crate::model::function::FunctionState { - id: p.id.clone(), - routine_kind: crate::model::function::RoutineKind::Procedure, - arg_types: p - .params - .iter() - .filter(|p| { - !matches!(&p.mode, crate::analysis::facts::ParamModeFact::Out) - }) - .map(|p| p.ty.clone()) - .collect(), - arg_type_ids: p - .params - .iter() - .filter(|p| { - !matches!(&p.mode, crate::analysis::facts::ParamModeFact::Out) - }) - .map(|parameter| self.resolve_type_reference(¶meter.ty)) - .collect(), - return_type: "void".to_string(), - return_type_id: None, - volatility: crate::model::function::Volatility::Volatile, - language: "sql".to_string(), - security: crate::model::function::SecurityMode::Invoker, - }, - ), - ); - MutationResult::Applied - } - Mutation::AlterProcedure(p) => { - use crate::analysis::facts::AlterFunctionAction; - use crate::model::function::{FunctionOverlay, RoutineKind}; - - match self.local.functions.get(&p.id) { - Some(FunctionOverlay::Present(function)) - if function.routine_kind == RoutineKind::Procedure => {} - Some(FunctionOverlay::Present(_)) => { - return MutationResult::Conflict { - reason: format!("'{}' is not a procedure", p.id), - }; - } - Some(FunctionOverlay::Dropped) => { - return MutationResult::Conflict { - reason: format!("procedure '{}' does not exist", p.id), - }; - } - None if self.baseline_available && self.baseline_covers_object(&p.id) => { - return MutationResult::Conflict { - reason: format!("procedure '{}' does not exist", p.id), - }; - } - None => { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - } - } - - match &p.action { - AlterFunctionAction::Rename { to, .. } => { - let signature = - p.id.name - .find('(') - .map(|index| &p.id.name[index..]) - .unwrap_or(""); - let new_id = ObjectId::new(p.id.schema.clone(), format!("{to}{signature}")); - self.move_function(&p.id, &new_id); - } - AlterFunctionAction::SchemaChange { new_schema } => { - let new_id = ObjectId::new(new_schema.clone(), p.id.name.clone()); - self.move_function(&p.id, &new_id); - } - _ => self.snapshot_function(&p.id), - } - MutationResult::Applied - } - Mutation::DropProcedure(p) => { - let mut any_applied = false; - for sig in &p.signatures { - let sig_str = format!("{}({})", sig.name.name.resolve(), sig.params.join(",")); - let schema = self.resolve_function_schema(&sig.name, &sig_str); - let id = ObjectId::new(schema, sig_str); - let is_procedure = matches!( - self.local.functions.get(&id), - Some(crate::model::function::FunctionOverlay::Present(function)) - if function.routine_kind - == crate::model::function::RoutineKind::Procedure - ); - if is_procedure { - any_applied = true; - self.snapshot_function(&id); - self.local - .functions - .insert(id, crate::model::function::FunctionOverlay::Dropped); - } else if self.local.functions.contains_key(&id) - || !p.if_exists - && self.baseline_available - && self.baseline_covers_object(&id) - { - return MutationResult::Conflict { - reason: format!("procedure '{}' does not exist", id), - }; - } else if !p.if_exists { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - } - } - if any_applied { - MutationResult::Applied - } else { - MutationResult::Skipped - } - } - Mutation::CreateAggregate(a) => { - match self.local.functions.get(&a.id) { - Some(crate::model::function::FunctionOverlay::Present(existing)) - if existing.routine_kind - == crate::model::function::RoutineKind::Aggregate - && a.or_replace => {} - Some(crate::model::function::FunctionOverlay::Present(_)) => { - return MutationResult::Conflict { - reason: format!("routine '{}' already exists", a.id), - }; - } - None if !self.baseline_available || !self.baseline_covers_object(&a.id) => { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - None | Some(crate::model::function::FunctionOverlay::Dropped) => {} - } - - self.snapshot_function(&a.id); - self.local.functions.insert( - a.id.clone(), - crate::model::function::FunctionOverlay::Present( - crate::model::function::FunctionState { - id: a.id.clone(), - routine_kind: crate::model::function::RoutineKind::Aggregate, - arg_types: a - .params - .iter() - .filter(|parameter| { - !matches!( - parameter.mode, - crate::analysis::facts::ParamModeFact::Out - ) - }) - .map(|parameter| parameter.ty.clone()) - .collect(), - arg_type_ids: a - .params - .iter() - .filter(|parameter| { - !matches!( - parameter.mode, - crate::analysis::facts::ParamModeFact::Out - ) - }) - .map(|parameter| self.resolve_type_reference(¶meter.ty)) - .collect(), - return_type: String::new(), - return_type_id: None, - volatility: crate::model::function::Volatility::Volatile, - language: "internal".to_string(), - security: crate::model::function::SecurityMode::Invoker, - }, - ), - ); - MutationResult::Applied - } - Mutation::AlterAggregate(a) => { - use crate::analysis::facts::AlterFunctionAction; - use crate::model::function::{FunctionOverlay, RoutineKind}; - - match self.local.functions.get(&a.id) { - Some(FunctionOverlay::Present(aggregate)) - if aggregate.routine_kind == RoutineKind::Aggregate => {} - Some(FunctionOverlay::Present(_)) => { - return MutationResult::Conflict { - reason: format!("'{}' is not an aggregate", a.id), - }; - } - Some(FunctionOverlay::Dropped) => { - return MutationResult::Conflict { - reason: format!("aggregate '{}' does not exist", a.id), - }; - } - None if self.baseline_available && self.baseline_covers_object(&a.id) => { - return MutationResult::Conflict { - reason: format!("aggregate '{}' does not exist", a.id), - }; - } - None => { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - } - } - - match &a.action { - AlterFunctionAction::Rename { to, .. } => { - let signature = - a.id.name - .find('(') - .map(|index| &a.id.name[index..]) - .unwrap_or(""); - let new_id = ObjectId::new(a.id.schema.clone(), format!("{to}{signature}")); - self.move_function(&a.id, &new_id); - } - AlterFunctionAction::SchemaChange { new_schema } => { - let new_id = ObjectId::new(new_schema.clone(), a.id.name.clone()); - self.move_function(&a.id, &new_id); - } - AlterFunctionAction::OwnerChange(_) => self.snapshot_function(&a.id), - _ => unreachable!("aggregate extraction only emits rename, owner, or schema"), - } - MutationResult::Applied - } - Mutation::DropAggregate(a) => { - let mut any_applied = false; - for signature in &a.signatures { - let signature_name = format!( - "{}({})", - signature.name.name.resolve(), - signature.params.join(",") - ); - let schema = self.resolve_function_schema(&signature.name, &signature_name); - let id = ObjectId::new(schema, signature_name); - let is_aggregate = matches!( - self.local.functions.get(&id), - Some(crate::model::function::FunctionOverlay::Present(routine)) - if routine.routine_kind - == crate::model::function::RoutineKind::Aggregate - ); - if is_aggregate { - any_applied = true; - self.snapshot_function(&id); - self.local - .functions - .insert(id, crate::model::function::FunctionOverlay::Dropped); - } else if self.local.functions.contains_key(&id) - || self.baseline_available && self.baseline_covers_object(&id) - { - if a.if_exists { - continue; - } - return MutationResult::Conflict { - reason: format!("aggregate '{}' does not exist", id), - }; - } else { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - if !a.if_exists { - return MutationResult::Skipped; - } - } - } - if a.cascade && any_applied { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - if any_applied { - MutationResult::Applied - } else { - MutationResult::Skipped - } - } - Mutation::CreatePublication(p) => { - match self.local.publications.get(&p.name) { - Some(crate::model::replication::PublicationOverlay::Present(_)) => { - return MutationResult::Conflict { - reason: format!("publication '{}' already exists", p.name), - }; - } - None => { - if !self.baseline_available { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - } - Some(crate::model::replication::PublicationOverlay::Dropped) => {} - } - if let Err(reason) = self.validate_publication_scope(&p.scope) { - return MutationResult::Conflict { reason }; - } - self.taint_inheritance_sensitive_publication_scope(&p.scope); - self.snapshot_publication(&p.name); - self.snapshot_generation_counter(); - self.local.generation_counter += 1; - let generation = self.local.generation_counter; - - let owner = self - .local - .current_role_known - .then(|| self.local.current_role.clone()); - self.local.publications.insert( - p.name.clone(), - crate::model::replication::PublicationOverlay::Present( - crate::model::replication::PublicationState { - name: p.name.clone(), - owner, - scope: p.scope.clone(), - params: p.params.clone(), - generation, - }, - ), - ); - - if let crate::analysis::facts::PublicationScope::Explicit(objects) = &p.scope { - self.snapshot_graph_full(); - for obj in objects { - if let crate::analysis::facts::PublicationObjectFact::Table { - name, .. - } = obj - { - let table_id = self.resolve_relation_id(name); - self.local.graph.edges.push(DependencyEdge::new( - table_id, - ObjectId::new("public", &p.name), - DependencyKind::PublicationIncludes { - publication_name: p.name.clone(), - }, - )); - } - } - } - MutationResult::Applied - } - Mutation::AlterPublication(p) => { - match self.local.publications.get(&p.name) { - Some(crate::model::replication::PublicationOverlay::Present(_)) => {} - Some(crate::model::replication::PublicationOverlay::Dropped) => { - return MutationResult::Conflict { - reason: format!("publication '{}' does not exist", p.name), - }; - } - None => { - if self.baseline_available { - return MutationResult::Conflict { - reason: format!("publication '{}' does not exist", p.name), - }; - } - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - } - } - self.snapshot_publication(&p.name); - self.snapshot_generation_counter(); - self.local.generation_counter += 1; - let new_gen = self.local.generation_counter; - use crate::analysis::facts::AlterPublicationActionFact; - - let current_scope = match self.local.publications.get(&p.name) { - Some(crate::model::replication::PublicationOverlay::Present(publication)) => { - publication.scope.clone() - } - _ => unreachable!("publication existence checked above"), - }; - let mut replacement_scope = None; - let mut rename_to = None; - match &p.action { - AlterPublicationActionFact::AddObjects(additions) => { - let additions_scope = - crate::analysis::facts::PublicationScope::Explicit(additions.clone()); - if let Err(reason) = self.validate_publication_scope(&additions_scope) { - return MutationResult::Conflict { reason }; - } - self.taint_inheritance_sensitive_publication_scope(&additions_scope); - let crate::analysis::facts::PublicationScope::Explicit(mut objects) = - current_scope - else { - return MutationResult::Conflict { - reason: format!( - "publication '{}' already includes all tables", - p.name - ), - }; - }; - let mut keys: HashSet = objects - .iter() - .map(|object| self.publication_object_key(object)) - .collect(); - for addition in additions { - let key = self.publication_object_key(addition); - if !keys.insert(key) { - return MutationResult::Conflict { - reason: format!( - "publication '{}' already contains the requested object", - p.name - ), - }; - } - objects.push(addition.clone()); - } - replacement_scope = - Some(crate::analysis::facts::PublicationScope::Explicit(objects)); - } - AlterPublicationActionFact::SetObjects(scope) => { - if let Err(reason) = self.validate_publication_scope(scope) { - return MutationResult::Conflict { reason }; - } - self.taint_inheritance_sensitive_publication_scope(scope); - replacement_scope = Some(scope.clone()); - } - AlterPublicationActionFact::DropObjects(removals) => { - self.taint_inheritance_sensitive_publication_scope( - &crate::analysis::facts::PublicationScope::Explicit(removals.clone()), - ); - let crate::analysis::facts::PublicationScope::Explicit(mut objects) = - current_scope - else { - return MutationResult::Conflict { - reason: format!("publication '{}' includes all tables", p.name), - }; - }; - for removal in removals { - let key = self.publication_object_key(removal); - let Some(position) = objects - .iter() - .position(|object| self.publication_object_key(object) == key) - else { - return MutationResult::Conflict { - reason: format!( - "publication '{}' does not contain the requested object", - p.name - ), - }; - }; - objects.remove(position); - } - replacement_scope = - Some(crate::analysis::facts::PublicationScope::Explicit(objects)); - } - AlterPublicationActionFact::SetOptions(options) => { - if let Some(crate::model::replication::PublicationOverlay::Present( - publication, - )) = self.local.publications.get_mut(&p.name) - { - for option in options { - publication - .params - .retain(|existing| existing.name != option.name); - publication.params.push(option.clone()); - } - } - } - AlterPublicationActionFact::OwnerChange(role) => { - if let Some((owner, known)) = self.role_fact_identity(role) { - if known { - if let Some( - crate::model::replication::PublicationOverlay::Present( - publication, - ), - ) = self.local.publications.get_mut(&p.name) - { - publication.owner = Some(owner); - } - } else { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - if let Some( - crate::model::replication::PublicationOverlay::Present( - publication, - ), - ) = self.local.publications.get_mut(&p.name) - { - publication.owner = None; - } - } - } else { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - if let Some(crate::model::replication::PublicationOverlay::Present( - publication, - )) = self.local.publications.get_mut(&p.name) - { - publication.owner = None; - } - } - } - AlterPublicationActionFact::Rename { to } => { - if matches!( - self.local.publications.get(to), - Some(crate::model::replication::PublicationOverlay::Present(_)) - ) { - return MutationResult::Conflict { - reason: format!("publication '{}' already exists", to), - }; - } - rename_to = Some(to.clone()); - } - } - - if let Some(scope) = replacement_scope { - if let Some(crate::model::replication::PublicationOverlay::Present( - publication, - )) = self.local.publications.get_mut(&p.name) - { - publication.scope = scope.clone(); - } - self.replace_publication_edges(&p.name, &scope); - } - if let Some(crate::model::replication::PublicationOverlay::Present(publication)) = - self.local.publications.get_mut(&p.name) - { - publication.generation = new_gen; - } - if let Some(to) = rename_to { - self.snapshot_publication(&to); - let Some(crate::model::replication::PublicationOverlay::Present( - mut publication, - )) = self.local.publications.remove(&p.name) - else { - unreachable!("publication existence checked above"); - }; - publication.name = to.clone(); - self.local.publications.insert( - to.clone(), - crate::model::replication::PublicationOverlay::Present(publication), - ); - self.snapshot_graph_full(); - for edge in &mut self.local.graph.edges { - if let DependencyKind::PublicationIncludes { publication_name } = - &mut edge.kind - && publication_name == &p.name - { - *publication_name = to.clone(); - edge.referenced = ObjectId::new("public", &to); - } - } - } - MutationResult::Applied - } - Mutation::DropPublication(p) => { - let mut present_names = Vec::new(); - for name in &p.names { - match self.local.publications.get(name) { - Some(crate::model::replication::PublicationOverlay::Present(_)) => { - present_names.push(name.clone()); - } - Some(crate::model::replication::PublicationOverlay::Dropped) => { - if !p.if_exists { - return MutationResult::Conflict { - reason: format!("publication '{}' does not exist", name), - }; - } - } - None if p.if_exists && self.baseline_available => {} - None if p.if_exists => { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - None => { - if self.baseline_available { - return MutationResult::Conflict { - reason: format!("publication '{}' does not exist", name), - }; - } - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - } - } - } - for name in &present_names { - self.snapshot_publication(name); - self.local.publications.insert( - name.clone(), - crate::model::replication::PublicationOverlay::Dropped, - ); - } - self.snapshot_graph_full(); - self.local.graph.edges.retain(|e| { - !(matches!(e.kind, DependencyKind::PublicationIncludes { .. }) - && present_names.contains(&e.referenced.name)) - }); - if present_names.is_empty() { - MutationResult::Skipped - } else { - MutationResult::Applied - } - } - Mutation::CreateSubscription(s) => { - let name = s.name.clone().unwrap_or_else(|| "unnamed_sub".into()); - match self.local.subscriptions.get(&name) { - Some(crate::model::replication::SubscriptionOverlay::Present(_)) => { - return MutationResult::Conflict { - reason: format!("subscription '{}' already exists", name), - }; - } - None => { - if !self.baseline_available { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - } - Some(crate::model::replication::SubscriptionOverlay::Dropped) => {} - } - - let params = s.params.as_deref(); - if let Err(reason) = Self::validate_subscription_boolean_options( - params, - &[ - "connect", - "create_slot", - "enabled", - "copy_data", - "binary", - "disable_on_error", - "password_required", - "run_as_owner", - "failover", - "two_phase", - ], - ) { - return MutationResult::Conflict { reason }; - } - let connects_to_publisher = - Self::subscription_boolean_option(params, "connect") != Some(false); - if !connects_to_publisher - && ["create_slot", "enabled", "copy_data"] - .iter() - .any(|name| Self::subscription_boolean_option(params, name) == Some(true)) - { - return MutationResult::Conflict { - reason: format!( - "subscription '{}' cannot enable connection-dependent options when connect is false", - name - ), - }; - } - let creates_slot = connects_to_publisher - && Self::subscription_boolean_option(params, "create_slot") != Some(false); - if !self.local.transactions.is_empty() && connects_to_publisher && creates_slot { - return MutationResult::Conflict { - reason: format!( - "subscription '{}' cannot create a replication slot inside a transaction", - name - ), - }; - } - self.snapshot_subscription(&name); - self.snapshot_generation_counter(); - self.local.generation_counter += 1; - let generation = self.local.generation_counter; - - if connects_to_publisher { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - - let enabled = connects_to_publisher - && Self::subscription_boolean_option(params, "enabled") != Some(false); - let slot_name = match Self::subscription_option(params, "slot_name") { - Some(value) if value.eq_ignore_ascii_case("none") => None, - Some(value) => Some(value.to_string()), - None => Some(name.clone()), - }; - if slot_name.is_none() && (enabled || creates_slot) { - return MutationResult::Conflict { - reason: format!( - "subscription '{}' with slot_name NONE must disable enabled and create_slot", - name - ), - }; - } - let mut unique_publications = HashSet::new(); - if !s - .publications - .iter() - .all(|publication| unique_publications.insert(publication)) - { - return MutationResult::Conflict { - reason: format!( - "subscription '{}' lists the same publication more than once", - name - ), - }; - } - - let owner = self - .local - .current_role_known - .then(|| self.local.current_role.clone()); - self.local.subscriptions.insert( - name.clone(), - crate::model::replication::SubscriptionOverlay::Present( - crate::model::replication::SubscriptionState { - name, - owner, - connection: s.connection.clone(), - publications: s.publications.clone(), - params: s.params.clone(), - enabled, - slot_name, - generation, - }, - ), - ); - MutationResult::Applied - } - Mutation::AlterSubscription(s) => { - match self.local.subscriptions.get(&s.name) { - Some(crate::model::replication::SubscriptionOverlay::Present(_)) => {} - Some(crate::model::replication::SubscriptionOverlay::Dropped) => { - return MutationResult::Conflict { - reason: format!("subscription '{}' does not exist", s.name), - }; - } - None => { - if self.baseline_available { - return MutationResult::Conflict { - reason: format!("subscription '{}' does not exist", s.name), - }; - } - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - } - } - let existing = match self.local.subscriptions.get(&s.name) { - Some(crate::model::replication::SubscriptionOverlay::Present(subscription)) => { - subscription - } - _ => unreachable!("subscription existence checked above"), - }; - let in_transaction = !self.local.transactions.is_empty(); - match &s.action { - crate::analysis::facts::AlterSubscriptionActionFact::Publications { - mode, - publications, - params, - } => { - if let Err(reason) = Self::validate_subscription_boolean_options( - Some(params), - &["refresh", "copy_data"], - ) { - return MutationResult::Conflict { reason }; - } - if in_transaction - && Self::subscription_boolean_option(Some(params), "refresh") - != Some(false) - { - return MutationResult::Conflict { - reason: format!( - "subscription '{}' cannot refresh publications inside a transaction", - s.name - ), - }; - } - - let mut unique = HashSet::new(); - match mode { - crate::analysis::facts::SubscriptionPublicationMode::Set => { - if !publications - .iter() - .all(|publication| unique.insert(publication)) - { - return MutationResult::Conflict { - reason: format!( - "subscription '{}' lists the same publication more than once", - s.name - ), - }; - } - } - crate::analysis::facts::SubscriptionPublicationMode::Add => { - for publication in publications { - if !unique.insert(publication) - || existing.publications.contains(publication) - { - return MutationResult::Conflict { - reason: format!( - "subscription '{}' already includes publication '{}'", - s.name, publication - ), - }; - } - } - } - crate::analysis::facts::SubscriptionPublicationMode::Drop => { - for publication in publications { - if !unique.insert(publication) - || !existing.publications.contains(publication) - { - return MutationResult::Conflict { - reason: format!( - "subscription '{}' does not include publication '{}'", - s.name, publication - ), - }; - } - } - } - } - } - crate::analysis::facts::AlterSubscriptionActionFact::RefreshPublication(_) - if in_transaction => - { - return MutationResult::Conflict { - reason: format!( - "subscription '{}' cannot refresh publications inside a transaction", - s.name - ), - }; - } - crate::analysis::facts::AlterSubscriptionActionFact::SetOptions(options) => { - if let Err(reason) = Self::validate_subscription_boolean_options( - Some(options), - &[ - "binary", - "disable_on_error", - "password_required", - "run_as_owner", - "failover", - "two_phase", - ], - ) { - return MutationResult::Conflict { reason }; - } - if existing.enabled - && options - .iter() - .any(|option| option.name.eq_ignore_ascii_case("slot_name")) - { - return MutationResult::Conflict { - reason: format!( - "subscription '{}' must be disabled before changing slot_name", - s.name - ), - }; - } - let changes_failover_or_two_phase = options.iter().any(|option| { - option.name.eq_ignore_ascii_case("failover") - || option.name.eq_ignore_ascii_case("two_phase") - }); - if changes_failover_or_two_phase && existing.enabled { - return MutationResult::Conflict { - reason: format!( - "subscription '{}' must be disabled before changing failover or two_phase", - s.name - ), - }; - } - let forbidden_in_transaction = options.iter().any(|option| { - option.name.eq_ignore_ascii_case("failover") - || (option.name.eq_ignore_ascii_case("two_phase") - && Self::postgres_boolean(&option.value) == Some(false)) - }); - if in_transaction && forbidden_in_transaction { - return MutationResult::Conflict { - reason: format!( - "subscription '{}' cannot change this setting inside a transaction", - s.name - ), - }; - } - } - crate::analysis::facts::AlterSubscriptionActionFact::SetEnabled(true) - if existing.slot_name.is_none() => - { - return MutationResult::Conflict { - reason: format!( - "subscription '{}' cannot be enabled without a slot_name", - s.name - ), - }; - } - _ => {} - } - self.snapshot_subscription(&s.name); - self.snapshot_generation_counter(); - self.local.generation_counter += 1; - let new_gen = self.local.generation_counter; - use crate::analysis::facts::{ - AlterSubscriptionActionFact, SubscriptionPublicationMode, - }; - let mut rename_to = None; - match &s.action { - AlterSubscriptionActionFact::SetConnection(connection) => { - if let Some(crate::model::replication::SubscriptionOverlay::Present( - subscription, - )) = self.local.subscriptions.get_mut(&s.name) - { - subscription.connection = connection.clone(); - } - } - AlterSubscriptionActionFact::SetServer(server) => { - if let Some(crate::model::replication::SubscriptionOverlay::Present( - subscription, - )) = self.local.subscriptions.get_mut(&s.name) - { - subscription.connection = - crate::analysis::facts::ConnectionTarget::Server(server.clone()); - } - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - AlterSubscriptionActionFact::Publications { - mode, - publications, - params, - } => { - let refreshes = Self::subscription_boolean_option(Some(params), "refresh") - != Some(false); - if refreshes { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - if let Some(crate::model::replication::SubscriptionOverlay::Present( - subscription, - )) = self.local.subscriptions.get_mut(&s.name) - { - match mode { - SubscriptionPublicationMode::Set => { - subscription.publications = publications.clone(); - } - SubscriptionPublicationMode::Add => { - subscription - .publications - .extend(publications.iter().cloned()); - } - SubscriptionPublicationMode::Drop => { - subscription - .publications - .retain(|existing| !publications.contains(existing)); - } - } - } - } - AlterSubscriptionActionFact::RefreshPublication(_) => { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - } - AlterSubscriptionActionFact::SetEnabled(enabled) => { - if let Some(crate::model::replication::SubscriptionOverlay::Present( - subscription, - )) = self.local.subscriptions.get_mut(&s.name) - { - subscription.enabled = *enabled; - } - } - AlterSubscriptionActionFact::SetOptions(options) => { - if let Some(crate::model::replication::SubscriptionOverlay::Present( - subscription, - )) = self.local.subscriptions.get_mut(&s.name) - { - for option in options { - Self::set_subscription_option(subscription, option); - if option.name.eq_ignore_ascii_case("slot_name") { - subscription.slot_name = - (!option.value.eq_ignore_ascii_case("none")) - .then(|| option.value.clone()); - } - } - } - } - AlterSubscriptionActionFact::Skip(options) => { - if let Some(crate::model::replication::SubscriptionOverlay::Present( - subscription, - )) = self.local.subscriptions.get_mut(&s.name) - { - for option in options { - let mut normalized = option.clone(); - normalized.name = "skip_lsn".to_string(); - Self::set_subscription_option(subscription, &normalized); - } - } - } - AlterSubscriptionActionFact::OwnerChange(role) => { - if let Some((owner, known)) = self.role_fact_identity(role) { - if known { - if let Some( - crate::model::replication::SubscriptionOverlay::Present( - subscription, - ), - ) = self.local.subscriptions.get_mut(&s.name) - { - subscription.owner = Some(owner); - } - } else { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - if let Some( - crate::model::replication::SubscriptionOverlay::Present( - subscription, - ), - ) = self.local.subscriptions.get_mut(&s.name) - { - subscription.owner = None; - } - } - } else { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - if let Some(crate::model::replication::SubscriptionOverlay::Present( - subscription, - )) = self.local.subscriptions.get_mut(&s.name) - { - subscription.owner = None; - } - } - } - AlterSubscriptionActionFact::Rename { to } => { - if matches!( - self.local.subscriptions.get(to), - Some(crate::model::replication::SubscriptionOverlay::Present(_)) - ) { - return MutationResult::Conflict { - reason: format!("subscription '{}' already exists", to), - }; - } - rename_to = Some(to.clone()); - } - } - - if let Some(crate::model::replication::SubscriptionOverlay::Present(subscription)) = - self.local.subscriptions.get_mut(&s.name) - { - subscription.generation = new_gen; - } - if let Some(to) = rename_to { - self.snapshot_subscription(&to); - let Some(crate::model::replication::SubscriptionOverlay::Present( - mut subscription, - )) = self.local.subscriptions.remove(&s.name) - else { - unreachable!("subscription existence checked above"); - }; - subscription.name = to.clone(); - self.local.subscriptions.insert( - to, - crate::model::replication::SubscriptionOverlay::Present(subscription), - ); - } - MutationResult::Applied - } - Mutation::DropSubscription(s) => { - let has_slot = match self.local.subscriptions.get(&s.name) { - Some(crate::model::replication::SubscriptionOverlay::Present(subscription)) => { - subscription.slot_name.is_some() - } - Some(crate::model::replication::SubscriptionOverlay::Dropped) => { - if !s.if_exists { - return MutationResult::Conflict { - reason: format!("subscription '{}' does not exist", s.name), - }; - } - return MutationResult::Skipped; - } - None if s.if_exists && self.baseline_available => { - return MutationResult::Skipped; - } - None if s.if_exists => { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - } - None => { - if self.baseline_available { - return MutationResult::Conflict { - reason: format!("subscription '{}' does not exist", s.name), - }; - } - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - } - }; - if has_slot && !self.local.transactions.is_empty() { - return MutationResult::Conflict { - reason: format!( - "subscription '{}' has a replication slot and cannot be dropped inside a transaction", - s.name - ), - }; - } - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - self.snapshot_subscription(&s.name); - self.local.subscriptions.insert( - s.name.clone(), - crate::model::replication::SubscriptionOverlay::Dropped, - ); - MutationResult::Applied - } - Mutation::CreateRole(r) => { - let role_id = ObjectId::new("", &r.name); - if matches!( - self.local.roles.get(&role_id), - Some(crate::model::role::RoleOverlay::Present(_)) - ) { - return MutationResult::Conflict { - reason: format!("role '{}' already exists", r.name), - }; - } - self.snapshot_role(&role_id); - self.snapshot_generation_counter(); - self.local.generation_counter += 1; - let _generation = self.local.generation_counter; - - self.local.roles.insert( - role_id.clone(), - crate::model::role::RoleOverlay::Present(crate::model::role::RoleState { - id: role_id, - can_login: r.can_login, - is_superuser: false, - member_of: Vec::new(), - can_set_role_to: Vec::new(), - granted_privileges: Vec::new(), - }), - ); - MutationResult::Applied - } - Mutation::AlterRole(r) => { - if let Some(role_id) = Self::resolve_role_name( - &r.name, - &self.local.current_role, - &self.local.session_role, - ) { - self.snapshot_role(&role_id); - if !self.local.roles.contains_key(&role_id) { - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; - } - self.snapshot_generation_counter(); - self.local.generation_counter += 1; - let _new_gen = self.local.generation_counter; - - MutationResult::Applied - } else { - MutationResult::Skipped - } - } - Mutation::DropRole(r) => { - for name in &r.names { - if let Some(role_id) = Self::resolve_role_name( - &crate::analysis::facts::RoleFact::Named { - name: name.clone(), - via_legacy_group_syntax: false, - }, - &self.local.current_role, - &self.local.session_role, - ) { - self.snapshot_role(&role_id); - if !r.if_exists - && !matches!( - self.local.roles.get(&role_id), - Some(crate::model::role::RoleOverlay::Present(_)) - ) - { - return MutationResult::Conflict { - reason: format!("role '{}' does not exist", name), - }; - } - self.local - .roles - .insert(role_id, crate::model::role::RoleOverlay::Dropped); - } - } - MutationResult::Applied - } - Mutation::Grant(grant) => { - let privileges = Self::resolve_grant_privileges(&grant.privileges); - let grantees = &grant.grantees; - match &grant.target { - crate::analysis::mutations::ResolvedGrantTarget::Tables(ids) => { - for id in ids { - self.apply_grant_to_relation(id, &privileges, grantees); - } - } - crate::analysis::mutations::ResolvedGrantTarget::AllTablesInSchema(schemas) => { - let target_ids: Vec = self - .local - .relations - .keys() - .filter(|id| schemas.contains(&id.schema)) - .cloned() - .collect(); - for id in &target_ids { - self.apply_grant_to_relation(id, &privileges, grantees); - } - } - } - MutationResult::Applied - } - Mutation::Revoke(revoke) => { - let privileges = Self::resolve_grant_privileges(&revoke.privileges); - let revokees = &revoke.revokees; - match &revoke.target { - crate::analysis::mutations::ResolvedGrantTarget::Tables(ids) => { - for id in ids { - self.apply_revoke_to_relation(id, &privileges, revokees); - } - } - crate::analysis::mutations::ResolvedGrantTarget::AllTablesInSchema(schemas) => { - let target_ids: Vec = self - .local - .relations - .keys() - .filter(|id| schemas.contains(&id.schema)) - .cloned() - .collect(); - for id in &target_ids { - self.apply_revoke_to_relation(id, &privileges, revokees); - } - } - } - MutationResult::Applied - } - Mutation::CreateDatabase(_) => MutationResult::Applied, - Mutation::AlterDatabase(_) => MutationResult::Applied, - Mutation::DropDatabase(_) => MutationResult::Applied, - Mutation::Vacuum { .. } => MutationResult::Applied, + Mutation::SearchPath(search_path) => self.apply_search_path(search_path), + Mutation::TimeoutSetting(timeout) => self.apply_timeout_setting(timeout), + Mutation::ResetSettings(target) => self.apply_reset_settings(target), + Mutation::CheckTimeouts => self.apply_check_timeouts(), + Mutation::SwitchRole { + role, + local, + is_session_auth, + } => self.apply_switch_role(role, *local, *is_session_auth), + Mutation::BeginTransaction => self.apply_begin_transaction(), + Mutation::CommitTransaction => self.apply_commit_transaction(false), + Mutation::CommitAndChain => self.apply_commit_transaction(true), + Mutation::RollbackTransaction => self.apply_rollback_transaction(false), + Mutation::RollbackAndChain => self.apply_rollback_transaction(true), + Mutation::RollbackToSavepoint(rollback) => self.apply_rollback_to_savepoint(rollback), + Mutation::Savepoint(savepoint) => self.apply_savepoint(savepoint), + Mutation::ReleaseSavepoint(release) => self.apply_release_savepoint(release), + Mutation::Opaque(opaque) => self.apply_opaque(opaque), + Mutation::CreateFunction(function) => self.apply_create_function(function), + Mutation::AlterFunction(function) => self.apply_alter_function(function), + Mutation::DropFunction(function) => self.apply_drop_function(function), + Mutation::CreateProcedure(procedure) => self.apply_create_procedure(procedure), + Mutation::AlterProcedure(procedure) => self.apply_alter_procedure(procedure), + Mutation::DropProcedure(procedure) => self.apply_drop_procedure(procedure), + Mutation::CreateAggregate(aggregate) => self.apply_create_aggregate(aggregate), + Mutation::AlterAggregate(aggregate) => self.apply_alter_aggregate(aggregate), + Mutation::DropAggregate(aggregate) => self.apply_drop_aggregate(aggregate), + Mutation::CreatePublication(publication) => self.apply_create_publication(publication), + Mutation::AlterPublication(publication) => self.apply_alter_publication(publication), + Mutation::DropPublication(publication) => self.apply_drop_publication(publication), + Mutation::CreateSubscription(subscription) => { + self.apply_create_subscription(subscription) + } + Mutation::AlterSubscription(subscription) => { + self.apply_alter_subscription(subscription) + } + Mutation::DropSubscription(subscription) => self.apply_drop_subscription(subscription), + Mutation::CreateRole(role) => self.apply_create_role(role), + Mutation::AlterRole(role) => self.apply_alter_role(role), + Mutation::DropRole(role) => self.apply_drop_role(role), + Mutation::Grant(grant) => self.apply_grant(grant), + Mutation::Revoke(revoke) => self.apply_revoke(revoke), + Mutation::CreateDatabase(create_database) => { + self.apply_create_database(create_database) + } + Mutation::AlterDatabase(alter_database) => self.apply_alter_database(alter_database), + Mutation::DropDatabase(drop_database) => self.apply_drop_database(drop_database), + Mutation::Vacuum { table_id, is_full } => self.apply_vacuum(table_id, *is_full), } } @@ -6142,7 +2129,7 @@ impl AnalysisState { publications: self.local.publications.clone(), triggers: self.local.triggers.clone(), constraints: self.local.constraints.clone(), - graph: self.local.graph.edges.clone(), + graph: self.local.graph.edges().to_vec(), pending_validation: self.local.pending_validation.clone(), baseline_relations: self.baseline_relations.clone(), baseline_indexes: self.baseline_indexes.clone(), @@ -6188,14 +2175,36 @@ impl AnalysisState { } self.snapshot_graph_full(); - self.local.graph.propagate_rename(old_id, new_id); - self.local.graph.edges.push(DependencyEdge::new( + self.local.graph.propagate_function_rename(old_id, new_id); + self.local.graph.add_edge(DependencyEdge::new( old_id.clone(), new_id.clone(), DependencyKind::RenameTo, )); } + pub(super) fn validate_function_move( + &mut self, + old_id: &ObjectId, + new_id: &ObjectId, + ) -> Result<(), MutationResult> { + if old_id == new_id { + return Ok(()); + } + self.ensure_schema_target(&new_id.schema)?; + match self.local.functions.get(new_id) { + Some(crate::model::function::FunctionOverlay::Present(_)) => { + Err(MutationResult::Conflict { + reason: format!("routine '{}' already exists", new_id), + }) + } + // A prior DROP in this migration leaves a tombstone but the + // namespace is available again, just as it is in PostgreSQL. + Some(crate::model::function::FunctionOverlay::Dropped) => Ok(()), + None => Ok(()), + } + } + fn snapshot_function(&mut self, id: &ObjectId) { if let Some(frame) = self.local.transactions.last_mut() { let previous = self.local.functions.get(id).cloned(); @@ -6320,7 +2329,7 @@ impl AnalysisState { fn snapshot_graph(&mut self) { if let Some(frame) = self.local.transactions.last_mut() { frame.undo_log.push(StateChange::GraphLengthMarker { - len: self.local.graph.edges.len(), + len: self.local.graph.edge_count(), }); } } @@ -6328,7 +2337,7 @@ impl AnalysisState { fn snapshot_graph_full(&mut self) { if let Some(frame) = self.local.transactions.last_mut() { frame.undo_log.push(StateChange::GraphSnapshot { - previous: self.local.graph.edges.clone(), + previous: self.local.graph.edges().to_vec(), }); } } @@ -6357,7 +2366,7 @@ impl AnalysisState { self.local.publications = snapshot.publications; self.local.triggers = snapshot.triggers; self.local.constraints = snapshot.constraints; - self.local.graph.edges = snapshot.graph; + self.local.graph.replace_edges(snapshot.graph); self.local.pending_validation = snapshot.pending_validation; self.baseline_relations = snapshot.baseline_relations; self.baseline_indexes = snapshot.baseline_indexes; @@ -6433,11 +2442,14 @@ impl AnalysisState { self.local.constraints.remove(&key); } } + StateChange::BaselineForeignKeysSnapshot { previous } => { + self.baseline_foreign_keys = previous; + } StateChange::GraphLengthMarker { len } => { - self.local.graph.edges.truncate(len); + self.local.graph.truncate(len); } StateChange::GraphSnapshot { previous } => { - self.local.graph.edges = previous; + self.local.graph.replace_edges(previous); } StateChange::RoleContextSnapshot { current_role, @@ -6486,4 +2498,30 @@ impl AnalysisState { } } } + + pub(crate) fn transaction_undo_checkpoint(&self) -> Option<(usize, usize)> { + self.local + .transactions + .last() + .map(|frame| (self.local.transactions.len(), frame.undo_log.len())) + } + + pub(crate) fn rollback_to_transaction_undo_checkpoint( + &mut self, + transaction_depth: usize, + undo_len: usize, + ) -> Result<(), &'static str> { + if self.local.transactions.len() != transaction_depth { + return Err("statement changed transaction depth while using an undo checkpoint"); + } + let Some(frame) = self.local.transactions.last_mut() else { + return Err("statement undo checkpoint lost its transaction frame"); + }; + if frame.undo_log.len() < undo_len { + return Err("statement shortened the transaction undo log unexpectedly"); + } + let statement_undo = frame.undo_log.split_off(undo_len); + self.rollback_undo_log(statement_undo); + Ok(()) + } } diff --git a/src/analysis/state/apply_misc.rs b/src/analysis/state/apply_misc.rs new file mode 100644 index 0000000..aa1f182 --- /dev/null +++ b/src/analysis/state/apply_misc.rs @@ -0,0 +1,49 @@ +use super::{AnalysisState, Confidence, MutationResult}; +use crate::analysis::mutations::{ + AlterDatabaseMutation, CreateDatabaseMutation, DropDatabaseMutation, +}; +use crate::ast::identifiers::ObjectId; + +impl AnalysisState { + pub(super) fn apply_check_timeouts(&mut self) -> MutationResult { + MutationResult::Applied + } + + pub(super) fn apply_create_database( + &mut self, + _create_database: &CreateDatabaseMutation, + ) -> MutationResult { + // Database objects are outside the current-database schema model. + // Keep the mutation available to database-specific rules, but do not + // claim an exact catalog state transition. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + MutationResult::Applied + } + + pub(super) fn apply_alter_database( + &mut self, + _alter_database: &AlterDatabaseMutation, + ) -> MutationResult { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + MutationResult::Applied + } + + pub(super) fn apply_drop_database( + &mut self, + _drop_database: &DropDatabaseMutation, + ) -> MutationResult { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + MutationResult::Applied + } + + pub(super) fn apply_vacuum( + &mut self, + _table_id: &Option, + _is_full: bool, + ) -> MutationResult { + MutationResult::Applied + } +} diff --git a/src/analysis/state/apply_policy_trigger.rs b/src/analysis/state/apply_policy_trigger.rs new file mode 100644 index 0000000..ca10259 --- /dev/null +++ b/src/analysis/state/apply_policy_trigger.rs @@ -0,0 +1,338 @@ +use super::{AnalysisState, MutationResult, ObjectLookup, RelationOverlay}; +use crate::analysis::graph::{DependencyEdge, DependencyKind}; +use crate::analysis::mutations::{ + CreatePolicyMutation, CreateTriggerMutation, DropPolicyMutation, DropTriggerMutation, + RenameTriggerMutation, +}; +use crate::ast::identifiers::ObjectId; +use crate::model::trigger::{TriggerEnableMode, TriggerOverlay, TriggerState}; + +type TriggerLookup = ObjectLookup; + +impl AnalysisState { + fn trigger_lookup(&self, id: &ObjectId) -> TriggerLookup { + match self.local.triggers.get(id) { + Some(TriggerOverlay::Present(_)) => TriggerLookup::Present, + Some(TriggerOverlay::Dropped) => TriggerLookup::Tombstone, + None if self.baseline_available && self.baseline_covers_object(id) => { + TriggerLookup::AuthoritativelyAbsent + } + None => TriggerLookup::Unknown, + } + } + + pub(super) fn apply_create_policy( + &mut self, + create_policy: &CreatePolicyMutation, + ) -> MutationResult { + if let Err(result) = self.ensure_relation_target( + &create_policy.table, + |kind| *kind == crate::model::relation::RelationKind::Table, + format!("policy relation '{}' does not exist", create_policy.table), + format!("policy relation '{}' is not a table", create_policy.table), + ) { + return result; + } + self.snapshot_relation(&create_policy.table); + if let Some(RelationOverlay::Present(rel)) = + self.local.relations.get_mut(&create_policy.table) + { + if rel.policies.contains(&create_policy.name) { + return MutationResult::Conflict { + reason: format!( + "policy '{}' already exists on relation '{}'", + create_policy.name, create_policy.table + ), + }; + } + rel.policies.insert(create_policy.name.clone()); + } else { + return MutationResult::Conflict { + reason: format!("relation '{}' does not exist", create_policy.table), + }; + } + if !create_policy.semantics_complete { + // Keep the policy identity for rule evaluation and DROP POLICY + // lookup, but do not claim authorization state is complete. + self.snapshot_confidence(); + self.local.confidence = super::Confidence::Tainted; + } + MutationResult::Applied + } + + pub(super) fn apply_drop_policy(&mut self, drop_policy: &DropPolicyMutation) -> MutationResult { + if let Err(result) = self.ensure_relation_target( + &drop_policy.table, + |kind| *kind == crate::model::relation::RelationKind::Table, + format!("policy relation '{}' does not exist", drop_policy.table), + format!("policy relation '{}' is not a table", drop_policy.table), + ) { + return result; + } + self.snapshot_relation(&drop_policy.table); + if let Some(RelationOverlay::Present(rel)) = + self.local.relations.get_mut(&drop_policy.table) + { + if !rel.policies.contains(&drop_policy.name) { + return if drop_policy.if_exists { + MutationResult::Skipped + } else { + MutationResult::Conflict { + reason: format!( + "policy '{}' does not exist on relation '{}'", + drop_policy.name, drop_policy.table + ), + } + }; + } + rel.policies.remove(&drop_policy.name); + } else { + return MutationResult::Conflict { + reason: format!("relation '{}' does not exist", drop_policy.table), + }; + } + MutationResult::Applied + } + + pub(super) fn apply_create_trigger( + &mut self, + create_trigger: &CreateTriggerMutation, + ) -> MutationResult { + let trigger_id = Self::trigger_key(&create_trigger.table, &create_trigger.name); + if self.trigger_lookup(&trigger_id) == TriggerLookup::Present { + return MutationResult::Conflict { + reason: format!( + "trigger '{}' already exists on relation '{}'", + create_trigger.name, create_trigger.table + ), + }; + } + if let Err(result) = self.ensure_relation_target( + &create_trigger.table, + |kind| { + matches!( + kind, + crate::model::relation::RelationKind::Table + | crate::model::relation::RelationKind::View + ) + }, + format!( + "trigger target relation '{}' does not exist", + create_trigger.table + ), + format!( + "trigger target '{}' is not a table or view", + create_trigger.table + ), + ) { + return result; + } + if let Err(result) = self.ensure_routine_target( + &create_trigger.function_id, + crate::model::function::RoutineKind::Function, + format!( + "trigger function '{}' does not exist", + create_trigger.function_id + ), + format!( + "trigger target '{}' is not a function", + create_trigger.function_id + ), + ) { + return result; + } + // PostgreSQL only accepts trigger-returning functions for a regular + // CREATE TRIGGER. The routine-kind check above is not sufficient: + // ordinary scalar functions share the same catalog namespace. A + // missing return type can occur in an incomplete/scoped cache, so do + // not guess in that case. + let Some(crate::model::function::FunctionOverlay::Present(function)) = + self.local.functions.get(&create_trigger.function_id) + else { + self.snapshot_confidence(); + self.local.confidence = super::Confidence::Tainted; + return MutationResult::Skipped; + }; + let return_type = function.return_type.trim(); + if return_type.is_empty() || return_type.eq_ignore_ascii_case("unknown") { + self.snapshot_confidence(); + self.local.confidence = super::Confidence::Tainted; + return MutationResult::Skipped; + } + let return_type = return_type + .split_once('.') + .filter(|(schema, _)| schema.eq_ignore_ascii_case("pg_catalog")) + .map(|(_, type_name)| type_name) + .unwrap_or(return_type); + if !return_type.eq_ignore_ascii_case("trigger") { + return MutationResult::Conflict { + reason: format!( + "trigger function '{}' must return type trigger", + create_trigger.function_id + ), + }; + } + self.snapshot_trigger(&trigger_id); + self.local.triggers.insert( + trigger_id.clone(), + TriggerOverlay::Present(TriggerState { + name: create_trigger.name.clone(), + id: trigger_id.clone(), + table_id: create_trigger.table.clone(), + enabled_mode: TriggerEnableMode::Origin, + generation: self.local.generation_counter, + }), + ); + self.snapshot_relation(&create_trigger.table); + if let Some(RelationOverlay::Present(rel)) = + self.local.relations.get_mut(&create_trigger.table) + { + rel.triggers.insert(create_trigger.name.clone()); + } + self.snapshot_graph_full(); + self.local.graph.add_edge(DependencyEdge::new( + trigger_id.clone(), + create_trigger.table.clone(), + DependencyKind::TriggerOnTable { + trigger_id: trigger_id.clone(), + function_id: create_trigger.function_id.clone(), + }, + )); + MutationResult::Applied + } + + pub(super) fn apply_drop_trigger( + &mut self, + drop_trigger: &DropTriggerMutation, + ) -> MutationResult { + if let Err(result) = self.ensure_relation_target( + &drop_trigger.table, + |kind| { + matches!( + kind, + crate::model::relation::RelationKind::Table + | crate::model::relation::RelationKind::View + ) + }, + format!( + "trigger target relation '{}' does not exist", + drop_trigger.table + ), + format!( + "trigger target '{}' is not a table or view", + drop_trigger.table + ), + ) { + return result; + } + let trigger_id = Self::trigger_key(&drop_trigger.table, &drop_trigger.name); + if self.trigger_lookup(&trigger_id) != TriggerLookup::Present { + return if drop_trigger.if_exists { + MutationResult::Skipped + } else { + MutationResult::Conflict { + reason: format!( + "trigger '{}' does not exist on relation '{}'", + drop_trigger.name, drop_trigger.table + ), + } + }; + } + self.snapshot_trigger(&trigger_id); + self.local + .triggers + .insert(trigger_id.clone(), TriggerOverlay::Dropped); + self.snapshot_relation(&drop_trigger.table); + if let Some(RelationOverlay::Present(rel)) = + self.local.relations.get_mut(&drop_trigger.table) + { + rel.triggers.remove(&drop_trigger.name); + } + self.snapshot_graph_full(); + self.local.graph.retain_edges(|edge| { + !(matches!(edge.kind, DependencyKind::TriggerOnTable { .. }) + && edge.dependent == trigger_id) + }); + MutationResult::Applied + } + + pub(super) fn apply_rename_trigger( + &mut self, + rename_trigger: &RenameTriggerMutation, + ) -> MutationResult { + if let Err(result) = self.ensure_relation_target( + &rename_trigger.table, + |kind| { + matches!( + kind, + crate::model::relation::RelationKind::Table + | crate::model::relation::RelationKind::View + ) + }, + format!( + "trigger target relation '{}' does not exist", + rename_trigger.table + ), + format!( + "trigger target '{}' is not a table or view", + rename_trigger.table + ), + ) { + return result; + } + let old_id = Self::trigger_key(&rename_trigger.table, &rename_trigger.name); + let new_id = Self::trigger_key(&rename_trigger.table, &rename_trigger.new_name); + let trigger = match self.trigger_lookup(&old_id) { + TriggerLookup::Present => match self.local.triggers.get(&old_id).cloned() { + Some(TriggerOverlay::Present(trigger)) => trigger, + _ => unreachable!("trigger lookup established presence"), + }, + TriggerLookup::Tombstone | TriggerLookup::AuthoritativelyAbsent => { + return MutationResult::Conflict { + reason: format!( + "trigger '{}' does not exist on relation '{}'", + rename_trigger.name, rename_trigger.table + ), + }; + } + TriggerLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = super::Confidence::Tainted; + return MutationResult::Skipped; + } + TriggerLookup::WrongKind => unreachable!("triggers have a dedicated namespace"), + }; + let mut trigger = trigger; + if old_id != new_id && self.trigger_lookup(&new_id) == TriggerLookup::Present { + return MutationResult::Conflict { + reason: format!( + "trigger '{}' already exists on relation '{}'", + rename_trigger.new_name, rename_trigger.table + ), + }; + } + self.snapshot_trigger(&old_id); + self.snapshot_trigger(&new_id); + self.snapshot_relation(&rename_trigger.table); + self.snapshot_graph_full(); + self.local.triggers.remove(&old_id); + trigger.id = new_id.clone(); + trigger.name = rename_trigger.new_name.clone(); + self.local + .triggers + .insert(new_id.clone(), TriggerOverlay::Present(trigger)); + if let Some(RelationOverlay::Present(relation)) = + self.local.relations.get_mut(&rename_trigger.table) + { + relation.triggers.remove(&rename_trigger.name); + relation.triggers.insert(rename_trigger.new_name.clone()); + } + self.local.graph.propagate_trigger_rename(&old_id, &new_id); + self.local.graph.add_edge(DependencyEdge::new( + old_id, + new_id, + DependencyKind::RenameTo, + )); + MutationResult::Applied + } +} diff --git a/src/analysis/state/apply_relation.rs b/src/analysis/state/apply_relation.rs new file mode 100644 index 0000000..5c23265 --- /dev/null +++ b/src/analysis/state/apply_relation.rs @@ -0,0 +1,3081 @@ +use super::{ + AnalysisState, CascadeResult, Confidence, MutationResult, ObjectLookup, RelationOverlay, +}; +use crate::analysis::facts::TableConstraintFact; +use crate::analysis::graph::{DependencyEdge, DependencyKind}; +use crate::analysis::mutations::{ + AlterTable, AlterTableActionMutation, CreateTable, DropTable, PersistenceMutation, Rename, +}; +use crate::ast::identifiers::ObjectId; +use crate::model::constraint::{ConstraintKind, ConstraintState}; +use crate::model::relation::{ColumnAction, RelationKind, RelationState}; +use crate::model::sequence::{SequenceKind, SequenceOverlay, SequenceState}; +use crate::model::trigger::TriggerOverlay; +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_available && self.baseline_covers_object(id) { + 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.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + 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; + } + + 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 + } + }; + if closure + .dropped_relations + .iter() + .any(|id| !self.relation_is_present(id) && !self.baseline_covers_object(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.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + 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| { + 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| { + matches!(e.kind, DependencyKind::ForeignKey { .. }) + && roots.contains(&resolve(&e.referenced)) + && !roots.contains(&resolve(&e.dependent)) + }); + let has_partition_deps = self.local.graph.edges().iter().any(|e| { + matches!(e.kind, DependencyKind::PartitionOf) + && roots.contains(&resolve(&e.referenced)) + && !roots.contains(&resolve(&e.dependent)) + }); + + if has_view_deps || has_fk_deps || has_partition_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 { .. } + ); + } + 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::model::replication::PublicationOverlay::Present(publication) = overlay + else { + return None; + }; + let crate::analysis::facts::PublicationScope::Explicit(objects) = + &publication.scope + else { + return None; + }; + let retained = objects + .iter() + .filter(|object| { + let crate::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::model::replication::PublicationOverlay::Present(publication)) = + self.local.publications.get_mut(&publication_name) + && let crate::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 + } + + 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; + } + if self.relation_namespace_is_taken(&create.id) { + return MutationResult::Conflict { + reason: format!("relation '{}' already exists", create.id), + }; + } + + 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), + }; + } + } + 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 + ), + }; + } + } + } + + 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(parent_id) = &create.partition_of { + let Some(RelationOverlay::Present(parent)) = self.local.relations.get(parent_id) else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + }; + 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() + ), + }; + } + if let Some(column) = fk.from_columns.iter().find(|name| { + !create + .columns + .iter() + .any(|candidate| candidate.name == **name) + }) { + return MutationResult::Conflict { + reason: format!( + "foreign key column '{}' does not exist on relation '{}'", + column, create.id + ), + }; + } + let target_columns: HashSet = if fk.to_table == create.id { + create + .columns + .iter() + .map(|column| column.name.clone()) + .collect() + } else { + if let Err(result) = self.ensure_relation_target( + &fk.to_table, + |kind| *kind == RelationKind::Table, + format!( + "foreign key references relation '{}' which does not exist", + fk.to_table + ), + format!( + "foreign key references '{}' which is not a table", + fk.to_table + ), + ) { + return result; + } + let Some(RelationOverlay::Present(parent)) = self.local.relations.get(&fk.to_table) + else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + }; + parent + .columns + .iter() + .map(|column| column.name.clone()) + .collect() + }; + // A scoped/programmatic V6 baseline may omit column facts while + // retaining the relation identity. Do not turn that absence into + // a false column conflict; key/index eligibility is checked + // separately and remains conservative. + let target_columns_known = + !self.baseline_relations.contains(&fk.to_table) || !target_columns.is_empty(); + if target_columns_known + && let Some(column) = fk + .to_columns + .iter() + .find(|name| !target_columns.contains(*name)) + { + return MutationResult::Conflict { + reason: format!( + "foreign key references column '{}.{}' which does not exist", + fk.to_table, column + ), + }; + } + let target_keys = if fk.to_table == create.id { + let mut keys = Vec::new(); + if primary_declarations == 1 { + 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() + }); + keys.push((columns, true)); + } + keys.extend(create.table_constraints.iter().filter_map( + |constraint| match constraint { + TableConstraintFact::Unique { columns, .. } => { + Some((columns.clone(), false)) + } + _ => None, + }, + )); + keys.extend( + create + .columns + .iter() + .filter(|column| column.is_unique) + .map(|column| (vec![column.name.clone()], false)), + ); + Some(keys) + } else { + self.unique_keys_for_relation(&fk.to_table) + }; + let referenced_columns = if fk.to_columns.is_empty() { + let primary_keys: Vec<&Vec> = target_keys + .as_ref() + .map(|keys| { + keys.iter() + .filter_map(|(columns, primary)| primary.then_some(columns)) + .collect() + }) + .unwrap_or_default(); + if target_keys.is_some() && primary_keys.len() != 1 { + return MutationResult::Conflict { + reason: format!( + "foreign key on '{}' omits referenced columns but target '{}' has no single primary key", + create.id, fk.to_table + ), + }; + } + primary_keys.first().cloned().cloned().unwrap_or_default() + } else { + fk.to_columns.clone() + }; + effective_fk_target_columns.push(referenced_columns.clone()); + if let Some(keys) = target_keys.as_ref() + && !keys + .iter() + .any(|(columns, _)| columns == &referenced_columns) + { + return MutationResult::Conflict { + reason: format!( + "foreign key on '{}' references columns on '{}' that are not backed by a primary key or unique key", + create.id, fk.to_table + ), + }; + } else if target_keys.is_none() { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + let source_types: Vec, Option)>> = fk + .from_columns + .iter() + .map(|column| { + create + .columns + .iter() + .find(|candidate| candidate.name == *column) + .map(|state| { + ( + state + .ty + .as_deref() + .and_then(|raw| self.resolve_type_reference(raw)), + state.ty.clone(), + ) + }) + }) + .collect(); + let target_types: Vec, Option)>> = + if fk.to_table == create.id { + referenced_columns + .iter() + .map(|column| { + create + .columns + .iter() + .find(|candidate| candidate.name == *column) + .map(|state| { + ( + state + .ty + .as_deref() + .and_then(|raw| self.resolve_type_reference(raw)), + state.ty.clone(), + ) + }) + }) + .collect() + } else { + self.local + .relations + .get(&fk.to_table) + .and_then(|overlay| match overlay { + RelationOverlay::Present(parent) => Some( + referenced_columns + .iter() + .map(|column| { + parent.get_column(column).map(|state| { + (state.type_id.clone(), state.data_type.clone()) + }) + }) + .collect(), + ), + RelationOverlay::Dropped => None, + }) + .unwrap_or_default() + }; + let mut type_evidence_unknown = false; + let type_mismatch = + source_types + .iter() + .zip(&target_types) + .any(|(source, target)| match (source, target) { + (Some((Some(source_id), _)), Some((Some(target_id), _))) => { + source_id != target_id + } + (Some((_, Some(source_ty))), Some((_, Some(target_ty)))) => { + !source_ty.trim().eq_ignore_ascii_case(target_ty.trim()) + } + _ => { + type_evidence_unknown = true; + false + } + }); + if type_mismatch { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + if type_evidence_unknown { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + } + + // 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. + let mut reserved_sequences = HashSet::new(); + let mut implicit_sequences = Vec::new(); + for column in &create.columns { + let kind = match column.generation { + crate::analysis::facts::ColumnGeneration::Serial => Some(SequenceKind::SerialLike), + crate::analysis::facts::ColumnGeneration::Identity => Some(SequenceKind::Identity), + crate::analysis::facts::ColumnGeneration::Ordinary => None, + }; + if let Some(kind) = kind { + let sequence_id = + self.next_implicit_sequence_id(&create.id, &column.name, &reserved_sequences); + reserved_sequences.insert(sequence_id.clone()); + implicit_sequences.push((sequence_id, column.name.clone(), kind)); + } + } + + // 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 primary_key_name = create + .columns + .iter() + .find(|column| column.is_primary_key) + .map(|column| column.primary_key_constraint_name.clone()) + .or_else(|| { + create.table_constraints.iter().find_map(|constraint| { + if let TableConstraintFact::PrimaryKey { + constraint_name, .. + } = constraint + { + Some(constraint_name.clone()) + } else { + None + } + }) + }); + let primary_key_constraint_name = primary_key_name.map(|explicit_name| { + explicit_name.unwrap_or_else(|| { + self.next_generated_constraint_name_avoiding( + &create.id, + &create.id.name, + None, + "pkey", + &reserved_constraint_names, + ) + }) + }); + if let Some(name) = &primary_key_constraint_name + && !reserved_constraint_names.insert(name.clone()) + { + return MutationResult::Conflict { + reason: format!("constraint '{}' is specified more than once", name), + }; + } + + let unique_constraints = create + .columns + .iter() + .filter(|column| column.is_unique) + .map(|column| { + ( + column.unique_constraint_name.clone(), + vec![column.name.clone()], + ) + }) + .chain(create.table_constraints.iter().filter_map(|constraint| { + if let TableConstraintFact::Unique { + constraint_name, + columns, + } = constraint + { + Some((constraint_name.clone(), columns.clone())) + } else { + None + } + })) + .collect::>(); + 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, + &create.id.name, + Some(&columns.join("_")), + "key", + &reserved_constraint_names, + ) + }); + if !reserved_constraint_names.insert(name.clone()) { + return MutationResult::Conflict { + reason: format!("constraint '{}' is specified more than once", name), + }; + } + unique_constraint_names.push((name, columns.clone())); + } + + let mut foreign_key_constraint_names = Vec::with_capacity(create.foreign_keys.len()); + for fk in &create.foreign_keys { + let name = fk.constraint_name.clone().unwrap_or_else(|| { + self.next_generated_constraint_name_avoiding( + &create.id, + &create.id.name, + Some(&fk.from_columns.join("_")), + "fkey", + &reserved_constraint_names, + ) + }); + if !reserved_constraint_names.insert(name.clone()) { + return MutationResult::Conflict { + reason: format!("constraint '{}' is specified more than once", name), + }; + } + foreign_key_constraint_names.push(name); + } + + let mut inline_constraint_names = Vec::new(); + for constraint in &create.table_constraints { + let (kind, explicit_name, label) = match constraint { + TableConstraintFact::Check { constraint_name } => { + (ConstraintKind::Check, constraint_name, "check") + } + TableConstraintFact::Exclude { constraint_name } => { + (ConstraintKind::Exclusion, constraint_name, "excl") + } + _ => 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 !reserved_constraint_names.insert(name.clone()) { + return MutationResult::Conflict { + reason: format!("constraint '{}' is specified more than once", name), + }; + } + inline_constraint_names.push((kind, name)); + } + + self.snapshot_relation(&create.id); + + self.snapshot_generation_counter(); + self.local.generation_counter += 1; + let generation = self.local.generation_counter; + + let resolved_persistence = match create.persistence { + PersistenceMutation::Permanent => crate::model::relation::Persistence::Permanent, + PersistenceMutation::Temporary => crate::model::relation::Persistence::Temporary, + PersistenceMutation::Unlogged => crate::model::relation::Persistence::Unlogged, + }; + + let mut rel_state = RelationState::new( + create.id.clone(), + ObjectId::new("", &self.local.current_role), + generation, + if create.as_select { None } else { Some(0) }, + RelationKind::Table, + resolved_persistence, + self.local.transactions.len(), + ); + + if create.as_select { + // 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 + // column-targeting transitions conservative. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + + // 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_by = create.partition_by.clone(); + + let pk_columns: HashSet<&str> = create + .table_constraints + .iter() + .filter_map(|tc| { + if let TableConstraintFact::PrimaryKey { columns, .. } = tc { + Some(columns.iter().map(|s| s.as_str())) + } else { + None + } + }) + .flatten() + .collect(); + + for col in &create.columns { + let is_pk = col.is_primary_key || pk_columns.contains(col.name.as_str()); + rel_state.apply_column_action(&ColumnAction::Add { + name: col.name.clone(), + data_type: col.ty.clone(), + 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.type_id = column + .data_type + .as_deref() + .and_then(|raw| self.resolve_type_reference(raw)); + } + } + + for (sequence_id, column_name, _) in &implicit_sequences { + if let Some(column) = rel_state + .columns + .iter_mut() + .find(|column| column.name == *column_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; + } + } + + self.local + .relations + .insert(create.id.clone(), RelationOverlay::Present(rel_state)); + + for (sequence_id, column_name, kind) in implicit_sequences { + 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: ObjectId::new("", &self.local.current_role), + owned_by: Some((create.id.clone(), column_name.clone())), + kind, + generation: self.local.generation_counter, + }), + ); + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + sequence_id, + create.id.clone(), + DependencyKind::SequenceOwnedBy { + column: column_name, + }, + )); + } + + if let Some(name) = primary_key_constraint_name.clone() { + 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::PrimaryKey, + validated: true, + }, + ); + } + + for (name, columns) in unique_constraint_names { + 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::Unique, + validated: true, + }, + ); + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + create.id.clone(), + create.id.clone(), + DependencyKind::ConstraintOnRelation { + constraint_name: name, + columns, + is_primary: false, + }, + )); + } + + if let Some(parent_id) = &create.partition_of { + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + create.id.clone(), + parent_id.clone(), + DependencyKind::PartitionOf, + )); + } + + if !create.foreign_keys.is_empty() { + self.snapshot_graph(); + } + + for ((fk, constraint_name), referenced_columns) in create + .foreign_keys + .iter() + .zip(foreign_key_constraint_names) + .zip(effective_fk_target_columns) + { + 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, + }, + ); + self.local.graph.add_edge(DependencyEdge::new( + create.id.clone(), + fk.to_table.clone(), + DependencyKind::ForeignKey { + constraint_name: Some(constraint_name), + from_columns: fk.from_columns.clone(), + to_columns: referenced_columns, + from_generation: generation, + }, + )); + } + for (kind, name) in inline_constraint_names { + self.snapshot_constraint(&create.id, &name); + self.local.constraints.insert( + (create.id.clone(), name.clone()), + ConstraintState { + table_id: create.id.clone(), + name, + kind, + validated: true, + }, + ); + } + if let Some(name) = primary_key_constraint_name { + 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( + create.id.clone(), + create.id.clone(), + DependencyKind::ConstraintOnRelation { + constraint_name: name, + columns, + is_primary: true, + }, + )); + } + MutationResult::Applied + } + + /// `ALTER TABLE ... ADD CONSTRAINT ... USING INDEX` transfers ownership + /// of the index to the constraint. PostgreSQL renames the index when an + /// explicit constraint name differs, so keep the modeled index identity + /// in sync with the catalog-visible name. + fn adopt_index_for_constraint( + &mut self, + index: &ObjectId, + table: &ObjectId, + constraint_name: &str, + ) { + let adopted = ObjectId::new(index.schema.clone(), constraint_name); + if adopted == *index { + return; + } + let Some(edge) = self + .local + .graph + .edges() + .iter() + .find(|edge| { + matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) + && edge.dependent == *index + && edge.referenced == *table + }) + .cloned() + else { + return; + }; + let DependencyKind::IndexOnRelation { + using_method, + has_predicate, + is_concurrent, + is_unique, + eligibility_known, + } = edge.kind + else { + return; + }; + self.snapshot_graph_full(); + self.local.graph.retain_edges(|existing| { + !(matches!(existing.kind, DependencyKind::IndexOnRelation { .. }) + && existing.dependent == *index) + }); + self.local.graph.add_edge(DependencyEdge::new( + adopted, + table.clone(), + DependencyKind::IndexOnRelation { + using_method, + has_predicate, + is_concurrent, + is_unique, + eligibility_known, + }, + )); + } + + pub(super) fn apply_alter_table(&mut self, alter: &AlterTable) -> MutationResult { + match self.relation_lookup(&alter.id, |kind| *kind == RelationKind::Table) { + ObjectLookup::Present => {} + ObjectLookup::WrongKind => { + return MutationResult::Conflict { + reason: format!("object '{}' is not a table", alter.id), + }; + } + ObjectLookup::AuthoritativelyAbsent | ObjectLookup::Tombstone => { + return MutationResult::Conflict { + reason: format!("relation '{}' does not exist", alter.id), + }; + } + ObjectLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + } + + if let AlterTableActionMutation::OwnerTo { new_owner } = &alter.action { + let Some((owner, known)) = self.role_fact_identity(new_owner) else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + }; + if !known { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + if known && self.local.roles_known && self.present_role(&owner).is_none() { + return MutationResult::Conflict { + reason: format!("role '{}' does not exist", owner), + }; + } + if known && !self.local.roles_known { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + self.snapshot_relation(&alter.id); + return match self.local.relations.get_mut(&alter.id) { + Some(RelationOverlay::Present(relation)) => { + relation.owner = ObjectId::new("", owner); + MutationResult::Applied + } + _ => MutationResult::Conflict { + reason: format!("relation '{}' does not exist", alter.id), + }, + }; + } + + // Validate all targets before taking snapshots or creating implicit + // sequences. RelationState's low-level column helper intentionally + // ignores missing names, but PostgreSQL rejects those ALTER TABLE + // actions; silently continuing would make later state look valid. + let Some(RelationOverlay::Present(relation)) = self.local.relations.get(&alter.id) else { + return MutationResult::Conflict { + reason: format!("relation '{}' does not exist", alter.id), + }; + }; + let relation_columns_known = + !relation.columns.is_empty() || relation.estimated_rows.is_some(); + // 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 + // conservative until their target list is known. + if !relation_columns_known + && matches!( + alter.action, + AlterTableActionMutation::DropColumn { .. } + | AlterTableActionMutation::RenameColumn { .. } + | AlterTableActionMutation::SetNotNull { .. } + | AlterTableActionMutation::DropNotNull { .. } + | AlterTableActionMutation::SetType { .. } + | AlterTableActionMutation::SetDefault { .. } + ) + { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + match &alter.action { + AlterTableActionMutation::AddColumn { + name, + ty, + if_not_exists, + .. + } if relation.has_column(name) => { + return if *if_not_exists { + MutationResult::Skipped + } else { + MutationResult::Conflict { + reason: format!( + "column '{}' already exists with type {}; this statement adds it again with type {}", + name, + relation + .columns + .iter() + .find(|column| column.name == *name) + .and_then(|column| column.data_type.as_deref()) + .unwrap_or("unknown"), + ty.as_deref().unwrap_or("unknown"), + ), + } + }; + } + AlterTableActionMutation::DropColumn { + name, if_exists, .. + } if !relation.has_column(name) => { + return if *if_exists { + MutationResult::Skipped + } else { + MutationResult::Conflict { + reason: format!( + "column '{}' does not exist on relation '{}'", + name, alter.id + ), + } + }; + } + AlterTableActionMutation::RenameColumn { from, to } => { + if !relation.has_column(from) { + return MutationResult::Conflict { + reason: format!( + "column '{}' does not exist on relation '{}'", + from, alter.id + ), + }; + } + if relation.has_column(to) { + return MutationResult::Conflict { + reason: format!( + "column '{}' already exists on relation '{}'", + to, alter.id + ), + }; + } + } + AlterTableActionMutation::SetNotNull { column } + | AlterTableActionMutation::DropNotNull { column } + | AlterTableActionMutation::SetType { column, .. } + | AlterTableActionMutation::SetDefault { column, .. } + if !relation.has_column(column) => + { + return MutationResult::Conflict { + reason: format!( + "column '{}' does not exist on relation '{}'", + column, alter.id + ), + }; + } + _ => {} + } + + match &alter.action { + AlterTableActionMutation::DropConstraint { + name, if_exists, .. + } => { + if !self + .local + .constraints + .contains_key(&(alter.id.clone(), name.clone())) + { + return if *if_exists && self.baseline_covers_object(&alter.id) { + MutationResult::Skipped + } else if self.baseline_covers_object(&alter.id) { + MutationResult::Conflict { + reason: format!( + "constraint '{}' does not exist on relation '{}'", + name, alter.id + ), + } + } else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + MutationResult::Skipped + }; + } + } + AlterTableActionMutation::ValidateConstraint { + constraint_name: name, + } => { + if !self + .local + .constraints + .contains_key(&(alter.id.clone(), name.clone())) + { + return MutationResult::Conflict { + reason: format!( + "constraint '{}' does not exist on relation '{}'", + name, alter.id + ), + }; + } + } + AlterTableActionMutation::RenameConstraint { old_name, new_name } => { + if !self + .local + .constraints + .contains_key(&(alter.id.clone(), old_name.clone())) + { + return MutationResult::Conflict { + reason: format!( + "constraint '{}' does not exist on relation '{}'", + old_name, alter.id + ), + }; + } + if self + .local + .constraints + .contains_key(&(alter.id.clone(), new_name.clone())) + { + return MutationResult::Conflict { + reason: format!( + "constraint '{}' already exists on relation '{}'", + new_name, alter.id + ), + }; + } + } + AlterTableActionMutation::AddForeignKey { + constraint_name, + from_columns, + .. + } => { + let 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(), + ) + }); + if self + .local + .constraints + .contains_key(&(alter.id.clone(), name.clone())) + { + return MutationResult::Conflict { + reason: format!( + "constraint '{}' already exists on relation '{}'", + name, alter.id + ), + }; + } + } + AlterTableActionMutation::AddCheckConstraint { + constraint_name, .. + } => { + let name = constraint_name.clone().unwrap_or_else(|| { + self.next_generated_constraint_name_avoiding( + &alter.id, + &alter.id.name, + None, + "check", + &HashSet::new(), + ) + }); + if self + .local + .constraints + .contains_key(&(alter.id.clone(), name.clone())) + { + return MutationResult::Conflict { + reason: format!( + "constraint '{}' already exists on relation '{}'", + name, alter.id + ), + }; + } + } + AlterTableActionMutation::AddExcludeConstraint { constraint_name } => { + let name = constraint_name.clone().unwrap_or_else(|| { + self.next_generated_constraint_name_avoiding( + &alter.id, + &alter.id.name, + None, + "excl", + &HashSet::new(), + ) + }); + if self + .local + .constraints + .contains_key(&(alter.id.clone(), name.clone())) + { + return MutationResult::Conflict { + reason: format!( + "constraint '{}' already exists on relation '{}'", + name, alter.id + ), + }; + } + } + AlterTableActionMutation::AddUniqueConstraint { + constraint_name, + columns, + using_index, + } + | AlterTableActionMutation::AddPrimaryKeyConstraint { + constraint_name, + columns, + using_index, + } => { + if using_index.is_none() { + 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 relation_columns_known && !relation.has_column(column) { + return MutationResult::Conflict { + reason: format!( + "constraint references column '{}' which does not exist on relation '{}'", + column, alter.id + ), + }; + } + } + } + if matches!( + &alter.action, + AlterTableActionMutation::AddPrimaryKeyConstraint { .. } + ) && self + .local + .constraints + .iter() + .any(|((table, _), constraint)| { + table == &alter.id && constraint.kind == ConstraintKind::PrimaryKey + }) + { + return MutationResult::Conflict { + reason: format!("relation '{}' already has a primary key", alter.id), + }; + } + let 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, + if matches!( + &alter.action, + AlterTableActionMutation::AddPrimaryKeyConstraint { .. } + ) { + "pkey" + } else { + "key" + }, + &HashSet::new(), + ) + }); + if self + .local + .constraints + .contains_key(&(alter.id.clone(), name.clone())) + { + return MutationResult::Conflict { + reason: format!( + "constraint '{}' already exists on relation '{}'", + name, alter.id + ), + }; + } + } + AlterTableActionMutation::AlterConstraint { name, .. } => { + let Some(name) = name else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Applied; + }; + if !self + .local + .constraints + .contains_key(&(alter.id.clone(), name.clone())) + { + return MutationResult::Conflict { + reason: format!( + "constraint '{}' does not exist on relation '{}'", + name, alter.id + ), + }; + } + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Applied; + } + AlterTableActionMutation::AttachPartition { + child, strategy, .. + } => { + if let Err(result) = self.ensure_relation_target( + child, + |kind| *kind == RelationKind::Table, + format!("partition child relation '{}' does not exist", child), + format!("partition child '{}' is not a table", child), + ) { + return result; + } + let Some(RelationOverlay::Present(parent)) = self.local.relations.get(&alter.id) + else { + unreachable!("alter target presence established above") + }; + let Some(partition_type) = &parent.partition_type else { + return MutationResult::Conflict { + reason: format!("partition parent '{}' is not partitioned", alter.id), + }; + }; + if strategy + .as_deref() + .is_some_and(|strategy| !strategy.eq_ignore_ascii_case(partition_type)) + { + return MutationResult::Conflict { + reason: format!( + "partition strategy for '{}' does not match parent '{}' ({})", + child, alter.id, partition_type + ), + }; + } + if self.local.graph.check_partition_cycle(&alter.id, child) { + // Preserve the established no-edge behavior for malformed + // ancestry, but make the uncertainty explicit. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Applied; + } + let existing_parent = self.local.graph.edges().iter().find_map(|edge| { + (matches!(edge.kind, DependencyKind::PartitionOf) && edge.dependent == *child) + .then_some(edge.referenced.clone()) + }); + if let Some(existing_parent) = existing_parent { + return MutationResult::Conflict { + reason: format!( + "partition '{}' is already attached to '{}'", + child, existing_parent + ), + }; + } + } + AlterTableActionMutation::DetachPartition { child } => { + if let Err(result) = self.ensure_relation_target( + child, + |kind| *kind == RelationKind::Table, + format!("partition child relation '{}' does not exist", child), + format!("partition child '{}' is not a table", child), + ) { + return result; + } + if !self.local.graph.edges().iter().any(|edge| { + matches!(edge.kind, DependencyKind::PartitionOf) + && edge.dependent == *child + && edge.referenced == alter.id + }) { + return MutationResult::Conflict { + reason: format!( + "partition '{}' is not attached to parent '{}'", + child, alter.id + ), + }; + } + } + // These are fully typed, but their physical storage details are + // intentionally outside the schema state. They do not change any + // modeled identity, so retain exact confidence while rules still + // report their rewrite/locking cost. + AlterTableActionMutation::SetStorage { .. } + | AlterTableActionMutation::SetAccessMethod => return MutationResult::Applied, + AlterTableActionMutation::Opaque => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Applied; + } + _ => {} + } + + let trigger_mode = match &alter.action { + AlterTableActionMutation::DisableTrigger { trigger_name } => Some(( + trigger_name.as_deref(), + crate::model::trigger::TriggerEnableMode::Disabled, + )), + AlterTableActionMutation::EnableTrigger { trigger_name } => Some(( + trigger_name.as_deref(), + crate::model::trigger::TriggerEnableMode::Origin, + )), + _ => 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 trigger_ids: Vec = self + .local + .triggers + .iter() + .filter_map(|(id, overlay)| { + let TriggerOverlay::Present(trigger) = overlay else { + return None; + }; + (trigger.table_id == alter.id + && (all || trigger_name == Some(trigger.name.as_str()))) + .then(|| id.clone()) + }) + .collect(); + if trigger_ids.is_empty() && !all { + return MutationResult::Conflict { + reason: format!( + "trigger '{}' does not exist on relation '{}'", + trigger_name.unwrap_or_default(), + alter.id + ), + }; + } + for trigger_id in trigger_ids { + self.snapshot_trigger(&trigger_id); + if let Some(TriggerOverlay::Present(trigger)) = + self.local.triggers.get_mut(&trigger_id) + { + trigger.enabled_mode = enabled_mode; + } + } + return MutationResult::Applied; + } + + let mut effective_fk_target_columns: Option> = None; + if let AlterTableActionMutation::AddForeignKey { + to_table, + from_columns, + to_columns, + .. + } = &alter.action + { + if from_columns.is_empty() { + return MutationResult::Conflict { + reason: format!( + "foreign key on relation '{}' has no source columns", + alter.id + ), + }; + } + if !to_columns.is_empty() && from_columns.len() != to_columns.len() { + return MutationResult::Conflict { + reason: format!( + "foreign key on '{}' has {} source columns but {} referenced columns", + alter.id, + from_columns.len(), + to_columns.len() + ), + }; + } + if let Some(RelationOverlay::Present(child)) = self.local.relations.get(&alter.id) + && (!self.baseline_relations.contains(&alter.id) || !child.columns.is_empty()) + && let Some(column) = from_columns.iter().find(|column| !child.has_column(column)) + { + return MutationResult::Conflict { + reason: format!( + "foreign key column '{}' does not exist on relation '{}'", + column, alter.id + ), + }; + } + + if let Err(result) = self.ensure_relation_target( + to_table, + |kind| *kind == RelationKind::Table, + format!( + "foreign key references relation '{}' which does not exist", + to_table + ), + format!("foreign key references '{}' which is not a table", to_table), + ) { + return result; + } + let Some(RelationOverlay::Present(parent)) = self.local.relations.get(to_table) else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + }; + let target_columns_known = + !self.baseline_relations.contains(to_table) || !parent.columns.is_empty(); + if target_columns_known + && let Some(column) = to_columns.iter().find(|column| !parent.has_column(column)) + { + return MutationResult::Conflict { + reason: format!( + "foreign key references column '{}.{}' which does not exist", + to_table, column + ), + }; + } + let target_keys = self.unique_keys_for_relation(to_table); + let mut fk_evidence_unknown = target_keys.is_none(); + let referenced_columns = if to_columns.is_empty() { + let primary_keys: Vec<&Vec> = target_keys + .as_ref() + .map(|keys| { + keys.iter() + .filter_map(|(columns, is_primary)| is_primary.then_some(columns)) + .collect() + }) + .unwrap_or_default(); + if target_keys.is_some() && primary_keys.len() != 1 { + return MutationResult::Conflict { + reason: format!( + "foreign key on '{}' omits referenced columns but target '{}' has no single primary key", + alter.id, to_table + ), + }; + } + primary_keys.first().cloned().cloned().unwrap_or_default() + } else { + to_columns.clone() + }; + effective_fk_target_columns = Some(referenced_columns.clone()); + if let Some(keys) = target_keys.as_ref() + && !keys + .iter() + .any(|(columns, _)| columns == &referenced_columns) + { + return MutationResult::Conflict { + reason: format!( + "foreign key on '{}' references columns on '{}' that are not backed by a primary key or unique key", + alter.id, to_table + ), + }; + } + let Some(child) = + self.local + .relations + .get(&alter.id) + .and_then(|overlay| match overlay { + RelationOverlay::Present(relation) => Some(relation.clone()), + RelationOverlay::Dropped => None, + }) + else { + unreachable!("alter target presence established above"); + }; + let source_types: Vec, Option)>> = from_columns + .iter() + .map(|column| { + child + .get_column(column) + .map(|state| (state.type_id.clone(), state.data_type.clone())) + }) + .collect(); + let target_types: Vec, Option)>> = referenced_columns + .iter() + .map(|column| { + parent + .get_column(column) + .map(|state| (state.type_id.clone(), state.data_type.clone())) + }) + .collect(); + let mut type_evidence_unknown = false; + let type_mismatch = + source_types + .iter() + .zip(&target_types) + .any(|(source, target)| match (source, target) { + (Some((Some(source_id), _)), Some((Some(target_id), _))) => { + source_id != target_id + } + (Some((_, Some(source_ty))), Some((_, Some(target_ty)))) => { + !source_ty.trim().eq_ignore_ascii_case(target_ty.trim()) + } + _ => { + type_evidence_unknown = true; + false + } + }); + if type_mismatch { + // PostgreSQL permits some binary-compatible type pairs, but + // the cache model does not carry the catalog cast graph. A + // mismatch therefore cannot be classified safely here. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + if type_evidence_unknown { + fk_evidence_unknown = true; + } + if fk_evidence_unknown { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + } + + let implicit_add = match &alter.action { + AlterTableActionMutation::AddColumn { + name, generation, .. + } => match generation { + crate::analysis::facts::ColumnGeneration::Serial => Some(( + self.next_implicit_sequence_id(&alter.id, name, &HashSet::new()), + name.clone(), + SequenceKind::SerialLike, + )), + crate::analysis::facts::ColumnGeneration::Identity => Some(( + self.next_implicit_sequence_id(&alter.id, name, &HashSet::new()), + name.clone(), + SequenceKind::Identity, + )), + crate::analysis::facts::ColumnGeneration::Ordinary => None, + }, + _ => None, + }; + let owned_sequences_for_column: Vec = match &alter.action { + AlterTableActionMutation::DropColumn { name, .. } + | AlterTableActionMutation::RenameColumn { from: name, .. } => self + .local + .sequences + .iter() + .filter_map(|(id, overlay)| match overlay { + SequenceOverlay::Present(sequence) + if sequence.owned_by.as_ref() + == Some(&(alter.id.clone(), name.clone())) => + { + Some(id.clone()) + } + _ => None, + }) + .collect(), + _ => Vec::new(), + }; + + let using_index = match &alter.action { + AlterTableActionMutation::AddUniqueConstraint { using_index, .. } + | AlterTableActionMutation::AddPrimaryKeyConstraint { using_index, .. } => { + using_index.as_ref() + } + _ => None, + }; + if let Some(index) = using_index { + let Some(edge) = self + .local + .graph + .edges() + .iter() + .find(|edge| { + matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) + && edge.dependent == *index + }) + .cloned() + else { + return MutationResult::Conflict { + reason: format!( + "constraint references index '{}' which does not exist", + index + ), + }; + }; + if edge.referenced != alter.id { + return MutationResult::Conflict { + reason: format!( + "constraint index '{}' belongs to relation '{}', not '{}'", + index, edge.referenced, alter.id + ), + }; + } + if let DependencyKind::IndexOnRelation { + has_predicate, + is_unique, + eligibility_known, + .. + } = &edge.kind + { + if !*eligibility_known { + // V6 baseline index rows do not retain uniqueness or + // predicate metadata. PostgreSQL would reject a USING + // INDEX constraint for an ineligible index, so do not + // manufacture an exact constraint in that case. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + if !*is_unique || *has_predicate { + return MutationResult::Conflict { + reason: format!( + "constraint index '{}' must be unique and non-partial", + index + ), + }; + } + } + + let constraint_name = match &alter.action { + AlterTableActionMutation::AddUniqueConstraint { + constraint_name, .. + } + | AlterTableActionMutation::AddPrimaryKeyConstraint { + constraint_name, .. + } => constraint_name + .clone() + .unwrap_or_else(|| index.name.clone()), + _ => unreachable!("using_index is only valid for key constraints"), + }; + let adopted_index = ObjectId::new(index.schema.clone(), constraint_name); + if adopted_index != *index && self.relation_namespace_object_is_present(&adopted_index) + { + return MutationResult::Conflict { + reason: format!("constraint index '{}' already exists", adopted_index), + }; + } + } + + let mut drop_column_constraints: HashSet<(ObjectId, String)> = HashSet::new(); + if let AlterTableActionMutation::DropColumn { name, cascade, .. } = &alter.action { + let resolved_table = self.local.graph.resolve_rename(&alter.id).clone(); + let mut unknown_dependency = false; + let mut known_dependency = false; + for edge in self.local.graph.edges() { + let dependent = self.local.graph.resolve_rename(&edge.dependent); + let referenced = self.local.graph.resolve_rename(&edge.referenced); + match &edge.kind { + DependencyKind::ForeignKey { + constraint_name, + from_columns, + to_columns, + .. + } if dependent == &resolved_table => { + if from_columns.is_empty() { + unknown_dependency = true; + } else if from_columns.iter().any(|column| column == name) { + known_dependency = true; + if let Some(constraint_name) = constraint_name { + drop_column_constraints + .insert((resolved_table.clone(), constraint_name.clone())); + } else { + unknown_dependency = true; + } + } + // The source-side columns are the only columns on this + // relation represented by the edge. Keep this branch + // explicit so a future edge shape cannot be mistaken + // for a source-column dependency. + let _ = to_columns; + } + DependencyKind::ForeignKey { + constraint_name, + to_columns, + .. + } if referenced == &resolved_table => { + if to_columns.is_empty() { + unknown_dependency = true; + } else if to_columns.iter().any(|column| column == name) { + known_dependency = true; + if let Some(constraint_name) = constraint_name { + drop_column_constraints + .insert((dependent.clone(), constraint_name.clone())); + } else { + unknown_dependency = true; + } + } + } + DependencyKind::ConstraintOnRelation { + constraint_name, + columns, + .. + } if dependent == &resolved_table => { + if columns.is_empty() { + unknown_dependency = true; + } else if columns.iter().any(|column| column == name) { + known_dependency = true; + drop_column_constraints + .insert((resolved_table.clone(), constraint_name.clone())); + } + } + // Index and view rows in Cache V6 do not carry the + // referenced column list. A drop may therefore be a + // PostgreSQL dependency error or a CASCADE operation; + // do not manufacture an exact state transition. + DependencyKind::IndexOnRelation { .. } if referenced == &resolved_table => { + unknown_dependency = true; + } + DependencyKind::ViewDependency { .. } if referenced == &resolved_table => { + unknown_dependency = true; + } + DependencyKind::ColumnGeneratedFrom { .. } + if dependent == &resolved_table || referenced == &resolved_table => + { + unknown_dependency = true; + } + _ => {} + } + } + + // CHECK/EXCLUDE expressions and baseline key definitions do not + // retain their column expressions in the current cache model. + // If one is present without a precise ConstraintOnRelation edge, + // the column may be a dependency and must remain conservative. + for ((table_id, constraint_name), constraint) in &self.local.constraints { + if self.local.graph.resolve_rename(table_id) != &resolved_table { + continue; + } + let represented = self.local.graph.edges().iter().any(|edge| { + edge.dependent == resolved_table + && matches!( + &edge.kind, + DependencyKind::ConstraintOnRelation { + constraint_name: name, + .. + } if name == constraint_name + ) + }); + if !represented + || matches!( + constraint.kind, + ConstraintKind::Check | ConstraintKind::Exclusion + ) + { + unknown_dependency = true; + } + } + + if unknown_dependency { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + if known_dependency && !cascade { + return MutationResult::Conflict { + reason: format!( + "column '{}.{}' has dependent objects; use CASCADE", + alter.id, name + ), + }; + } + } + + self.snapshot_relation(&alter.id); + let action_type_id = match &alter.action { + AlterTableActionMutation::AddColumn { ty, .. } => ty + .as_deref() + .and_then(|raw| self.resolve_type_reference(raw)), + AlterTableActionMutation::SetType { ty, .. } => self.resolve_type_reference(ty), + _ => None, + }; + let rel_overlay = self.local.relations.get_mut(&alter.id); + if let Some(RelationOverlay::Present(rel)) = rel_overlay { + let generation = rel.generation; + match &alter.action { + AlterTableActionMutation::AddColumn { + name, + ty, + if_not_exists, + not_null, + default, + depends_on, + generation: _, + } => { + 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 let Some(column) = rel.columns.iter_mut().find(|column| column.name == *name) + { + column.type_id = action_type_id.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) + { + 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(), + }, + )); + } + } + AlterTableActionMutation::DropColumn { + name, if_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 '{}' does not exist on relation '{}'", + name, alter.id + ), + }; + } + rel.apply_column_action(&ColumnAction::Drop { name: name.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(), + }); + } + AlterTableActionMutation::DropNotNull { column } => { + rel.apply_column_action(&ColumnAction::DropNotNull { + name: column.clone(), + }); + } + AlterTableActionMutation::SetType { column, ty, .. } => { + if !rel.has_column(column) { + self.local.confidence = Confidence::Tainted; + } + 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::SetDefault { column, default } => { + if !rel.has_column(column) { + self.local.confidence = Confidence::Tainted; + } + rel.apply_column_action(&ColumnAction::SetDefault { + name: column.clone(), + default: default.clone(), + }); + } + 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, + }, + ); + 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()), + from_generation: generation, + }, + )); + } + AlterTableActionMutation::DropConstraint { name, .. } => { + self.snapshot_constraint(&alter.id, name); + self.local + .constraints + .remove(&(alter.id.clone(), name.clone())); + 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(); + 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), + _ => 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 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())); + } + 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())); + } + self.snapshot_graph_full(); + self.local.graph.mutate_edges(|edges| { + for edge in edges { + if edge.dependent == alter.id + && let DependencyKind::ForeignKey { + constraint_name, .. + } = &mut edge.kind + && constraint_name.as_deref() == Some(old_name) + { + *constraint_name = Some(new_name.clone()); + } + } + }); + } + AlterTableActionMutation::AddCheckConstraint { + constraint_name, + not_valid, + } => { + let constraint_name = constraint_name.clone().unwrap_or_else(|| { + self.next_generated_constraint_name_avoiding( + &alter.id, + &alter.id.name, + None, + "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, + }, + ); + if *not_valid { + self.snapshot_pending_validation(); + self.local + .pending_validation + .insert((alter.id.clone(), constraint_name.clone())); + } + } + 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(), + ) + }); + 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, + }, + ); + if columns.is_empty() || !relation_columns_known { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } 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::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(), + ) + }); + 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, + }, + ); + if columns.is_empty() || !relation_columns_known { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } 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, + }, + )); + } + } + AlterTableActionMutation::AddExcludeConstraint { constraint_name } => { + 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, + kind: ConstraintKind::Exclusion, + validated: true, + }, + ); + } + AlterTableActionMutation::ValidateConstraint { constraint_name } => { + self.snapshot_constraint(&alter.id, constraint_name); + if let Some(constraint) = self + .local + .constraints + .get_mut(&(alter.id.clone(), constraint_name.clone())) + { + constraint.validated = true; + } + if self + .local + .pending_validation + .contains(&(alter.id.clone(), constraint_name.clone())) + { + self.snapshot_pending_validation(); + self.local + .pending_validation + .remove(&(alter.id.clone(), constraint_name.clone())); + } + } + AlterTableActionMutation::AttachPartition { child, .. } => { + // Reject attachments that would make partition ancestry cyclic. + if self.local.graph.check_partition_cycle(&alter.id, child) { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } else { + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + child.clone(), + alter.id.clone(), + DependencyKind::PartitionOf, + )); + } + } + AlterTableActionMutation::DetachPartition { child } => { + self.snapshot_graph(); + self.local.graph.retain_edges(|e| { + !(matches!(e.kind, DependencyKind::PartitionOf) + && e.dependent == *child + && e.referenced == alter.id) + }); + } + _ => {} + } + } + if let AlterTableActionMutation::RenameColumn { from, to } = &alter.action { + self.snapshot_graph_full(); + self.local.graph.mutate_edges(|edges| { + for edge in edges { + match &mut edge.kind { + DependencyKind::ForeignKey { + from_columns, + to_columns, + .. + } => { + if edge.dependent == alter.id { + for column in from_columns { + if column == from { + *column = to.clone(); + } + } + } + if edge.referenced == alter.id { + for column in to_columns { + if column == from { + *column = to.clone(); + } + } + } + } + DependencyKind::ConstraintOnRelation { columns, .. } + if edge.dependent == alter.id => + { + for column in columns { + if column == from { + *column = to.clone(); + } + } + } + DependencyKind::ColumnGeneratedFrom { + column, + depends_on_column, + } => { + if edge.dependent == alter.id && column == from { + *column = to.clone(); + } + if edge.referenced == alter.id && depends_on_column == from { + *depends_on_column = to.clone(); + } + } + _ => {} + } + } + }); + + // 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::model::replication::PublicationOverlay::Present(publication) = + overlay + else { + return None; + }; + let indexes = match &publication.scope { + crate::analysis::facts::PublicationScope::Explicit(objects) => objects + .iter() + .enumerate() + .filter_map(|(index, object)| { + let crate::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::model::replication::PublicationOverlay::Present(publication)) = + self.local.publications.get_mut(&publication_name) + && let crate::analysis::facts::PublicationScope::Explicit(objects) = + &mut publication.scope + { + for index in object_indexes { + if let Some(crate::analysis::facts::PublicationObjectFact::Table { + columns: Some(columns), + .. + }) = objects.get_mut(index) + { + for column in columns { + if column == from { + *column = to.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(); + 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())), + _ => { + // The preflight above has already rejected unknown + // column-bearing edges; this arm keeps unrelated + // topology intact. + true + } + } + }); + } + match &alter.action { + AlterTableActionMutation::DropColumn { .. } => { + 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 { 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(); + } + self.snapshot_graph_full(); + self.local.graph.mutate_edges(|edges| { + for edge in edges { + if edge.dependent == sequence_id + && let DependencyKind::SequenceOwnedBy { column } = &mut edge.kind + { + *column = to.clone(); + } + } + }); + } + } + _ => {} + } + 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_relations.contains(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 { + keys.push((columns.clone(), *is_primary)); + } + } + DependencyKind::IndexOnRelation { + is_unique: true, .. + } => unknown = true, + _ => {} + } + } + if !keys.is_empty() { + return Some(keys); + } + 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); + match self.relation_or_index_lookup(&rename.old_id) { + RelationLookup::Present => {} + _ if self.baseline_covers_object(&rename.old_id) => { + return MutationResult::Conflict { + reason: format!("relation '{}' does not exist", rename.old_id), + }; + } + RelationLookup::Tombstone + | RelationLookup::AuthoritativelyAbsent + | RelationLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + 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.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + + let publication_scope_updates: Vec<(String, Vec)> = self + .local + .publications + .iter() + .filter_map(|(publication_name, overlay)| { + let crate::model::replication::PublicationOverlay::Present(publication) = overlay + else { + return None; + }; + let crate::analysis::facts::PublicationScope::Explicit(objects) = + &publication.scope + else { + return None; + }; + let indexes = objects + .iter() + .enumerate() + .filter_map(|(index, object)| { + let crate::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) => + { + 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(); + } + } + let triggers_to_move: Vec<(ObjectId, crate::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, + )); + } + 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); + } + + for (publication_name, object_indexes) in publication_scope_updates { + self.snapshot_publication(&publication_name); + if let Some(crate::model::replication::PublicationOverlay::Present(publication)) = + self.local.publications.get_mut(&publication_name) + && let crate::analysis::facts::PublicationScope::Explicit(objects) = + &mut publication.scope + { + for index in object_indexes { + let Some(crate::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::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::ast::identifiers::Ident::new( + rename.new_id.schema.clone(), + schema_quoted, + )); + } + } + } + } + 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); + } + if renames_index { + self.local + .graph + .propagate_index_rename(&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()); + } + 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 { + (table, name) + } + }) + .collect(); + } + if renames_index && self.baseline_indexes.remove(&rename.old_id) { + self.baseline_indexes.insert(rename.new_id.clone()); + } + + MutationResult::Applied + } + + pub(super) fn apply_change_relation_owner( + &mut self, + id: &ObjectId, + new_owner: &crate::analysis::facts::RoleFact, + ) -> MutationResult { + let Some((owner, known)) = self.role_fact_identity(new_owner) else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + }; + if !known { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + if known && self.local.roles_known && self.present_role(&owner).is_none() { + return MutationResult::Conflict { + reason: format!("role '{}' does not exist", owner), + }; + } + if known && !self.local.roles_known { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + match self.relation_lookup(id, |_| true) { + RelationLookup::Present => { + self.snapshot_relation(id); + let Some(RelationOverlay::Present(relation)) = self.local.relations.get_mut(id) + else { + unreachable!("relation lookup established presence") + }; + relation.owner = ObjectId::new("", owner); + 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.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + MutationResult::Skipped + } + } + } +} diff --git a/src/analysis/state/apply_replication.rs b/src/analysis/state/apply_replication.rs new file mode 100644 index 0000000..961e468 --- /dev/null +++ b/src/analysis/state/apply_replication.rs @@ -0,0 +1,901 @@ +use super::{AnalysisState, Confidence, MutationResult, ObjectLookup}; +use crate::analysis::graph::{DependencyEdge, DependencyKind}; +use crate::analysis::mutations::{ + AlterPublicationMutation, AlterSubscriptionMutation, CreatePublicationMutation, + CreateSubscriptionMutation, DropPublicationMutation, DropSubscriptionMutation, +}; +use crate::ast::identifiers::ObjectId; +use crate::model::replication::{PublicationOverlay, SubscriptionOverlay}; +use std::collections::HashSet; + +type PublicationLookup = ObjectLookup; +type SubscriptionLookup = ObjectLookup; + +impl AnalysisState { + fn publication_lookup(&self, name: &str) -> PublicationLookup { + match self.local.publications.get(name) { + Some(PublicationOverlay::Present(_)) => PublicationLookup::Present, + Some(PublicationOverlay::Dropped) => PublicationLookup::Tombstone, + None if self.baseline_available => PublicationLookup::AuthoritativelyAbsent, + None => PublicationLookup::Unknown, + } + } + + fn subscription_lookup(&self, name: &str) -> SubscriptionLookup { + match self.local.subscriptions.get(name) { + Some(SubscriptionOverlay::Present(_)) => SubscriptionLookup::Present, + Some(SubscriptionOverlay::Dropped) => SubscriptionLookup::Tombstone, + None if self.baseline_available => SubscriptionLookup::AuthoritativelyAbsent, + None => SubscriptionLookup::Unknown, + } + } + + pub(super) fn apply_create_publication( + &mut self, + p: &CreatePublicationMutation, + ) -> MutationResult { + match self.publication_lookup(&p.name) { + PublicationLookup::Present => { + return MutationResult::Conflict { + reason: format!("publication '{}' already exists", p.name), + }; + } + PublicationLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + PublicationLookup::Tombstone | PublicationLookup::AuthoritativelyAbsent => {} + PublicationLookup::WrongKind => { + unreachable!("publication names have a dedicated namespace") + } + } + if let Err(reason) = self.validate_publication_scope(&p.scope) { + return MutationResult::Conflict { reason }; + } + self.taint_inheritance_sensitive_publication_scope(&p.scope); + self.snapshot_publication(&p.name); + self.snapshot_generation_counter(); + self.local.generation_counter += 1; + let generation = self.local.generation_counter; + + let owner = self + .local + .current_role_known + .then(|| self.local.current_role.clone()); + self.local.publications.insert( + p.name.clone(), + crate::model::replication::PublicationOverlay::Present( + crate::model::replication::PublicationState { + name: p.name.clone(), + owner, + scope: p.scope.clone(), + params: p.params.clone(), + generation, + }, + ), + ); + + if let crate::analysis::facts::PublicationScope::Explicit(objects) = &p.scope { + self.snapshot_graph_full(); + for obj in objects { + if let crate::analysis::facts::PublicationObjectFact::Table { name, .. } = obj { + let table_id = self.resolve_relation_id(name); + self.local.graph.add_edge(DependencyEdge::new( + table_id, + ObjectId::new("public", &p.name), + DependencyKind::PublicationIncludes { + publication_name: p.name.clone(), + }, + )); + } + } + } + MutationResult::Applied + } + + pub(super) fn apply_alter_publication( + &mut self, + p: &AlterPublicationMutation, + ) -> MutationResult { + match self.publication_lookup(&p.name) { + PublicationLookup::Present => {} + PublicationLookup::Tombstone | PublicationLookup::AuthoritativelyAbsent => { + return MutationResult::Conflict { + reason: format!("publication '{}' does not exist", p.name), + }; + } + PublicationLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + PublicationLookup::WrongKind => { + unreachable!("publication names have a dedicated namespace") + } + } + // Validate a rename destination before taking statement snapshots or + // advancing generation state. In a scoped/incomplete catalog an + // absent destination is not authoritative, so applying the rename + // would hide a possible namespace collision. + if let crate::analysis::facts::AlterPublicationActionFact::Rename { to } = &p.action { + match self.publication_lookup(to) { + PublicationLookup::Present => { + return MutationResult::Conflict { + reason: format!("publication '{}' already exists", to), + }; + } + PublicationLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + PublicationLookup::Tombstone + | PublicationLookup::AuthoritativelyAbsent + | PublicationLookup::WrongKind => {} + } + } + if let crate::analysis::facts::AlterPublicationActionFact::OwnerChange(role) = &p.action + && let Some((owner, known)) = self.role_fact_identity(role) + && known + && self.local.roles_known + && self.present_role(&owner).is_none() + { + return MutationResult::Conflict { + reason: format!("role '{}' does not exist", owner), + }; + } + self.snapshot_publication(&p.name); + self.snapshot_generation_counter(); + self.local.generation_counter += 1; + let new_gen = self.local.generation_counter; + use crate::analysis::facts::AlterPublicationActionFact; + + let current_scope = match self.local.publications.get(&p.name) { + Some(crate::model::replication::PublicationOverlay::Present(publication)) => { + publication.scope.clone() + } + _ => unreachable!("publication existence checked above"), + }; + let mut replacement_scope = None; + let mut rename_to = None; + match &p.action { + AlterPublicationActionFact::AddObjects(additions) => { + let additions_scope = + crate::analysis::facts::PublicationScope::Explicit(additions.clone()); + if let Err(reason) = self.validate_publication_scope(&additions_scope) { + return MutationResult::Conflict { reason }; + } + self.taint_inheritance_sensitive_publication_scope(&additions_scope); + let crate::analysis::facts::PublicationScope::Explicit(mut objects) = current_scope + else { + return MutationResult::Conflict { + reason: format!("publication '{}' already includes all tables", p.name), + }; + }; + let mut keys: HashSet = objects + .iter() + .map(|object| self.publication_object_key(object)) + .collect(); + for addition in additions { + let key = self.publication_object_key(addition); + if !keys.insert(key) { + return MutationResult::Conflict { + reason: format!( + "publication '{}' already contains the requested object", + p.name + ), + }; + } + objects.push(addition.clone()); + } + replacement_scope = + Some(crate::analysis::facts::PublicationScope::Explicit(objects)); + } + AlterPublicationActionFact::SetObjects(scope) => { + if let Err(reason) = self.validate_publication_scope(scope) { + return MutationResult::Conflict { reason }; + } + self.taint_inheritance_sensitive_publication_scope(scope); + replacement_scope = Some(scope.clone()); + } + AlterPublicationActionFact::DropObjects(removals) => { + self.taint_inheritance_sensitive_publication_scope( + &crate::analysis::facts::PublicationScope::Explicit(removals.clone()), + ); + let crate::analysis::facts::PublicationScope::Explicit(mut objects) = current_scope + else { + return MutationResult::Conflict { + reason: format!("publication '{}' includes all tables", p.name), + }; + }; + for removal in removals { + let key = self.publication_object_key(removal); + let Some(position) = objects + .iter() + .position(|object| self.publication_object_key(object) == key) + else { + return MutationResult::Conflict { + reason: format!( + "publication '{}' does not contain the requested object", + p.name + ), + }; + }; + objects.remove(position); + } + replacement_scope = + Some(crate::analysis::facts::PublicationScope::Explicit(objects)); + } + AlterPublicationActionFact::SetOptions(options) => { + if let Some(crate::model::replication::PublicationOverlay::Present(publication)) = + self.local.publications.get_mut(&p.name) + { + for option in options { + publication + .params + .retain(|existing| existing.name != option.name); + publication.params.push(option.clone()); + } + } + } + AlterPublicationActionFact::OwnerChange(role) => { + if let Some((owner, known)) = self.role_fact_identity(role) { + if known { + if !self.local.roles_known { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + if let Some(crate::model::replication::PublicationOverlay::Present( + publication, + )) = self.local.publications.get_mut(&p.name) + { + publication.owner = Some(owner); + } + } else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + if let Some(crate::model::replication::PublicationOverlay::Present( + publication, + )) = self.local.publications.get_mut(&p.name) + { + publication.owner = None; + } + } + } else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + if let Some(crate::model::replication::PublicationOverlay::Present( + publication, + )) = self.local.publications.get_mut(&p.name) + { + publication.owner = None; + } + } + } + AlterPublicationActionFact::Rename { to } => { + rename_to = Some(to.clone()); + } + } + + if let Some(scope) = replacement_scope { + if let Some(crate::model::replication::PublicationOverlay::Present(publication)) = + self.local.publications.get_mut(&p.name) + { + publication.scope = scope.clone(); + } + self.replace_publication_edges(&p.name, &scope); + } + if let Some(crate::model::replication::PublicationOverlay::Present(publication)) = + self.local.publications.get_mut(&p.name) + { + publication.generation = new_gen; + } + if let Some(to) = rename_to { + self.snapshot_publication(&to); + let Some(crate::model::replication::PublicationOverlay::Present(mut publication)) = + self.local.publications.remove(&p.name) + else { + unreachable!("publication existence checked above"); + }; + publication.name = to.clone(); + self.local.publications.insert( + to.clone(), + crate::model::replication::PublicationOverlay::Present(publication), + ); + self.snapshot_graph_full(); + self.local.graph.mutate_edges(|edges| { + for edge in edges { + if let DependencyKind::PublicationIncludes { publication_name } = &mut edge.kind + && publication_name == &p.name + { + *publication_name = to.clone(); + edge.referenced = ObjectId::new("public", &to); + } + } + }); + } + MutationResult::Applied + } + + pub(super) fn apply_drop_publication(&mut self, p: &DropPublicationMutation) -> MutationResult { + let mut present_names = Vec::new(); + let mut unknown_target = false; + for name in &p.names { + match self.publication_lookup(name) { + PublicationLookup::Present => present_names.push(name.clone()), + PublicationLookup::Tombstone => { + if !p.if_exists { + return MutationResult::Conflict { + reason: format!("publication '{}' does not exist", name), + }; + } + } + PublicationLookup::AuthoritativelyAbsent if p.if_exists => {} + PublicationLookup::Unknown if p.if_exists => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + unknown_target = true; + } + PublicationLookup::AuthoritativelyAbsent => { + return MutationResult::Conflict { + reason: format!("publication '{}' does not exist", name), + }; + } + PublicationLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + PublicationLookup::WrongKind => { + unreachable!("publication names have a dedicated namespace") + } + } + } + if unknown_target { + return MutationResult::Skipped; + } + for name in &present_names { + self.snapshot_publication(name); + self.local.publications.insert( + name.clone(), + crate::model::replication::PublicationOverlay::Dropped, + ); + } + self.snapshot_graph_full(); + self.local.graph.retain_edges(|e| { + !(matches!(e.kind, DependencyKind::PublicationIncludes { .. }) + && present_names.contains(&e.referenced.name)) + }); + if present_names.is_empty() { + MutationResult::Skipped + } else { + MutationResult::Applied + } + } + + pub(super) fn apply_create_subscription( + &mut self, + s: &CreateSubscriptionMutation, + ) -> MutationResult { + let name = s.name.clone().unwrap_or_else(|| "unnamed_sub".into()); + match self.subscription_lookup(&name) { + SubscriptionLookup::Present => { + return MutationResult::Conflict { + reason: format!("subscription '{}' already exists", name), + }; + } + SubscriptionLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + SubscriptionLookup::Tombstone | SubscriptionLookup::AuthoritativelyAbsent => {} + SubscriptionLookup::WrongKind => { + unreachable!("subscription names have a dedicated namespace") + } + } + + let params = s.params.as_deref(); + if let Err(reason) = Self::validate_subscription_boolean_options( + params, + &[ + "connect", + "create_slot", + "enabled", + "copy_data", + "binary", + "disable_on_error", + "password_required", + "run_as_owner", + "failover", + "two_phase", + ], + ) { + return MutationResult::Conflict { reason }; + } + let connects_to_publisher = + Self::subscription_boolean_option(params, "connect") != Some(false); + if !connects_to_publisher + && ["create_slot", "enabled", "copy_data"] + .iter() + .any(|name| Self::subscription_boolean_option(params, name) == Some(true)) + { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' cannot enable connection-dependent options when connect is false", + name + ), + }; + } + let creates_slot = connects_to_publisher + && Self::subscription_boolean_option(params, "create_slot") != Some(false); + if !self.local.transactions.is_empty() && connects_to_publisher && creates_slot { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' cannot create a replication slot inside a transaction", + name + ), + }; + } + self.snapshot_subscription(&name); + self.snapshot_generation_counter(); + self.local.generation_counter += 1; + let generation = self.local.generation_counter; + + if connects_to_publisher { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + + let enabled = connects_to_publisher + && Self::subscription_boolean_option(params, "enabled") != Some(false); + let slot_name = match Self::subscription_option(params, "slot_name") { + Some(value) if value.eq_ignore_ascii_case("none") => None, + Some(value) => Some(value.to_string()), + None => Some(name.clone()), + }; + if slot_name.is_none() && (enabled || creates_slot) { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' with slot_name NONE must disable enabled and create_slot", + name + ), + }; + } + let mut unique_publications = HashSet::new(); + if !s + .publications + .iter() + .all(|publication| unique_publications.insert(publication)) + { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' lists the same publication more than once", + name + ), + }; + } + + let owner = self + .local + .current_role_known + .then(|| self.local.current_role.clone()); + self.local.subscriptions.insert( + name.clone(), + crate::model::replication::SubscriptionOverlay::Present( + crate::model::replication::SubscriptionState { + name, + owner, + connection: s.connection.clone(), + publications: s.publications.clone(), + params: s.params.clone(), + enabled, + slot_name, + generation, + }, + ), + ); + MutationResult::Applied + } + + pub(super) fn apply_alter_subscription( + &mut self, + s: &AlterSubscriptionMutation, + ) -> MutationResult { + match self.subscription_lookup(&s.name) { + SubscriptionLookup::Present => {} + SubscriptionLookup::Tombstone | SubscriptionLookup::AuthoritativelyAbsent => { + return MutationResult::Conflict { + reason: format!("subscription '{}' does not exist", s.name), + }; + } + SubscriptionLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + SubscriptionLookup::WrongKind => { + unreachable!("subscription names have a dedicated namespace") + } + } + // As with publications, an unknown destination in an incomplete + // catalog cannot be treated as free for a namespace rename. + if let crate::analysis::facts::AlterSubscriptionActionFact::Rename { to } = &s.action { + match self.subscription_lookup(to) { + SubscriptionLookup::Present => { + return MutationResult::Conflict { + reason: format!("subscription '{}' already exists", to), + }; + } + SubscriptionLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + SubscriptionLookup::Tombstone + | SubscriptionLookup::AuthoritativelyAbsent + | SubscriptionLookup::WrongKind => {} + } + } + if let crate::analysis::facts::AlterSubscriptionActionFact::OwnerChange(role) = &s.action + && let Some((owner, known)) = self.role_fact_identity(role) + && known + && self.local.roles_known + && self.present_role(&owner).is_none() + { + return MutationResult::Conflict { + reason: format!("role '{}' does not exist", owner), + }; + } + let existing = match self.local.subscriptions.get(&s.name) { + Some(crate::model::replication::SubscriptionOverlay::Present(subscription)) => { + subscription + } + _ => unreachable!("subscription existence checked above"), + }; + let in_transaction = !self.local.transactions.is_empty(); + match &s.action { + crate::analysis::facts::AlterSubscriptionActionFact::Publications { + mode, + publications, + params, + } => { + if let Err(reason) = Self::validate_subscription_boolean_options( + Some(params), + &["refresh", "copy_data"], + ) { + return MutationResult::Conflict { reason }; + } + if in_transaction + && Self::subscription_boolean_option(Some(params), "refresh") != Some(false) + { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' cannot refresh publications inside a transaction", + s.name + ), + }; + } + + let mut unique = HashSet::new(); + match mode { + crate::analysis::facts::SubscriptionPublicationMode::Set => { + if !publications + .iter() + .all(|publication| unique.insert(publication)) + { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' lists the same publication more than once", + s.name + ), + }; + } + } + crate::analysis::facts::SubscriptionPublicationMode::Add => { + for publication in publications { + if !unique.insert(publication) + || existing.publications.contains(publication) + { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' already includes publication '{}'", + s.name, publication + ), + }; + } + } + } + crate::analysis::facts::SubscriptionPublicationMode::Drop => { + for publication in publications { + if !unique.insert(publication) + || !existing.publications.contains(publication) + { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' does not include publication '{}'", + s.name, publication + ), + }; + } + } + } + } + } + crate::analysis::facts::AlterSubscriptionActionFact::RefreshPublication(_) + if in_transaction => + { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' cannot refresh publications inside a transaction", + s.name + ), + }; + } + crate::analysis::facts::AlterSubscriptionActionFact::SetOptions(options) => { + if let Err(reason) = Self::validate_subscription_boolean_options( + Some(options), + &[ + "binary", + "disable_on_error", + "password_required", + "run_as_owner", + "failover", + "two_phase", + ], + ) { + return MutationResult::Conflict { reason }; + } + if existing.enabled + && options + .iter() + .any(|option| option.name.eq_ignore_ascii_case("slot_name")) + { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' must be disabled before changing slot_name", + s.name + ), + }; + } + let changes_failover_or_two_phase = options.iter().any(|option| { + option.name.eq_ignore_ascii_case("failover") + || option.name.eq_ignore_ascii_case("two_phase") + }); + if changes_failover_or_two_phase && existing.enabled { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' must be disabled before changing failover or two_phase", + s.name + ), + }; + } + let forbidden_in_transaction = options.iter().any(|option| { + option.name.eq_ignore_ascii_case("failover") + || (option.name.eq_ignore_ascii_case("two_phase") + && Self::postgres_boolean(&option.value) == Some(false)) + }); + if in_transaction && forbidden_in_transaction { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' cannot change this setting inside a transaction", + s.name + ), + }; + } + } + crate::analysis::facts::AlterSubscriptionActionFact::SetEnabled(true) + if existing.slot_name.is_none() => + { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' cannot be enabled without a slot_name", + s.name + ), + }; + } + _ => {} + } + self.snapshot_subscription(&s.name); + self.snapshot_generation_counter(); + self.local.generation_counter += 1; + let new_gen = self.local.generation_counter; + use crate::analysis::facts::{AlterSubscriptionActionFact, SubscriptionPublicationMode}; + let mut rename_to = None; + match &s.action { + AlterSubscriptionActionFact::SetConnection(connection) => { + if let Some(crate::model::replication::SubscriptionOverlay::Present(subscription)) = + self.local.subscriptions.get_mut(&s.name) + { + subscription.connection = connection.clone(); + } + } + AlterSubscriptionActionFact::SetServer(server) => { + if let Some(crate::model::replication::SubscriptionOverlay::Present(subscription)) = + self.local.subscriptions.get_mut(&s.name) + { + subscription.connection = + crate::analysis::facts::ConnectionTarget::Server(server.clone()); + } + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + AlterSubscriptionActionFact::Publications { + mode, + publications, + params, + } => { + let refreshes = + Self::subscription_boolean_option(Some(params), "refresh") != Some(false); + if refreshes { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + if let Some(crate::model::replication::SubscriptionOverlay::Present(subscription)) = + self.local.subscriptions.get_mut(&s.name) + { + match mode { + SubscriptionPublicationMode::Set => { + subscription.publications = publications.clone(); + } + SubscriptionPublicationMode::Add => { + subscription + .publications + .extend(publications.iter().cloned()); + } + SubscriptionPublicationMode::Drop => { + subscription + .publications + .retain(|existing| !publications.contains(existing)); + } + } + } + } + AlterSubscriptionActionFact::RefreshPublication(_) => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + AlterSubscriptionActionFact::SetEnabled(enabled) => { + if let Some(crate::model::replication::SubscriptionOverlay::Present(subscription)) = + self.local.subscriptions.get_mut(&s.name) + { + subscription.enabled = *enabled; + } + } + AlterSubscriptionActionFact::SetOptions(options) => { + if let Some(crate::model::replication::SubscriptionOverlay::Present(subscription)) = + self.local.subscriptions.get_mut(&s.name) + { + for option in options { + Self::set_subscription_option(subscription, option); + if option.name.eq_ignore_ascii_case("slot_name") { + subscription.slot_name = (!option.value.eq_ignore_ascii_case("none")) + .then(|| option.value.clone()); + } + } + } + } + AlterSubscriptionActionFact::Skip(options) => { + if let Some(crate::model::replication::SubscriptionOverlay::Present(subscription)) = + self.local.subscriptions.get_mut(&s.name) + { + for option in options { + let mut normalized = option.clone(); + normalized.name = "skip_lsn".to_string(); + Self::set_subscription_option(subscription, &normalized); + } + } + } + AlterSubscriptionActionFact::OwnerChange(role) => { + if let Some((owner, known)) = self.role_fact_identity(role) { + if known { + if !self.local.roles_known { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + if let Some(crate::model::replication::SubscriptionOverlay::Present( + subscription, + )) = self.local.subscriptions.get_mut(&s.name) + { + subscription.owner = Some(owner); + } + } else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + if let Some(crate::model::replication::SubscriptionOverlay::Present( + subscription, + )) = self.local.subscriptions.get_mut(&s.name) + { + subscription.owner = None; + } + } + } else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + if let Some(crate::model::replication::SubscriptionOverlay::Present( + subscription, + )) = self.local.subscriptions.get_mut(&s.name) + { + subscription.owner = None; + } + } + } + AlterSubscriptionActionFact::Rename { to } => { + rename_to = Some(to.clone()); + } + } + + if let Some(crate::model::replication::SubscriptionOverlay::Present(subscription)) = + self.local.subscriptions.get_mut(&s.name) + { + subscription.generation = new_gen; + } + if let Some(to) = rename_to { + self.snapshot_subscription(&to); + let Some(crate::model::replication::SubscriptionOverlay::Present(mut subscription)) = + self.local.subscriptions.remove(&s.name) + else { + unreachable!("subscription existence checked above"); + }; + subscription.name = to.clone(); + self.local.subscriptions.insert( + to, + crate::model::replication::SubscriptionOverlay::Present(subscription), + ); + } + MutationResult::Applied + } + + pub(super) fn apply_drop_subscription( + &mut self, + s: &DropSubscriptionMutation, + ) -> MutationResult { + let has_slot = match self.subscription_lookup(&s.name) { + SubscriptionLookup::Present => match self.local.subscriptions.get(&s.name) { + Some(SubscriptionOverlay::Present(subscription)) => { + subscription.slot_name.is_some() + } + _ => unreachable!("subscription lookup established presence"), + }, + SubscriptionLookup::Tombstone => { + if !s.if_exists { + return MutationResult::Conflict { + reason: format!("subscription '{}' does not exist", s.name), + }; + } + return MutationResult::Skipped; + } + SubscriptionLookup::AuthoritativelyAbsent => { + if !s.if_exists { + return MutationResult::Conflict { + reason: format!("subscription '{}' does not exist", s.name), + }; + } + return MutationResult::Skipped; + } + SubscriptionLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + SubscriptionLookup::WrongKind => { + unreachable!("subscription names have a dedicated namespace") + } + }; + if has_slot && !self.local.transactions.is_empty() { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' has a replication slot and cannot be dropped inside a transaction", + s.name + ), + }; + } + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + self.snapshot_subscription(&s.name); + self.local.subscriptions.insert( + s.name.clone(), + crate::model::replication::SubscriptionOverlay::Dropped, + ); + MutationResult::Applied + } +} diff --git a/src/analysis/state/apply_role.rs b/src/analysis/state/apply_role.rs new file mode 100644 index 0000000..ff05974 --- /dev/null +++ b/src/analysis/state/apply_role.rs @@ -0,0 +1,321 @@ +use super::{AnalysisState, Confidence, MutationResult, ObjectLookup}; +use crate::analysis::facts::RoleFact; +use crate::analysis::mutations::{ + AlterRoleMutation, CreateRoleMutation, DropRoleMutation, GrantMutation, ResolvedGrantTarget, + RevokeMutation, +}; +use crate::ast::identifiers::ObjectId; +use crate::model::role::{RoleOverlay, RoleState}; + +type RoleLookup = ObjectLookup; + +impl AnalysisState { + fn role_lookup(&self, id: &ObjectId) -> RoleLookup { + match self.local.roles.get(id) { + Some(RoleOverlay::Present(_)) => RoleLookup::Present, + Some(RoleOverlay::Dropped) => RoleLookup::Tombstone, + None if self.local.roles_known => RoleLookup::AuthoritativelyAbsent, + None => RoleLookup::Unknown, + } + } + + pub(super) fn apply_create_role(&mut self, role: &CreateRoleMutation) -> MutationResult { + let role_id = ObjectId::new("", &role.name); + match self.role_lookup(&role_id) { + RoleLookup::Present => { + return MutationResult::Conflict { + reason: format!("role '{}' already exists", role.name), + }; + } + RoleLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + RoleLookup::Tombstone | RoleLookup::AuthoritativelyAbsent => {} + RoleLookup::WrongKind => unreachable!("roles have a dedicated namespace"), + } + if !role.inherits { + // RoleState intentionally does not carry the PostgreSQL INHERIT + // bit yet; do not claim an exact state transition for NOINHERIT. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + self.snapshot_role(&role_id); + self.snapshot_generation_counter(); + self.local.generation_counter += 1; + self.local.roles.insert( + role_id.clone(), + RoleOverlay::Present(RoleState { + id: role_id, + can_login: role.can_login, + is_superuser: false, + member_of: Vec::new(), + can_set_role_to: Vec::new(), + granted_privileges: Vec::new(), + }), + ); + MutationResult::Applied + } + + pub(super) fn apply_alter_role(&mut self, role: &AlterRoleMutation) -> MutationResult { + let Some(role_id) = Self::resolve_role_name( + &role.name, + &self.local.current_role, + &self.local.session_role, + ) else { + return MutationResult::Skipped; + }; + self.snapshot_role(&role_id); + match self.role_lookup(&role_id) { + RoleLookup::Present => {} + RoleLookup::Tombstone | RoleLookup::AuthoritativelyAbsent => { + return MutationResult::Conflict { + reason: format!("role '{}' does not exist", role_id.name), + }; + } + RoleLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + RoleLookup::WrongKind => unreachable!("roles have a dedicated namespace"), + } + if role.inherits.is_some() { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + self.snapshot_generation_counter(); + self.local.generation_counter += 1; + MutationResult::Applied + } + + pub(super) fn apply_drop_role(&mut self, role: &DropRoleMutation) -> MutationResult { + let mut present_roles = Vec::new(); + for name in &role.names { + if let Some(role_id) = Self::resolve_role_name( + &RoleFact::Named { + name: name.clone(), + via_legacy_group_syntax: false, + }, + &self.local.current_role, + &self.local.session_role, + ) { + match self.role_lookup(&role_id) { + RoleLookup::Present => present_roles.push(role_id), + RoleLookup::Tombstone | RoleLookup::AuthoritativelyAbsent if role.if_exists => { + continue; + } + RoleLookup::Tombstone | RoleLookup::AuthoritativelyAbsent => { + return MutationResult::Conflict { + reason: format!("role '{}' does not exist", name), + }; + } + RoleLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + RoleLookup::WrongKind => unreachable!("roles have a dedicated namespace"), + } + } + } + // DROP ROLE can fail because a role owns objects or has + // memberships/privileges. Those dependencies are not represented + // completely in RoleState, so do not claim an exact drop when a + // catalog-backed role list is available. + if self.local.roles_known && !present_roles.is_empty() { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + for role_id in present_roles { + self.snapshot_role(&role_id); + self.local.roles.insert(role_id, RoleOverlay::Dropped); + } + MutationResult::Applied + } + + pub(super) fn apply_grant(&mut self, grant: &GrantMutation) -> MutationResult { + if let Err(result) = self.validate_grant_targets(&grant.target) { + return result; + } + let grantees = match self.validate_role_facts(&grant.grantees) { + Ok(roles) => roles, + Err(result) => return result, + }; + if let Some(granted_by) = grant.granted_by.as_ref() { + if let Err(result) = self.validate_role_facts(std::slice::from_ref(granted_by)) { + return result; + } + // GRANTED BY changes the authorization check, which is not part + // of the privilege matrix. Keep the matrix untouched rather + // than silently treating an authorization-sensitive statement as + // exact. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + if grant.with_grant_option { + // PrivilegeMatrix records effective privileges but not grant + // options or grant chains. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + let privileges = self.resolve_grant_privileges(&grant.privileges); + match &grant.target { + ResolvedGrantTarget::Tables(ids) => { + for id in ids { + self.apply_grant_to_relation(id, &privileges, &grantees); + } + } + ResolvedGrantTarget::AllTablesInSchema(schemas) => { + // The cache does not retain every PostgreSQL relation kind + // eligible for ALL TABLES IN SCHEMA. Apply the modeled subset + // but do not claim that the resulting privilege matrix is + // complete. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + let target_ids: Vec = self + .local + .relations + .iter() + .filter_map(|(id, overlay)| { + (schemas.contains(&id.schema) + && matches!( + overlay, + crate::model::relation::RelationOverlay::Present(_) + )) + .then_some(id.clone()) + }) + .collect(); + for id in &target_ids { + self.apply_grant_to_relation(id, &privileges, &grantees); + } + } + } + MutationResult::Applied + } + + pub(super) fn apply_revoke(&mut self, revoke: &RevokeMutation) -> MutationResult { + if let Err(result) = self.validate_grant_targets(&revoke.target) { + return result; + } + let revokees = match self.validate_role_facts(&revoke.revokees) { + Ok(roles) => roles, + Err(result) => return result, + }; + if let Some(granted_by) = revoke.granted_by.as_ref() { + if let Err(result) = self.validate_role_facts(std::slice::from_ref(granted_by)) { + return result; + } + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + if revoke.grant_option_only || revoke.cascade { + // The matrix has no grant-option or dependency-chain state, so a + // GRANT OPTION/CASCADE revoke cannot be represented exactly. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + let privileges = self.resolve_grant_privileges(&revoke.privileges); + match &revoke.target { + ResolvedGrantTarget::Tables(ids) => { + for id in ids { + self.apply_revoke_to_relation(id, &privileges, &revokees); + } + } + ResolvedGrantTarget::AllTablesInSchema(schemas) => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + let target_ids: Vec = self + .local + .relations + .iter() + .filter_map(|(id, overlay)| { + (schemas.contains(&id.schema) + && matches!( + overlay, + crate::model::relation::RelationOverlay::Present(_) + )) + .then_some(id.clone()) + }) + .collect(); + for id in &target_ids { + self.apply_revoke_to_relation(id, &privileges, &revokees); + } + } + } + MutationResult::Applied + } + + fn validate_grant_targets( + &mut self, + target: &ResolvedGrantTarget, + ) -> Result<(), MutationResult> { + match target { + ResolvedGrantTarget::Tables(ids) => { + for id in ids { + self.ensure_relation_target( + id, + |kind| { + matches!( + kind, + crate::model::relation::RelationKind::Table + | crate::model::relation::RelationKind::View + | crate::model::relation::RelationKind::MaterializedView + ) + }, + format!("grant target relation '{}' does not exist", id), + format!("grant target '{}' is not grantable", id), + )?; + } + } + ResolvedGrantTarget::AllTablesInSchema(schemas) => { + for schema in schemas { + self.ensure_schema_target(schema)?; + } + } + } + Ok(()) + } + + fn validate_role_facts(&mut self, facts: &[RoleFact]) -> Result, MutationResult> { + let mut ids = Vec::with_capacity(facts.len()); + for fact in facts { + let Some((name, _identity_known)) = self.role_fact_identity(fact) else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return Err(MutationResult::Skipped); + }; + // PUBLIC is a PostgreSQL pseudo-role, not a row in pg_roles. + if name.eq_ignore_ascii_case("public") { + ids.push(ObjectId::new("", "public")); + continue; + } + let id = ObjectId::new("", name.clone()); + match self.role_lookup(&id) { + RoleLookup::Present => ids.push(id), + RoleLookup::Tombstone | RoleLookup::AuthoritativelyAbsent => { + return Err(MutationResult::Conflict { + reason: format!("role '{}' does not exist", name), + }); + } + RoleLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + // A cache without a complete role catalog cannot prove + // the role's existence, but the privilege grant itself + // is still useful state. Preserve it while making the + // uncertainty explicit instead of silently dropping it. + ids.push(id); + } + RoleLookup::WrongKind => unreachable!("roles have a dedicated namespace"), + } + } + Ok(ids) + } +} diff --git a/src/analysis/state/apply_routine.rs b/src/analysis/state/apply_routine.rs new file mode 100644 index 0000000..aeea7c9 --- /dev/null +++ b/src/analysis/state/apply_routine.rs @@ -0,0 +1,754 @@ +use super::{AnalysisState, Confidence, MutationResult, ObjectLookup, RelationOverlay}; +use crate::analysis::facts::{ + AlterFunctionAction, FuncOptionFact, ParamModeFact, RetTypeFact, SecurityKind, VolatilityKind, +}; +use crate::analysis::graph::DependencyKind; +use crate::analysis::mutations::{ + AlterAggregateMutation, AlterFunctionMutation, AlterProcedureMutation, CreateAggregateMutation, + CreateFunctionMutation, CreateProcedureMutation, DropAggregateMutation, DropFunctionMutation, + DropProcedureMutation, +}; +use crate::ast::identifiers::ObjectId; +use crate::model::function::{ + FunctionOverlay, FunctionState, RoutineKind, SecurityMode, Volatility, +}; +use crate::model::trigger::TriggerOverlay; + +type RoutineLookup = ObjectLookup; + +impl AnalysisState { + fn routine_lookup( + &self, + id: &ObjectId, + expected: impl FnOnce(RoutineKind) -> bool, + ) -> ObjectLookup { + match self.local.functions.get(id) { + Some(FunctionOverlay::Present(routine)) if expected(routine.routine_kind) => { + ObjectLookup::Present + } + Some(FunctionOverlay::Present(_)) => ObjectLookup::WrongKind, + Some(FunctionOverlay::Dropped) => ObjectLookup::Tombstone, + None if self.baseline_available && self.baseline_covers_object(id) => { + ObjectLookup::AuthoritativelyAbsent + } + None => ObjectLookup::Unknown, + } + } + + pub(super) fn apply_create_function( + &mut self, + function: &CreateFunctionMutation, + ) -> MutationResult { + if let Err(result) = self.ensure_schema_target(&function.id.schema) { + return result; + } + let routine_kind = if function + .options + .iter() + .any(|option| matches!(option, FuncOptionFact::Window)) + { + RoutineKind::Window + } else { + RoutineKind::Function + }; + match self.routine_lookup(&function.id, |kind| kind == routine_kind) { + RoutineLookup::Present if !function.or_replace => { + return MutationResult::Conflict { + reason: format!("routine '{}' already exists", function.id), + }; + } + RoutineLookup::WrongKind => { + return MutationResult::Conflict { + reason: format!("routine '{}' already exists", function.id), + }; + } + RoutineLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + _ => {} + } + self.snapshot_function(&function.id); + self.snapshot_generation_counter(); + self.local.generation_counter += 1; + + let volatility = function + .options + .iter() + .find_map(|option| match option { + FuncOptionFact::Volatility(volatility) => Some(match volatility { + VolatilityKind::Volatile => Volatility::Volatile, + VolatilityKind::Stable => Volatility::Stable, + VolatilityKind::Immutable => Volatility::Immutable, + }), + _ => None, + }) + .unwrap_or(Volatility::Volatile); + let security = function + .options + .iter() + .find_map(|option| match option { + FuncOptionFact::Security(security) => Some(match security { + SecurityKind::Invoker => SecurityMode::Invoker, + SecurityKind::Definer => SecurityMode::Definer, + }), + _ => None, + }) + .unwrap_or(SecurityMode::Invoker); + let language = function + .options + .iter() + .find_map(|option| match option { + FuncOptionFact::Language(language) => Some(language.clone()), + _ => None, + }) + .unwrap_or_else(|| "sql".to_string()); + + if function.options.iter().any(Self::function_option_unmodeled) { + // FunctionState intentionally stores only the attributes used by + // current rules. Keep the useful identity/volatility fields, but + // taint the state when PostgreSQL attributes such as STRICT, + // PARALLEL, COST, or SUPPORT cannot be represented. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + + self.local.functions.insert( + function.id.clone(), + FunctionOverlay::Present(FunctionState { + id: function.id.clone(), + routine_kind, + arg_types: function + .params + .iter() + .filter(|parameter| !matches!(¶meter.mode, ParamModeFact::Out)) + .map(|parameter| parameter.ty.clone()) + .collect(), + arg_type_ids: function + .params + .iter() + .filter(|parameter| !matches!(¶meter.mode, ParamModeFact::Out)) + .map(|parameter| self.resolve_type_reference(¶meter.ty)) + .collect(), + return_type: function + .return_type + .as_ref() + .map(|return_type| match return_type { + RetTypeFact::Scalar(ty) => ty.clone(), + RetTypeFact::Table(columns) => columns + .iter() + .map(|column| { + format!( + "{} {}", + column.name, + column.ty.as_deref().unwrap_or("unknown") + ) + }) + .collect::>() + .join(", "), + }) + .unwrap_or_default(), + return_type_id: function.return_type.as_ref().and_then(|return_type| { + match return_type { + RetTypeFact::Scalar(ty) => self.resolve_type_reference(ty), + RetTypeFact::Table(_) => None, + } + }), + volatility, + language, + security, + }), + ); + MutationResult::Applied + } + + pub(super) fn apply_alter_function( + &mut self, + function: &AlterFunctionMutation, + ) -> MutationResult { + match self.routine_lookup(&function.id, |kind| { + matches!(kind, RoutineKind::Function | RoutineKind::Window) + }) { + RoutineLookup::Present => {} + RoutineLookup::WrongKind => { + return MutationResult::Conflict { + reason: format!("'{}' is not a function", function.id), + }; + } + RoutineLookup::Tombstone | RoutineLookup::AuthoritativelyAbsent => { + return MutationResult::Conflict { + reason: format!("function '{}' does not exist", function.id), + }; + } + RoutineLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + } + + match &function.action { + AlterFunctionAction::OptionsChange(options) => { + self.snapshot_function(&function.id); + if let Some(FunctionOverlay::Present(existing)) = + self.local.functions.get_mut(&function.id) + { + for option in options { + match option { + FuncOptionFact::Volatility(volatility) => { + existing.volatility = match volatility { + VolatilityKind::Volatile => Volatility::Volatile, + VolatilityKind::Stable => Volatility::Stable, + VolatilityKind::Immutable => Volatility::Immutable, + }; + } + FuncOptionFact::Security(security) => { + existing.security = match security { + SecurityKind::Invoker => SecurityMode::Invoker, + SecurityKind::Definer => SecurityMode::Definer, + }; + } + FuncOptionFact::Language(language) => { + existing.language = language.clone(); + } + _ => {} + } + } + } + if options.iter().any(Self::function_option_unmodeled) { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + } + AlterFunctionAction::Rename { to, .. } => { + let signature = function + .id + .name + .find('(') + .map(|index| &function.id.name[index..]) + .unwrap_or(""); + let new_id = ObjectId::new(function.id.schema.clone(), format!("{to}{signature}")); + if let Err(result) = self.validate_function_move(&function.id, &new_id) { + return result; + } + self.move_function(&function.id, &new_id); + } + AlterFunctionAction::SchemaChange { new_schema } => { + let new_id = ObjectId::new(new_schema.clone(), function.id.name.clone()); + if let Err(result) = self.validate_function_move(&function.id, &new_id) { + return result; + } + self.move_function(&function.id, &new_id); + } + AlterFunctionAction::OwnerChange(_) + | AlterFunctionAction::DependsOnExtension { .. } + | AlterFunctionAction::NoDependsOnExtension { .. } => { + // Ownership and extension dependencies are not represented + // by FunctionState, so retaining Applied would overstate the + // precision of subsequent dependency checks. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + } + MutationResult::Applied + } + + pub(super) fn apply_drop_function( + &mut self, + function: &DropFunctionMutation, + ) -> MutationResult { + // PostgreSQL resolves every target before applying a multi-target + // DROP. Preflight the complete set first so an unknown or invalid + // later signature cannot leave an earlier function dropped in the + // simulator when the statement itself would fail. + let mut targets: Vec<(ObjectId, Vec<(ObjectId, ObjectId)>)> = Vec::new(); + for signature in &function.signatures { + let signature_name = format!( + "{}({})", + signature.name.name.resolve(), + signature.params.join(",") + ); + let schema = self.resolve_function_schema(&signature.name, &signature_name); + let id = ObjectId::new(schema, signature_name); + match self.routine_lookup(&id, |kind| { + matches!(kind, RoutineKind::Function | RoutineKind::Window) + }) { + RoutineLookup::WrongKind => { + return MutationResult::Conflict { + reason: format!("function '{}' does not exist", id), + }; + } + RoutineLookup::Tombstone if function.if_exists => {} + RoutineLookup::Tombstone => { + return MutationResult::Conflict { + reason: format!("function '{}' does not exist", id), + }; + } + RoutineLookup::AuthoritativelyAbsent if !function.if_exists => { + return MutationResult::Conflict { + reason: format!("function '{}' does not exist", id), + }; + } + RoutineLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + RoutineLookup::Present => { + let dependent_triggers: Vec<(ObjectId, ObjectId)> = self + .local + .graph + .edges() + .iter() + .filter_map(|edge| { + let DependencyKind::TriggerOnTable { function_id, .. } = &edge.kind + else { + return None; + }; + (function_id == &id) + .then(|| (edge.dependent.clone(), edge.referenced.clone())) + }) + .collect(); + if !dependent_triggers.is_empty() && !function.cascade { + return MutationResult::Conflict { + reason: format!( + "function '{}' still has dependent triggers; use CASCADE", + id + ), + }; + } + if !targets.iter().any(|(existing, _)| existing == &id) { + targets.push((id, dependent_triggers)); + } + } + RoutineLookup::AuthoritativelyAbsent => {} + } + } + + if targets.is_empty() { + return MutationResult::Skipped; + } + + let any_applied = !targets.is_empty(); + for (id, dependent_triggers) in &targets { + self.snapshot_function(id); + self.local + .functions + .insert(id.clone(), FunctionOverlay::Dropped); + + if function.cascade { + for (trigger_id, table_id) in dependent_triggers.iter() { + let trigger_name = + self.local + .triggers + .get(trigger_id) + .and_then(|overlay| match overlay { + TriggerOverlay::Present(trigger) => Some(trigger.name.clone()), + TriggerOverlay::Dropped => None, + }); + self.snapshot_trigger(trigger_id); + self.local + .triggers + .insert(trigger_id.clone(), TriggerOverlay::Dropped); + self.snapshot_relation(table_id); + if let Some(RelationOverlay::Present(relation)) = + self.local.relations.get_mut(table_id) + && let Some(trigger_name) = trigger_name + { + relation.triggers.remove(&trigger_name); + } + } + if !dependent_triggers.is_empty() { + self.snapshot_graph_full(); + self.local.graph.retain_edges(|edge| { + !dependent_triggers + .iter() + .any(|(trigger_id, _)| edge.dependent == *trigger_id) + }); + } + } + } + if any_applied { + MutationResult::Applied + } else { + MutationResult::Skipped + } + } + + pub(super) fn apply_create_procedure( + &mut self, + procedure: &CreateProcedureMutation, + ) -> MutationResult { + if let Err(result) = self.ensure_schema_target(&procedure.id.schema) { + return result; + } + match self.routine_lookup(&procedure.id, |kind| kind == RoutineKind::Procedure) { + RoutineLookup::Present if procedure.or_replace => {} + RoutineLookup::Present | RoutineLookup::WrongKind => { + return MutationResult::Conflict { + reason: format!("routine '{}' already exists", procedure.id), + }; + } + RoutineLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + RoutineLookup::Tombstone | RoutineLookup::AuthoritativelyAbsent => {} + } + self.snapshot_function(&procedure.id); + self.snapshot_generation_counter(); + self.local.generation_counter += 1; + + let volatility = procedure + .options + .iter() + .find_map(|option| match option { + FuncOptionFact::Volatility(volatility) => Some(match volatility { + VolatilityKind::Volatile => Volatility::Volatile, + VolatilityKind::Stable => Volatility::Stable, + VolatilityKind::Immutable => Volatility::Immutable, + }), + _ => None, + }) + .unwrap_or(Volatility::Volatile); + let security = procedure + .options + .iter() + .find_map(|option| match option { + FuncOptionFact::Security(security) => Some(match security { + SecurityKind::Invoker => SecurityMode::Invoker, + SecurityKind::Definer => SecurityMode::Definer, + }), + _ => None, + }) + .unwrap_or(SecurityMode::Invoker); + let language = procedure + .options + .iter() + .find_map(|option| match option { + FuncOptionFact::Language(language) => Some(language.clone()), + _ => None, + }) + .unwrap_or_else(|| "sql".to_string()); + if procedure + .options + .iter() + .any(Self::function_option_unmodeled) + { + // Procedures share the catalog fields modeled by FunctionState, + // but their AS body and other function options are not retained. + // Preserve the useful attributes while making the uncertainty + // visible to downstream verdicts. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + + self.local.functions.insert( + procedure.id.clone(), + FunctionOverlay::Present(FunctionState { + id: procedure.id.clone(), + routine_kind: RoutineKind::Procedure, + arg_types: procedure + .params + .iter() + .filter(|parameter| !matches!(¶meter.mode, ParamModeFact::Out)) + .map(|parameter| parameter.ty.clone()) + .collect(), + arg_type_ids: procedure + .params + .iter() + .filter(|parameter| !matches!(¶meter.mode, ParamModeFact::Out)) + .map(|parameter| self.resolve_type_reference(¶meter.ty)) + .collect(), + return_type: "void".to_string(), + return_type_id: None, + volatility, + language, + security, + }), + ); + MutationResult::Applied + } + + pub(super) fn apply_alter_procedure( + &mut self, + procedure: &AlterProcedureMutation, + ) -> MutationResult { + match self.routine_lookup(&procedure.id, |kind| kind == RoutineKind::Procedure) { + RoutineLookup::Present => {} + RoutineLookup::WrongKind => { + return MutationResult::Conflict { + reason: format!("'{}' is not a procedure", procedure.id), + }; + } + RoutineLookup::Tombstone | RoutineLookup::AuthoritativelyAbsent => { + return MutationResult::Conflict { + reason: format!("procedure '{}' does not exist", procedure.id), + }; + } + RoutineLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + } + + match &procedure.action { + AlterFunctionAction::Rename { to, .. } => { + let signature = procedure + .id + .name + .find('(') + .map(|index| &procedure.id.name[index..]) + .unwrap_or(""); + let new_id = ObjectId::new(procedure.id.schema.clone(), format!("{to}{signature}")); + if let Err(result) = self.validate_function_move(&procedure.id, &new_id) { + return result; + } + self.move_function(&procedure.id, &new_id); + } + AlterFunctionAction::SchemaChange { new_schema } => { + let new_id = ObjectId::new(new_schema.clone(), procedure.id.name.clone()); + if let Err(result) = self.validate_function_move(&procedure.id, &new_id) { + return result; + } + self.move_function(&procedure.id, &new_id); + } + _ => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + } + MutationResult::Applied + } + + pub(super) fn apply_drop_procedure( + &mut self, + procedure: &DropProcedureMutation, + ) -> MutationResult { + let mut targets = Vec::new(); + for signature in &procedure.signatures { + let signature_name = format!( + "{}({})", + signature.name.name.resolve(), + signature.params.join(",") + ); + let schema = self.resolve_function_schema(&signature.name, &signature_name); + let id = ObjectId::new(schema, signature_name); + match self.routine_lookup(&id, |kind| kind == RoutineKind::Procedure) { + RoutineLookup::Present => { + if !targets.contains(&id) { + targets.push(id); + } + } + RoutineLookup::WrongKind => { + return MutationResult::Conflict { + reason: format!("procedure '{}' does not exist", id), + }; + } + RoutineLookup::Tombstone if procedure.if_exists => {} + RoutineLookup::Tombstone => { + return MutationResult::Conflict { + reason: format!("procedure '{}' does not exist", id), + }; + } + RoutineLookup::AuthoritativelyAbsent if !procedure.if_exists => { + return MutationResult::Conflict { + reason: format!("procedure '{}' does not exist", id), + }; + } + RoutineLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + RoutineLookup::AuthoritativelyAbsent => {} + } + } + for id in &targets { + self.snapshot_function(id); + self.local + .functions + .insert(id.clone(), FunctionOverlay::Dropped); + } + if !targets.is_empty() { + MutationResult::Applied + } else { + MutationResult::Skipped + } + } + + pub(super) fn apply_create_aggregate( + &mut self, + aggregate: &CreateAggregateMutation, + ) -> MutationResult { + if let Err(result) = self.ensure_schema_target(&aggregate.id.schema) { + return result; + } + match self.routine_lookup(&aggregate.id, |kind| kind == RoutineKind::Aggregate) { + RoutineLookup::Present if aggregate.or_replace => {} + RoutineLookup::Present | RoutineLookup::WrongKind => { + return MutationResult::Conflict { + reason: format!("routine '{}' already exists", aggregate.id), + }; + } + RoutineLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + RoutineLookup::Tombstone | RoutineLookup::AuthoritativelyAbsent => {} + } + // Aggregate transition options (SFUNC/STYPE/final/combine state and + // related catalog dependencies) are not carried by this mutation. + // Keep the routine identity for conservative lookup, but never claim + // the resulting aggregate state is exact. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + self.snapshot_function(&aggregate.id); + self.local.functions.insert( + aggregate.id.clone(), + FunctionOverlay::Present(FunctionState { + id: aggregate.id.clone(), + routine_kind: RoutineKind::Aggregate, + arg_types: aggregate + .params + .iter() + .filter(|parameter| !matches!(parameter.mode, ParamModeFact::Out)) + .map(|parameter| parameter.ty.clone()) + .collect(), + arg_type_ids: aggregate + .params + .iter() + .filter(|parameter| !matches!(parameter.mode, ParamModeFact::Out)) + .map(|parameter| self.resolve_type_reference(¶meter.ty)) + .collect(), + return_type: String::new(), + return_type_id: None, + volatility: Volatility::Volatile, + language: "internal".to_string(), + security: SecurityMode::Invoker, + }), + ); + MutationResult::Applied + } + + pub(super) fn apply_alter_aggregate( + &mut self, + aggregate: &AlterAggregateMutation, + ) -> MutationResult { + match self.routine_lookup(&aggregate.id, |kind| kind == RoutineKind::Aggregate) { + RoutineLookup::Present => {} + RoutineLookup::WrongKind => { + return MutationResult::Conflict { + reason: format!("'{}' is not an aggregate", aggregate.id), + }; + } + RoutineLookup::Tombstone | RoutineLookup::AuthoritativelyAbsent => { + return MutationResult::Conflict { + reason: format!("aggregate '{}' does not exist", aggregate.id), + }; + } + RoutineLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + } + match &aggregate.action { + AlterFunctionAction::Rename { to, .. } => { + let signature = aggregate + .id + .name + .find('(') + .map(|index| &aggregate.id.name[index..]) + .unwrap_or(""); + let new_id = ObjectId::new(aggregate.id.schema.clone(), format!("{to}{signature}")); + if let Err(result) = self.validate_function_move(&aggregate.id, &new_id) { + return result; + } + self.move_function(&aggregate.id, &new_id); + } + AlterFunctionAction::SchemaChange { new_schema } => { + let new_id = ObjectId::new(new_schema.clone(), aggregate.id.name.clone()); + if let Err(result) = self.validate_function_move(&aggregate.id, &new_id) { + return result; + } + self.move_function(&aggregate.id, &new_id); + } + AlterFunctionAction::OwnerChange(_) => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + _ => unreachable!("aggregate extraction only emits rename, owner, or schema"), + } + MutationResult::Applied + } + + pub(super) fn apply_drop_aggregate( + &mut self, + aggregate: &DropAggregateMutation, + ) -> MutationResult { + let mut targets = Vec::new(); + for signature in &aggregate.signatures { + let signature_name = format!( + "{}({})", + signature.name.name.resolve(), + signature.params.join(",") + ); + let schema = self.resolve_function_schema(&signature.name, &signature_name); + let id = ObjectId::new(schema, signature_name); + match self.routine_lookup(&id, |kind| kind == RoutineKind::Aggregate) { + RoutineLookup::Present => { + if !targets.contains(&id) { + targets.push(id); + } + } + RoutineLookup::WrongKind => { + return MutationResult::Conflict { + reason: format!("aggregate '{}' does not exist", id), + }; + } + RoutineLookup::Tombstone | RoutineLookup::AuthoritativelyAbsent + if aggregate.if_exists => {} + RoutineLookup::Tombstone | RoutineLookup::AuthoritativelyAbsent => { + return MutationResult::Conflict { + reason: format!("aggregate '{}' does not exist", id), + }; + } + RoutineLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + } + } + for id in &targets { + self.snapshot_function(id); + self.local + .functions + .insert(id.clone(), FunctionOverlay::Dropped); + } + if aggregate.cascade && !targets.is_empty() { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + if !targets.is_empty() { + MutationResult::Applied + } else { + MutationResult::Skipped + } + } + + fn function_option_unmodeled(option: &FuncOptionFact) -> bool { + !matches!( + option, + FuncOptionFact::Language(_) + | FuncOptionFact::Volatility(_) + | FuncOptionFact::Security(_) + | FuncOptionFact::Window + ) + } +} diff --git a/src/analysis/state/apply_schema.rs b/src/analysis/state/apply_schema.rs new file mode 100644 index 0000000..af4b39e --- /dev/null +++ b/src/analysis/state/apply_schema.rs @@ -0,0 +1,429 @@ +use super::{AnalysisState, Confidence, MutationResult, ObjectLookup}; +use crate::analysis::facts::{PublicationObjectFact, PublicationScope}; +use crate::analysis::graph::DependencyKind; +use crate::analysis::mutations::{AlterSchemaMutation, CreateSchemaMutation, DropSchemaMutation}; +use crate::ast::identifiers::ObjectId; +use crate::model::function::FunctionOverlay; +use crate::model::relation::RelationOverlay; +use crate::model::replication::PublicationOverlay; +use crate::model::schema::{SchemaOverlay, SchemaState}; +use crate::model::sequence::{SequenceKind, SequenceOverlay}; +use crate::model::trigger::TriggerOverlay; +use crate::model::types::TypeOverlay; + +type SchemaLookup = ObjectLookup; +type SequenceDrop = (ObjectId, SequenceKind, Option<(ObjectId, String)>); + +impl AnalysisState { + pub(super) fn apply_create_schema( + &mut self, + create_schema: &CreateSchemaMutation, + ) -> MutationResult { + match self.schema_lookup(&create_schema.name) { + SchemaLookup::Present => { + return if create_schema.if_not_exists { + MutationResult::Skipped + } else { + MutationResult::Conflict { + reason: format!("schema '{}' already exists", create_schema.name), + } + }; + } + SchemaLookup::AuthoritativelyAbsent | SchemaLookup::Tombstone => {} + SchemaLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + SchemaLookup::WrongKind => unreachable!("schemas have a dedicated namespace"), + } + let (owner_name, owner_known) = match &create_schema.authorization { + Some(role) => match self.role_fact_identity(role) { + Some(identity) => identity, + None => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + (self.local.current_role.clone(), false) + } + }, + None => ( + self.local.current_role.clone(), + self.local.current_role_known, + ), + }; + if owner_known && self.local.roles_known && self.present_role(&owner_name).is_none() { + return MutationResult::Conflict { + reason: format!("role '{}' does not exist", owner_name), + }; + } + if !owner_known || !self.local.roles_known { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + self.snapshot_generation_counter(); + self.local.generation_counter += 1; + let generation = self.local.generation_counter; + self.snapshot_schema(&create_schema.name); + self.local.schemas.insert( + create_schema.name.clone(), + SchemaOverlay::Present(SchemaState { + name: create_schema.name.clone(), + owner: ObjectId::new("", owner_name), + generation, + }), + ); + self.snapshot_search_path(); + self.refresh_role_sensitive_search_path(); + MutationResult::Applied + } + + pub(super) fn apply_alter_schema( + &mut self, + alter_schema: &AlterSchemaMutation, + ) -> MutationResult { + match alter_schema { + AlterSchemaMutation::OwnerTo { name, new_owner } => { + match self.schema_lookup(name) { + SchemaLookup::Present => {} + SchemaLookup::Tombstone | SchemaLookup::AuthoritativelyAbsent => { + return MutationResult::Conflict { + reason: format!("schema '{}' does not exist", name), + }; + } + SchemaLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + SchemaLookup::WrongKind => { + unreachable!("schemas do not share an overlay with other object kinds") + } + } + let Some((owner_name, owner_known)) = self.role_fact_identity(new_owner) else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + }; + if owner_known && self.local.roles_known && self.present_role(&owner_name).is_none() + { + return MutationResult::Conflict { + reason: format!("role '{}' does not exist", owner_name), + }; + } + if !owner_known || !self.local.roles_known { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + self.snapshot_schema(name); + if let Some(SchemaOverlay::Present(schema)) = self.local.schemas.get_mut(name) { + schema.owner = ObjectId::new("", owner_name); + } + MutationResult::Applied + } + AlterSchemaMutation::Rename { old_name, new_name } => { + match self.schema_lookup(old_name) { + SchemaLookup::Present => {} + SchemaLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + SchemaLookup::Tombstone | SchemaLookup::AuthoritativelyAbsent => { + return MutationResult::Conflict { + reason: format!("schema '{}' does not exist", old_name), + }; + } + SchemaLookup::WrongKind => { + unreachable!("schemas do not share an overlay with other object kinds") + } + } + match self.schema_lookup(new_name) { + SchemaLookup::Present => { + return MutationResult::Conflict { + reason: format!("schema '{}' already exists", new_name), + }; + } + SchemaLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + SchemaLookup::Tombstone | SchemaLookup::AuthoritativelyAbsent => {} + SchemaLookup::WrongKind => { + unreachable!("schemas do not share an overlay with other object kinds") + } + } + self.snapshot_search_path(); + self.rename_schema_namespace(old_name, new_name); + MutationResult::Applied + } + } + } + + pub(super) fn apply_drop_schema(&mut self, drop_schema: &DropSchemaMutation) -> MutationResult { + let mut unknown_target = false; + for name in &drop_schema.names { + match self.schema_lookup(name) { + SchemaLookup::Present => {} + SchemaLookup::Tombstone | SchemaLookup::AuthoritativelyAbsent => { + if !drop_schema.if_exists { + return MutationResult::Conflict { + reason: format!("schema '{}' does not exist", name), + }; + } + } + SchemaLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + unknown_target = true; + } + SchemaLookup::WrongKind => { + unreachable!("schemas do not share an overlay with other object kinds") + } + } + } + // PostgreSQL resolves the complete object list before applying the + // DROP. A scoped cache cannot prove an unknown schema is absent even + // with IF EXISTS, so it cannot safely remove the known siblings. + if unknown_target { + return MutationResult::Skipped; + } + let present_names: Vec = drop_schema + .names + .iter() + .filter(|name| self.schema_is_present(name)) + .cloned() + .collect(); + if present_names.is_empty() { + return MutationResult::Skipped; + } + if drop_schema.cascade { + self.snapshot_namespace(); + let dropped_schema_names: std::collections::HashSet = + present_names.iter().cloned().collect(); + let relation_roots: Vec = self + .local + .relations + .iter() + .filter_map(|(id, overlay)| { + (dropped_schema_names.contains(&id.schema) + && matches!(overlay, RelationOverlay::Present(_))) + .then_some(id.clone()) + }) + .collect(); + let mut cascade = super::CascadeResult::default(); + for root in &relation_roots { + let closure = self.get_cascade_closure(root); + cascade.dropped_relations.extend(closure.dropped_relations); + cascade.dropped_indexes.extend(closure.dropped_indexes); + cascade + .dropped_constraints + .extend(closure.dropped_constraints); + } + let all_dropped_relations = cascade.dropped_relations; + let dropped_relations: std::collections::HashSet = all_dropped_relations + .iter() + .filter(|id| self.relation_is_present(id)) + .cloned() + .collect(); + if all_dropped_relations + .iter() + .any(|id| !self.relation_is_present(id)) + { + // A scoped cache may retain a dependency edge without the + // dependent relation's catalog row. PostgreSQL would drop it, + // but the simulator cannot reproduce its full state exactly. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + for id in &dropped_relations { + self.local + .relations + .insert(id.clone(), RelationOverlay::Dropped); + } + + let types_to_drop: Vec = self + .local + .types + .keys() + .filter(|id| dropped_schema_names.contains(&id.schema)) + .cloned() + .collect(); + for id in types_to_drop { + self.local.types.insert(id, TypeOverlay::Dropped); + } + + let sequences_to_drop: Vec = self + .local + .sequences + .iter() + .filter_map(|(id, overlay)| { + let SequenceOverlay::Present(sequence) = overlay else { + return None; + }; + let owned_by_dropped_relation = + sequence.owned_by.as_ref().is_some_and(|(table, _)| { + all_dropped_relations.contains(self.local.graph.resolve_rename(table)) + }); + (dropped_schema_names.contains(&id.schema) || owned_by_dropped_relation) + .then(|| (id.clone(), sequence.kind.clone(), sequence.owned_by.clone())) + }) + .collect(); + for (id, kind, owned_by) in sequences_to_drop { + self.clear_sequence_defaults_on_cascade(&id, kind, owned_by); + self.local.sequences.insert(id, SequenceOverlay::Dropped); + } + + let functions_to_drop: std::collections::HashSet = self + .local + .functions + .keys() + .filter(|id| dropped_schema_names.contains(&id.schema)) + .cloned() + .collect(); + for id in &functions_to_drop { + self.local + .functions + .insert(id.clone(), FunctionOverlay::Dropped); + } + + let triggers_to_drop: Vec = self + .local + .triggers + .iter() + .filter_map(|(id, overlay)| { + let TriggerOverlay::Present(trigger) = overlay else { + return None; + }; + let function_is_dropped = self.local.graph.edges().iter().any(|edge| { + matches!( + &edge.kind, + DependencyKind::TriggerOnTable { trigger_id, function_id } + if trigger_id == id && functions_to_drop.contains(function_id) + ) + }); + (dropped_schema_names.contains(&id.schema) + || all_dropped_relations + .contains(self.local.graph.resolve_rename(&trigger.table_id)) + || function_is_dropped) + .then_some(id.clone()) + }) + .collect(); + let dropped_trigger_ids: std::collections::HashSet = + triggers_to_drop.iter().cloned().collect(); + for id in triggers_to_drop { + self.local.triggers.insert(id, TriggerOverlay::Dropped); + } + + self.remove_dropped_constraints(&all_dropped_relations, &cascade.dropped_constraints); + + self.local + .pending_validation + .retain(|(table, _)| !dropped_schema_names.contains(&table.schema)); + for overlay in self.local.publications.values_mut() { + let PublicationOverlay::Present(publication) = overlay else { + continue; + }; + let PublicationScope::Explicit(objects) = &mut publication.scope else { + continue; + }; + objects.retain(|object| match object { + PublicationObjectFact::Table { name, .. } => name + .schema + .as_ref() + .is_none_or(|schema| !dropped_schema_names.contains(&schema.resolve())), + PublicationObjectFact::SchemaTables { schema, .. } => { + !dropped_schema_names.contains(schema) + } + _ => true, + }); + } + + 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); + let referenced = resolution_graph.resolve_rename(&edge.referenced); + if all_dropped_relations.contains(dependent) + || all_dropped_relations.contains(referenced) + || cascade.dropped_indexes.contains(dependent) + || dropped_schema_names.contains(&edge.dependent.schema) + || dropped_schema_names.contains(&edge.referenced.schema) + { + return false; + } + match &edge.kind { + DependencyKind::ForeignKey { + constraint_name: Some(name), + .. + } => !cascade + .dropped_constraints + .contains(&(dependent.clone(), name.clone())), + DependencyKind::TriggerOnTable { + trigger_id, + function_id, + } => { + !dropped_trigger_ids.contains(trigger_id) + && !functions_to_drop.contains(function_id) + } + _ => true, + } + }); + } else { + let has_external_dependents = self.local.graph.edges().iter().any(|edge| { + !matches!(&edge.kind, DependencyKind::RenameTo) + && drop_schema.names.contains(&edge.referenced.schema) + && !drop_schema.names.contains(&edge.dependent.schema) + }); + if has_external_dependents { + return MutationResult::Conflict { + reason: format!( + "schema(s) {:?} have dependent objects outside the schema; use CASCADE", + drop_schema.names + ), + }; + } + let has_relation = self.local.relations.iter().any(|(id, overlay)| { + drop_schema.names.contains(&id.schema) + && !matches!(overlay, RelationOverlay::Dropped) + }); + let has_type = self.local.types.iter().any(|(id, overlay)| { + drop_schema.names.contains(&id.schema) && !matches!(overlay, TypeOverlay::Dropped) + }); + let has_sequence = self.local.sequences.iter().any(|(id, overlay)| { + drop_schema.names.contains(&id.schema) + && !matches!(overlay, SequenceOverlay::Dropped) + }); + let has_function = self.local.functions.iter().any(|(id, overlay)| { + drop_schema.names.contains(&id.schema) + && !matches!(overlay, FunctionOverlay::Dropped) + }); + let has_trigger = self.local.triggers.iter().any(|(id, overlay)| { + drop_schema.names.contains(&id.schema) + && !matches!(overlay, TriggerOverlay::Dropped) + }); + if has_relation || has_type || has_sequence || has_function || has_trigger { + return MutationResult::Conflict { + reason: format!( + "schema(s) {:?} still contain objects; use CASCADE to drop them", + drop_schema.names + ), + }; + } + // The state model deliberately omits several PostgreSQL object + // families. With RESTRICT, any omitted object can make this + // statement fail, so an apparently empty modeled namespace is not + // sufficient evidence to drop the schema exactly. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + for name in present_names { + self.snapshot_schema(&name); + self.local.schemas.insert(name, SchemaOverlay::Dropped); + } + self.snapshot_search_path(); + self.refresh_role_sensitive_search_path(); + MutationResult::Applied + } +} diff --git a/src/analysis/state/apply_sequence.rs b/src/analysis/state/apply_sequence.rs new file mode 100644 index 0000000..0c71b59 --- /dev/null +++ b/src/analysis/state/apply_sequence.rs @@ -0,0 +1,518 @@ +use super::{AnalysisState, Confidence, MutationResult, ObjectLookup, RelationOverlay}; +use crate::analysis::expr_ir::ExprIr; +use crate::analysis::graph::{DependencyEdge, DependencyKind}; +use crate::analysis::mutations::{ + AlterSequenceActionMutation, AlterSequenceMutation, CreateSequenceMutation, + DropSequenceMutation, +}; +use crate::ast::identifiers::ObjectId; +use crate::model::sequence::{SequenceKind, SequenceOverlay, SequenceState}; + +type SequenceLookup = ObjectLookup; + +impl AnalysisState { + fn sequence_literal_matches(raw: &str, sequence: &ObjectId) -> bool { + let trimmed = raw.trim(); + let value = if let Some(rest) = trimmed.strip_prefix('\'') { + let mut value = String::new(); + let mut chars = rest.chars(); + while let Some(ch) = chars.next() { + match ch { + '\'' if chars.as_str().starts_with('\'') => { + value.push('\''); + chars.next(); + } + '\'' => break, + other => value.push(other), + } + } + value + } else { + trimmed + .split_once("::") + .map_or(trimmed, |(value, _)| value) + .trim_matches('"') + .to_string() + }; + let quote = |identifier: &str| format!("\"{}\"", identifier.replace('"', "\"\"")); + [ + format!("{}.{}", sequence.schema, sequence.name), + format!("{}.{}", quote(&sequence.schema), quote(&sequence.name)), + format!("{}.{}", sequence.schema, quote(&sequence.name)), + format!("{}.{}", quote(&sequence.schema), sequence.name), + sequence.name.clone(), + quote(&sequence.name), + ] + .iter() + .any(|candidate| candidate == &value) + } + + fn expression_references_sequence(expression: &ExprIr, sequence: &ObjectId) -> bool { + match expression { + ExprIr::FunctionCall { name, args } => { + let is_nextval = name + .rsplit('.') + .next() + .is_some_and(|name| name.eq_ignore_ascii_case("nextval")); + (is_nextval + && args.first().is_some_and(|argument| { + Self::expression_references_sequence(argument, sequence) + })) + || args + .iter() + .any(|argument| Self::expression_references_sequence(argument, sequence)) + } + ExprIr::Literal(value) => Self::sequence_literal_matches(value, sequence), + ExprIr::BinaryOp { left, right, .. } => { + Self::expression_references_sequence(left, sequence) + || Self::expression_references_sequence(right, sequence) + } + ExprIr::Cast { expr, .. } => Self::expression_references_sequence(expr, sequence), + ExprIr::ColumnRef(_) | ExprIr::Omitted => false, + } + } + + fn raw_default_references_sequence(raw: &str, sequence: &ObjectId) -> bool { + let function_present = raw + .to_ascii_lowercase() + .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') + .any(|token| token == "nextval"); + if !function_present { + return false; + } + let qualified = format!("'{}.{}'", sequence.schema, sequence.name); + let unqualified = format!("'{}'", sequence.name); + let quoted_qualified = format!("'\"{}\".\"{}\"'", sequence.schema, sequence.name); + let quoted_unqualified = format!("'\"{}\"'", sequence.name); + raw.contains(&qualified) + || raw.contains(&unqualified) + || raw.contains("ed_qualified) + || raw.contains("ed_unqualified) + || raw + .to_ascii_lowercase() + .contains(&qualified.to_ascii_lowercase()) + } + + pub(super) fn clear_sequence_defaults_on_cascade( + &mut self, + sequence: &ObjectId, + kind: SequenceKind, + owned_by: Option<(ObjectId, String)>, + ) { + let relation_ids = self + .local + .relations + .iter() + .filter_map(|(id, overlay)| { + matches!(overlay, RelationOverlay::Present(_)).then_some(id.clone()) + }) + .collect::>(); + let mut columns_to_clear = Vec::new(); + for relation_id in relation_ids { + let Some(RelationOverlay::Present(relation)) = self.local.relations.get(&relation_id) + else { + continue; + }; + for column in &relation.columns { + let generated_default = kind == SequenceKind::SerialLike + && owned_by + .as_ref() + .is_some_and(|(table, name)| table == &relation_id && name == &column.name); + let references_sequence = + column.default.as_ref().is_some_and(|default| { + Self::expression_references_sequence(default, sequence) + }) || column.default_expr_text.as_deref().is_some_and(|default| { + Self::raw_default_references_sequence(default, sequence) + }); + if generated_default || references_sequence { + columns_to_clear.push((relation_id.clone(), column.name.clone())); + } + } + } + for (relation_id, column_name) in columns_to_clear { + self.snapshot_relation(&relation_id); + if let Some(RelationOverlay::Present(relation)) = + self.local.relations.get_mut(&relation_id) + && let Some(column) = relation + .columns + .iter_mut() + .find(|column| column.name == column_name) + { + column.default = None; + column.default_expr_text = None; + } + } + } + + fn sequence_lookup(&self, id: &ObjectId) -> SequenceLookup { + match self.local.sequences.get(id) { + Some(SequenceOverlay::Present(_)) => SequenceLookup::Present, + Some(SequenceOverlay::Dropped) => SequenceLookup::Tombstone, + None if self.baseline_available && self.baseline_covers_object(id) => { + SequenceLookup::AuthoritativelyAbsent + } + None => SequenceLookup::Unknown, + } + } + + pub(super) fn apply_create_sequence( + &mut self, + create: &CreateSequenceMutation, + ) -> 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; + } + if self.relation_namespace_is_taken(&create.id) { + return MutationResult::Conflict { + reason: format!("relation '{}' already exists", create.id), + }; + } + if let Some((table_id, column)) = &create.owned_by { + if table_id.schema != create.id.schema { + return MutationResult::Conflict { + reason: "sequence must be in the same schema as its owning table".to_string(), + }; + } + if let Err(result) = self.ensure_relation_target( + table_id, + |kind| *kind == crate::model::relation::RelationKind::Table, + format!("relation '{}' does not exist", table_id), + format!("sequence owner '{}' is not a table", table_id), + ) { + return result; + } + match self.local.relations.get(table_id) { + Some(RelationOverlay::Present(table)) => { + if !table.has_column(column) { + return MutationResult::Conflict { + reason: format!("column '{}.{}' does not exist", table_id, column), + }; + } + if self.local.current_role_known && table.owner.name != self.local.current_role + { + return MutationResult::Conflict { + reason: "sequence and table must have the same owner".to_string(), + }; + } + } + _ if self.baseline_covers_object(table_id) && self.baseline_available => { + return MutationResult::Conflict { + reason: format!("relation '{}' does not exist", table_id), + }; + } + _ => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + } + } + self.snapshot_sequence(&create.id); + self.snapshot_generation_counter(); + self.local.generation_counter += 1; + let generation = self.local.generation_counter; + self.local.sequences.insert( + create.id.clone(), + SequenceOverlay::Present(SequenceState { + id: create.id.clone(), + owner: ObjectId::new("", self.local.current_role.clone()), + owned_by: create.owned_by.clone(), + kind: if create.owned_by.is_some() { + SequenceKind::Owned + } else { + SequenceKind::Standalone + }, + generation, + }), + ); + if let Some((table_id, column)) = &create.owned_by { + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + create.id.clone(), + table_id.clone(), + DependencyKind::SequenceOwnedBy { + column: column.clone(), + }, + )); + } + MutationResult::Applied + } + + pub(super) fn apply_alter_sequence(&mut self, alter: &AlterSequenceMutation) -> MutationResult { + match self.sequence_lookup(&alter.id) { + SequenceLookup::Present => {} + SequenceLookup::AuthoritativelyAbsent if alter.if_exists => { + return MutationResult::Skipped; + } + SequenceLookup::AuthoritativelyAbsent => { + return MutationResult::Conflict { + reason: format!("sequence '{}' does not exist", alter.id), + }; + } + SequenceLookup::Tombstone + if self.baseline_available && self.baseline_covers_object(&alter.id) => + { + return MutationResult::Conflict { + reason: format!("sequence '{}' does not exist", alter.id), + }; + } + SequenceLookup::Tombstone if alter.if_exists => return MutationResult::Skipped, + SequenceLookup::Tombstone | SequenceLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + SequenceLookup::WrongKind => unreachable!("sequence lookup has no kind predicate"), + } + let current = match self.local.sequences.get(&alter.id) { + Some(SequenceOverlay::Present(sequence)) => sequence.clone(), + _ => unreachable!("presence checked above"), + }; + match &alter.action { + AlterSequenceActionMutation::OwnedBy(owned_by) => { + if current.kind == SequenceKind::Identity { + return MutationResult::Conflict { + reason: "cannot change ownership of an identity sequence".to_string(), + }; + } + if let Some((table_id, column)) = owned_by { + if table_id.schema != alter.id.schema { + return MutationResult::Conflict { + reason: "sequence must be in the same schema as its owning table" + .to_string(), + }; + } + if let Err(result) = self.ensure_relation_target( + table_id, + |kind| *kind == crate::model::relation::RelationKind::Table, + format!("relation '{}' does not exist", table_id), + format!("sequence owner '{}' is not a table", table_id), + ) { + return result; + } + let Some(RelationOverlay::Present(table)) = self.local.relations.get(table_id) + else { + unreachable!("relation target presence checked above"); + }; + if !table.has_column(column) { + return MutationResult::Conflict { + reason: format!("column '{}.{}' does not exist", table_id, column), + }; + } + if table.owner != current.owner { + return MutationResult::Conflict { + reason: "sequence and table must have the same owner".to_string(), + }; + } + } + self.snapshot_sequence(&alter.id); + self.snapshot_graph_full(); + self.local.graph.retain_edges(|edge| { + !(matches!(edge.kind, DependencyKind::SequenceOwnedBy { .. }) + && edge.dependent == alter.id) + }); + if let Some(SequenceOverlay::Present(sequence)) = + self.local.sequences.get_mut(&alter.id) + { + sequence.owned_by = owned_by.clone(); + sequence.kind = if owned_by.is_some() { + SequenceKind::Owned + } else { + SequenceKind::Standalone + }; + } + if let Some((table_id, column)) = owned_by { + self.local.graph.add_edge(DependencyEdge::new( + alter.id.clone(), + table_id.clone(), + DependencyKind::SequenceOwnedBy { + column: column.clone(), + }, + )); + } + MutationResult::Applied + } + AlterSequenceActionMutation::OwnerTo(owner) => { + if current.kind == SequenceKind::Identity { + return MutationResult::Conflict { + reason: "cannot alter an identity sequence independently".to_string(), + }; + } + let Some((owner_name, known)) = self.role_fact_identity(owner) else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + }; + if known && self.local.roles_known && self.present_role(&owner_name).is_none() { + return MutationResult::Conflict { + reason: format!("role '{}' does not exist", owner_name), + }; + } + if known && !self.local.roles_known { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + if let Some((table_id, _)) = ¤t.owned_by { + if let Err(result) = self.ensure_relation_target( + table_id, + |kind| *kind == crate::model::relation::RelationKind::Table, + format!("relation '{}' does not exist", table_id), + format!("sequence owner '{}' is not a table", table_id), + ) { + return result; + } + let Some(RelationOverlay::Present(table)) = self.local.relations.get(table_id) + else { + unreachable!("sequence owner presence checked above"); + }; + if table.owner.name != owner_name { + return MutationResult::Conflict { + reason: "sequence and table must have the same owner".to_string(), + }; + } + } + self.snapshot_sequence(&alter.id); + if let Some(SequenceOverlay::Present(sequence)) = + self.local.sequences.get_mut(&alter.id) + { + sequence.owner = ObjectId::new("", owner_name); + } + MutationResult::Applied + } + AlterSequenceActionMutation::RenameTo(new_id) + | AlterSequenceActionMutation::SetSchema(new_id) => { + if current.kind == SequenceKind::Identity { + return MutationResult::Conflict { + reason: "cannot alter an identity sequence independently".to_string(), + }; + } + if let Err(result) = self.ensure_schema_target(&new_id.schema) { + return result; + } + if self.relation_namespace_is_taken(new_id) { + return MutationResult::Conflict { + reason: format!("relation '{}' already exists", new_id), + }; + } + if let Some((table_id, _)) = ¤t.owned_by + && table_id.schema != new_id.schema + { + return MutationResult::Conflict { + reason: "sequence must be in the same schema as its owning table" + .to_string(), + }; + } + self.snapshot_namespace(); + let mut moved = current; + moved.id = new_id.clone(); + self.local.sequences.remove(&alter.id); + self.local + .sequences + .insert(new_id.clone(), SequenceOverlay::Present(moved)); + self.local + .graph + .propagate_sequence_rename(&alter.id, new_id); + self.local.graph.add_edge(DependencyEdge::new( + alter.id.clone(), + new_id.clone(), + DependencyKind::RenameTo, + )); + if self.baseline_sequences.remove(&alter.id) { + self.baseline_sequences.insert(new_id.clone()); + } + MutationResult::Applied + } + AlterSequenceActionMutation::Other => { + // No typed state transition exists for this Squawk action; + // retaining Applied would make subsequent analysis look exact. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + MutationResult::Skipped + } + } + } + + pub(super) fn apply_drop_sequence(&mut self, drop: &DropSequenceMutation) -> MutationResult { + for id in &drop.ids { + match self.sequence_lookup(id) { + SequenceLookup::Present => {} + SequenceLookup::AuthoritativelyAbsent | SequenceLookup::Tombstone + if drop.if_exists => {} + SequenceLookup::AuthoritativelyAbsent | SequenceLookup::Tombstone => { + return MutationResult::Conflict { + reason: format!("sequence '{}' does not exist", id), + }; + } + SequenceLookup::Unknown => { + // IF EXISTS cannot prove that an out-of-scope object is + // absent. Do not apply the other targets and then claim + // an exact state transition. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + SequenceLookup::WrongKind => { + unreachable!("sequence lookup has no kind predicate") + } + } + } + let present: Vec = drop + .ids + .iter() + .filter(|id| self.sequence_is_present(id)) + .cloned() + .collect(); + if present.is_empty() { + return MutationResult::Skipped; + } + for id in &present { + let Some(SequenceOverlay::Present(sequence)) = self.local.sequences.get(id) else { + continue; + }; + if sequence.kind == SequenceKind::Identity { + return MutationResult::Conflict { + reason: format!("cannot drop identity sequence '{}' independently", id), + }; + } + if sequence.kind == SequenceKind::SerialLike && !drop.cascade { + return MutationResult::Conflict { + reason: format!("sequence '{}' still has dependent defaults", id), + }; + } + } + if drop.cascade { + let sequences = present + .iter() + .filter_map(|id| { + self.local + .sequences + .get(id) + .and_then(|overlay| match overlay { + SequenceOverlay::Present(sequence) => { + Some((id.clone(), sequence.kind.clone(), sequence.owned_by.clone())) + } + SequenceOverlay::Dropped => None, + }) + }) + .collect::>(); + for (id, kind, owned_by) in sequences { + self.clear_sequence_defaults_on_cascade(&id, kind, owned_by); + } + } + for id in &present { + self.snapshot_sequence(id); + self.local + .sequences + .insert(id.clone(), SequenceOverlay::Dropped); + } + self.snapshot_graph_full(); + self.local.graph.retain_edges(|edge| { + !(matches!(edge.kind, DependencyKind::SequenceOwnedBy { .. }) + && present.contains(&edge.dependent)) + }); + MutationResult::Applied + } +} diff --git a/src/analysis/state/apply_settings.rs b/src/analysis/state/apply_settings.rs new file mode 100644 index 0000000..b02f8b0 --- /dev/null +++ b/src/analysis/state/apply_settings.rs @@ -0,0 +1,192 @@ +use super::{AnalysisState, Confidence, MutationResult}; +use crate::analysis::facts::{ + ResetSettingTarget, RoleFact, SearchPathTarget, TimeoutSetting, TimeoutSettingValue, +}; +use crate::analysis::mutations::{OpaqueMutation, SearchPathChange, TimeoutSettingChange}; + +impl AnalysisState { + pub(super) fn apply_search_path(&mut self, change: &SearchPathChange) -> MutationResult { + if change.local && self.local.transactions.is_empty() { + return MutationResult::Skipped; + } + self.snapshot_search_path(); + self.snapshot_confidence(); + let template = match &change.target { + SearchPathTarget::Default => self.local.default_search_path_template.clone(), + SearchPathTarget::Schemas(schemas) => schemas.clone(), + }; + self.local.search_path_template = template.clone(); + if !change.local { + self.local.session_search_path_template = template; + } + self.refresh_role_sensitive_search_path(); + MutationResult::Applied + } + + pub(super) fn apply_timeout_setting( + &mut self, + change: &TimeoutSettingChange, + ) -> MutationResult { + if change.local && self.local.transactions.is_empty() { + return MutationResult::Skipped; + } + let next = match &change.value { + TimeoutSettingValue::Default => match change.setting { + TimeoutSetting::Lock => self.local.lock_timeout.default, + TimeoutSetting::Statement => self.local.statement_timeout.default, + }, + TimeoutSettingValue::Milliseconds(milliseconds) => Some(*milliseconds), + TimeoutSettingValue::Current => match change.setting { + TimeoutSetting::Lock => self.local.lock_timeout.effective, + TimeoutSetting::Statement => self.local.statement_timeout.effective, + }, + TimeoutSettingValue::Invalid(reason) => { + return MutationResult::Conflict { + reason: reason.clone(), + }; + } + }; + self.snapshot_timeout_settings(); + let setting = match change.setting { + TimeoutSetting::Lock => &mut self.local.lock_timeout, + TimeoutSetting::Statement => &mut self.local.statement_timeout, + }; + setting.effective = next; + if !change.local { + setting.session = next; + } + MutationResult::Applied + } + + pub(super) fn apply_reset_settings(&mut self, target: &ResetSettingTarget) -> MutationResult { + if matches!( + target, + ResetSettingTarget::All | ResetSettingTarget::SearchPath + ) { + self.snapshot_search_path(); + self.snapshot_confidence(); + let template = self.local.default_search_path_template.clone(); + self.local.session_search_path_template = template.clone(); + self.local.search_path_template = template; + self.refresh_role_sensitive_search_path(); + } + if matches!( + target, + ResetSettingTarget::All + | ResetSettingTarget::LockTimeout + | ResetSettingTarget::StatementTimeout + ) { + self.snapshot_timeout_settings(); + if matches!( + target, + ResetSettingTarget::All | ResetSettingTarget::LockTimeout + ) { + self.local.lock_timeout.session = self.local.lock_timeout.default; + self.local.lock_timeout.effective = self.local.lock_timeout.default; + } + if matches!( + target, + ResetSettingTarget::All | ResetSettingTarget::StatementTimeout + ) { + self.local.statement_timeout.session = self.local.statement_timeout.default; + self.local.statement_timeout.effective = self.local.statement_timeout.default; + } + } + MutationResult::Applied + } + + pub(super) fn apply_switch_role( + &mut self, + role: &Option, + local: bool, + is_session_auth: bool, + ) -> MutationResult { + if local && self.local.transactions.is_empty() { + return MutationResult::Skipped; + } + + let (target_name, target_known) = if let Some(role) = role { + let Some(identity) = self.role_fact_identity(role) else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + }; + identity + } else if is_session_auth { + ( + self.local.authenticated_role.clone(), + self.local.authenticated_role_known, + ) + } else { + ( + self.local.session_role.clone(), + self.local.session_role_known, + ) + }; + let persistent_role_reset_target = if role.is_none() && !is_session_auth { + Some(( + self.local.persistent_session_role.clone(), + self.local.persistent_session_role_known, + )) + } else { + None + }; + + let authorized = if role.is_none() { + Some(true) + } else if is_session_auth { + self.can_set_session_authorization_to(&target_name) + } else { + self.can_set_role_to(&target_name) + }; + match authorized { + Some(false) => { + return MutationResult::Conflict { + reason: if self.present_role(&target_name).is_none() { + format!("role '{}' does not exist", target_name) + } else { + format!("permission denied to set role '{}'", target_name) + }, + }; + } + None => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + Some(true) => {} + } + + self.snapshot_role_context(); + self.snapshot_search_path(); + self.snapshot_confidence(); + if is_session_auth { + self.local.session_role = target_name.clone(); + self.local.session_role_known = target_known; + self.local.current_role = target_name.clone(); + self.local.current_role_known = target_known; + if !local { + self.local.persistent_session_role = target_name.clone(); + self.local.persistent_session_role_known = target_known; + self.local.persistent_current_role = target_name; + self.local.persistent_current_role_known = target_known; + } + } else { + self.local.current_role = target_name.clone(); + self.local.current_role_known = target_known; + if !local { + let (persistent_name, persistent_known) = + persistent_role_reset_target.unwrap_or((target_name, target_known)); + self.local.persistent_current_role = persistent_name; + self.local.persistent_current_role_known = persistent_known; + } + } + self.refresh_role_sensitive_search_path(); + MutationResult::Applied + } + + pub(super) fn apply_opaque(&mut self, _opaque: &OpaqueMutation) -> MutationResult { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + MutationResult::Applied + } +} diff --git a/src/analysis/state/apply_transaction.rs b/src/analysis/state/apply_transaction.rs new file mode 100644 index 0000000..5de8025 --- /dev/null +++ b/src/analysis/state/apply_transaction.rs @@ -0,0 +1,135 @@ +use super::{AnalysisState, Confidence, MutationResult}; +use crate::analysis::mutations::{ + ReleaseSavepointMutation, RollbackToSavepointMutation, SavepointMutation, +}; +use crate::analysis::transaction::TransactionFrame; + +impl AnalysisState { + pub(super) fn apply_begin_transaction(&mut self) -> MutationResult { + if self.local.transactions.is_empty() { + self.local.transactions.push(TransactionFrame::root()); + MutationResult::Applied + } else { + MutationResult::Skipped + } + } + + pub(super) fn apply_commit_transaction(&mut self, chain: bool) -> MutationResult { + if chain && self.local.transactions.is_empty() { + self.local.confidence = Confidence::Tainted; + return MutationResult::Conflict { + reason: "COMMIT AND CHAIN can only be used in transaction blocks".to_string(), + }; + } + + if self.local.transaction_aborted { + while let Some(frame) = self.local.transactions.pop() { + self.rollback_frame(frame); + } + } else { + while self.local.transactions.pop().is_some() {} + self.restore_persistent_role_context(); + } + self.local.transaction_aborted = false; + if chain { + self.local.transactions.push(TransactionFrame::root()); + } + MutationResult::Applied + } + + pub(super) fn apply_rollback_transaction(&mut self, chain: bool) -> MutationResult { + if chain && self.local.transactions.is_empty() { + self.local.confidence = Confidence::Tainted; + return MutationResult::Conflict { + reason: "ROLLBACK AND CHAIN can only be used in transaction blocks".to_string(), + }; + } + while let Some(frame) = self.local.transactions.pop() { + self.rollback_frame(frame); + } + self.local.transaction_aborted = false; + if chain { + self.local.transactions.push(TransactionFrame::root()); + } + MutationResult::Applied + } + + pub(super) fn apply_rollback_to_savepoint( + &mut self, + rollback: &RollbackToSavepointMutation, + ) -> MutationResult { + let Some(position) = self + .local + .transactions + .iter() + .rposition(|frame| frame.is_named_savepoint(&rollback.name)) + else { + self.local.confidence = Confidence::Tainted; + if !self.local.transactions.is_empty() { + self.local.transaction_aborted = true; + } + return MutationResult::Conflict { + reason: format!("savepoint '{}' does not exist", rollback.name), + }; + }; + let rolled_back = self.local.transactions.split_off(position + 1); + for frame in rolled_back.into_iter().rev() { + self.rollback_frame(frame); + } + let undo_log = std::mem::take(&mut self.local.transactions[position].undo_log); + self.rollback_undo_log(undo_log); + self.local.transaction_aborted = false; + MutationResult::Applied + } + + pub(super) fn apply_savepoint(&mut self, savepoint: &SavepointMutation) -> MutationResult { + if self.local.transactions.is_empty() { + self.local.confidence = Confidence::Tainted; + return MutationResult::Conflict { + reason: "SAVEPOINT can only be used in transaction blocks".to_string(), + }; + } + self.local + .transactions + .push(TransactionFrame::savepoint(savepoint.name.clone())); + MutationResult::Applied + } + + pub(super) fn apply_release_savepoint( + &mut self, + release: &ReleaseSavepointMutation, + ) -> MutationResult { + let Some(position) = self + .local + .transactions + .iter() + .rposition(|frame| frame.is_named_savepoint(&release.name)) + else { + self.local.confidence = Confidence::Tainted; + if !self.local.transactions.is_empty() { + self.local.transaction_aborted = true; + } + return MutationResult::Conflict { + reason: format!("savepoint '{}' does not exist", release.name), + }; + }; + if position == 0 { + self.local.confidence = Confidence::Tainted; + return MutationResult::Conflict { + reason: format!("savepoint '{}' is not inside a transaction", release.name), + }; + } + + let released = self.local.transactions.split_off(position); + let Some(outer) = self.local.transactions.last_mut() else { + self.local.confidence = Confidence::Tainted; + return MutationResult::Conflict { + reason: format!("savepoint '{}' is not inside a transaction", release.name), + }; + }; + for frame in released { + outer.undo_log.extend(frame.undo_log); + } + MutationResult::Applied + } +} diff --git a/src/analysis/state/apply_type.rs b/src/analysis/state/apply_type.rs new file mode 100644 index 0000000..2a1017b --- /dev/null +++ b/src/analysis/state/apply_type.rs @@ -0,0 +1,453 @@ +use super::{AnalysisState, Confidence, MutationResult, ObjectLookup, RelationOverlay}; +use crate::analysis::graph::{DependencyEdge, DependencyKind}; +use crate::analysis::mutations::{ + AlterDomainMutation, AlterTypeActionMutation, AlterTypeMutation, CreateDomainMutation, + CreateTypeMutation, DropDomainMutation, DropTypeMutation, Rename, +}; +use crate::ast::identifiers::ObjectId; +use crate::model::function::FunctionOverlay; +use crate::model::types::{TypeKind, TypeOverlay, TypeState}; +use std::collections::HashSet; + +type TypeLookup = ObjectLookup; + +impl AnalysisState { + pub(super) fn apply_create_type(&mut self, create: &CreateTypeMutation) -> MutationResult { + if let Err(result) = self.ensure_schema_target(&create.id.schema) { + return result; + } + if self.relation_namespace_is_taken(&create.id) { + return MutationResult::Conflict { + reason: format!("type '{}' already exists", create.id), + }; + } + self.snapshot_type(&create.id); + self.snapshot_generation_counter(); + self.local.generation_counter += 1; + let generation = self.local.generation_counter; + self.local.types.insert( + create.id.clone(), + TypeOverlay::Present(TypeState { + id: create.id.clone(), + generation, + kind: create.kind.clone(), + }), + ); + MutationResult::Applied + } + + pub(super) fn apply_rename_type(&mut self, rename: &Rename) -> MutationResult { + match self.type_lookup(&rename.old_id, |_| true) { + TypeLookup::Present => {} + _ if self.baseline_covers_object(&rename.old_id) => { + return MutationResult::Conflict { + reason: format!("type '{}' does not exist", rename.old_id), + }; + } + TypeLookup::Tombstone | TypeLookup::AuthoritativelyAbsent | TypeLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + TypeLookup::WrongKind => unreachable!("all present type kinds are accepted"), + } + if rename.old_id != rename.new_id && self.relation_namespace_is_taken(&rename.new_id) { + return MutationResult::Conflict { + reason: format!("type '{}' 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.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + + let mut remapped_functions = Vec::new(); + for (function_id, overlay) in &self.local.functions { + let FunctionOverlay::Present(function) = overlay else { + continue; + }; + let new_arg_types = function + .arg_types + .iter() + .enumerate() + .map(|(index, raw)| { + if function.arg_type_ids.get(index) == Some(&Some(rename.old_id.clone())) { + Self::remapped_type_display( + raw, + &rename.new_id, + rename.old_id.schema != rename.new_id.schema, + ) + } else { + raw.clone() + } + }) + .collect::>(); + let new_return_type = if function.return_type_id == Some(rename.old_id.clone()) { + Self::remapped_type_display( + &function.return_type, + &rename.new_id, + rename.old_id.schema != rename.new_id.schema, + ) + } else { + function.return_type.clone() + }; + let base_name = function_id + .name + .split_once('(') + .map(|(name, _)| name) + .unwrap_or(&function_id.name); + let mut new_function_id = ObjectId::new( + &function_id.schema, + format!("{}({})", base_name, new_arg_types.join(",")), + ); + new_function_id.inferred_schema = function_id.inferred_schema; + if new_function_id != *function_id + || new_arg_types != function.arg_types + || new_return_type != function.return_type + { + remapped_functions.push(( + function_id.clone(), + new_function_id, + new_arg_types, + new_return_type, + )); + } + } + let moved_function_ids = remapped_functions + .iter() + .map(|(old_id, _, _, _)| old_id) + .collect::>(); + let mut destinations = HashSet::new(); + for (_, new_id, _, _) in &remapped_functions { + if !destinations.insert(new_id) + || (self.local.functions.contains_key(new_id) + && !moved_function_ids.contains(new_id)) + { + return MutationResult::Conflict { + reason: format!( + "routine '{}' already exists after renaming type '{}'", + new_id, rename.old_id + ), + }; + } + } + + self.snapshot_namespace(); + if let Some(TypeOverlay::Present(mut state)) = self.local.types.remove(&rename.old_id) { + state.id = rename.new_id.clone(); + self.local + .types + .insert(rename.new_id.clone(), TypeOverlay::Present(state)); + } + for overlay in self.local.relations.values_mut() { + if let RelationOverlay::Present(relation) = overlay { + for column in &mut relation.columns { + if column.type_id == Some(rename.old_id.clone()) { + column.data_type = Some(Self::remapped_type_display( + column.data_type.as_deref().unwrap_or_default(), + &rename.new_id, + rename.old_id.schema != rename.new_id.schema, + )); + column.type_id = Some(rename.new_id.clone()); + } + } + } + } + for overlay in self.local.types.values_mut() { + if let TypeOverlay::Present(TypeState { + kind: + TypeKind::Domain { + base_type, + base_type_id, + }, + .. + }) = overlay + && *base_type_id == Some(rename.old_id.clone()) + { + *base_type = Self::remapped_type_display( + base_type, + &rename.new_id, + rename.old_id.schema != rename.new_id.schema, + ); + *base_type_id = Some(rename.new_id.clone()); + } + } + for (old_id, new_id, arg_types, return_type) in remapped_functions { + if let Some(FunctionOverlay::Present(mut function)) = + self.local.functions.remove(&old_id) + { + function.id = new_id.clone(); + function.arg_types = arg_types; + for type_id in &mut function.arg_type_ids { + if *type_id == Some(rename.old_id.clone()) { + *type_id = Some(rename.new_id.clone()); + } + } + function.return_type = return_type; + if function.return_type_id == Some(rename.old_id.clone()) { + function.return_type_id = Some(rename.new_id.clone()); + } + self.local + .functions + .insert(new_id.clone(), FunctionOverlay::Present(function)); + if old_id != new_id { + self.local.graph.propagate_function_rename(&old_id, &new_id); + self.local.graph.add_edge(DependencyEdge::new( + old_id, + new_id, + DependencyKind::RenameTo, + )); + } + } + } + MutationResult::Applied + } + + pub(super) fn apply_alter_type(&mut self, alter: &AlterTypeMutation) -> MutationResult { + match self.type_lookup(&alter.id, |_| true) { + TypeLookup::Present => {} + TypeLookup::WrongKind => unreachable!("all present type kinds are accepted"), + TypeLookup::AuthoritativelyAbsent | TypeLookup::Tombstone => { + return MutationResult::Conflict { + reason: format!("type '{}' does not exist", alter.id), + }; + } + TypeLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + } + if matches!(&alter.action, AlterTypeActionMutation::AddValue { .. }) + && !matches!( + self.local.types.get(&alter.id), + Some(TypeOverlay::Present(TypeState { + kind: TypeKind::Enum { .. }, + .. + })) + ) + { + return MutationResult::Conflict { + reason: format!("type '{}' is not an enum", alter.id), + }; + } + self.snapshot_type(&alter.id); + if let Some(TypeOverlay::Present(existing)) = self.local.types.get_mut(&alter.id) { + match &alter.action { + AlterTypeActionMutation::AddValue { + new_value, + neighbor, + before, + } => { + if let TypeKind::Enum { variants } = &mut existing.kind { + if variants.contains(new_value) { + return MutationResult::Skipped; + } + let insertion_index = if let Some(neighbor) = neighbor { + let Some(index) = variants.iter().position(|value| value == neighbor) + else { + return MutationResult::Conflict { + reason: format!( + "enum label '{}' does not exist on type '{}'", + neighbor, alter.id + ), + }; + }; + if *before { index } else { index + 1 } + } else { + variants.len() + }; + variants.insert(insertion_index, new_value.clone()); + } + } + AlterTypeActionMutation::RenameValue { + old_value, + new_value, + } => { + let TypeKind::Enum { variants } = &mut existing.kind else { + return MutationResult::Conflict { + reason: format!("type '{}' is not an enum", alter.id), + }; + }; + let Some(old_index) = variants.iter().position(|value| value == old_value) + else { + return MutationResult::Conflict { + reason: format!( + "'{}' is not an existing label of enum '{}'", + old_value, alter.id + ), + }; + }; + if variants.iter().any(|value| value == new_value) { + return MutationResult::Conflict { + reason: format!( + "enum label '{}' already exists on type '{}'", + new_value, alter.id + ), + }; + } + variants[old_index] = new_value.clone(); + } + } + } + MutationResult::Applied + } + + pub(super) fn apply_create_domain(&mut self, create: &CreateDomainMutation) -> MutationResult { + if let Err(result) = self.ensure_schema_target(&create.id.schema) { + return result; + } + if self.relation_namespace_is_taken(&create.id) { + return MutationResult::Conflict { + reason: format!("type '{}' already exists", create.id), + }; + } + self.snapshot_type(&create.id); + self.snapshot_generation_counter(); + self.local.generation_counter += 1; + let generation = self.local.generation_counter; + self.local.types.insert( + create.id.clone(), + TypeOverlay::Present(TypeState { + id: create.id.clone(), + generation, + kind: TypeKind::Domain { + base_type: create.base_type.clone(), + base_type_id: self.resolve_type_reference(&create.base_type), + }, + }), + ); + MutationResult::Applied + } + + pub(super) fn apply_alter_domain(&mut self, alter: &AlterDomainMutation) -> MutationResult { + match self.type_lookup(&alter.id, |kind| matches!(kind, TypeKind::Domain { .. })) { + TypeLookup::Present => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + MutationResult::Skipped + } + TypeLookup::WrongKind => MutationResult::Conflict { + reason: format!("type '{}' is not a domain", alter.id), + }, + TypeLookup::AuthoritativelyAbsent | TypeLookup::Tombstone => MutationResult::Conflict { + reason: format!("domain '{}' does not exist", alter.id), + }, + TypeLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + MutationResult::Skipped + } + } + } + + pub(super) fn apply_drop_domain(&mut self, drop: &DropDomainMutation) -> MutationResult { + let mut present = Vec::new(); + for id in &drop.ids { + match self.type_lookup(id, |kind| matches!(kind, TypeKind::Domain { .. })) { + TypeLookup::Present => present.push(id.clone()), + TypeLookup::WrongKind => { + return MutationResult::Conflict { + reason: format!("type '{}' is not a domain", id), + }; + } + TypeLookup::AuthoritativelyAbsent | TypeLookup::Tombstone if drop.if_exists => {} + TypeLookup::AuthoritativelyAbsent | TypeLookup::Tombstone => { + return MutationResult::Conflict { + reason: format!("domain '{}' does not exist", id), + }; + } + TypeLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + } + } + if present.is_empty() { + return MutationResult::Skipped; + } + if let Some(dependent) = present.iter().find(|id| self.has_type_dependents(id)) { + if !drop.cascade { + return MutationResult::Conflict { + reason: format!("domain '{}' has dependent objects; use CASCADE", dependent), + }; + } + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + for id in &present { + self.snapshot_type(id); + self.local.types.insert(id.clone(), TypeOverlay::Dropped); + } + MutationResult::Applied + } + + pub(super) fn apply_drop_type(&mut self, drop: &DropTypeMutation) -> MutationResult { + let mut present = Vec::new(); + for id in &drop.ids { + match self.type_lookup(id, |kind| !matches!(kind, TypeKind::Domain { .. })) { + TypeLookup::Present => present.push(id.clone()), + TypeLookup::WrongKind => { + return MutationResult::Conflict { + reason: format!("type '{}' is a domain; use DROP DOMAIN", id), + }; + } + TypeLookup::AuthoritativelyAbsent | TypeLookup::Tombstone if drop.if_exists => {} + TypeLookup::AuthoritativelyAbsent | TypeLookup::Tombstone => { + return MutationResult::Conflict { + reason: format!("type '{}' does not exist", id), + }; + } + TypeLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + } + } + if present.is_empty() { + return MutationResult::Skipped; + } + if let Some(dependent) = present.iter().find(|id| self.has_type_dependents(id)) { + if !drop.cascade { + return MutationResult::Conflict { + reason: format!("type '{}' has dependent objects; use CASCADE", dependent), + }; + } + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + for id in &present { + self.snapshot_type(id); + self.local.types.insert(id.clone(), TypeOverlay::Dropped); + } + MutationResult::Applied + } + + fn has_type_dependents(&self, id: &ObjectId) -> bool { + self.local.relations.values().any(|overlay| { + matches!(overlay, RelationOverlay::Present(relation) + if relation.columns.iter().any(|column| column.type_id.as_ref() == Some(id))) + }) || self.local.functions.values().any(|overlay| { + matches!(overlay, FunctionOverlay::Present(function) + if function.arg_type_ids.iter().flatten().any(|type_id| type_id == id) + || function.return_type_id.as_ref() == Some(id)) + }) || self.local.types.values().any(|overlay| { + matches!(overlay, TypeOverlay::Present(TypeState { + kind: TypeKind::Domain { base_type_id: Some(base), .. }, + .. + }) if base == id) + }) + } +} diff --git a/src/analysis/state/apply_view_index.rs b/src/analysis/state/apply_view_index.rs new file mode 100644 index 0000000..ae549d4 --- /dev/null +++ b/src/analysis/state/apply_view_index.rs @@ -0,0 +1,521 @@ +use super::{ + AnalysisState, CascadeResult, Confidence, MutationResult, ObjectLookup, RelationOverlay, +}; +use crate::analysis::graph::{DependencyEdge, DependencyKind}; +use crate::analysis::mutations::{ + CreateIndex, CreateMaterializedView, CreateView, DropIndex, DropMaterializedViewMutation, + DropViewMutation, RefreshMaterializedViewMutation, +}; +use crate::ast::identifiers::ObjectId; +use crate::model::relation::{Persistence, RelationKind, RelationState}; +use std::collections::HashSet; + +type RelationLookup = ObjectLookup; +type IndexLookup = ObjectLookup; + +impl AnalysisState { + fn validate_view_dependencies( + &mut self, + dependent: &ObjectId, + dependencies: &[ObjectId], + ) -> Result<(), MutationResult> { + for dependency in dependencies { + // Recursive views are represented by a self-reference in some + // PostgreSQL catalog versions. It does not identify an external + // object that must already exist. + if dependency == dependent { + continue; + } + self.ensure_relation_target( + dependency, + |_| true, + format!("view dependency relation '{}' does not exist", dependency), + format!("view dependency '{}' is not a relation", dependency), + )?; + } + Ok(()) + } + + pub(crate) fn cascade_for_relations(&self, roots: &[ObjectId]) -> CascadeResult { + let mut cascade = CascadeResult::default(); + for root in roots { + let closure = self.get_cascade_closure(root); + cascade.dropped_relations.extend(closure.dropped_relations); + cascade.dropped_indexes.extend(closure.dropped_indexes); + cascade + .dropped_constraints + .extend(closure.dropped_constraints); + } + cascade + } + + fn remove_dropped_relation_edges( + &mut self, + dropped_relations: &HashSet, + dropped_indexes: &HashSet, + ) { + 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); + let referenced = resolution_graph.resolve_rename(&edge.referenced); + if dropped_indexes.contains(dependent) { + return false; + } + if dropped_relations.contains(dependent) { + return false; + } + if dropped_relations.contains(referenced) { + return false; + } + true + }); + } + + fn has_external_view_dependents(&self, roots: &HashSet) -> bool { + self.local.graph.edges().iter().any(|edge| { + matches!(edge.kind, DependencyKind::ViewDependency { .. }) + && roots.contains(self.local.graph.resolve_rename(&edge.referenced)) + && !roots.contains(self.local.graph.resolve_rename(&edge.dependent)) + }) + } + + fn apply_drop_relation_family( + &mut self, + present: &[ObjectId], + cascade: bool, + kind_name: &str, + ) -> MutationResult { + let roots = present.iter().cloned().collect::>(); + if !cascade && self.has_external_view_dependents(&roots) { + return MutationResult::Conflict { + reason: format!( + "relation '{}' still has dependent views; use CASCADE", + present + .first() + .map(ToString::to_string) + .unwrap_or_else(|| kind_name.to_string()) + ), + }; + } + + let cascade_result = cascade.then(|| self.cascade_for_relations(present)); + if let Some(result) = &cascade_result + && result + .dropped_relations + .iter() + .any(|id| !self.relation_is_present(id)) + { + // A scoped cache can expose a dependency edge for a relation whose + // metadata was intentionally omitted. CASCADE will remove that + // relation in PostgreSQL, but the simulator cannot reproduce its + // full state, so the result is necessarily tainted. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + let all_dropped_relations = cascade_result + .as_ref() + .map(|result| result.dropped_relations.clone()) + .unwrap_or_else(|| roots.clone()); + let dropped_relations = all_dropped_relations + .iter() + .filter(|id| self.relation_is_present(id)) + .cloned() + .collect::>(); + let dropped_indexes = cascade_result + .as_ref() + .map(|result| result.dropped_indexes.clone()) + .unwrap_or_default(); + let dropped_constraints = cascade_result + .as_ref() + .map(|result| result.dropped_constraints.clone()) + .unwrap_or_default(); + + for id in &dropped_relations { + self.snapshot_relation(id); + self.local + .relations + .insert(id.clone(), RelationOverlay::Dropped); + } + let resolution_graph = self.local.graph.clone(); + let triggers_to_drop = self + .local + .triggers + .iter() + .filter_map(|(id, overlay)| { + let crate::model::trigger::TriggerOverlay::Present(trigger) = overlay else { + return None; + }; + dropped_relations + .contains(resolution_graph.resolve_rename(&trigger.table_id)) + .then(|| id.clone()) + }) + .collect::>(); + for trigger_id in triggers_to_drop { + self.snapshot_trigger(&trigger_id); + self.local + .triggers + .insert(trigger_id, crate::model::trigger::TriggerOverlay::Dropped); + } + // Even when a scoped cache omitted a dependent view, its dependency + // edge is known and PostgreSQL CASCADE removes that edge. Use the full + // closure for topology cleanup while only marking modeled overlays. + self.remove_dropped_constraints(&all_dropped_relations, &dropped_constraints); + self.remove_dropped_relation_edges(&all_dropped_relations, &dropped_indexes); + MutationResult::Applied + } + + fn index_lookup(&self, id: &ObjectId) -> IndexLookup { + if self.local.graph.edges().iter().any(|edge| { + matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) && edge.dependent == *id + }) { + IndexLookup::Present + } else if self.baseline_available && self.baseline_covers_object(id) { + IndexLookup::AuthoritativelyAbsent + } else { + IndexLookup::Unknown + } + } + + pub(super) fn apply_create_view(&mut self, create: &CreateView) -> MutationResult { + if let Err(result) = self.ensure_schema_target(&create.id.schema) { + return result; + } + let existing_view = matches!( + self.relation_lookup(&create.id, |kind| *kind == RelationKind::View), + RelationLookup::Present + ); + if self.relation_namespace_is_taken(&create.id) && (!create.or_replace || !existing_view) { + return MutationResult::Conflict { + reason: format!("relation '{}' already exists", create.id), + }; + } + if let Err(result) = self.validate_view_dependencies(&create.id, &create.depends_on) { + return result; + } + let owner = self + .local + .relations + .get(&create.id) + .and_then(|overlay| match overlay { + RelationOverlay::Present(relation) => Some(relation.owner.clone()), + RelationOverlay::Dropped => None, + }) + .unwrap_or_else(|| ObjectId::new("", &self.local.current_role)); + self.snapshot_relation(&create.id); + self.snapshot_generation_counter(); + self.local.generation_counter += 1; + let generation = self.local.generation_counter; + let mut relation = if existing_view { + match self.local.relations.get(&create.id) { + Some(RelationOverlay::Present(existing)) => existing.clone(), + _ => unreachable!("existing view lookup established presence"), + } + } else { + RelationState::new( + create.id.clone(), + owner, + generation, + None, + RelationKind::View, + Persistence::Permanent, + self.local.transactions.len(), + ) + }; + // CREATE OR REPLACE VIEW keeps the relation identity, ownership, + // privileges, triggers, and other relation metadata. Only the + // generation records the replacement in the simulated state. + relation.id = create.id.clone(); + relation.generation = generation; + relation.kind = RelationKind::View; + relation.persistence = Persistence::Permanent; + self.local + .relations + .insert(create.id.clone(), RelationOverlay::Present(relation)); + self.snapshot_graph_full(); + if existing_view { + self.local.graph.retain_edges(|edge| { + !(matches!(edge.kind, DependencyKind::ViewDependency { .. }) + && edge.dependent == create.id) + }); + } + for dependency in &create.depends_on { + self.local.graph.add_edge(DependencyEdge::new( + create.id.clone(), + dependency.clone(), + DependencyKind::ViewDependency { + view_generation: generation, + }, + )); + } + MutationResult::Applied + } + + pub(super) fn apply_create_materialized_view( + &mut self, + create: &CreateMaterializedView, + ) -> MutationResult { + if let Err(result) = self.ensure_schema_target(&create.id.schema) { + return result; + } + if self.relation_namespace_is_taken(&create.id) { + return MutationResult::Conflict { + reason: format!("relation '{}' already exists", create.id), + }; + } + if let Err(result) = self.validate_view_dependencies(&create.id, &create.depends_on) { + return result; + } + self.snapshot_relation(&create.id); + self.snapshot_generation_counter(); + self.local.generation_counter += 1; + let generation = self.local.generation_counter; + self.local.relations.insert( + create.id.clone(), + RelationOverlay::Present(RelationState::new( + create.id.clone(), + ObjectId::new("", &self.local.current_role), + generation, + None, + RelationKind::MaterializedView, + Persistence::Permanent, + self.local.transactions.len(), + )), + ); + self.snapshot_graph_full(); + for dependency in &create.depends_on { + self.local.graph.add_edge(DependencyEdge::new( + create.id.clone(), + dependency.clone(), + DependencyKind::ViewDependency { + view_generation: generation, + }, + )); + } + MutationResult::Applied + } + + pub(super) fn apply_refresh_materialized_view( + &mut self, + refresh: &RefreshMaterializedViewMutation, + ) -> MutationResult { + match self.relation_lookup(&refresh.id, |kind| *kind == RelationKind::MaterializedView) { + RelationLookup::Present => MutationResult::Applied, + RelationLookup::WrongKind => MutationResult::Conflict { + reason: format!("'{}' is not a materialized view", refresh.id), + }, + RelationLookup::AuthoritativelyAbsent | RelationLookup::Tombstone => { + MutationResult::Conflict { + reason: format!("materialized view '{}' does not exist", refresh.id), + } + } + RelationLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + MutationResult::Skipped + } + } + } + + pub(super) fn apply_create_index(&mut self, create: &CreateIndex) -> MutationResult { + if let Err(result) = self.ensure_schema_target(&create.id.schema) { + return result; + } + if create.if_not_exists && self.index_lookup(&create.id) == IndexLookup::Present { + return MutationResult::Skipped; + } + if self.relation_namespace_is_taken(&create.id) { + return MutationResult::Conflict { + reason: format!("relation '{}' already exists", create.id), + }; + } + if let Err(result) = self.ensure_relation_target( + &create.table, + |kind| matches!(kind, RelationKind::Table | RelationKind::MaterializedView), + format!("index target relation '{}' does not exist", create.table), + format!("index target '{}' cannot be indexed", create.table), + ) { + return result; + } + self.snapshot_graph(); + self.local.graph.add_edge(DependencyEdge::new( + create.id.clone(), + create.table.clone(), + DependencyKind::IndexOnRelation { + using_method: create.using_method.clone(), + has_predicate: create.has_predicate, + is_concurrent: create.concurrently, + is_unique: create.unique, + eligibility_known: true, + }, + )); + MutationResult::Applied + } + + pub(super) fn apply_drop_view(&mut self, drop: &DropViewMutation) -> MutationResult { + let mut present = Vec::new(); + let mut unknown_target = false; + for id in &drop.ids { + match self.relation_lookup(id, |kind| *kind == RelationKind::View) { + RelationLookup::Present => present.push(id.clone()), + RelationLookup::WrongKind => { + return MutationResult::Conflict { + reason: format!("'{}' is not a view", id), + }; + } + RelationLookup::AuthoritativelyAbsent if drop.if_exists => {} + RelationLookup::AuthoritativelyAbsent => { + return MutationResult::Conflict { + reason: format!("view '{}' does not exist", id), + }; + } + RelationLookup::Tombstone if drop.if_exists => {} + RelationLookup::Tombstone => { + return MutationResult::Conflict { + reason: format!("view '{}' does not exist", id), + }; + } + RelationLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + unknown_target = true; + } + } + } + // PostgreSQL resolves all names in a multi-target DROP before + // executing the statement. Do not remove known targets when another + // target is outside a scoped/incomplete baseline. + if unknown_target { + return MutationResult::Skipped; + } + if present.is_empty() { + return MutationResult::Skipped; + } + self.apply_drop_relation_family(&present, drop.cascade, "view") + } + + pub(super) fn apply_drop_materialized_view( + &mut self, + drop: &DropMaterializedViewMutation, + ) -> MutationResult { + let mut present = Vec::new(); + let mut unknown_target = false; + for id in &drop.ids { + match self.relation_lookup(id, |kind| *kind == RelationKind::MaterializedView) { + RelationLookup::Present => present.push(id.clone()), + RelationLookup::WrongKind => { + return MutationResult::Conflict { + reason: format!("'{}' is not a materialized view", id), + }; + } + RelationLookup::AuthoritativelyAbsent if drop.if_exists => {} + RelationLookup::AuthoritativelyAbsent => { + return MutationResult::Conflict { + reason: format!("materialized view '{}' does not exist", id), + }; + } + RelationLookup::Tombstone if drop.if_exists => {} + RelationLookup::Tombstone => { + return MutationResult::Conflict { + reason: format!("materialized view '{}' does not exist", id), + }; + } + RelationLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + unknown_target = true; + } + } + } + if unknown_target { + return MutationResult::Skipped; + } + if present.is_empty() { + return MutationResult::Skipped; + } + self.apply_drop_relation_family(&present, drop.cascade, "materialized view") + } + + pub(super) fn apply_drop_index(&mut self, drop: &DropIndex) -> MutationResult { + // PostgreSQL resolves every target before it applies a multi-index + // DROP. Preflight the complete statement so a later invalid target + // cannot leave an earlier index removed from simulated state. + let mut targets = Vec::new(); + for id in &drop.ids { + match self.index_lookup(id) { + IndexLookup::Present => { + if !targets.contains(id) { + targets.push(id.clone()); + } + } + IndexLookup::AuthoritativelyAbsent if drop.if_exists => {} + IndexLookup::AuthoritativelyAbsent => { + return MutationResult::Conflict { + reason: format!("index '{}' does not exist", id), + }; + } + IndexLookup::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + IndexLookup::WrongKind | IndexLookup::Tombstone => { + unreachable!("indexes have no overlay kind or tombstone") + } + } + } + + if targets.is_empty() { + return MutationResult::Skipped; + } + + for id in &targets { + let Some(index_edge) = self.local.graph.edges().iter().find(|edge| { + matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) && edge.dependent == *id + }) else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + }; + let referenced_table = self.local.graph.resolve_rename(&index_edge.referenced); + let backs_constraint = + self.local + .constraints + .iter() + .any(|((table, name), constraint)| { + name == &id.name + && self.local.graph.resolve_rename(table) == referenced_table + && matches!( + constraint.kind, + crate::model::constraint::ConstraintKind::PrimaryKey + | crate::model::constraint::ConstraintKind::Unique + ) + }); + if backs_constraint { + return MutationResult::Conflict { + reason: format!( + "cannot drop index '{}' because a constraint requires it", + id + ), + }; + } + if matches!( + index_edge.kind, + DependencyKind::IndexOnRelation { + eligibility_known: false, + .. + } + ) { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + } + self.snapshot_graph(); + self.local.graph.retain_edges(|edge| { + !(matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) + && targets.contains(&edge.dependent)) + }); + MutationResult::Applied + } +} diff --git a/src/analysis/transaction.rs b/src/analysis/transaction.rs index a596cbc..7331bb7 100644 --- a/src/analysis/transaction.rs +++ b/src/analysis/transaction.rs @@ -90,6 +90,9 @@ pub enum StateChange { name: String, previous: Option, }, + BaselineForeignKeysSnapshot { + previous: HashSet<(ObjectId, String)>, + }, RoleContextSnapshot { current_role: String, current_role_known: bool, diff --git a/src/ast/identifiers.rs b/src/ast/identifiers.rs index 47111bc..b7cdf0f 100644 --- a/src/ast/identifiers.rs +++ b/src/ast/identifiers.rs @@ -14,15 +14,28 @@ impl Ident { } } - /// Returns the lookup spelling used by the analyzer. Quoted identifiers - /// preserve their contents; unquoted identifiers are lowercased. + /// Returns the lookup spelling used by PostgreSQL and the analyzer. Quoted + /// 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 { - if self.quoted { + let resolved = if self.quoted { self.text.clone() } else { - self.text.to_lowercase() - } + self.text.to_ascii_lowercase() + }; + truncate_postgres_identifier(&resolved).to_string() + } +} + +fn truncate_postgres_identifier(value: &str) -> &str { + const MAX_IDENTIFIER_BYTES: usize = 63; + + let mut end = value.len().min(MAX_IDENTIFIER_BYTES); + while !value.is_char_boundary(end) { + end -= 1; } + &value[..end] } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -37,6 +50,22 @@ impl QualifiedName { } } +#[cfg(test)] +mod tests { + use super::Ident; + + #[test] + fn identifiers_follow_postgresql_byte_truncation() { + let ascii = "A".repeat(70); + assert_eq!(Ident::new(ascii, false).resolve(), "a".repeat(63)); + + let quoted = format!("{}suffix", "é".repeat(32)); + let resolved = Ident::new(quoted, true).resolve(); + assert_eq!(resolved.len(), 62); + assert_eq!(resolved, "é".repeat(31)); + } +} + /// 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)] diff --git a/src/ast/visitor.rs b/src/ast/visitor.rs index 19723e9..c817f0c 100644 --- a/src/ast/visitor.rs +++ b/src/ast/visitor.rs @@ -11,9 +11,9 @@ 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, Path, PathSegment, PathSegmentRef, - RelationNameRef, ReleaseSavepoint, Revoke, RevokeCommand, Rollback, Set, Stmt, TableArg, - TableConstraint, + DropTable, DropTrigger, DropType, DropView, Grant, NameRef, PartitionType, Path, PathSegment, + PathSegmentRef, RelationNameRef, ReleaseSavepoint, Revoke, RevokeCommand, Rollback, Set, Stmt, + TableArg, TableConstraint, }; use squawk_syntax::{SyntaxKind, ast}; @@ -203,17 +203,10 @@ impl AstVisitor { return Some(StatementFact::SchemaNeutralNoop); } - if ast::PrepareTransaction::cast(syntax.clone()).is_some() { - let name = syntax - .descendants() - .find_map(ast::Literal::cast) - .map(|l| l.syntax().text().to_string().trim_matches('\'').to_string()) - .or_else(|| { - syntax - .descendants() - .find_map(PathSegment::cast) - .map(Self::resolve_name) - }) + if let Some(node) = ast::PrepareTransaction::cast(syntax.clone()) { + let name = node + .literal() + .and_then(|literal| Self::resolve_string_literal(&literal)) .unwrap_or_default(); return Some(StatementFact::PrepareTransaction { name }); } @@ -360,6 +353,21 @@ 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()) @@ -404,6 +412,13 @@ impl AstVisitor { } 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() @@ -429,13 +444,16 @@ impl AstVisitor { } fn extract_drop_table(node: &DropTable) -> Option { - let path = node + let names: Vec = node .table_name_refs() .filter_map(|r| r.path_ref()) .filter_map(|p| Self::path_ref_to_qualified_name(&p)) - .next()?; + .collect(); + if names.is_empty() { + return None; + } Some(StatementFact::DropTable { - name: path, + names, if_exists: node.if_exists().is_some(), cascade: Self::is_cascade(node.drop_behavior()), }) @@ -445,6 +463,7 @@ impl AstVisitor { let path = node.table_relation_name()?.table_name_ref()?.path_ref()?; let table_name = Self::path_ref_to_qualified_name(&path)?; let mut actions = Vec::new(); + let mut unsupported_action = false; for action in node.actions() { if let Some(ap) = AttachPartition::cast(action.syntax().clone()) { @@ -453,15 +472,11 @@ impl AstVisitor { .and_then(|tn| tn.path_ref()) .and_then(|p| Self::path_ref_to_qualified_name(&p)) { - let text = ap.syntax().text().to_string().to_uppercase(); - let strategy = if text.contains("FOR VALUES IN") { - Some("LIST".to_string()) - } else if text.contains("FOR VALUES FROM") { - Some("RANGE".to_string()) - } else if text.contains("FOR VALUES WITH") { - Some("HASH".to_string()) - } else { - None + let strategy = match ap.partition_type() { + Some(PartitionType::PartitionForValuesIn(_)) => Some("LIST".to_string()), + Some(PartitionType::PartitionForValuesFrom(_)) => Some("RANGE".to_string()), + Some(PartitionType::PartitionForValuesWith(_)) => Some("HASH".to_string()), + Some(PartitionType::PartitionDefault(_)) | None => None, }; actions.push(AlterTableActionFact::AttachPartition { child, strategy }); } @@ -479,23 +494,24 @@ impl AstVisitor { } if let Some(ac) = AlterConstraint::cast(action.syntax().clone()) { let deferrable = ac.deferrable_constraint_option().is_some(); - actions.push(AlterTableActionFact::AlterConstraint { - name: None, - deferrable, - }); + let name = ac + .constraint_name_ref() + .and_then(|name| name.path_ref()) + .and_then(|path| Self::path_ref_to_qualified_name(&path)) + .map(|name| name.name.resolve()); + actions.push(AlterTableActionFact::AlterConstraint { name, deferrable }); continue; } if let Some(rc) = ast::RenameConstraint::cast(action.syntax().clone()) { let old_name = rc - .syntax() - .descendants() - .find_map(NameRef::cast) - .map(|nr| Self::resolve_name_ref(&nr)); + .constraint_name_ref() + .and_then(|name| name.path_ref()) + .and_then(|path| Self::path_ref_to_qualified_name(&path)) + .map(|name| name.name.resolve()); let new_name = rc - .syntax() - .descendants() - .find_map(PathSegment::cast) - .map(Self::resolve_name); + .constraint_name() + .and_then(|name| name.ident_token()) + .map(|token| Self::resolve_identifier_token(token.text())); if let (Some(old_name), Some(new_name)) = (old_name, new_name) { actions.push(AlterTableActionFact::RenameConstraint { old_name, new_name }); } @@ -574,6 +590,7 @@ impl AstVisitor { actions.push(AlterTableActionFact::DropColumn { name, if_exists: drop.if_exists().is_some(), + cascade: Self::is_cascade(drop.drop_behavior()), }); } } @@ -645,7 +662,11 @@ impl AstVisitor { .and_then(|pr| Self::path_ref_to_qualified_name(&pr)) .map(|qn| qn.name.resolve()) { - actions.push(AlterTableActionFact::DropConstraint { name }); + actions.push(AlterTableActionFact::DropConstraint { + name, + if_exists: dc.if_exists().is_some(), + cascade: Self::is_cascade(dc.drop_behavior()), + }); } } AlterTableAction::AlterColumn(alter_col) => { @@ -679,12 +700,10 @@ impl AstVisitor { } AlterTableAction::ValidateConstraint(vc) => { if let Some(constraint_name) = vc - .syntax() - .descendants() - .find_map(PathSegmentRef::cast) - .map(|name| { - Self::identifier_from_name(name.text(), name.is_quoted()).resolve() - }) + .constraint_name_ref() + .and_then(|name| name.path_ref()) + .and_then(|path| Self::path_ref_to_qualified_name(&path)) + .map(|name| name.name.resolve()) { actions.push(AlterTableActionFact::ValidateConstraint { constraint_name }); } @@ -846,11 +865,20 @@ impl AstVisitor { let c_name = Self::resolve_identifier_token(parts[idx + 1]); actions.push(AlterTableActionFact::SetStorage { column: c_name }); } + } else { + // Parser-accepted actions that are not represented in + // our fact model must take the engine's explicit + // opaque path instead of becoming an exact no-op. + unsupported_action = true; } } } } + if unsupported_action { + return None; + } + Some(StatementFact::AlterTable { name: table_name, actions, @@ -996,10 +1024,7 @@ impl AstVisitor { Some(AlterTableActionFact::DropNotNull { column: col_name }) } AlterColumnOption::SetType(st) => { - let has_using = st - .syntax() - .descendants() - .any(|t| t.kind() == SyntaxKind::USING_KW); + let has_using = st.using_token().is_some(); Some(AlterTableActionFact::SetType { column: col_name, ty: st.ty()?.syntax().text().to_string(), @@ -1144,8 +1169,15 @@ impl AstVisitor { .and_then(|using_index| using_index.index_ref()) .and_then(|index_ref| index_ref.path_ref()) .and_then(|path| Self::path_ref_to_qualified_name(&path)); + let columns = unique + .syntax() + .descendants() + .find_map(ast::ConstraintColumnRefList::cast) + .map(Self::extract_constraint_column_list_names) + .unwrap_or_default(); return Some(AlterTableActionFact::AddUniqueConstraint { constraint_name, + columns, using_index, }); } @@ -1165,8 +1197,15 @@ impl AstVisitor { .and_then(|using_index| using_index.index_ref()) .and_then(|index_ref| index_ref.path_ref()) .and_then(|path| Self::path_ref_to_qualified_name(&path)); + let columns = primary_key + .syntax() + .descendants() + .find_map(ast::ConstraintColumnRefList::cast) + .map(Self::extract_constraint_column_list_names) + .unwrap_or_default(); return Some(AlterTableActionFact::AddPrimaryKeyConstraint { constraint_name, + columns, using_index, }); } @@ -1215,7 +1254,20 @@ impl AstVisitor { .map(Self::extract_constraint_column_list_names) .or_else(|| uc.using_index().map(|_| Vec::new()))?, }), - TableConstraint::CheckConstraint(_) => Some(TableConstraintFact::Check), + TableConstraint::CheckConstraint(check) => Some(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())), + }), + TableConstraint::ExcludeConstraint(exclude) => Some(TableConstraintFact::Exclude { + constraint_name: exclude + .constraint_name_clause() + .and_then(|clause| clause.constraint_name()) + .and_then(|name| name.ident_token()) + .map(|token| Self::resolve_identifier_token(token.text())), + }), _ => None, } } @@ -1388,10 +1440,16 @@ impl AstVisitor { names, if_exists: node.if_exists().is_some(), concurrently: node.concurrently_token().is_some(), + cascade: Self::is_cascade(node.drop_behavior()), }) } fn extract_create_view(node: &CreateView) -> Option { + // View options and WITH CHECK OPTION alter write/security semantics + // that are not represented by the relation state or dependency graph. + if node.with_check_option().is_some() || node.with_params().is_some() { + return None; + } let path = node.view()?.path()?; Some(StatementFact::CreateView { name: Self::path_to_qualified_name(&path)?, @@ -1431,30 +1489,12 @@ impl AstVisitor { }, }) } - ast::AlterViewAction::AlterViewColumn(avc) => { - let col_token = avc.name()?.ident_token()?; - let col_name = Self::resolve_identifier_token(col_token.text()); - - match avc.alter_view_column_action()? { - ast::AlterViewColumnAction::DropDefault(_) => Some(StatementFact::AlterView { - name, - action: crate::analysis::facts::AlterViewAction::DropDefault { - column: col_name, - }, - }), - ast::AlterViewColumnAction::SetDefault(set_default) => { - let expr = set_default.expr()?; - Some(StatementFact::AlterView { - name, - action: crate::analysis::facts::AlterViewAction::SetDefault { - column: col_name, - default: Some(crate::analysis::expr_visitor::ExprVisitor::convert( - expr, - )), - }, - }) - } - } + ast::AlterViewAction::AlterViewColumn(_) => { + // View column defaults are not represented by the relation + // state and the resolver has no corresponding mutation. + // Keep these parser-valid actions opaque rather than + // returning a fact that is silently discarded. + None } ast::AlterViewAction::RenameColumn(rc) => { let from_token = rc.column_name_ref()?.ident_token()?; @@ -1468,22 +1508,20 @@ impl AstVisitor { action: crate::analysis::facts::AlterViewAction::RenameColumn { from, to }, }) } - ast::AlterViewAction::SetOptions(_) => Some(StatementFact::AlterView { - name, - action: crate::analysis::facts::AlterViewAction::SetOptions { - options: Vec::new(), - }, - }), - ast::AlterViewAction::ResetOptions(_) => Some(StatementFact::AlterView { - name, - action: crate::analysis::facts::AlterViewAction::ResetOptions { - options: Vec::new(), - }, - }), + ast::AlterViewAction::SetOptions(_) | ast::AlterViewAction::ResetOptions(_) => None, } } fn extract_create_materialized_view(node: &CreateMaterializedView) -> Option { + // A materialized view created WITH NO DATA cannot be refreshed + // concurrently until it has been populated; populated state is not + // modeled, so preserve the opaque path for this explicit form. + if node + .data_option() + .is_some_and(|option| matches!(option, ast::DataOption::WithNoData(_))) + { + return None; + } let path = node.view()?.path()?; Some(StatementFact::CreateMaterializedView { name: Self::path_to_qualified_name(&path)?, @@ -1493,17 +1531,19 @@ impl AstVisitor { fn extract_alter_materialized_view(node: &ast::AlterMaterializedView) -> Option { let path = node.view_ref()?.path_ref()?; - let new_name = node.action().find_map(|action| { - if let squawk_syntax::ast::AlterMaterializedViewAction::ViewRenameTo(rt) = action { - let segment = rt.view()?.path()?.segment()?; - Some(Self::identifier_from_name( - segment.text(), - segment.is_quoted(), - )) - } else { - None - } - }); + let Some(squawk_syntax::ast::AlterMaterializedViewAction::ViewRenameTo(rt)) = + node.action().next() + else { + // SET SCHEMA, column changes, extension dependencies, and other + // parser-valid actions are not represented by this mutation. + // Do not turn them into a fact with no resolver mutation. + return None; + }; + let segment = rt.view()?.path()?.segment()?; + let new_name = Some(Self::identifier_from_name( + segment.text(), + segment.is_quoted(), + )); Some(StatementFact::AlterMaterializedView { name: Self::path_ref_to_qualified_name(&path)?, new_name, @@ -1519,13 +1559,16 @@ impl AstVisitor { } fn extract_drop_view(node: &DropView) -> Option { - let path = node + let names: Vec = node .view_refs() .filter_map(|r| r.path_ref()) .filter_map(|p| Self::path_ref_to_qualified_name(&p)) - .next()?; + .collect(); + if names.is_empty() { + return None; + } Some(StatementFact::DropView { - name: path, + names, if_exists: node.if_exists().is_some(), cascade: Self::is_cascade(node.drop_behavior()), }) @@ -1600,19 +1643,23 @@ impl AstVisitor { continue; } - let relation = n + let Some(relation) = n .syntax() .ancestors() .skip(1) - .find_map(RelationNameRef::cast); + .find_map(RelationNameRef::cast) + else { + // Path segments outside a relation name are expressions, + // casts, function calls, aliases, or CTE internals. They are + // not relation dependencies (for example, `regclass` in a + // `nextval(...::regclass)` cast), so do not turn them into + // phantom graph edges. + continue; + }; - let qname = if let Some(rn) = relation { - if let Some(pr) = rn.path_ref() { - if let Some(qn) = Self::path_ref_to_qualified_name(&pr) { - qn - } else { - QualifiedName::new(None, Ident::new(clean_text.clone(), is_quoted)) - } + let qname = if let Some(pr) = relation.path_ref() { + if let Some(qn) = Self::path_ref_to_qualified_name(&pr) { + qn } else { QualifiedName::new(None, Ident::new(clean_text.clone(), is_quoted)) } @@ -1633,7 +1680,10 @@ impl AstVisitor { Some(StatementFact::CreateSequence { name, if_not_exists: node.if_not_exists().is_some(), - owned_by: Self::extract_owned_by(node.syntax()), + owned_by: node.sequence_options().find_map(|option| match option { + ast::SequenceOption::OptionOwnedBy(owned_by) => Self::extract_owned_by(&owned_by), + _ => None, + }), }) } @@ -1664,26 +1714,17 @@ impl AstVisitor { ) }) .unwrap_or(crate::analysis::facts::AlterSequenceActionFact::Other), - Some(ast::AlterSequenceAction::SequenceOption(_)) => { - let owned_option = node - .syntax() - .descendants() - .find_map(ast::OptionOwnedBy::cast); - match owned_option { - Some(option) - if matches!( - option.owned_by_target(), - Some(ast::OwnedByTarget::OwnedByNone(_)) - ) => - { - crate::analysis::facts::AlterSequenceActionFact::OwnedBy(None) - } - Some(_) => crate::analysis::facts::AlterSequenceActionFact::OwnedBy( - Self::extract_owned_by(node.syntax()), - ), - None => crate::analysis::facts::AlterSequenceActionFact::Other, + Some(ast::AlterSequenceAction::SequenceOption(ast::SequenceOption::OptionOwnedBy( + option, + ))) => match option.owned_by_target() { + Some(ast::OwnedByTarget::OwnedByNone(_)) => { + crate::analysis::facts::AlterSequenceActionFact::OwnedBy(None) } - } + Some(_) => crate::analysis::facts::AlterSequenceActionFact::OwnedBy( + Self::extract_owned_by(&option), + ), + None => crate::analysis::facts::AlterSequenceActionFact::Other, + }, _ => crate::analysis::facts::AlterSequenceActionFact::Other, }; Some(StatementFact::AlterSequence { @@ -1707,46 +1748,49 @@ impl AstVisitor { }) } - fn extract_owned_by(node: &squawk_syntax::SyntaxNode) -> Option<(QualifiedName, String)> { - for opt in node.descendants().filter_map(ast::OptionOwnedBy::cast) { - let ast::OwnedByTarget::QualifiedColumnNameRef(name) = opt.owned_by_target()? else { - continue; - }; - let path_ref = name.path_ref()?; - let mut segments = Vec::new(); - let mut current_ref = Some(path_ref); - - while let Some(pr) = current_ref { - if let Some(segment) = pr.segment() { - segments.push(Self::identifier_from_name( - segment.text(), - segment.is_quoted(), - )); - } - current_ref = pr.qualifier(); - } + fn extract_owned_by(opt: &ast::OptionOwnedBy) -> Option<(QualifiedName, String)> { + let ast::OwnedByTarget::QualifiedColumnNameRef(name) = opt.owned_by_target()? else { + return None; + }; + let path_ref = name.path_ref()?; + let mut segments = Vec::new(); + let mut current_ref = Some(path_ref); - segments.reverse(); + while let Some(pr) = current_ref { + if let Some(segment) = pr.segment() { + segments.push(Self::identifier_from_name( + segment.text(), + segment.is_quoted(), + )); + } + current_ref = pr.qualifier(); + } - if segments.len() >= 2 { - let col_name = segments.last().unwrap().clone().resolve(); - let table_len = segments.len() - 1; - let table_name = if table_len == 1 { - QualifiedName::new(None, segments[0].clone()) - } else { - QualifiedName::new( - Some(segments[table_len - 2].clone()), - segments[table_len - 1].clone(), - ) - }; + segments.reverse(); - return Some((table_name, col_name)); - } + if segments.len() < 2 { + return None; } - None + let col_name = segments.pop()?.resolve(); + let table_len = segments.len(); + let table_name = if table_len == 1 { + QualifiedName::new(None, segments[0].clone()) + } else { + QualifiedName::new( + Some(segments[table_len - 2].clone()), + segments[table_len - 1].clone(), + ) + }; + Some((table_name, col_name)) } fn extract_create_domain(node: &CreateDomain) -> Option { + // Domain constraints and collations affect every column using the + // domain, but are not represented in TypeState. Do not claim an + // exact domain when either catalog-visible property is present. + if node.collate().is_some() || node.constraints().next().is_some() { + return None; + } let path = node.domain()?.path()?; let base_type = node .ty() @@ -1844,9 +1888,13 @@ impl AstVisitor { }) .collect::>>()?, }, - ast::CreateTypeKind::RangeType(_) => TypeCreationKind::Range, - ast::CreateTypeKind::CompositeType(_) => TypeCreationKind::Composite, - ast::CreateTypeKind::BaseType(_) => TypeCreationKind::Base, + // 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, }; Some(StatementFact::CreateType(CreateTypeFact { name, kind })) @@ -1901,13 +1949,20 @@ impl AstVisitor { new_value: literals.get(1)?.clone(), }); } - _ => return Some(StatementFact::AlterType(AlterTypeFact { name, actions })), + // PostgreSQL also accepts attribute changes, OWNER changes, and + // type options. Those facts are not represented in the state + // model; returning an empty action list would turn them into a + // silent no-op, so route them through UnsupportedStatement. + _ => return None, } Some(StatementFact::AlterType(AlterTypeFact { name, actions })) } fn extract_create_policy(node: &CreatePolicy) -> Option { + let semantics_complete = node.policy_roles().is_none() + && node.using_expr_clause().is_none() + && node.with_check_expr_clause().is_none(); let name_token = node.policy()?.ident_token()?; let name = Self::resolve_identifier_token(name_token.text()); let path = node.on_table()?.table_name_ref()?.path_ref()?; @@ -1946,6 +2001,7 @@ impl AstVisitor { table, permissive, command, + semantics_complete, }) } @@ -1966,18 +2022,10 @@ impl AstVisitor { let name = Self::resolve_identifier_token(name_token.text()); let path = node.on_relation()?.relation_name_ref()?.path_ref()?; let table = Self::path_ref_to_qualified_name(&path)?; - let function = node.call_expr().and_then(|call| { - let node_ref = call.syntax(); - let fn_name = node_ref.descendants().find_map(PathSegment::cast).map(|n| { - QualifiedName::new(None, Self::identifier_from_name(n.text(), n.is_quoted())) - }); - if fn_name.is_some() { - return fn_name; - } - node_ref.descendants().find_map(NameRef::cast).map(|n| { - QualifiedName::new(None, Self::identifier_from_name(n.text(), n.is_quoted())) - }) - }); + let function = node + .call_expr() + .and_then(|call| call.expr()) + .and_then(Self::expr_to_qualified_name); Some(StatementFact::CreateTrigger { name, table, @@ -2109,12 +2157,13 @@ impl AstVisitor { let is_leakproof = f.leakproof_token().is_some(); crate::analysis::facts::FuncOptionFact::Leakproof(is_leakproof) } + ast::FuncOption::NotLeakproofFuncOption(_) => { + crate::analysis::facts::FuncOptionFact::Leakproof(false) + } ast::FuncOption::ParallelFuncOption(f) => { crate::analysis::facts::FuncOptionFact::Parallel( - f.syntax() - .descendants() - .find_map(ast::NameRef::cast) - .map(|n| n.text()) + f.ident_token() + .map(|token| Self::resolve_identifier_token(token.text())) .unwrap_or_default(), ) } @@ -2128,15 +2177,29 @@ impl AstVisitor { .unwrap_or_default(), ), ast::FuncOption::AsFuncOption(f) => { - let lit = f - .syntax() - .descendants() - .find_map(ast::Literal::cast) - .map(|l| l.syntax().text().to_string().trim_matches('\'').to_string()); + let (definition, obj_file, link_symbol) = match f.as_func_target() { + Some(ast::AsFuncTarget::AsDefinition(definition)) => ( + definition + .literal() + .and_then(|literal| Self::resolve_string_literal(&literal)), + None, + None, + ), + Some(ast::AsFuncTarget::AsObjFile(obj_file)) => ( + None, + obj_file + .obj_file() + .and_then(|literal| Self::resolve_string_literal(&literal)), + obj_file + .link_symbol() + .and_then(|literal| Self::resolve_string_literal(&literal)), + ), + None => (None, None, None), + }; crate::analysis::facts::FuncOptionFact::As { - definition: lit, - obj_file: None, - link_symbol: None, + definition, + obj_file, + link_symbol, } } ast::FuncOption::TransformFuncOption(_) => { @@ -2883,8 +2946,11 @@ impl AstVisitor { fn extract_create_role(node: &squawk_syntax::ast::CreateRole) -> Option { let name = Self::resolve_identifier_token(node.role()?.ident_token()?.text()); - let (inherits, can_login) = + let (inherits, can_login, unsupported) = Self::extract_create_role_options(node.role_option_list(), false); + if unsupported { + return None; + } Some(StatementFact::CreateRole( crate::analysis::facts::CreateRoleFact { name, @@ -2896,8 +2962,11 @@ impl AstVisitor { fn extract_create_user(node: &squawk_syntax::ast::CreateUser) -> Option { let name = Self::resolve_identifier_token(node.role()?.ident_token()?.text()); - let (inherits, can_login) = + let (inherits, can_login, unsupported) = Self::extract_create_role_options(node.role_option_list(), true); + if unsupported { + return None; + } Some(StatementFact::CreateRole( crate::analysis::facts::CreateRoleFact { name, @@ -2910,9 +2979,10 @@ impl AstVisitor { fn extract_create_role_options( options: Option, default_login: bool, - ) -> (bool, bool) { + ) -> (bool, bool, bool) { let mut inherits = true; let mut can_login = default_login; + let mut unsupported = false; if let Some(options) = options { for option in options.role_options() { match option { @@ -2924,14 +2994,14 @@ impl AstVisitor { "noinherit" => inherits = false, "login" => can_login = true, "nologin" => can_login = false, - _ => {} + _ => unsupported = true, } } - _ => {} + _ => unsupported = true, } } } - (inherits, can_login) + (inherits, can_login, unsupported) } fn extract_alter_role(node: &squawk_syntax::ast::AlterRole) -> Option { @@ -2940,8 +3010,20 @@ impl AstVisitor { ast::AlterRoleAction::RoleOptionList(ol) => { let mut found = None; for o in ol.role_options() { - if matches!(o, ast::RoleOption::RoleOptionInherit(_)) { - found = Some(true); + match o { + ast::RoleOption::RoleOptionInherit(_) => found = Some(true), + ast::RoleOption::RoleOptionGeneric(option) => { + match option + .ident_token() + .map(|token| token.text().to_ascii_lowercase()) + .as_deref() + { + Some("inherit") => found = Some(true), + Some("noinherit") => found = Some(false), + _ => {} + } + } + _ => {} } } found @@ -2998,6 +3080,10 @@ impl AstVisitor { } else if cmd.all_token().is_some() { crate::analysis::facts::PrivilegeFact::All } else if let Some(role_ref) = cmd.role_ref() { + // Squawk 2.63.0 exposes PostgreSQL 17 MAINTAIN through the + // grammar's generic identifier branch (there is no + // `maintain_token()` accessor), so recognize it before treating + // the same branch as legacy role-membership syntax. if let Some(ident) = role_ref.ident_token() { let raw = ident.text().to_string(); let name = Self::resolve_identifier_token(&raw); @@ -3006,6 +3092,7 @@ impl AstVisitor { "insert" => return crate::analysis::facts::PrivilegeFact::Insert, "update" => return crate::analysis::facts::PrivilegeFact::Update, "delete" => return crate::analysis::facts::PrivilegeFact::Delete, + "maintain" => return crate::analysis::facts::PrivilegeFact::Maintain, _ => {} } } @@ -3016,6 +3103,7 @@ impl AstVisitor { "insert" => crate::analysis::facts::PrivilegeFact::Insert, "update" => crate::analysis::facts::PrivilegeFact::Update, "delete" => crate::analysis::facts::PrivilegeFact::Delete, + "maintain" => crate::analysis::facts::PrivilegeFact::Maintain, _ => crate::analysis::facts::PrivilegeFact::Unknown, } } @@ -3423,7 +3511,12 @@ impl AstVisitor { "search_path" => ResetSettingTarget::SearchPath, "lock_timeout" => ResetSettingTarget::LockTimeout, "statement_timeout" => ResetSettingTarget::StatementTimeout, - _ => return Some(StatementFact::SchemaNeutralNoop), + "application_name" => return Some(StatementFact::SchemaNeutralNoop), + // An unknown GUC may affect DDL behavior (for example + // replication or constraint enforcement). Keep it on the + // explicit opaque path instead of silently claiming no + // state impact. + _ => return None, } } ResetTarget::ResetTimeZone(_) | ResetTarget::ResetTransactionIsolation(_) => { @@ -3593,6 +3686,51 @@ impl AstVisitor { } } + fn expr_to_qualified_name(expr: ast::Expr) -> Option { + fn collect_segments(expr: ast::Expr, segments: &mut Vec) -> bool { + match expr { + ast::Expr::NameRef(name) => { + segments.push(AstVisitor::identifier_from_name( + name.text(), + name.is_quoted(), + )); + true + } + ast::Expr::FieldExpr(field) => { + let Some(base) = field.base() else { + return false; + }; + if !collect_segments(base, segments) { + return false; + } + let Some(name) = field.field() else { + return false; + }; + segments.push(AstVisitor::identifier_from_name( + name.text(), + name.is_quoted(), + )); + true + } + _ => false, + } + } + + let mut segments = Vec::new(); + if !collect_segments(expr, &mut segments) || segments.is_empty() { + return None; + } + + if segments.len() >= 2 { + Some(QualifiedName::new( + Some(segments[0].clone()), + segments[1].clone(), + )) + } else { + Some(QualifiedName::new(None, segments[0].clone())) + } + } + fn path_to_qualified_name(path: &Path) -> Option { let mut segments: Vec = Vec::new(); diff --git a/src/ast/visitor_tests.rs b/src/ast/visitor_tests.rs index c3c37fc..3ecba08 100644 --- a/src/ast/visitor_tests.rs +++ b/src/ast/visitor_tests.rs @@ -54,6 +54,28 @@ mod tests { ); } + #[test] + fn postgres17_maintain_privilege_is_extracted_from_grant_and_revoke() { + for sql in [ + "GRANT MAINTAIN ON TABLE test_table TO app_user;", + "REVOKE MAINTAIN ON TABLE test_table FROM app_user;", + ] { + let fact = parse_and_extract_statement(sql).expect("privilege fact"); + let privileges = match fact { + StatementFact::Grant(grant) => grant.privileges, + StatementFact::Revoke(revoke) => revoke.privileges, + other => panic!("expected grant or revoke fact, got {other:?}"), + }; + assert_eq!( + privileges, + crate::analysis::facts::PrivilegeSpec::List(vec![ + crate::analysis::facts::PrivilegeFact::Maintain, + ]), + "MAINTAIN must remain a typed relation privilege: {sql}" + ); + } + } + #[test] fn test_create_table_with_columns() { let sql = "CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(255) NOT NULL);"; @@ -123,6 +145,33 @@ mod tests { )); } + #[test] + fn create_table_preserves_table_check_and_exclusion_constraint_names() { + let fact = parse_and_extract_statement( + "CREATE TABLE reservations ( + id integer, + period int4range, + CONSTRAINT reservations_id_check CHECK (id > 0), + CONSTRAINT reservations_period_excl EXCLUDE USING gist (period WITH &&) + );", + ) + .expect("create table fact"); + + let StatementFact::CreateTable { + table_constraints, .. + } = fact + else { + panic!("expected create table fact"); + }; + assert!(matches!( + table_constraints.as_slice(), + [ + TableConstraintFact::Check { constraint_name: Some(check) }, + TableConstraintFact::Exclude { constraint_name: Some(exclude) }, + ] if check == "reservations_id_check" && exclude == "reservations_period_excl" + )); + } + #[test] fn test_create_table_with_quoted_identifiers() { let sql = r#"CREATE TABLE "MyTable" ("MyColumn" INT);"#; @@ -176,6 +225,27 @@ mod tests { } } + #[test] + fn qualified_trigger_function_preserves_schema() { + let fact = parse_and_extract_statement( + "CREATE TRIGGER trg AFTER INSERT ON s.t1 FOR EACH ROW EXECUTE FUNCTION s.notify_func();", + ) + .expect("trigger fact"); + + let StatementFact::CreateTrigger { + function: Some(function), + .. + } = fact + else { + panic!("expected qualified trigger function"); + }; + assert_eq!( + function.schema.as_ref().map(Ident::resolve), + Some("s".into()) + ); + assert_eq!(function.name.resolve(), "notify_func"); + } + #[test] fn test_create_table_with_default_expr() { let sql = "CREATE TABLE events (id INT, created_at TIMESTAMP DEFAULT NOW());"; @@ -491,6 +561,7 @@ mod tests { let AlterTableActionFact::AddUniqueConstraint { constraint_name, using_index, + .. } = &actions[0] else { panic!("expected unique constraint fact"); @@ -515,6 +586,7 @@ mod tests { let AlterTableActionFact::AddPrimaryKeyConstraint { constraint_name, using_index, + .. } = &actions[0] else { panic!("expected primary-key constraint fact"); @@ -549,8 +621,9 @@ mod tests { let facts = parse_and_extract_statement(sql); assert!(facts.is_some()); match facts.unwrap() { - StatementFact::DropTable { name, .. } => { - assert_eq!(name.name.resolve(), "users"); + StatementFact::DropTable { names, .. } => { + assert_eq!(names.len(), 1); + assert_eq!(names[0].name.resolve(), "users"); } _ => panic!("Expected DropTable fact"), } @@ -563,9 +636,10 @@ mod tests { assert!(facts.is_some()); match facts.unwrap() { StatementFact::DropTable { - name, if_exists, .. + names, if_exists, .. } => { - assert_eq!(name.name.resolve(), "users"); + assert_eq!(names.len(), 1); + assert_eq!(names[0].name.resolve(), "users"); assert!(if_exists); } _ => panic!("Expected DropTable fact"), @@ -578,8 +652,9 @@ mod tests { let facts = parse_and_extract_statement(sql); assert!(facts.is_some()); match facts.unwrap() { - StatementFact::DropTable { name, cascade, .. } => { - assert_eq!(name.name.resolve(), "users"); + StatementFact::DropTable { names, cascade, .. } => { + assert_eq!(names.len(), 1); + assert_eq!(names[0].name.resolve(), "users"); assert!(cascade); } _ => panic!("Expected DropTable fact"), @@ -767,6 +842,16 @@ mod tests { assert!(matches!(facts, Some(StatementFact::CommitTransaction))); } + #[test] + fn prepare_transaction_uses_the_typed_literal_and_decodes_quotes() { + let fact = parse_and_extract_statement("PREPARE TRANSACTION 'a''b';") + .expect("prepare transaction fact"); + assert!(matches!( + fact, + StatementFact::PrepareTransaction { name } if name == "a'b" + )); + } + #[test] fn test_rollback_and_chain() { let facts = parse_and_extract_statement("ROLLBACK AND CHAIN;"); @@ -964,6 +1049,36 @@ mod tests { } } + #[test] + fn unsupported_create_role_options_are_not_silent_noops() { + for sql in [ + "CREATE ROLE privileged SUPERUSER;", + "CREATE USER app CREATEDB;", + "CREATE ROLE member IN ROLE admins;", + ] { + let parsed = SourceFile::parse(sql); + let statement = parsed.tree().stmts().next().expect("statement"); + assert!( + AstVisitor::extract(&statement).is_none(), + "unmodeled role option must use the opaque engine path: {sql}" + ); + } + } + + #[test] + fn alter_role_extracts_both_inherit_options() { + for (sql, expected) in [ + ("ALTER ROLE app_user WITH INHERIT;", Some(true)), + ("ALTER ROLE app_user WITH NOINHERIT;", Some(false)), + ("ALTER ROLE app_user WITH LOGIN;", None), + ] { + let Some(StatementFact::AlterRole(role)) = parse_and_extract_statement(sql) else { + panic!("expected alter role fact for {sql}"); + }; + assert_eq!(role.inherits, expected, "{sql}"); + } + } + #[test] fn global_object_identifiers_follow_postgresql_case_rules() { let Some(StatementFact::CreatePublication(publication)) = parse_and_extract_statement( @@ -1275,6 +1390,7 @@ mod tests { parse_and_extract_statement("RESET application_name;"), Some(StatementFact::SchemaNeutralNoop) ); + assert!(parse_and_extract_statement("RESET synchronous_commit;").is_none()); assert_eq!( parse_and_extract_statement("SET application_name = 'migration-check';"), Some(StatementFact::SchemaNeutralNoop) @@ -1348,8 +1464,9 @@ mod tests { let facts = parse_and_extract_statement(sql); assert!(facts.is_some()); match facts.unwrap() { - StatementFact::DropView { name, cascade, .. } => { - assert_eq!(name.name.resolve(), "user_view"); + StatementFact::DropView { names, cascade, .. } => { + assert_eq!(names.len(), 1); + assert_eq!(names[0].name.resolve(), "user_view"); assert!(cascade); } _ => panic!("Expected DropView fact"), @@ -1411,6 +1528,27 @@ mod tests { )); } + #[test] + fn attach_partition_strategy_comes_from_typed_partition_node() { + // The range bound deliberately contains the words "FOR VALUES IN". + // Classifying from statement text would misidentify this as a LIST + // partition; Squawk's typed PartitionType is authoritative. + let fact = parse_and_extract_statement( + "ALTER TABLE parent ATTACH PARTITION child FOR VALUES FROM ('FOR VALUES IN') TO ('z');", + ) + .expect("attach partition fact"); + let StatementFact::AlterTable { actions, .. } = fact else { + panic!("expected alter table fact"); + }; + assert!(matches!( + actions.as_slice(), + [AlterTableActionFact::AttachPartition { + strategy: Some(strategy), + .. + }] if strategy == "RANGE" + )); + } + #[test] fn test_alter_table_detach_partition() { let sql = "ALTER TABLE parent DETACH PARTITION child;"; @@ -1430,6 +1568,12 @@ mod tests { assert_eq!(ident.resolve(), "myident"); } + #[test] + fn unquoted_identifiers_use_postgres_ascii_folding() { + let ident = Ident::new("ÄTable".to_string(), false); + assert_eq!(ident.resolve(), "Ätable"); + } + #[test] fn test_qualified_name_new() { let schema = Some(Ident::new("my_schema".to_string(), false)); @@ -1902,34 +2046,203 @@ mod tests { } #[test] - fn squawk_263_typed_view_sequence_policy_and_function_children_preserve_facts() { - let facts = parse_and_extract( - "ALTER VIEW report ALTER COLUMN total SET DEFAULT 0; - ALTER VIEW report ALTER COLUMN total DROP DEFAULT; - CREATE SEQUENCE event_ids OWNED BY public.events.id; - ALTER SEQUENCE event_ids OWNED BY NONE; - CREATE POLICY readers ON events FOR SELECT TO PUBLIC USING (true); - CREATE FUNCTION stable_owner() RETURNS integer - LANGUAGE sql IMMUTABLE SECURITY DEFINER AS 'SELECT 1';", + 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" + ); + } + + #[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}" + ); + } + } + + #[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_eq!(facts.len(), 6); + let parsed = + SourceFile::parse("CREATE TABLE snapshot AS SELECT id FROM source WITH NO DATA;"); + let statement = parsed.tree().stmts().next().expect("statement"); assert!(matches!( - &facts[0], - StatementFact::AlterView { - action: crate::analysis::facts::AlterViewAction::SetDefault { column, .. }, + AstVisitor::extract(&statement), + Some(StatementFact::CreateTable { + as_select: true, .. - } if column == "total" + }) )); + } + + #[test] + fn unsupported_create_view_semantics_are_not_silent_noops() { + for sql in [ + "CREATE VIEW writable AS SELECT id FROM source WITH CHECK OPTION;", + "CREATE VIEW protected AS SELECT id FROM source WITH (security_barrier = true);", + ] { + let parsed = SourceFile::parse(sql); + let statement = parsed.tree().stmts().next().expect("statement"); + assert!( + AstVisitor::extract(&statement).is_none(), + "unsupported CREATE VIEW semantics must use the opaque engine path: {sql}" + ); + } + } + + #[test] + fn unsupported_create_materialized_view_population_is_not_silent() { + let parsed = SourceFile::parse( + "CREATE MATERIALIZED VIEW snapshot AS SELECT id FROM source WITH NO DATA;", + ); + let statement = parsed.tree().stmts().next().expect("statement"); + assert!( + AstVisitor::extract(&statement).is_none(), + "unpopulated materialized views must use the opaque engine path" + ); + } + + #[test] + fn unsupported_create_domain_constraints_are_not_silent() { + for sql in [ + "CREATE DOMAIN bounded AS integer CHECK (VALUE > 0);", + "CREATE DOMAIN collated AS text COLLATE \"C\";", + ] { + let parsed = SourceFile::parse(sql); + let statement = parsed.tree().stmts().next().expect("statement"); + assert!( + AstVisitor::extract(&statement).is_none(), + "unmodeled CREATE DOMAIN semantics must use the opaque engine path: {sql}" + ); + } + } + + #[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}" + ); + } + } + + #[test] + fn unsupported_create_policy_semantics_are_not_silent() { + for sql in [ + "CREATE POLICY scoped ON source TO app_user;", + "CREATE POLICY filtered ON source USING (id > 0);", + "CREATE POLICY checked ON source WITH CHECK (id > 0);", + ] { + let parsed = SourceFile::parse(sql); + let statement = parsed.tree().stmts().next().expect("statement"); + let Some(StatementFact::CreatePolicy { + semantics_complete, .. + }) = AstVisitor::extract(&statement) + else { + panic!("expected policy fact retaining rule-visible semantics: {sql}"); + }; + assert!( + !semantics_complete, + "policy semantics must be marked incomplete: {sql}" + ); + } + } + + #[test] + fn expression_indexes_preserve_index_rule_metadata() { + let parsed = + SourceFile::parse("CREATE UNIQUE INDEX normalized_name ON source ((lower(name)));"); + let statement = parsed.tree().stmts().next().expect("statement"); assert!(matches!( - &facts[1], - StatementFact::AlterView { - action: crate::analysis::facts::AlterViewAction::DropDefault { column }, + AstVisitor::extract(&statement), + Some(StatementFact::CreateIndex { + unique: true, + concurrently: false, + has_predicate: false, .. - } if column == "total" + }) )); + } + + #[test] + fn unsupported_alter_type_actions_are_not_silent_noops() { + for sql in [ + "ALTER TYPE mood OWNER TO app_user;", + "ALTER TYPE mood ADD ATTRIBUTE label text;", + ] { + let parsed = SourceFile::parse(sql); + let statement = parsed.tree().stmts().next().expect("statement"); + assert!( + AstVisitor::extract(&statement).is_none(), + "unsupported ALTER TYPE action must use the opaque engine path: {sql}" + ); + } + } + + #[test] + fn unsupported_alter_materialized_view_actions_are_not_silent_noops() { + let parsed = SourceFile::parse("ALTER MATERIALIZED VIEW report SET SCHEMA archive;"); + let statement = parsed.tree().stmts().next().expect("statement"); + assert!( + AstVisitor::extract(&statement).is_none(), + "unsupported ALTER MATERIALIZED VIEW action must use the opaque engine path" + ); + } + + #[test] + fn unsupported_alter_view_actions_are_not_silent_noops() { + for sql in [ + "ALTER VIEW report ALTER COLUMN total SET DEFAULT 0;", + "ALTER VIEW report ALTER COLUMN total DROP DEFAULT;", + "ALTER VIEW report SET (security_barrier = true);", + ] { + let parsed = SourceFile::parse(sql); + let statement = parsed.tree().stmts().next().expect("statement"); + assert!( + AstVisitor::extract(&statement).is_none(), + "unsupported ALTER VIEW action must use the opaque engine path: {sql}" + ); + } + } + + #[test] + fn squawk_263_typed_view_sequence_policy_and_function_children_preserve_facts() { + let facts = parse_and_extract( + "CREATE SEQUENCE event_ids OWNED BY public.events.id; + ALTER SEQUENCE event_ids OWNED BY NONE; + CREATE POLICY readers ON events FOR SELECT TO PUBLIC USING (true); + CREATE FUNCTION stable_owner() RETURNS integer + LANGUAGE sql IMMUTABLE SECURITY DEFINER AS 'SELECT 1';", + ); + assert_eq!(facts.len(), 4); assert!(matches!( - &facts[2], + &facts[0], StatementFact::CreateSequence { owned_by: Some((table, column)), .. @@ -1938,20 +2251,20 @@ mod tests { && column == "id" )); assert!(matches!( - &facts[3], + &facts[1], StatementFact::AlterSequence { action: crate::analysis::facts::AlterSequenceActionFact::OwnedBy(None), .. } )); assert!(matches!( - &facts[4], + &facts[2], StatementFact::CreatePolicy { command: crate::analysis::facts::PolicyCommand::Select, .. } )); - let StatementFact::CreateFunction(function) = &facts[5] else { + let StatementFact::CreateFunction(function) = &facts[3] else { panic!("expected create function fact"); }; assert!(function.options.iter().any(|option| matches!( @@ -1968,6 +2281,33 @@ mod tests { ))); } + #[test] + fn function_options_use_typed_targets_and_decode_literals() { + let fact = parse_and_extract_statement( + "CREATE FUNCTION native_fn() RETURNS integer LANGUAGE c PARALLEL SAFE NOT LEAKPROOF AS 'lib''x', 'entry''x';", + ) + .expect("create function fact"); + let StatementFact::CreateFunction(function) = fact else { + panic!("expected create function fact"); + }; + assert!(function.options.iter().any(|option| matches!( + option, + crate::analysis::facts::FuncOptionFact::Parallel(value) if value == "safe" + ))); + assert!(function.options.iter().any(|option| matches!( + option, + crate::analysis::facts::FuncOptionFact::Leakproof(false) + ))); + assert!(function.options.iter().any(|option| matches!( + option, + crate::analysis::facts::FuncOptionFact::As { + obj_file: Some(obj_file), + link_symbol: Some(link_symbol), + definition: None, + } if obj_file == "lib'x" && link_symbol == "entry'x" + ))); + } + #[test] fn squawk_263_typed_replication_and_privilege_children_preserve_facts() { let facts = parse_and_extract( diff --git a/src/db/cache.rs b/src/db/cache.rs index ac4b243..172c1b3 100644 --- a/src/db/cache.rs +++ b/src/db/cache.rs @@ -1,7 +1,7 @@ use crate::ast::identifiers::ObjectId; use crate::model::constraint::ConstraintState; use crate::model::function::FunctionState; -use crate::model::relation::RelationState; +use crate::model::relation::{RelationKind, RelationState}; use crate::model::replication::{PublicationState, SubscriptionState}; use crate::model::role::RoleState; use crate::model::schema::SchemaState; @@ -9,7 +9,7 @@ use crate::model::sequence::SequenceState; use crate::model::trigger::TriggerEnableMode; use crate::model::types::TypeState; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ForeignKeyCache { @@ -135,7 +135,10 @@ impl DbCacheVersioned { "This cache format is unsupported. Run `safe-migrate sync` to rebuild it." .to_string(), ), - DbCacheVersioned::V6(c) => Ok(*c), + DbCacheVersioned::V6(c) => { + c.validate_semantics()?; + Ok(*c) + } } } } @@ -175,6 +178,292 @@ impl DbCache { pub fn baseline_relations(&self) -> impl Iterator { self.relations.iter() } + + pub(crate) fn validate_semantics(&self) -> Result<(), String> { + for (id, relation) in &self.relations { + if id != &relation.id { + return Err(format!( + "relation cache key '{}' disagrees with embedded identity '{}'", + id, relation.id + )); + } + } + for (id, function) in &self.functions { + if id != &function.id { + return Err(format!( + "routine cache key '{}' disagrees with embedded identity '{}'", + id, function.id + )); + } + } + for (id, ty) in &self.types { + if id != &ty.id { + return Err(format!( + "type cache key '{}' disagrees with embedded identity '{}'", + id, ty.id + )); + } + } + for (id, role) in &self.roles { + if id != &role.id { + return Err(format!( + "role cache key '{}' disagrees with embedded identity '{}'", + id, role.id + )); + } + } + for (name, schema) in &self.schemas { + if name != &schema.name { + return Err(format!( + "schema cache key '{}' disagrees with embedded identity '{}'", + name, schema.name + )); + } + } + for (id, sequence) in &self.sequences { + if id != &sequence.id { + return Err(format!( + "sequence cache key '{}' disagrees with embedded identity '{}'", + id, sequence.id + )); + } + } + for (name, publication) in &self.publications { + if name != &publication.name { + return Err(format!( + "publication cache key '{}' disagrees with embedded identity '{}'", + name, publication.name + )); + } + } + for (name, subscription) in &self.subscriptions { + if name != &subscription.name { + return Err(format!( + "subscription cache key '{}' disagrees with embedded identity '{}'", + name, subscription.name + )); + } + } + + for schema in &self.search_path { + if !self.schemas.is_empty() && !self.schemas.contains_key(schema) { + return Err(format!( + "effective search path references missing schema '{}'", + schema + )); + } + } + + for (id, sequence) in &self.sequences { + if let Some((table_id, column_name)) = &sequence.owned_by { + let Some(relation) = self.relations.get(table_id) else { + let omitted_owner_schema = + self.metadata.schemas.as_ref().is_some_and(|schemas| { + !schemas.iter().any(|schema| schema == &table_id.schema) + }); + if omitted_owner_schema { + continue; + } + return Err(format!( + "sequence '{}' ownership references missing relation '{}'", + id, table_id + )); + }; + if !matches!(relation.kind, RelationKind::Table) { + return Err(format!( + "sequence '{}' ownership target '{}' is not a table", + id, table_id + )); + } + if !relation.has_column(column_name) { + return Err(format!( + "sequence '{}' ownership references missing column '{}.{}'", + id, table_id, column_name + )); + } + } + } + + for (id, role) in &self.roles { + for target in role.member_of.iter().chain(&role.can_set_role_to) { + if !self.roles.contains_key(target) { + return Err(format!( + "role '{}' membership references missing role '{}'", + id, target + )); + } + } + } + + let mut constraint_keys = HashSet::new(); + for constraint in &self.constraints { + let Some(relation) = self.relations.get(&constraint.table_id) else { + return Err(format!( + "constraint '{}.{}' references a missing relation", + constraint.table_id, constraint.name + )); + }; + if !matches!(relation.kind, RelationKind::Table) { + return Err(format!( + "constraint '{}.{}' targets a non-table relation", + constraint.table_id, constraint.name + )); + } + if !constraint_keys.insert((constraint.table_id.clone(), constraint.name.clone())) { + return Err(format!( + "constraint '{}.{}' appears more than once", + constraint.table_id, constraint.name + )); + } + } + + let mut index_ids = HashSet::new(); + for index in &self.indexes { + let Some(relation) = self.relations.get(&index.table_id) else { + return Err(format!( + "index '{}' references missing relation '{}'", + index.index_id, index.table_id + )); + }; + if !matches!( + relation.kind, + RelationKind::Table | RelationKind::MaterializedView + ) { + return Err(format!( + "index '{}' targets a non-indexable relation '{}'", + index.index_id, index.table_id + )); + } + if !index_ids.insert(index.index_id.clone()) { + return Err(format!("index '{}' appears more than once", index.index_id)); + } + } + + let mut trigger_ids = HashSet::new(); + for trigger in &self.triggers { + let Some(relation) = self.relations.get(&trigger.table_id) else { + return Err(format!( + "trigger '{}' references missing relation '{}'", + trigger.trigger_id, trigger.table_id + )); + }; + if !matches!(relation.kind, RelationKind::Table | RelationKind::View) { + return Err(format!( + "trigger '{}' targets a relation kind that cannot have triggers", + trigger.trigger_id + )); + } + if !trigger_ids.insert(trigger.trigger_id.clone()) { + return Err(format!( + "trigger '{}' appears more than once", + trigger.trigger_id + )); + } + } + + let mut foreign_key_ids = HashSet::new(); + for foreign_key in &self.foreign_keys { + let Some(from_relation) = self.relations.get(&foreign_key.from_table) else { + return Err(format!( + "foreign key '{}.{}' references a missing relation", + foreign_key.from_table, foreign_key.constraint_name + )); + }; + let Some(to_relation) = self.relations.get(&foreign_key.to_table) else { + return Err(format!( + "foreign key '{}.{}' references a missing relation", + foreign_key.from_table, foreign_key.constraint_name + )); + }; + if !matches!(from_relation.kind, RelationKind::Table) + || !matches!(to_relation.kind, RelationKind::Table) + { + return Err(format!( + "foreign key '{}.{}' must reference tables", + foreign_key.from_table, foreign_key.constraint_name + )); + } + if !foreign_key_ids.insert(( + foreign_key.from_table.clone(), + foreign_key.constraint_name.clone(), + )) { + return Err(format!( + "foreign key '{}.{}' appears more than once", + foreign_key.from_table, foreign_key.constraint_name + )); + } + if !self.constraints.iter().any(|constraint| { + constraint.table_id == foreign_key.from_table + && constraint.name == foreign_key.constraint_name + && matches!( + constraint.kind, + crate::model::constraint::ConstraintKind::ForeignKey + ) + }) { + return Err(format!( + "foreign key '{}.{}' has no matching constraint", + foreign_key.from_table, foreign_key.constraint_name + )); + } + } + + for dependency in &self.dependencies { + if dependency.deptype != "view" { + // Early Cache V6 writers included generic pg_depend rows. + // Hydration never consumed them, but retaining readability + // is part of the V6 byte-compatibility contract. + continue; + } + if dependency + .obj_schema + .as_deref() + .is_some_and(|schema| schema == "information_schema" || schema.starts_with("pg_")) + || dependency.ref_schema.as_deref().is_some_and(|schema| { + schema == "information_schema" || schema.starts_with("pg_") + }) + { + // Early V6 writers also retained system-catalog view + // dependencies while deliberately omitting those relations. + continue; + } + let object_id = dependency + .obj_schema + .as_deref() + .zip(dependency.obj_name.as_deref()) + .map(|(schema, name)| ObjectId::new(schema, name)) + .ok_or_else(|| "view dependency is missing its object identity".to_string())?; + let referenced_id = dependency + .ref_schema + .as_deref() + .zip(dependency.ref_name.as_deref()) + .map(|(schema, name)| ObjectId::new(schema, name)) + .ok_or_else(|| { + format!( + "view dependency for '{}' is missing its referenced identity", + object_id + ) + })?; + let omitted_schema = |schema: Option<&str>| { + self.metadata + .schemas + .as_ref() + .zip(schema) + .is_some_and(|(schemas, schema)| !schemas.iter().any(|known| known == schema)) + }; + let object_missing = !self.relations.contains_key(&object_id); + let referenced_missing = !self.relations.contains_key(&referenced_id); + if (object_missing && !omitted_schema(dependency.obj_schema.as_deref())) + || (referenced_missing && !omitted_schema(dependency.ref_schema.as_deref())) + { + return Err(format!( + "view dependency '{} -> {}' references a missing relation", + object_id, referenced_id + )); + } + } + + Ok(()) + } } #[cfg(test)] @@ -209,4 +498,151 @@ mod tests { assert_eq!(DbCacheVersioned::V6(Box::default()).format_version(), 6); assert_eq!(CACHE_V6_MAGIC, b"SMCACHE06"); } + + #[test] + fn current_cache_rejects_mismatched_embedded_identity() { + let mut cache = DbCache::new(); + cache.schemas.insert( + "app".to_string(), + SchemaState { + name: "other".to_string(), + owner: ObjectId::new("", "postgres"), + generation: 0, + }, + ); + + let error = DbCacheVersioned::V6(Box::new(cache)) + .into_cache() + .unwrap_err(); + assert!(error.contains("schema cache key 'app'")); + } + + #[test] + fn scoped_cache_accepts_a_dependency_to_an_omitted_schema() { + let view_id = ObjectId::new("app", "v"); + let mut cache = DbCache::new(); + cache.metadata.schemas = Some(vec!["app".to_string()]); + cache.insert_baseline( + view_id.clone(), + RelationState::new( + view_id.clone(), + ObjectId::new("", "postgres"), + 0, + None, + crate::model::relation::RelationKind::View, + crate::model::relation::Persistence::Permanent, + 0, + ), + ); + cache.dependencies.push(DependencyCache { + classid: 0, + objid: 0, + objsubid: 0, + refclassid: 0, + refobjid: 0, + refobjsubid: 0, + deptype: "view".to_string(), + obj_schema: Some("app".to_string()), + obj_name: Some("v".to_string()), + ref_schema: Some("tenant".to_string()), + ref_name: Some("base".to_string()), + }); + + assert!(cache.validate_semantics().is_ok()); + } + + #[test] + fn current_cache_rejects_dangling_index_relationship() { + let mut cache = DbCache::new(); + cache.indexes.push(IndexCache { + index_id: ObjectId::new("public", "items_idx"), + table_id: ObjectId::new("public", "items"), + }); + + let error = DbCacheVersioned::V6(Box::new(cache)) + .into_cache() + .unwrap_err(); + assert!(error.contains("references missing relation 'public.items'")); + } + + #[test] + fn current_cache_rejects_cross_catalog_contradictions() { + let mut missing_search_schema = DbCache::new(); + missing_search_schema.schemas.insert( + "app".to_string(), + SchemaState { + name: "app".to_string(), + owner: ObjectId::new("", "postgres"), + generation: 0, + }, + ); + assert!( + missing_search_schema + .validate_semantics() + .unwrap_err() + .contains("search path references missing schema 'public'") + ); + + let mut missing_sequence_owner = DbCache::new(); + let sequence_id = ObjectId::new("public", "items_id_seq"); + missing_sequence_owner.sequences.insert( + sequence_id.clone(), + SequenceState { + id: sequence_id, + owner: ObjectId::new("", "postgres"), + owned_by: Some((ObjectId::new("public", "items"), "id".to_string())), + kind: crate::model::sequence::SequenceKind::Owned, + generation: 0, + }, + ); + assert!( + missing_sequence_owner + .validate_semantics() + .unwrap_err() + .contains("ownership references missing relation 'public.items'") + ); + + let mut missing_membership_role = DbCache::new(); + let role_id = ObjectId::new("", "member"); + missing_membership_role.roles.insert( + role_id.clone(), + RoleState { + id: role_id, + can_login: true, + is_superuser: false, + member_of: vec![ObjectId::new("", "missing")], + can_set_role_to: Vec::new(), + granted_privileges: Vec::new(), + }, + ); + assert!( + missing_membership_role + .validate_semantics() + .unwrap_err() + .contains("membership references missing role") + ); + + let mut incomplete_view_dependency = DbCache::new(); + incomplete_view_dependency + .dependencies + .push(DependencyCache { + classid: 0, + objid: 0, + objsubid: 0, + refclassid: 0, + refobjid: 0, + refobjsubid: 0, + deptype: "view".to_string(), + obj_schema: None, + obj_name: None, + ref_schema: None, + ref_name: None, + }); + assert!( + incomplete_view_dependency + .validate_semantics() + .unwrap_err() + .contains("view dependency is missing its object identity") + ); + } } diff --git a/src/db/cache_file.rs b/src/db/cache_file.rs index 7fe2999..8cf099c 100644 --- a/src/db/cache_file.rs +++ b/src/db/cache_file.rs @@ -65,6 +65,13 @@ pub fn protect_cache_bytes(cache_bytes: Vec, encryption_enabled: bool) -> Re Ok(envelope) } +pub(crate) fn validate_cache_encryption_configuration(encryption_enabled: bool) -> Result<()> { + if encryption_enabled { + cipher_from_environment()?; + } + Ok(()) +} + /// 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. @@ -189,4 +196,12 @@ mod tests { .expect_err("encryption-enabled configuration must reject plaintext caches"); assert!(error.to_string().contains("not encrypted")); } + + #[test] + fn encryption_configuration_is_validated_without_payload_processing() { + let _guard = EnvironmentValueGuard::set(CACHE_KEY_ENV, "not-a-key"); + let error = validate_cache_encryption_configuration(true).unwrap_err(); + assert!(error.to_string().contains("64 hexadecimal characters")); + assert!(validate_cache_encryption_configuration(false).is_ok()); + } } diff --git a/src/engine/engine.rs b/src/engine/engine.rs index cc8dd29..fc69159 100644 --- a/src/engine/engine.rs +++ b/src/engine/engine.rs @@ -1,17 +1,71 @@ use crate::analysis::mutations::Mutation; use crate::analysis::resolver::Resolver; -use crate::analysis::state::AnalysisState; +use crate::analysis::state::{AnalysisState, PreState}; use crate::ast::visitor::AstVisitor; use crate::engine::config::Config; use crate::report::violations::{ReportFinding, SourceLocation, Violation}; use crate::rules::Rule; use crate::rules::registry; use squawk_syntax::{ - SyntaxKind, + Parse, SyntaxKind, ast::{AstNode, SourceFile}, }; use std::collections::HashSet; +enum StatementCheckpoint { + Full(Option>), + TransactionUndo { + transaction_depth: usize, + undo_len: usize, + }, +} + +impl StatementCheckpoint { + fn capture(state: &AnalysisState, mutations: &[Mutation]) -> Self { + let changes_transaction_structure = mutations.iter().any(|mutation| { + matches!( + mutation, + Mutation::BeginTransaction + | Mutation::CommitTransaction + | Mutation::CommitAndChain + | Mutation::RollbackTransaction + | Mutation::RollbackAndChain + | Mutation::RollbackToSavepoint(_) + | Mutation::Savepoint(_) + | Mutation::ReleaseSavepoint(_) + ) + }); + if !changes_transaction_structure + && let Some((transaction_depth, undo_len)) = state.transaction_undo_checkpoint() + { + Self::TransactionUndo { + transaction_depth, + undo_len, + } + } else { + Self::Full(Some(Box::new(state.clone()))) + } + } + + fn restore(&mut self, state: &mut AnalysisState) -> Result<(), String> { + match self { + Self::Full(checkpoint) => { + let Some(checkpoint) = checkpoint.take() else { + return Err("statement checkpoint was already restored".to_string()); + }; + *state = *checkpoint; + Ok(()) + } + Self::TransactionUndo { + transaction_depth, + undo_len, + } => state + .rollback_to_transaction_undo_checkpoint(*transaction_depth, *undo_len) + .map_err(str::to_string), + } + } +} + pub struct SafeMigrateEngine { config: Config, rules: Vec>, @@ -85,7 +139,7 @@ impl SafeMigrateEngine { .stmts() .map(|statement| statement.syntax().text_range()) .collect(); - let violations = self.analyze_normalized_file(filename, &normalized_sql, state)?; + let violations = self.analyze_parsed_file(filename, &normalized_sql, &parsed, state)?; findings.extend( violations .into_iter() @@ -149,11 +203,21 @@ impl SafeMigrateEngine { fn analyze_normalized_file( &self, - _filename: &str, + filename: &str, sql: &str, state: &mut AnalysisState, ) -> Result, Vec> { let parsed = SourceFile::parse(sql); + self.analyze_parsed_file(filename, sql, &parsed, state) + } + + fn analyze_parsed_file( + &self, + _filename: &str, + sql: &str, + parsed: &Parse, + state: &mut AnalysisState, + ) -> Result, Vec> { let errors: Vec = parsed.errors().iter().map(|e| e.to_string()).collect(); if !errors.is_empty() { return Err(errors); @@ -161,6 +225,7 @@ impl SafeMigrateEngine { let mut all_violations = Vec::new(); let mut warned_keys = HashSet::new(); + let mut pre_state = PreState::default(); let mut file_ignores = HashSet::new(); for token in parsed @@ -210,7 +275,6 @@ impl SafeMigrateEngine { // state, findings, or deduplication keys. A parsed statement // without a typed extractor is explicitly opaque: silently // ignoring it would claim exact confidence for later SQL. - let statement_checkpoint = state.clone(); let statement_confidence = state.local.confidence.clone(); let mut statement_violations = Vec::new(); let mut statement_warned_keys = HashSet::new(); @@ -223,14 +287,17 @@ impl SafeMigrateEngine { if squawk_linter::analyze::possibly_slow_stmt(&stmt) { mutations.push(Mutation::CheckTimeouts); } + let mut statement_checkpoint = StatementCheckpoint::capture(state, &mutations); for mutation in mutations { let pre_cascade = match &mutation { - Mutation::DropTable(d) if d.cascade => Some(state.get_cascade_closure(&d.id)), + Mutation::DropTable(d) if d.cascade => { + Some(state.cascade_for_relations(&d.ids)) + } _ => None, }; - let pre_state = state.capture_pre_state(); + state.capture_pre_state_into(&mut pre_state); let result = state.apply(&mutation, pre_cascade.as_ref()); let statement_failed = matches!( @@ -239,7 +306,11 @@ impl SafeMigrateEngine { ); if statement_failed { let transaction_aborted = state.local.transaction_aborted; - *state = statement_checkpoint.clone(); + if let Err(error) = statement_checkpoint.restore(state) { + return Err(vec![format!( + "failed to restore PostgreSQL statement atomicity: {error}" + )]); + } if transaction_aborted && !state.local.transactions.is_empty() { state.local.transaction_aborted = true; } diff --git a/src/main.rs b/src/main.rs index 965550e..ef977f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -784,9 +784,13 @@ fn maybe_auto_sync( "[ INFO ] Automatic cache sync enabled. Refreshing {}.", cache.display() ); - let schemas = config - .sync_schemas(None) - .expect("configuration was validated before automatic synchronization"); + 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, Err(error) => { @@ -859,42 +863,33 @@ fn decode_cache(cache_path: &Path, cache_encryption: bool) -> Result<(DbCache, u ) })?; let mut decoder = decoder.take(MAX_CACHE_DECODE_BYTES as u64 + 1); - let mut payload = Vec::new(); - decoder.read_to_end(&mut payload).map_err(|error| { - anyhow!( - "Cache file '{}' is corrupted while decompressing: {}", - cache_path.display(), - error - ) - })?; - if payload.len() > MAX_CACHE_DECODE_BYTES { + let mut header = vec![0; CACHE_V6_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_V6_MAGIC.len() || header != CACHE_V6_MAGIC { anyhow::bail!( - "Cache file '{}' exceeds the {} MiB decoded-size limit", - cache_path.display(), - MAX_CACHE_DECODE_BYTES / (1024 * 1024) + "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 (encoded_payload, header_version) = if let Some(v6_payload) = - payload.strip_prefix(CACHE_V6_MAGIC) - { - (v6_payload, 6) - } else { - anyhow::bail!( - "Cache file '{}' uses an unsupported cache format. Run `safe-migrate sync` to rebuild it.", - cache_path.display() - ); - }; - - let (versioned, bytes_read): (DbCacheVersioned, usize) = bincode::serde::decode_from_slice( - encoded_payload, - config, - ) - .map_err(|error| { + 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", @@ -908,12 +903,29 @@ fn decode_cache(cache_path: &Path, cache_encryption: bool) -> Result<(DbCache, u error ) })?; - if bytes_read != encoded_payload.len() { + 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 = 6; let format_version = versioned.format_version(); if format_version != header_version { anyhow::bail!( diff --git a/src/model/relation.rs b/src/model/relation.rs index efa8540..2a0a549 100644 --- a/src/model/relation.rs +++ b/src/model/relation.rs @@ -13,6 +13,11 @@ pub enum Privilege { References, Trigger, All, + /// PostgreSQL 17's table-maintenance privilege. + /// + /// Keep this variant after the historical variants so V6 cache enum + /// discriminants remain stable for caches written before PostgreSQL 17. + Maintain, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] diff --git a/src/model/role.rs b/src/model/role.rs index 0b06e5f..a13bc09 100644 --- a/src/model/role.rs +++ b/src/model/role.rs @@ -11,6 +11,8 @@ pub enum Privilege { References, Trigger, All, + /// PostgreSQL 17's table-maintenance privilege. + Maintain, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/src/report/reporter.rs b/src/report/reporter.rs index 6c763fa..4f64135 100644 --- a/src/report/reporter.rs +++ b/src/report/reporter.rs @@ -139,8 +139,13 @@ impl Reporter { findings .iter() .map(|finding| { - let mut value = serde_json::to_value(finding) - .expect("Report findings must always serialize to JSON"); + let mut value = serde_json::to_value(finding).unwrap_or_else(|error| { + serde_json::json!({ + "rule_id": finding.violation.rule_id, + "message": "Failed to serialize report finding", + "serialization_error": error.to_string(), + }) + }); if let Some(descriptor) = registry::find_primary_rule(finding.violation.rule_id) && let Some(object) = value.as_object_mut() { diff --git a/src/report/reporter_tests.rs b/src/report/reporter_tests.rs index 797abcc..cb33c4d 100644 --- a/src/report/reporter_tests.rs +++ b/src/report/reporter_tests.rs @@ -69,7 +69,8 @@ mod tests { statement_index: Some(1), }; - let markdown = Reporter::markdown_report(&[finding], &Confidence::Exact); + let markdown = + Reporter::markdown_report(std::slice::from_ref(&finding), &Confidence::Exact); assert!(markdown.starts_with("# safe-migrate report\n")); assert!(markdown.contains("**Verdict:** CAUTIOUS")); assert!(markdown.contains("### WARN — test-rule (`test-rule`)")); @@ -101,6 +102,54 @@ mod tests { assert!(report["violations"][0]["rule_summary"].is_string()); } + #[test] + fn representative_reports_match_complete_goldens() { + let finding = ReportFinding { + violation: make_violation("test-rule", ViolationTier::Tier2, "needs review"), + location: Some(SourceLocation { + file: "migrations/001.sql".to_string(), + line: 3, + column: 5, + }), + statement_index: Some(1), + }; + + let json = Reporter::json_report_with_locations( + std::slice::from_ref(&finding), + &Confidence::Exact, + ); + let expected_json: serde_json::Value = serde_json::from_str(include_str!( + "../../tests/golden/representative-report.json" + )) + .expect("JSON golden must be valid"); + assert_eq!(json, expected_json); + + let markdown = + Reporter::markdown_report(std::slice::from_ref(&finding), &Confidence::Exact); + assert_eq!( + markdown, + include_str!("../../tests/golden/representative-report.md") + ); + + let canonical_json = serde_json::to_string_pretty(&json).expect("serialize report"); + for _ in 0..32 { + assert_eq!( + serde_json::to_string_pretty(&Reporter::json_report_with_locations( + std::slice::from_ref(&finding), + &Confidence::Exact, + )) + .expect("serialize repeated report"), + canonical_json, + "JSON output must be byte-stable across repeated runs" + ); + assert_eq!( + Reporter::markdown_report(std::slice::from_ref(&finding), &Confidence::Exact), + markdown, + "Markdown output must be byte-stable across repeated runs" + ); + } + } + #[test] fn markdown_report_uses_a_safe_fence_for_sql_containing_backticks() { let finding = ReportFinding { diff --git a/src/rules/conflict.rs b/src/rules/conflict.rs index 4d294c1..7ccaf33 100644 --- a/src/rules/conflict.rs +++ b/src/rules/conflict.rs @@ -65,7 +65,7 @@ mod tests { use super::*; use crate::analysis::mutations::Mutation; use crate::analysis::state::MutationResult; - use std::collections::HashMap; + use std::collections::{HashMap, HashSet}; #[test] fn test_conflict_rule_emits_tier1_on_conflict() { @@ -87,6 +87,7 @@ mod tests { sequences: HashMap::new(), types: HashMap::new(), indexes: Vec::new(), + baseline_foreign_keys: HashSet::new(), }, &crate::analysis::state::AnalysisState::new(crate::db::cache::DbCache::new()), &Config::default(), @@ -118,6 +119,7 @@ mod tests { sequences: HashMap::new(), types: HashMap::new(), indexes: Vec::new(), + baseline_foreign_keys: HashSet::new(), }, &crate::analysis::state::AnalysisState::new(crate::db::cache::DbCache::new()), &Config::default(), diff --git a/src/rules/destructive.rs b/src/rules/destructive.rs index 1ee3f82..5fef667 100644 --- a/src/rules/destructive.rs +++ b/src/rules/destructive.rs @@ -23,26 +23,24 @@ impl Rule for CascadingDropRule { &self, mutation: &Mutation, result: &MutationResult, - _pre_state: &crate::analysis::state::PreState, + pre_state: &crate::analysis::state::PreState, state: &AnalysisState, _config: &Config, cascade_closure: Option<&CascadeResult>, ) -> Vec { - if *result == MutationResult::Skipped { - return vec![]; - } - let mut violations = Vec::new(); - if let Mutation::DropTable(drop) = mutation + if !matches!(result, MutationResult::Conflict { .. }) + && let Mutation::DropTable(drop) = mutation && drop.cascade && let Some(closure) = cascade_closure { let mut affects_baseline = false; let mut has_fk_pulled = false; + let roots = drop.ids.iter().collect::>(); for rel_id in &closure.dropped_relations { - if rel_id != &drop.id && state.baseline_relations.contains(rel_id) { + if !roots.contains(rel_id) && state.baseline_relations.contains(rel_id) { affects_baseline = true; if state.baseline_fk_dependencies.contains(rel_id) { has_fk_pulled = true; @@ -52,7 +50,7 @@ impl Rule for CascadingDropRule { if !affects_baseline { for (from_table, cname) in &closure.dropped_constraints { - if state + if pre_state .baseline_foreign_keys .contains(&(from_table.clone(), cname.clone())) { @@ -71,7 +69,11 @@ impl Rule for CascadingDropRule { if affects_baseline { let mut reason = format!( "DROP TABLE {} CASCADE silently destroys pre-existing database dependencies", - drop.id + drop.ids + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") ); if has_fk_pulled { reason.push_str( @@ -83,7 +85,12 @@ impl Rule for CascadingDropRule { rule_id: self.id(), operation_kind: OperationKind::DropTable, object_kind: ObjectKind::Table, - object_name: drop.id.to_string(), + object_name: drop + .ids + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "), tier: self.default_tier(), reason, recipe: self.recipe(), @@ -398,7 +405,26 @@ impl Rule for ReversibilityRule { config: &Config, _cascade_closure: Option<&CascadeResult>, ) -> Vec { - if *result != MutationResult::Applied { + let skipped_known_drop_column = *result == MutationResult::Skipped + && matches!( + mutation, + Mutation::AlterTable(crate::analysis::mutations::AlterTable { + id: _, + action: AlterTableActionMutation::DropColumn { .. }, + }) + ) + && matches!( + mutation, + Mutation::AlterTable(alter) + if pre_state.relations.get(&alter.id).is_some_and(|relation| { + matches!( + &alter.action, + AlterTableActionMutation::DropColumn { name, .. } + if relation.has_column(name) + ) + }) + ); + if *result != MutationResult::Applied && !skipped_known_drop_column { return vec![]; } @@ -446,10 +472,10 @@ impl Rule for ReversibilityRule { .and_then(|r| r.estimated_rows) .unwrap_or(config.default_rows) } else if let Mutation::DropTable(d) = mutation { - pre_state - .relations - .get(&d.id) - .and_then(|r| r.estimated_rows) + d.ids + .iter() + .filter_map(|id| pre_state.relations.get(id).and_then(|r| r.estimated_rows)) + .max() .unwrap_or(config.default_rows) } else { config.default_rows @@ -485,7 +511,11 @@ impl Rule for ReversibilityRule { Mutation::DropTable(d) => ( OperationKind::DropTable, ObjectKind::Table, - d.id.to_string(), + d.ids + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "), ), Mutation::DropDatabase(d) => ( OperationKind::DropDatabase, @@ -831,7 +861,20 @@ impl Rule for TypeChangeRewriteRule { config: &Config, _cascade_closure: Option<&CascadeResult>, ) -> Vec { - if *result == MutationResult::Skipped { + // A skipped type change against a present relation can mean that the + // baseline did not expose enough column/type evidence to mutate state. + // Keep the rewrite warning conservative in that case; silently + // dropping it would turn an uncertain ACCESS EXCLUSIVE operation into + // a false clean result. Other skipped mutations remain non-events. + if *result == MutationResult::Skipped + && !matches!( + mutation, + Mutation::AlterTable(crate::analysis::mutations::AlterTable { + action: AlterTableActionMutation::SetType { .. }, + .. + }) + ) + { return vec![]; } diff --git a/src/rules/drift.rs b/src/rules/drift.rs index 4004cff..6b69de1 100644 --- a/src/rules/drift.rs +++ b/src/rules/drift.rs @@ -59,22 +59,26 @@ impl Rule for DriftDetectionRule { }); } Mutation::DropTable(d) => { - if !d.if_exists && !pre_state.relations.contains_key(&d.id) { - violations.push(Violation { source_range: None, - rule_id: self.id(), - operation_kind: OperationKind::DropTable, - object_kind: ObjectKind::Table, - object_name: d.id.to_string(), - tier: self.default_tier(), - reason: format!( - "Migration DROPs table \"{}\" which does not exist in the production baseline", - d.id - ), - recipe: self.recipe(), - dedup_key: None, - sql: None, - fk_dependency_related: false, - }); + if !d.if_exists { + for id in &d.ids { + if !pre_state.relations.contains_key(id) { + violations.push(Violation { source_range: None, + rule_id: self.id(), + operation_kind: OperationKind::DropTable, + object_kind: ObjectKind::Table, + object_name: id.to_string(), + tier: self.default_tier(), + reason: format!( + "Migration DROPs table \"{}\" which does not exist in the production baseline", + id + ), + recipe: self.recipe(), + dedup_key: None, + sql: None, + fk_dependency_related: false, + }); + } + } } } Mutation::AlterTable(a) => { @@ -184,22 +188,24 @@ impl Rule for DriftDetectionRule { } } Mutation::DropIndex(d) => { - if !d.if_exists && !pre_state.indexes.iter().any(|idx| idx.dependent == d.id) { - violations.push(Violation { source_range: None, + for id in &d.ids { + if !d.if_exists && !pre_state.indexes.iter().any(|idx| idx.dependent == *id) { + violations.push(Violation { source_range: None, rule_id: self.id(), operation_kind: OperationKind::DropIndex, object_kind: ObjectKind::Index, - object_name: d.id.to_string(), + object_name: id.to_string(), tier: self.default_tier(), reason: format!( "Migration DROPs index \"{}\" which does not exist in the production baseline", - d.id + id ), recipe: self.recipe(), dedup_key: None, sql: None, fk_dependency_related: false, - }); + }); + } } } Mutation::DropDomain(d) => { diff --git a/src/rules/idempotency.rs b/src/rules/idempotency.rs index defa5af..9ee03c9 100644 --- a/src/rules/idempotency.rs +++ b/src/rules/idempotency.rs @@ -90,14 +90,16 @@ impl Rule for IdempotencyRule { ); } - // Drop Guards (Singular targets) + // Drop Guards Mutation::DropTable(d) if !d.if_exists => { - add_violation( - OperationKind::DropTable, - ObjectKind::Table, - d.id.to_string(), - format!("DROP TABLE {} without IF EXISTS", d.id), - ); + for id in &d.ids { + add_violation( + OperationKind::DropTable, + ObjectKind::Table, + id.to_string(), + format!("DROP TABLE {} without IF EXISTS", id), + ); + } } Mutation::DropSchema(d) if !d.if_exists => { for name in &d.names { @@ -110,12 +112,14 @@ impl Rule for IdempotencyRule { } } Mutation::DropIndex(d) if !d.if_exists => { - add_violation( - OperationKind::DropIndex, - ObjectKind::Index, - d.id.to_string(), - format!("DROP INDEX {} without IF EXISTS", d.id), - ); + for id in &d.ids { + add_violation( + OperationKind::DropIndex, + ObjectKind::Index, + id.to_string(), + format!("DROP INDEX {} without IF EXISTS", id), + ); + } } Mutation::DropPolicy(d) if !d.if_exists => { add_violation( diff --git a/src/rules/indexes.rs b/src/rules/indexes.rs index 3fca9dd..377b215 100644 --- a/src/rules/indexes.rs +++ b/src/rules/indexes.rs @@ -28,7 +28,16 @@ impl Rule for ConcurrentIndexRule { _cascade: Option<&CascadeResult>, ) -> Vec { if *result == MutationResult::Skipped { - return vec![]; + // An index that is present in the pre-state still incurs the + // synchronous DROP INDEX risk even when V6 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. + let known_drop_target = matches!(mutation, Mutation::DropIndex(drop) + if drop.ids.iter().any(|id| pre_state.indexes.iter().any(|edge| edge.dependent == *id))); + if !known_drop_target { + return vec![]; + } } let mut violations = Vec::new(); @@ -107,40 +116,8 @@ impl Rule for ConcurrentIndexRule { // DROP INDEX classification does not emit a stale-statistics finding. - if pre_state.relations.is_empty() { - let rows = config.default_rows; - let tier = if rows >= tier1_threshold { - ViolationTier::Tier1 - } else if rows >= tier2_threshold { - ViolationTier::Tier2 - } else { - ViolationTier::Tier3 - }; - - violations.push(Violation { - source_range: None, - rule_id, - operation_kind: OperationKind::DropIndex, - object_kind: ObjectKind::Index, - object_name: drop.id.to_string(), - tier, - reason: format!("Synchronous index drop for {}", drop.id), - recipe: self.recipe(), - dedup_key: None, - sql: None, - fk_dependency_related: false, - }); - } else { - let mut target_relations = Vec::new(); - for idx in &pre_state.indexes { - if idx.dependent == drop.id - && let Some(rel) = pre_state.relations.get(&idx.referenced) - { - target_relations.push(rel); - } - } - - if target_relations.is_empty() { + for id in &drop.ids { + if pre_state.relations.is_empty() { let rows = config.default_rows; let tier = if rows >= tier1_threshold { ViolationTier::Tier1 @@ -155,15 +132,47 @@ impl Rule for ConcurrentIndexRule { rule_id, operation_kind: OperationKind::DropIndex, object_kind: ObjectKind::Index, - object_name: drop.id.to_string(), + object_name: id.to_string(), tier, - reason: format!("Synchronous index drop for {}", drop.id), + reason: format!("Synchronous index drop for {}", id), recipe: self.recipe(), dedup_key: None, sql: None, fk_dependency_related: false, }); } else { + let target_relations = pre_state + .indexes + .iter() + .filter_map(|idx| { + (idx.dependent == *id) + .then(|| pre_state.relations.get(&idx.referenced)) + .flatten() + }) + .collect::>(); + if target_relations.is_empty() { + let rows = config.default_rows; + let tier = if rows >= tier1_threshold { + ViolationTier::Tier1 + } else if rows >= tier2_threshold { + ViolationTier::Tier2 + } else { + ViolationTier::Tier3 + }; + violations.push(Violation { + source_range: None, + rule_id, + operation_kind: OperationKind::DropIndex, + object_kind: ObjectKind::Index, + object_name: id.to_string(), + tier, + reason: format!("Synchronous index drop for {}", id), + recipe: self.recipe(), + dedup_key: None, + sql: None, + fk_dependency_related: false, + }); + } for rel in target_relations { if rel.persistence == Persistence::Temporary { continue; @@ -178,15 +187,14 @@ impl Rule for ConcurrentIndexRule { ViolationTier::Tier3 }; - let reason = - format!("Synchronous index drop for {} on {}", drop.id, rel.id); + let reason = format!("Synchronous index drop for {} on {}", id, rel.id); violations.push(Violation { source_range: None, rule_id, operation_kind: OperationKind::DropIndex, object_kind: ObjectKind::Index, - object_name: drop.id.to_string(), + object_name: id.to_string(), tier, reason, recipe: self.recipe(), diff --git a/src/rules/security.rs b/src/rules/security.rs index 83bc855..2ce21c1 100644 --- a/src/rules/security.rs +++ b/src/rules/security.rs @@ -26,7 +26,16 @@ impl Rule for OverbroadGrantRule { _config: &Config, _cascade_closure: Option<&CascadeResult>, ) -> Vec { - if *result == MutationResult::Skipped { + // `WITH GRANT OPTION` is itself the security-sensitive operation. The + // state matrix intentionally skips it because grant chains are not + // modeled, but that uncertainty must not suppress the syntax-level + // warning for a statement PostgreSQL will execute. + let skipped_grant_option = *result == MutationResult::Skipped + && matches!( + mutation, + Mutation::Grant(grant) if grant.with_grant_option + ); + if *result == MutationResult::Skipped && !skipped_grant_option { return vec![]; } let mut violations = Vec::new(); diff --git a/src/rules/transactions.rs b/src/rules/transactions.rs index 3030faf..eb646d5 100644 --- a/src/rules/transactions.rs +++ b/src/rules/transactions.rs @@ -45,22 +45,24 @@ impl Rule for ConcurrentInsideTransactionRule { }); } Mutation::DropIndex(d) if d.concurrently => { - violations.push(Violation { - source_range: None, - rule_id: self.id(), - operation_kind: OperationKind::DropIndex, - object_kind: ObjectKind::Index, - object_name: d.id.to_string(), - tier: self.default_tier(), - reason: format!( - "DROP INDEX CONCURRENTLY on {} inside a transaction block", - d.id - ), - recipe: self.recipe(), - dedup_key: None, - sql: None, - fk_dependency_related: false, - }); + for id in &d.ids { + violations.push(Violation { + source_range: None, + rule_id: self.id(), + operation_kind: OperationKind::DropIndex, + object_kind: ObjectKind::Index, + object_name: id.to_string(), + tier: self.default_tier(), + reason: format!( + "DROP INDEX CONCURRENTLY on {} inside a transaction block", + id + ), + recipe: self.recipe(), + dedup_key: None, + sql: None, + fk_dependency_related: false, + }); + } } _ => {} } diff --git a/src/rules/views.rs b/src/rules/views.rs index 8f81680..b2102a9 100644 --- a/src/rules/views.rs +++ b/src/rules/views.rs @@ -102,7 +102,7 @@ impl Rule for MaterializedViewRefreshRule { } } else { // CONCURRENTLY refresh requires at least one unique index - let has_unique_index = state.local.graph.edges.iter().any(|e| { + let has_unique_index = state.local.graph.edges().iter().any(|e| { if let crate::analysis::graph::DependencyKind::IndexOnRelation { is_unique, .. diff --git a/src/sync.rs b/src/sync.rs index 3684d27..d94a65d 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -1,25 +1,31 @@ use crate::ast::identifiers::ObjectId; use crate::db::cache::{CACHE_V6_MAGIC, DbCache, DbCacheVersioned, ForeignKeyCache, IndexCache}; -use crate::db::cache_file::{MAX_CACHE_DECODE_BYTES, MAX_CACHE_FILE_BYTES, protect_cache_bytes}; +use crate::db::cache_file::{ + MAX_CACHE_DECODE_BYTES, MAX_CACHE_FILE_BYTES, protect_cache_bytes, + validate_cache_encryption_configuration, +}; use crate::model::relation::{Persistence, RelationKind, RelationState}; use anyhow::{Context, Result}; use postgres::config::Host; use postgres::{Client, Config as PostgresConfig, GenericClient, IsolationLevel, NoTls}; use std::io::{self, Write}; use std::path::Path; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tempfile::NamedTempFile; #[cfg(windows)] use std::fs; const MIN_POSTGRES_VERSION_NUM: u32 = 140_000; +const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); pub 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.")?; @@ -35,7 +41,7 @@ pub fn sync_cache( } fn connect_database(db_url: &str) -> Result { - let config: PostgresConfig = db_url + let mut config: PostgresConfig = db_url .parse() .context("DATABASE_URL is not a valid PostgreSQL connection string")?; @@ -45,11 +51,19 @@ fn connect_database(db_url: &str) -> Result { ); } + apply_connection_safety_defaults(&mut config); + config .connect(NoTls) .context("Failed to connect to PostgreSQL") } +fn apply_connection_safety_defaults(config: &mut PostgresConfig) { + if config.get_connect_timeout().is_none() { + config.connect_timeout(DEFAULT_CONNECT_TIMEOUT); + } +} + pub(crate) fn database_config_is_local(config: &PostgresConfig) -> bool { config .get_hostaddrs() @@ -144,6 +158,84 @@ pub(crate) fn is_system_schema(schema: &str) -> bool { schema == "information_schema" || schema.starts_with("pg_") } +fn sequence_kind_from_pg( + dependency_type: Option<&str>, + has_nextval_default: bool, +) -> Result { + match dependency_type { + Some("i") => Ok(crate::model::sequence::SequenceKind::Identity), + Some("a") if has_nextval_default => Ok(crate::model::sequence::SequenceKind::SerialLike), + Some("a") => Ok(crate::model::sequence::SequenceKind::Owned), + None => Ok(crate::model::sequence::SequenceKind::Standalone), + Some(other) => anyhow::bail!("unsupported pg_depend type '{other}'"), + } +} + +fn relation_kind_from_pg(code: u8) -> Result { + match code { + b'r' | b'p' => Ok(RelationKind::Table), + b'v' => Ok(RelationKind::View), + b'm' => Ok(RelationKind::MaterializedView), + other => anyhow::bail!("unsupported pg_class.relkind byte {other}"), + } +} + +fn persistence_from_pg(code: u8) -> Result { + match code { + b'p' => Ok(Persistence::Permanent), + b't' => Ok(Persistence::Temporary), + b'u' => Ok(Persistence::Unlogged), + other => anyhow::bail!("unsupported pg_class.relpersistence byte {other}"), + } +} + +fn partition_strategy_from_pg(code: Option<&str>) -> Result> { + match code { + None => Ok(None), + Some("r") => Ok(Some("RANGE".to_string())), + Some("l") => Ok(Some("LIST".to_string())), + Some("h") => Ok(Some("HASH".to_string())), + Some(other) => anyhow::bail!("unsupported partition strategy '{other}'"), + } +} + +fn routine_volatility_from_pg(code: &str) -> Result { + match code { + "v" => Ok(crate::model::function::Volatility::Volatile), + "s" => Ok(crate::model::function::Volatility::Stable), + "i" => Ok(crate::model::function::Volatility::Immutable), + other => anyhow::bail!("unknown pg_proc.provolatile value '{other}'"), + } +} + +fn routine_kind_from_pg(code: &str) -> Result { + match code { + "f" => Ok(crate::model::function::RoutineKind::Function), + "p" => Ok(crate::model::function::RoutineKind::Procedure), + "a" => Ok(crate::model::function::RoutineKind::Aggregate), + "w" => Ok(crate::model::function::RoutineKind::Window), + other => anyhow::bail!("unknown pg_proc.prokind value '{other}'"), + } +} + +fn subscription_streaming_from_pg(code: &str) -> Result<&'static str> { + match code { + "t" | "true" => Ok("true"), + "f" | "false" => Ok("false"), + "p" => Ok("parallel"), + other => anyhow::bail!("unknown subscription streaming mode '{other}'"), + } +} + +fn subscription_two_phase_from_pg(code: &str) -> Result<&'static str> { + match code { + "d" => Ok("false"), + "e" => Ok("true"), + "p" => Ok("pending"), + other => anyhow::bail!("unknown subscription two-phase state '{other}'"), + } +} + fn write_cache(out_path: &Path, cache: DbCache, cache_encryption: bool) -> Result<()> { write_cache_with_protection(out_path, cache, |compressed| { protect_cache_bytes(compressed, cache_encryption) @@ -171,6 +263,10 @@ fn write_cache_with_protection_and_limits( max_file_bytes: u64, max_decode_bytes: usize, ) -> Result<()> { + cache + .validate_semantics() + .map_err(anyhow::Error::msg) + .context("Refusing to write a semantically invalid Cache V6 baseline")?; let parent = out_path.parent().unwrap_or_else(|| Path::new(".")); let mut temp_file = NamedTempFile::new_in(parent).with_context(|| { format!( @@ -223,6 +319,10 @@ fn write_cache_with_protection_and_limits( .write_all(&cache_bytes) .context("Failed to write cache payload")?; temp_file.flush().context("Failed to flush cache payload")?; + temp_file + .as_file() + .sync_all() + .context("Failed to synchronize cache payload before installation")?; replace_cache(temp_file, out_path)?; @@ -287,11 +387,39 @@ fn replace_cache(temp_file: NamedTempFile, out_path: &Path) -> Result<()> { out_path.display() ) })?; + let parent = out_path.parent().unwrap_or_else(|| Path::new(".")); + std::fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .with_context(|| { + format!( + "Installed cache but failed to synchronize its parent directory: {}", + parent.display() + ) + })?; Ok(()) } #[cfg(windows)] fn replace_cache(temp_file: NamedTempFile, out_path: &Path) -> Result<()> { + let backup = out_path.with_extension("safe-migrate.backup"); + if backup.exists() { + if out_path.exists() { + fs::remove_file(&backup).with_context(|| { + format!( + "Failed to remove stale cache backup before replacement: {}", + backup.display() + ) + })?; + } else { + fs::rename(&backup, out_path).with_context(|| { + format!( + "Failed to restore interrupted cache replacement from backup: {}", + backup.display() + ) + })?; + } + } + if !out_path.exists() { temp_file .persist(out_path) @@ -300,7 +428,6 @@ fn replace_cache(temp_file: NamedTempFile, out_path: &Path) -> Result<()> { return Ok(()); } - let backup = out_path.with_extension("safe-migrate.backup"); fs::rename(out_path, &backup).with_context(|| { format!( "Failed to stage existing cache for replacement: {}", @@ -358,103 +485,219 @@ pub fn populate_cache_in_current_transaction( populate_cache_from_client(client, schemas) } -fn populate_cache_from_client( +fn load_view_dependencies( client: &mut impl GenericClient, - schemas: Option<&[String]>, -) -> Result { - let mut cache = DbCache::new(); - let schema_values = schemas.map(|items| items.to_vec()); - cache.metadata.created_at_unix_secs = Some( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - ); - cache.metadata.schemas = schema_values.clone(); - - let schema_filter = "AND ($1::text[] IS NULL OR n.nspname = ANY($1))"; - let schema_filter_with_fk = r#" - AND ( - $1::text[] IS NULL - OR n.nspname = ANY($1) - OR c.oid IN ( - SELECT conrelid FROM pg_constraint cst - JOIN pg_class c2 ON c2.oid = cst.confrelid - JOIN pg_namespace n2 ON n2.oid = c2.relnamespace - WHERE n2.nspname = ANY($1) - ) - OR c.oid IN ( - SELECT confrelid FROM pg_constraint cst - JOIN pg_class c2 ON c2.oid = cst.conrelid - JOIN pg_namespace n2 ON n2.oid = c2.relnamespace - WHERE n2.nspname = ANY($1) - ) - ) + schema_values: &Option>, +) -> Result> { + let query = r#" + SELECT DISTINCT + 'pg_class'::regclass::oid AS classid, + vc.oid AS objid, + 0 AS objsubid, + 'pg_class'::regclass::oid AS refclassid, + tc.oid AS refobjid, + 0 AS refobjsubid, + vn.nspname AS obj_schema, + vc.relname AS obj_name, + tn.nspname AS ref_schema, + tc.relname AS ref_name + FROM pg_rewrite rw + JOIN pg_class vc ON vc.oid = rw.ev_class + JOIN pg_namespace vn ON vn.oid = vc.relnamespace + JOIN pg_depend d ON d.objid = rw.oid + JOIN pg_class tc ON tc.oid = d.refobjid + JOIN pg_namespace tn ON tn.oid = tc.relnamespace + WHERE vc.relkind IN ('v', 'm') + AND d.classid = 'pg_rewrite'::regclass + AND d.refclassid = 'pg_class'::regclass + AND d.deptype = 'n' + AND tc.oid <> vc.oid + AND tc.relkind IN ('r', 'p', 'v', 'm') + AND vn.nspname NOT LIKE 'pg\_%' ESCAPE '\' + AND vn.nspname <> 'information_schema' + AND tn.nspname NOT LIKE 'pg\_%' ESCAPE '\' + AND tn.nspname <> 'information_schema' + AND ( + $1::text[] IS NULL + OR vn.nspname = ANY($1) + OR tn.nspname = ANY($1) + ) "#; - let schema_filter_n1_or_n2 = - "AND ($1::text[] IS NULL OR n1.nspname = ANY($1) OR n2.nspname = ANY($1))"; - let schema_filter_nt = r#" - AND ( - $1::text[] IS NULL - OR n_t.nspname = ANY($1) - OR t.oid IN ( - SELECT conrelid FROM pg_constraint cst - JOIN pg_class c2 ON c2.oid = cst.confrelid - JOIN pg_namespace n2 ON n2.oid = c2.relnamespace - WHERE n2.nspname = ANY($1) - ) - OR t.oid IN ( - SELECT confrelid FROM pg_constraint cst - JOIN pg_class c2 ON c2.oid = cst.conrelid - JOIN pg_namespace n2 ON n2.oid = c2.relnamespace - WHERE n2.nspname = ANY($1) - ) + + let rows = client + .query(query, &[schema_values]) + .context("Failed to load view dependencies from pg_rewrite/pg_depend")?; + rows.into_iter() + .map(|row| { + Ok(crate::db::cache::DependencyCache { + classid: row.try_get(0).context("view dependency classid")?, + objid: row.try_get(1).context("view dependency object oid")?, + objsubid: row.try_get(2).context("view dependency object sub-id")?, + refclassid: row + .try_get(3) + .context("view dependency referenced classid")?, + refobjid: row + .try_get(4) + .context("view dependency referenced object oid")?, + refobjsubid: row + .try_get(5) + .context("view dependency referenced object sub-id")?, + deptype: "view".to_string(), + obj_schema: Some(row.try_get(6).context("view dependency schema")?), + obj_name: Some(row.try_get(7).context("view dependency name")?), + ref_schema: Some( + row.try_get(8) + .context("view dependency referenced schema")?, + ), + ref_name: Some(row.try_get(9).context("view dependency referenced name")?), + }) + }) + .collect() +} + +fn load_roles( + client: &mut impl GenericClient, + pg_version_num: u32, +) -> Result> { + let mut roles = std::collections::HashMap::new(); + let rows = client + .query( + "SELECT rolname, rolcanlogin, rolsuper FROM pg_roles ORDER BY rolname;", + &[], ) - "#; + .context("Failed to load role identities from pg_roles")?; + for row in rows { + let name: String = row.try_get(0).context("role name")?; + let id = ObjectId::new("", &name); + roles.insert( + id.clone(), + crate::model::role::RoleState { + id, + can_login: row.try_get(1).context("role login capability")?, + is_superuser: row.try_get(2).context("role superuser capability")?, + member_of: Vec::new(), + can_set_role_to: Vec::new(), + granted_privileges: Vec::new(), + }, + ); + } + + let membership_query = if pg_version_num >= 160_000 { + "SELECT member.rolname, parent.rolname, 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;" + } else { + "SELECT member.rolname, parent.rolname, 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;" + }; + let memberships = client + .query(membership_query, &[]) + .context("Failed to load role memberships from pg_auth_members")?; + 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")?); + let set_option: bool = row.try_get(2).context("role membership SET option")?; + if let Some(role) = roles.get_mut(&member) { + role.member_of.push(parent.clone()); + if set_option { + role.can_set_role_to.push(parent); + } + } + } + Ok(roles) +} - // Server version and connection provenance. - let version_row = client.query_one("SHOW server_version_num;", &[])?; - let version_str: String = version_row.get(0); - let version = version_str +struct ProvenanceCatalog { + pg_version_num: u32, + metadata: crate::db::cache::CacheMetadata, + search_path: Vec, +} + +fn load_provenance( + client: &mut impl GenericClient, + schemas: Option<&[String]>, +) -> Result { + let version_row = client + .query_one("SHOW server_version_num;", &[]) + .context("Failed to load PostgreSQL server version")?; + let version_str: String = version_row + .try_get(0) + .context("PostgreSQL server version field")?; + let pg_version_num = version_str .parse::() .context("PostgreSQL returned an invalid server_version_num")?; - ensure_supported_postgres_version(version)?; - cache.pg_version_num = Some(version); - - let provenance_row = client.query_one( - "SELECT current_database(), current_user, session_user, current_setting('search_path'), - (SELECT setting::bigint FROM pg_settings WHERE name = 'lock_timeout'), - (SELECT setting::bigint FROM pg_settings WHERE name = 'statement_timeout');", - &[], - )?; - cache.metadata.source_database = Some(provenance_row.get(0)); - cache.metadata.source_role = Some(provenance_row.get(1)); - cache.metadata.source_session_role = Some(provenance_row.get(2)); - let search_path_setting: String = provenance_row.get(3); - cache.metadata.source_search_path = Some(parse_search_path_setting(&search_path_setting)); - let lock_timeout_ms = provenance_row - .try_get::<_, Option>(4)? + ensure_supported_postgres_version(pg_version_num)?; + + let row = client + .query_one( + "SELECT current_database(), current_user, session_user, current_setting('search_path'), + (SELECT setting::bigint FROM pg_settings WHERE name = 'lock_timeout'), + (SELECT setting::bigint FROM pg_settings WHERE name = 'statement_timeout');", + &[], + ) + .context("Failed to load synchronization provenance and timeout settings")?; + let search_path_setting: String = row + .try_get(3) + .context("synchronization provenance search_path")?; + let lock_timeout_ms = row + .try_get::<_, Option>(4) + .context("synchronization provenance lock_timeout field")? .context("PostgreSQL did not report lock_timeout")?; - let statement_timeout_ms = provenance_row - .try_get::<_, Option>(5)? + let statement_timeout_ms = row + .try_get::<_, Option>(5) + .context("synchronization provenance statement_timeout field")? .context("PostgreSQL did not report statement_timeout")?; - cache.metadata.source_lock_timeout_ms = lock_timeout_ms - .try_into() - .context("PostgreSQL returned a negative lock_timeout")?; - cache.metadata.source_statement_timeout_ms = statement_timeout_ms - .try_into() - .context("PostgreSQL returned a negative statement_timeout")?; - - // Resolve role/database defaults and special entries such as "$user" exactly - // as PostgreSQL does, while excluding the implicit pg_catalog lookup. An - // explicit schema scope remains the resolution boundary, but selected - // schemas retain their live PostgreSQL priority. - let search_path_row = client.query_one("SELECT current_schemas(false);", &[])?; - cache.search_path = cache_search_path(search_path_row.get(0), schemas); - // Schemas are an authoritative catalog only for the requested sync scope. - // FK-only external schemas pulled in below deliberately do not enter it. - let schema_query = format!( + let search_path_row = client + .query_one("SELECT current_schemas(false);", &[]) + .context("Failed to load the effective PostgreSQL search path")?; + let effective_search_path = search_path_row + .try_get(0) + .context("effective PostgreSQL search path field")?; + + Ok(ProvenanceCatalog { + pg_version_num, + metadata: crate::db::cache::CacheMetadata { + created_at_unix_secs: Some( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + ), + source_database: Some( + row.try_get(0) + .context("synchronization provenance database field")?, + ), + source_role: Some( + row.try_get(1) + .context("synchronization provenance current-role field")?, + ), + source_session_role: Some( + row.try_get(2) + .context("synchronization provenance session-role field")?, + ), + source_search_path: Some(parse_search_path_setting(&search_path_setting)), + source_lock_timeout_ms: lock_timeout_ms + .try_into() + .context("PostgreSQL returned a negative lock_timeout")?, + source_statement_timeout_ms: statement_timeout_ms + .try_into() + .context("PostgreSQL returned a negative statement_timeout")?, + schemas: schemas.map(<[String]>::to_vec), + }, + search_path: cache_search_path(effective_search_path, schemas), + }) +} + +fn load_schemas( + client: &mut impl GenericClient, + schema_values: &Option>, + schema_filter: &str, +) -> Result> { + let query = format!( "SELECT n.nspname, pg_catalog.pg_get_userbyid(n.nspowner) FROM pg_namespace n WHERE n.nspname NOT LIKE 'pg\\_%' ESCAPE '\\' @@ -462,30 +705,35 @@ fn populate_cache_from_client( {schema_filter} ORDER BY n.nspname;" ); - for row in client.query(&schema_query, &[&schema_values])? { - let name: String = row.get(0); - let owner: String = row.get(1); - cache.schemas.insert( - name.clone(), - crate::model::schema::SchemaState { - name, - owner: relation_owner_id(owner), - generation: 0, - }, - ); - } - // A scoped request can name schemas that do not exist yet. PostgreSQL's - // effective search path skips those entries, so do not let them become - // inferred-present namespaces when the cache is hydrated. - cache - .search_path - .retain(|schema| cache.schemas.contains_key(schema)); + let rows = client + .query(&query, &[schema_values]) + .context("Failed to load schemas from pg_namespace")?; + rows.into_iter() + .map(|row| { + let name: String = row.try_get(0).context("schema name")?; + let owner: String = row.try_get(1).context("schema owner")?; + Ok(( + name.clone(), + crate::model::schema::SchemaState { + name, + owner: relation_owner_id(owner), + generation: 0, + }, + )) + }) + .collect() +} - // A sequence can have at most one pg_depend ownership relationship. The - // dependency flavor distinguishes identity's internal dependency from an - // ordinary OWNED BY relationship. An auto dependency is serial-like only - // when the owning column also has the sequence-backed nextval default. - let sequence_query = format!( +fn load_sequences( + client: &mut impl GenericClient, + schema_values: &Option>, +) -> Result> { + // Keep a sequence when either side of OWNED BY is in the requested + // scope. A sequence can live in a different schema from its owning + // table, and dropping it without that edge would make a later migration + // look exact while missing PostgreSQL's ownership dependency. + let schema_filter = "AND ($1::text[] IS NULL OR n.nspname = ANY($1) OR tn.nspname = ANY($1))"; + let query = format!( "SELECT n.nspname AS sequence_schema, s.relname AS sequence_name, @@ -517,38 +765,52 @@ fn populate_cache_from_client( {schema_filter} ORDER BY n.nspname, s.relname;" ); - for row in client.query(&sequence_query, &[&schema_values])? { - let id = ObjectId::new(row.get::<_, String>(0), row.get::<_, String>(1)); - let owner = relation_owner_id(row.get::<_, String>(2)); - let table_schema: Option = row.get(3); - let table_name: Option = row.get(4); - let column_name: Option = row.get(5); - let dependency_type: Option = row.get(6); - let has_nextval_default: bool = row.get(7); - let owned_by = table_schema - .zip(table_name) - .zip(column_name) - .map(|((schema, table), column)| (ObjectId::new(schema, table), column)); - let kind = match dependency_type.as_deref() { - Some("i") => crate::model::sequence::SequenceKind::Identity, - Some("a") if has_nextval_default => crate::model::sequence::SequenceKind::SerialLike, - Some("a") => crate::model::sequence::SequenceKind::Owned, - _ => crate::model::sequence::SequenceKind::Standalone, - }; - cache.sequences.insert( - id.clone(), - crate::model::sequence::SequenceState { - id, - owner, - owned_by, - kind, - generation: 0, - }, - ); - } + let rows = client + .query(&query, &[schema_values]) + .context("Failed to load sequences and ownership from pg_class/pg_depend")?; + rows.into_iter() + .map(|row| { + let id = ObjectId::new( + row.try_get::<_, String>(0).context("sequence schema")?, + row.try_get::<_, String>(1).context("sequence name")?, + ); + let owner = relation_owner_id(row.try_get::<_, String>(2).context("sequence owner")?); + let table_schema: Option = + row.try_get(3).context("sequence owner table schema")?; + let table_name: Option = row.try_get(4).context("sequence owner table name")?; + let column_name: Option = + row.try_get(5).context("sequence owner column name")?; + let dependency_type: Option = + row.try_get(6).context("sequence dependency type")?; + let has_nextval_default: bool = + row.try_get(7).context("sequence-backed default marker")?; + let owned_by = table_schema + .zip(table_name) + .zip(column_name) + .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))?; + Ok(( + id.clone(), + crate::model::sequence::SequenceState { + id, + owner, + owned_by, + kind, + generation: 0, + }, + )) + }) + .collect() +} - // Relations and statistics. - let table_query = format!( +fn load_relations_and_columns( + client: &mut impl GenericClient, + schemas: Option<&[String]>, + schema_values: &Option>, + schema_filter_with_fk: &str, +) -> Result> { + let relation_query = format!( " SELECT n.nspname AS schema_name, @@ -570,39 +832,37 @@ fn populate_cache_from_client( {schema_filter_with_fk}; " ); - - for row in client.query(&table_query, &[&schema_values])? { - let schema_name: String = row.get("schema_name"); - let relation_name: String = row.get("relation_name"); - let relkind: i8 = row.get("relation_kind"); - let persistence_char: i8 = row.get("persistence"); - let owner_name: String = row.get("owner_name"); - let raw_rows: i64 = row.get("estimated_rows"); - let relpages: i64 = row.get("relpages"); - - let last_analyze: Option = row.get("last_analyze"); - let last_autoanalyze: Option = row.get("last_autoanalyze"); + let rows = client + .query(&relation_query, &[schema_values]) + .context("Failed to load relations and statistics from pg_class")?; + let mut relations = std::collections::HashMap::new(); + for row in rows { + let schema_name: String = row.try_get("schema_name").context("relation schema")?; + 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 owner_name: String = row.try_get("owner_name").context("relation owner")?; + let raw_rows: i64 = row + .try_get("estimated_rows") + .context("relation estimated row count")?; + let relpages: i64 = row.try_get("relpages").context("relation page count")?; + let last_analyze: Option = row + .try_get("last_analyze") + .context("relation last-analyze timestamp")?; + let last_autoanalyze: Option = row + .try_get("last_autoanalyze") + .context("relation last-autoanalyze timestamp")?; let object_id = ObjectId::new(&schema_name, &relation_name); - - let kind = match relkind as u8 { - b'v' => RelationKind::View, - b'm' => RelationKind::MaterializedView, - _ => RelationKind::Table, - }; - - let persistence = match persistence_char as u8 { - b't' => Persistence::Temporary, - b'u' => Persistence::Unlogged, - _ => Persistence::Permanent, - }; - + let kind = relation_kind_from_pg(relkind as u8) + .with_context(|| format!("relation '{}' kind", object_id))?; + let persistence = persistence_from_pg(persistence_char as u8) + .with_context(|| format!("relation '{}' persistence", object_id))?; let estimated_rows = if raw_rows < 0 { None } else { Some(raw_rows as u64) }; - let mut state = RelationState::new( object_id.clone(), relation_owner_id(owner_name), @@ -612,31 +872,28 @@ fn populate_cache_from_client( persistence, 0, ); - state.relpages = Some(relpages as u64); + state.relpages = Some( + relpages + .try_into() + .with_context(|| format!("relation '{}' has a negative page count", object_id))?, + ); state.last_analyze = last_analyze; state.last_autoanalyze = last_autoanalyze; - - let partition_strategy: Option = row.get("partition_strategy"); - if let Some(ref strat) = partition_strategy { - state.partition_type = Some(match strat.as_str() { - "r" => "RANGE".to_string(), - "l" => "LIST".to_string(), - "h" => "HASH".to_string(), - _ => strat.to_uppercase(), - }); - } - - if let Some(s) = schemas - && !s.contains(&schema_name) + let partition_strategy: Option = row + .try_get("partition_strategy") + .context("relation partition strategy")?; + state.partition_type = partition_strategy_from_pg(partition_strategy.as_deref()) + .with_context(|| format!("relation '{}' partition strategy", object_id))?; + if let Some(scoped_schemas) = schemas + && !scoped_schemas.contains(&schema_name) { state.mark_fk_dependency(); } - - cache.insert_baseline(object_id, state); + relations.insert(object_id, state); } - // Columns and width statistics. - let col_query = format!(" + let column_query = format!( + " SELECT n.nspname AS schema_name, c.relname AS relation_name, @@ -656,36 +913,64 @@ fn populate_cache_from_client( AND n.nspname NOT IN ('pg_catalog', 'information_schema') {schema_filter_with_fk} ORDER BY n.nspname, c.relname; - "); - - for row in client.query(&col_query, &[&schema_values])? { - let schema_name: String = row.get("schema_name"); - let relation_name: String = row.get("relation_name"); - let column_name: String = row.get("column_name"); - let type_name: String = row.get("type_name"); - let not_null: bool = row.get("not_null"); - let avg_width: Option = row.get("avg_width"); - let default_expr_text: Option = row.get("default_expr_text"); - let type_modifier: Option = row.get("type_modifier"); - - let relation_id = ObjectId::new(&schema_name, &relation_name); - if let Some(rel) = cache.relations.get_mut(&relation_id) { - rel.columns.push(crate::model::column::Column { - name: column_name, - data_type: Some(type_name), - type_id: None, - is_nullable: !not_null, - default: None, - avg_width, - default_expr_text, - type_modifier, - }); - } + " + ); + let rows = client + .query(&column_query, &[schema_values]) + .context("Failed to load relation columns from pg_attribute")?; + for row in rows { + let relation_id = ObjectId::new( + row.try_get::<_, String>("schema_name") + .context("column relation schema")?, + row.try_get::<_, String>("relation_name") + .context("column relation name")?, + ); + let relation = relations.get_mut(&relation_id).with_context(|| { + format!( + "column catalog row references relation '{}' omitted by the relation loader", + relation_id + ) + })?; + relation.columns.push(crate::model::column::Column { + name: row.try_get("column_name").context("column name")?, + data_type: Some(row.try_get("type_name").context("column type")?), + type_id: None, + is_nullable: !row + .try_get::<_, bool>("not_null") + .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")?, + type_modifier: row + .try_get("type_modifier") + .context("column type modifier")?, + }); } + Ok(relations) +} + +struct RelationDecoration { + relation_id: ObjectId, + triggers: Vec, + policies: Vec, +} + +struct RelationGrant { + relation_id: ObjectId, + grantee: ObjectId, + privilege: crate::model::relation::Privilege, +} - // Triggers and policies. - let tp_query = format!(" - SELECT +fn load_relation_decorations( + client: &mut impl GenericClient, + schema_values: &Option>, + schema_filter_with_fk: &str, +) -> Result<(Vec, Vec)> { + let topology_query = format!( + " + SELECT 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, @@ -697,23 +982,26 @@ fn populate_cache_from_client( 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; - "); - - for row in client.query(&tp_query, &[&schema_values])? { - let schema_name: String = row.get("schema_name"); - let relation_name: String = row.get("relation_name"); - let triggers: Vec = row.get("triggers"); - let policies: Vec = row.get("policies"); - - let object_id = ObjectId::new(&schema_name, &relation_name); - - if let Some(rel) = cache.relations.get_mut(&object_id) { - rel.triggers.extend(triggers); - rel.policies.extend(policies); - } - } + " + ); + let decorations = client + .query(&topology_query, &[schema_values]) + .context("Failed to load relation triggers and policies")? + .into_iter() + .map(|row| { + Ok(RelationDecoration { + relation_id: ObjectId::new( + row.try_get::<_, String>("schema_name") + .context("decorated relation schema")?, + row.try_get::<_, String>("relation_name") + .context("decorated relation name")?, + ), + triggers: row.try_get("triggers").context("relation trigger names")?, + policies: row.try_get("policies").context("relation policy names")?, + }) + }) + .collect::>>()?; - // Explicit non-owner relation privileges. let acl_query = format!( " SELECT @@ -733,37 +1021,52 @@ fn populate_cache_from_client( {schema_filter_with_fk}; " ); + let grants = client + .query(&acl_query, &[schema_values]) + .context("Failed to load explicit relation privileges")? + .into_iter() + .map(|row| { + let privilege_type: String = row + .try_get("privilege_type") + .context("relation privilege type")?; + let privilege = match privilege_type.as_str() { + "SELECT" => crate::model::relation::Privilege::Select, + "INSERT" => crate::model::relation::Privilege::Insert, + "UPDATE" => crate::model::relation::Privilege::Update, + "DELETE" => crate::model::relation::Privilege::Delete, + "TRUNCATE" => crate::model::relation::Privilege::Truncate, + "REFERENCES" => crate::model::relation::Privilege::References, + "TRIGGER" => crate::model::relation::Privilege::Trigger, + "MAINTAIN" => crate::model::relation::Privilege::Maintain, + other => anyhow::bail!("unsupported relation privilege type '{other}'"), + }; + Ok(RelationGrant { + relation_id: ObjectId::new( + row.try_get::<_, String>("schema_name") + .context("privileged relation schema")?, + row.try_get::<_, String>("relation_name") + .context("privileged relation name")?, + ), + grantee: ObjectId::new( + "", + row.try_get::<_, String>("grantee") + .context("relation privilege grantee")?, + ), + privilege, + }) + }) + .collect::>>()?; + Ok((decorations, grants)) +} - for row in client.query(&acl_query, &[&schema_values])? { - let schema_name: String = row.get("schema_name"); - let relation_name: String = row.get("relation_name"); - let grantee: String = row.get("grantee"); - let privilege_type: String = row.get("privilege_type"); - let privilege = match privilege_type.as_str() { - "SELECT" => crate::model::relation::Privilege::Select, - "INSERT" => crate::model::relation::Privilege::Insert, - "UPDATE" => crate::model::relation::Privilege::Update, - "DELETE" => crate::model::relation::Privilege::Delete, - "TRUNCATE" => crate::model::relation::Privilege::Truncate, - "REFERENCES" => crate::model::relation::Privilege::References, - "TRIGGER" => crate::model::relation::Privilege::Trigger, - _ => continue, - }; - if let Some(relation) = cache - .relations - .get_mut(&ObjectId::new(&schema_name, &relation_name)) - { - relation.privileges.grant( - ObjectId::new("", grantee), - [privilege].into_iter().collect(), - ); - } - } - - // Trigger functions. - let trig_query = format!( +fn load_triggers( + client: &mut impl GenericClient, + schema_values: &Option>, + schema_filter_with_fk: &str, +) -> Result> { + let query = format!( " - SELECT + SELECT n.nspname AS table_schema, c.relname AS table_name, t.tgname AS trigger_name, @@ -776,32 +1079,52 @@ fn populate_cache_from_client( JOIN pg_proc f ON f.oid = t.tgfoid JOIN pg_namespace fn ON fn.oid = f.pronamespace WHERE t.tgisinternal = false + AND c.relkind IN ('r', 'p', 'v', 'm') AND n.nspname NOT IN ('pg_catalog', 'information_schema') {schema_filter_with_fk}; " ); + client + .query(&query, &[schema_values]) + .context("Failed to load triggers and trigger functions")? + .into_iter() + .map(|row| { + let table_schema: String = row.try_get("table_schema").context("trigger schema")?; + let enabled_mode: String = row + .try_get("enabled_mode") + .context("trigger enabled mode")?; + Ok(crate::db::cache::TriggerCache { + trigger_id: ObjectId::new( + &table_schema, + row.try_get::<_, String>("trigger_name") + .context("trigger name")?, + ), + table_id: ObjectId::new( + &table_schema, + row.try_get::<_, String>("table_name") + .context("trigger table name")?, + ), + function_id: ObjectId::new( + row.try_get::<_, String>("function_schema") + .context("trigger function schema")?, + row.try_get::<_, String>("function_name") + .context("trigger function name")?, + ), + enabled_mode: crate::model::trigger::TriggerEnableMode::from_pg_code(&enabled_mode) + .ok_or_else(|| { + anyhow::anyhow!("unknown pg_trigger.tgenabled value {enabled_mode}") + })?, + }) + }) + .collect() +} - for row in client.query(&trig_query, &[&schema_values])? { - let table_schema: String = row.get("table_schema"); - let table_name: String = row.get("table_name"); - let trigger_name: String = row.get("trigger_name"); - let enabled_mode: String = row.get("enabled_mode"); - let function_schema: String = row.get("function_schema"); - let function_name: String = row.get("function_name"); - - cache.triggers.push(crate::db::cache::TriggerCache { - trigger_id: ObjectId::new(&table_schema, &trigger_name), - table_id: ObjectId::new(&table_schema, &table_name), - function_id: ObjectId::new(&function_schema, &function_name), - enabled_mode: crate::model::trigger::TriggerEnableMode::from_pg_code(&enabled_mode) - .ok_or_else(|| { - anyhow::anyhow!("unknown pg_trigger.tgenabled value {enabled_mode}") - })?, - }); - } - - // Table constraints. - let constraint_query = format!( +fn load_constraints( + client: &mut impl GenericClient, + schema_values: &Option>, + schema_filter_with_fk: &str, +) -> Result> { + let query = format!( " SELECT n.nspname AS table_schema, @@ -813,39 +1136,52 @@ fn populate_cache_from_client( JOIN pg_class c ON c.oid = con.conrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE con.contype IN ('c', 'f', 'p', 'u', 'x') + AND c.relkind IN ('r', 'p', 'v', 'm') AND n.nspname NOT IN ('pg_catalog', 'information_schema') - {schema_filter}; + {schema_filter_with_fk}; " ); - - for row in client.query(&constraint_query, &[&schema_values])? { - let table_schema: String = row.get("table_schema"); - let table_name: String = row.get("table_name"); - let constraint_name: String = row.get("constraint_name"); - let constraint_type: String = row.get("constraint_type"); - let validated: bool = row.get("validated"); - let kind = match constraint_type.as_str() { - "c" => crate::model::constraint::ConstraintKind::Check, - "f" => crate::model::constraint::ConstraintKind::ForeignKey, - "p" => crate::model::constraint::ConstraintKind::PrimaryKey, - "u" => crate::model::constraint::ConstraintKind::Unique, - "x" => crate::model::constraint::ConstraintKind::Exclusion, - _ => continue, - }; - cache - .constraints - .push(crate::model::constraint::ConstraintState { - table_id: ObjectId::new(&table_schema, &table_name), - name: constraint_name, + client + .query(&query, &[schema_values]) + .context("Failed to load table constraints from pg_constraint")? + .into_iter() + .map(|row| { + let constraint_type: String = + row.try_get("constraint_type").context("constraint type")?; + let kind = match constraint_type.as_str() { + "c" => crate::model::constraint::ConstraintKind::Check, + "f" => crate::model::constraint::ConstraintKind::ForeignKey, + "p" => crate::model::constraint::ConstraintKind::PrimaryKey, + "u" => crate::model::constraint::ConstraintKind::Unique, + "x" => crate::model::constraint::ConstraintKind::Exclusion, + other => anyhow::bail!("unsupported pg_constraint.contype '{other}'"), + }; + Ok(crate::model::constraint::ConstraintState { + table_id: ObjectId::new( + row.try_get::<_, String>("table_schema") + .context("constraint table schema")?, + row.try_get::<_, String>("table_name") + .context("constraint table name")?, + ), + name: row.try_get("constraint_name").context("constraint name")?, kind, - validated, - }); - } + validated: row + .try_get("validated") + .context("constraint validation state")?, + }) + }) + .collect() +} - // Foreign keys. - let fk_query = format!( +fn load_foreign_keys( + client: &mut impl GenericClient, + schemas: Option<&[String]>, + schema_values: &Option>, + schema_filter_n1_or_n2: &str, +) -> Result> { + let query = format!( " - SELECT + SELECT c.conname AS constraint_name, n1.nspname AS from_schema, t1.relname AS from_table, n2.nspname AS to_schema, t2.relname AS to_table @@ -858,45 +1194,58 @@ fn populate_cache_from_client( {schema_filter_n1_or_n2}; " ); + client + .query(&query, &[schema_values]) + .context("Failed to load foreign keys from pg_constraint")? + .into_iter() + .map(|row| { + let constraint_name: String = row + .try_get("constraint_name") + .context("foreign-key constraint name")?; + let from_schema: String = row + .try_get("from_schema") + .context("foreign-key source schema")?; + let from_table: String = row + .try_get("from_table") + .context("foreign-key source table")?; + let to_schema: String = row + .try_get("to_schema") + .context("foreign-key target schema")?; + let to_table: String = row + .try_get("to_table") + .context("foreign-key target table")?; + if let Some(scoped_schemas) = schemas + && (!scoped_schemas.contains(&from_schema) + || !scoped_schemas.contains(&to_schema)) + { + let (out_of_scope_schema, out_of_scope_table) = + if !scoped_schemas.contains(&from_schema) { + (&from_schema, &from_table) + } else { + (&to_schema, &to_table) + }; + eprintln!( + "[WARN] Foreign key '{}' crosses schema boundary. Table '{}.{}' was pulled into cache as a dependency to evaluate cross-team locks.", + constraint_name, out_of_scope_schema, out_of_scope_table + ); + } + Ok(ForeignKeyCache { + constraint_name, + from_table: ObjectId::new(from_schema, from_table), + to_table: ObjectId::new(to_schema, to_table), + }) + }) + .collect() +} - for row in client.query(&fk_query, &[&schema_values])? { - let constraint_name: String = row.get("constraint_name"); - let from_schema: String = row.get("from_schema"); - let from_table: String = row.get("from_table"); - let to_schema: String = row.get("to_schema"); - let to_table: String = row.get("to_table"); - - if let Some(s) = schemas - && (!s.contains(&from_schema) || !s.contains(&to_schema)) - { - // Determine which one is out of scope to print a helpful warning - let out_of_scope_schema = if !s.contains(&from_schema) { - &from_schema - } else { - &to_schema - }; - let out_of_scope_table = if !s.contains(&from_schema) { - &from_table - } else { - &to_table - }; - eprintln!( - "[WARN] Foreign key '{}' crosses schema boundary. Table '{}.{}' was pulled into cache as a dependency to evaluate cross-team locks.", - constraint_name, out_of_scope_schema, out_of_scope_table - ); - } - - cache.foreign_keys.push(ForeignKeyCache { - constraint_name, - from_table: ObjectId::new(&from_schema, &from_table), - to_table: ObjectId::new(&to_schema, &to_table), - }); - } - - // Indexes. - let idx_query = format!( +fn load_indexes( + client: &mut impl GenericClient, + schema_values: &Option>, + schema_filter_nt: &str, +) -> Result> { + let query = format!( " - SELECT + SELECT n_i.nspname AS index_schema, i.relname AS index_name, n_t.nspname AS table_schema, t.relname AS table_name FROM pg_index x @@ -912,25 +1261,40 @@ fn populate_cache_from_client( {schema_filter_nt}; " ); - - for row in client.query(&idx_query, &[&schema_values])? { - let index_schema: String = row.get("index_schema"); - let index_name: String = row.get("index_name"); - let table_schema: String = row.get("table_schema"); - let table_name: String = row.get("table_name"); - + let rows = client + .query(&query, &[schema_values]) + .context("Failed to load valid indexes from pg_index")?; + let mut indexes = Vec::with_capacity(rows.len()); + for row in rows { + let index_schema: String = row.try_get("index_schema").context("index schema")?; + let table_schema: String = row + .try_get("table_schema") + .context("indexed table schema")?; if is_system_schema(&index_schema) || is_system_schema(&table_schema) { continue; } - - cache.indexes.push(IndexCache { - index_id: ObjectId::new(&index_schema, &index_name), - table_id: ObjectId::new(&table_schema, &table_name), + indexes.push(IndexCache { + index_id: ObjectId::new( + index_schema, + row.try_get::<_, String>("index_name") + .context("index name")?, + ), + table_id: ObjectId::new( + table_schema, + row.try_get::<_, String>("table_name") + .context("indexed table name")?, + ), }); } + Ok(indexes) +} - // Routines share one PostgreSQL namespace, regardless of kind. - let func_query = format!( +fn load_routines( + client: &mut impl GenericClient, + schema_values: &Option>, + schema_filter: &str, +) -> Result> { + let query = format!( " SELECT n.nspname AS schema_name, @@ -953,67 +1317,128 @@ fn populate_cache_from_client( {schema_filter}; " ); + client + .query(&query, &[schema_values]) + .context("Failed to load routines from pg_proc")? + .into_iter() + .map(|row| { + let schema_name: String = row.try_get("schema_name").context("routine schema")?; + let function_name: String = row.try_get("func_name").context("routine name")?; + let raw_arg_types: Vec = + row.try_get("arg_types").context("routine argument types")?; + let volatility_code: String = + row.try_get("volatility").context("routine volatility")?; + let volatility = routine_volatility_from_pg(&volatility_code)?; + let routine_kind_code: String = row.try_get("routine_kind").context("routine kind")?; + let routine_kind = routine_kind_from_pg(&routine_kind_code)?; + let arg_types = raw_arg_types + .iter() + .map(|arg_type| { + crate::analysis::resolver::Resolver::normalize_function_arg_type(arg_type) + }) + .collect::>(); + let id = ObjectId::new( + schema_name, + format!("{}({})", function_name, arg_types.join(",")), + ); + let security_definer: bool = row + .try_get("security_definer") + .context("routine security mode")?; + Ok(( + id.clone(), + crate::model::function::FunctionState { + id, + routine_kind, + arg_types, + arg_type_ids: Vec::new(), + return_type: row + .try_get::<_, Option>("return_type") + .context("routine return type")? + .unwrap_or_default(), + return_type_id: None, + volatility, + language: row.try_get("language").context("routine language")?, + security: if security_definer { + crate::model::function::SecurityMode::Definer + } else { + crate::model::function::SecurityMode::Invoker + }, + }, + )) + }) + .collect() +} - for row in client.query(&func_query, &[&schema_values])? { - let schema_name: String = row.get("schema_name"); - let func_name: String = row.get("func_name"); - let arg_types: Vec = row.get("arg_types"); - let return_type: Option = row.get("return_type"); - let volatility_char: String = row.get("volatility"); - let routine_kind_char: String = row.get("routine_kind"); - let language: String = row.get("language"); - let security_definer: bool = row.get("security_definer"); - - let volatility = match volatility_char.as_str() { - "v" => crate::model::function::Volatility::Volatile, - "s" => crate::model::function::Volatility::Stable, - "i" => crate::model::function::Volatility::Immutable, - _ => crate::model::function::Volatility::Volatile, - }; - - let security = if security_definer { - crate::model::function::SecurityMode::Definer - } else { - crate::model::function::SecurityMode::Invoker - }; - - let routine_kind = match routine_kind_char.as_str() { - "f" => crate::model::function::RoutineKind::Function, - "p" => crate::model::function::RoutineKind::Procedure, - "a" => crate::model::function::RoutineKind::Aggregate, - "w" => crate::model::function::RoutineKind::Window, - other => anyhow::bail!("PostgreSQL returned unknown pg_proc.prokind '{other}'"), - }; - - let arg_types = arg_types - .iter() - .map(|arg_type| { - crate::analysis::resolver::Resolver::normalize_function_arg_type(arg_type) - }) - .collect::>(); - let arg_types_str = arg_types.join(","); - - let id = ObjectId::new(&schema_name, format!("{}({})", func_name, arg_types_str)); - - cache.functions.insert( - id.clone(), - crate::model::function::FunctionState { - id, - routine_kind, - arg_types, - arg_type_ids: Vec::new(), - return_type: return_type.unwrap_or_default(), - return_type_id: None, - volatility, - language, - security, - }, - ); - } +fn load_types( + client: &mut impl GenericClient, + schema_values: &Option>, + schema_filter: &str, +) -> Result> { + let query = format!( + " + SELECT + n.nspname AS schema_name, + t.typname AS type_name, + t.typtype::text AS type_kind, + CASE WHEN t.typtype = 'd' + THEN pg_catalog.format_type(t.typbasetype, t.typtypmod) + ELSE NULL + END AS domain_base_type, + COALESCE( + array_agg(e.enumlabel ORDER BY e.enumsortorder) + FILTER (WHERE e.enumlabel IS NOT NULL), + ARRAY[]::text[] + ) AS enum_labels + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + LEFT JOIN pg_enum e ON e.enumtypid = t.oid + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') + AND t.typtype IN ('e', 'd') + {schema_filter} + GROUP BY n.nspname, t.typname, t.typtype, t.typbasetype, t.typtypmod; + " + ); + client + .query(&query, &[schema_values]) + .context("Failed to load user-defined enum and domain types")? + .into_iter() + .map(|row| { + let type_kind: String = row.try_get("type_kind").context("type kind")?; + let kind = match type_kind.as_str() { + "e" => crate::model::types::TypeKind::Enum { + variants: row.try_get("enum_labels").context("enum labels")?, + }, + "d" => crate::model::types::TypeKind::Domain { + base_type: row + .try_get::<_, Option>("domain_base_type") + .context("domain base type")? + .context("PostgreSQL omitted the base type for a domain")?, + base_type_id: None, + }, + other => anyhow::bail!("unsupported pg_type.typtype '{other}'"), + }; + let id = ObjectId::new( + row.try_get::<_, String>("schema_name") + .context("type schema")?, + row.try_get::<_, String>("type_name").context("type name")?, + ); + Ok(( + id.clone(), + crate::model::types::TypeState { + id, + generation: 0, + kind, + }, + )) + }) + .collect() +} - // Publications are database-level objects. Their catalog is synchronized - // in full even when relation synchronization is schema-scoped. - let publication_query = if cache.pg_version_num.unwrap_or_default() >= 180_000 { +fn load_publications( + client: &mut impl GenericClient, + pg_version_num: u32, +) -> Result> { + let publication_query = if pg_version_num >= 180_000 { r#" SELECT p.oid, p.pubname::text AS publication_name, pg_catalog.pg_get_userbyid(p.pubowner) AS owner_name, @@ -1032,22 +1457,29 @@ fn populate_cache_from_client( ORDER BY p.oid "# }; - let mut publication_names = std::collections::HashMap::::new(); - for row in client.query(publication_query, &[])? { - let oid: u32 = row.get("oid"); - let name: String = row.get("publication_name"); + let rows = client + .query(publication_query, &[]) + .context("Failed to load publications from pg_publication")?; + let mut names_by_oid = std::collections::HashMap::::new(); + let mut publications = std::collections::HashMap::new(); + for row in rows { + let oid: u32 = row.try_get("oid").context("publication OID")?; + let name: String = row + .try_get("publication_name") + .context("publication name")?; let mut operations = Vec::new(); - if row.get::<_, bool>("pubinsert") { - operations.push("insert"); - } - if row.get::<_, bool>("pubupdate") { - operations.push("update"); - } - if row.get::<_, bool>("pubdelete") { - operations.push("delete"); - } - if row.get::<_, bool>("pubtruncate") { - operations.push("truncate"); + for (field, operation) in [ + ("pubinsert", "insert"), + ("pubupdate", "update"), + ("pubdelete", "delete"), + ("pubtruncate", "truncate"), + ] { + if row + .try_get::<_, bool>(field) + .with_context(|| format!("publication '{name}' {field}"))? + { + operations.push(operation); + } } let mut params = vec![ crate::analysis::facts::AttributeFact { @@ -1056,31 +1488,42 @@ fn populate_cache_from_client( }, crate::analysis::facts::AttributeFact { name: "publish_via_partition_root".to_string(), - value: row.get::<_, bool>("pubviaroot").to_string(), + value: row + .try_get::<_, bool>("pubviaroot") + .context("publication partition-root mode")? + .to_string(), }, ]; - if let Some(generated_columns) = row.get::<_, Option>("generated_columns") { + if let Some(generated_columns) = row + .try_get::<_, Option>("generated_columns") + .context("publication generated-column mode")? + { let value = match generated_columns.as_str() { "n" => "none", "s" => "stored", - other => other, + other => anyhow::bail!( + "publication '{name}' has unknown generated-column mode '{other}'" + ), }; params.push(crate::analysis::facts::AttributeFact { name: "publish_generated_columns".to_string(), value: value.to_string(), }); } - let scope = if row.get::<_, bool>("puballtables") { + let scope = if row + .try_get::<_, bool>("puballtables") + .context("publication all-tables mode")? + { crate::analysis::facts::PublicationScope::AllTables { except: Vec::new() } } else { crate::analysis::facts::PublicationScope::Explicit(Vec::new()) }; - publication_names.insert(oid, name.clone()); - cache.publications.insert( + names_by_oid.insert(oid, name.clone()); + publications.insert( name.clone(), crate::model::replication::PublicationState { name, - owner: Some(row.get("owner_name")), + owner: Some(row.try_get("owner_name").context("publication owner")?), scope, params, generation: 0, @@ -1088,7 +1531,7 @@ fn populate_cache_from_client( ); } - let publication_rel_query = if cache.pg_version_num.unwrap_or_default() >= 150_000 { + let relation_query = if pg_version_num >= 150_000 { r#" SELECT pr.prpubid, n.nspname::text AS schema_name, c.relname::text AS relation_name, @@ -1116,57 +1559,66 @@ fn populate_cache_from_client( ORDER BY pr.prpubid, pr.oid "# }; - for row in client.query(publication_rel_query, &[])? { - let publication_oid: u32 = row.get("prpubid"); - let Some(publication_name) = publication_names.get(&publication_oid) else { - anyhow::bail!( - "publication membership references unknown publication OID {publication_oid}" - ); - }; - let Some(publication) = cache.publications.get_mut(publication_name) else { - anyhow::bail!("publication '{publication_name}' disappeared during synchronization"); - }; + for row in client + .query(relation_query, &[]) + .context("Failed to load publication relation membership")? + { + let oid: u32 = row.try_get("prpubid").context("publication relation OID")?; + let name = names_by_oid.get(&oid).with_context(|| { + format!("publication relation membership references unknown publication OID {oid}") + })?; + let publication = publications + .get_mut(name) + .with_context(|| format!("publication '{name}' disappeared during assembly"))?; let crate::analysis::facts::PublicationScope::Explicit(objects) = &mut publication.scope else { continue; }; - let schema_name: String = row.get("schema_name"); - let relation_name: String = row.get("relation_name"); objects.push(crate::analysis::facts::PublicationObjectFact::Table { name: crate::ast::identifiers::QualifiedName::new( - Some(crate::ast::identifiers::Ident::new(schema_name, true)), - crate::ast::identifiers::Ident::new(relation_name, true), + Some(crate::ast::identifiers::Ident::new( + row.try_get::<_, String>("schema_name") + .context("publication relation schema")?, + true, + )), + crate::ast::identifiers::Ident::new( + row.try_get::<_, String>("relation_name") + .context("publication relation name")?, + true, + ), ), only: true, include_partitions: false, - columns: row.get("columns"), + columns: row + .try_get("columns") + .context("publication relation column list")?, row_filter: row - .get::<_, Option>("row_filter") + .try_get::<_, Option>("row_filter") + .context("publication relation row filter")? .map(crate::analysis::facts::PublicationRowFilter::CatalogSql), }); } - if cache.pg_version_num.unwrap_or_default() >= 150_000 { - for row in client.query( - r#" - SELECT pn.pnpubid, n.nspname::text AS schema_name - FROM pg_publication_namespace pn - JOIN pg_namespace n ON n.oid = pn.pnnspid - ORDER BY pn.pnpubid, pn.oid - "#, - &[], - )? { - let publication_oid: u32 = row.get("pnpubid"); - let Some(publication_name) = publication_names.get(&publication_oid) else { - anyhow::bail!( - "publication schema membership references unknown publication OID {publication_oid}" - ); - }; - let Some(publication) = cache.publications.get_mut(publication_name) else { - anyhow::bail!( - "publication '{publication_name}' disappeared during synchronization" - ); - }; + if pg_version_num >= 150_000 { + for row in client + .query( + r#" + SELECT pn.pnpubid, n.nspname::text AS schema_name + FROM pg_publication_namespace pn + JOIN pg_namespace n ON n.oid = pn.pnnspid + ORDER BY pn.pnpubid, pn.oid + "#, + &[], + ) + .context("Failed to load publication schema membership")? + { + let oid: u32 = row.try_get("pnpubid").context("publication schema OID")?; + let name = names_by_oid.get(&oid).with_context(|| { + format!("publication schema membership references unknown publication OID {oid}") + })?; + let publication = publications + .get_mut(name) + .with_context(|| format!("publication '{name}' disappeared during assembly"))?; let crate::analysis::facts::PublicationScope::Explicit(objects) = &mut publication.scope else { @@ -1174,16 +1626,23 @@ fn populate_cache_from_client( }; objects.push( crate::analysis::facts::PublicationObjectFact::SchemaTables { - schema: row.get("schema_name"), + schema: row + .try_get("schema_name") + .context("publication member schema name")?, row_filter: None, }, ); } } + Ok(publications) +} - // Connection strings are intentionally excluded. Later PostgreSQL versions - // add safe subscription settings, so each query exposes one stable shape. - let subscription_query = match cache.pg_version_num.unwrap_or_default() { +fn load_subscriptions( + client: &mut impl GenericClient, + pg_version_num: u32, +) -> Result> { + // Every version-specific query deliberately omits pg_subscription.subconninfo. + let query = match pg_version_num { 170_000.. => { r#" SELECT s.subname::text AS subscription_name, @@ -1261,294 +1720,298 @@ fn populate_cache_from_client( "# } }; - for row in client.query(subscription_query, &[])? { - let name: String = row.get("subscription_name"); - let mut params = vec![ - crate::analysis::facts::AttributeFact { - name: "binary".to_string(), - value: row.get::<_, bool>("subbinary").to_string(), - }, - crate::analysis::facts::AttributeFact { - name: "streaming".to_string(), - value: match row.get::<_, String>("streaming").as_str() { - "t" | "true" => "true".to_string(), - "f" | "false" => "false".to_string(), - "p" => "parallel".to_string(), - other => other.to_string(), + client + .query(query, &[]) + .context("Failed to load non-secret subscription metadata")? + .into_iter() + .map(|row| { + let name: String = row + .try_get("subscription_name") + .context("subscription name")?; + let streaming_code: String = row + .try_get("streaming") + .with_context(|| format!("subscription '{name}' streaming mode"))?; + let streaming = subscription_streaming_from_pg(&streaming_code) + .with_context(|| format!("subscription '{name}' streaming mode"))?; + let mut params = vec![ + crate::analysis::facts::AttributeFact { + name: "binary".to_string(), + value: row + .try_get::<_, bool>("subbinary") + .with_context(|| format!("subscription '{name}' binary mode"))? + .to_string(), }, - }, - crate::analysis::facts::AttributeFact { - name: "synchronous_commit".to_string(), - value: row.get("subsynccommit"), - }, - ]; - let mut push_param = |name: &str, value: Option| { - if let Some(value) = value { - params.push(crate::analysis::facts::AttributeFact { - name: name.to_string(), - value, - }); + crate::analysis::facts::AttributeFact { + name: "streaming".to_string(), + value: streaming.to_string(), + }, + crate::analysis::facts::AttributeFact { + name: "synchronous_commit".to_string(), + value: row + .try_get("subsynccommit") + .with_context(|| format!("subscription '{name}' synchronous_commit"))?, + }, + ]; + let two_phase = row + .try_get::<_, Option>("two_phase_state") + .with_context(|| format!("subscription '{name}' two-phase state"))? + .map(|state| subscription_two_phase_from_pg(&state).map(str::to_string)) + .transpose()?; + let mut push_param = |param_name: &str, value: Option| { + if let Some(value) = value { + params.push(crate::analysis::facts::AttributeFact { + name: param_name.to_string(), + value, + }); + } + }; + push_param("two_phase", two_phase); + for (field, param_name) in [ + ("disable_on_error", "disable_on_error"), + ("password_required", "password_required"), + ("run_as_owner", "run_as_owner"), + ("failover", "failover"), + ] { + push_param( + param_name, + row.try_get::<_, Option>(field) + .with_context(|| format!("subscription '{name}' {field}"))? + .map(|value| value.to_string()), + ); } - }; - push_param( - "two_phase", - row.get::<_, Option>("two_phase_state") - .map(|state| match state.as_str() { - "d" => "false".to_string(), - "e" => "true".to_string(), - "p" => "pending".to_string(), - other => other.to_string(), - }), - ); - push_param( - "disable_on_error", - row.get::<_, Option>("disable_on_error") - .map(|value| value.to_string()), - ); - push_param( - "password_required", - row.get::<_, Option>("password_required") - .map(|value| value.to_string()), - ); - push_param( - "run_as_owner", - row.get::<_, Option>("run_as_owner") - .map(|value| value.to_string()), - ); - push_param( - "failover", - row.get::<_, Option>("failover") - .map(|value| value.to_string()), - ); - push_param("origin", row.get("origin")); - push_param( - "skip_lsn", - row.get::<_, Option>("skip_lsn") - .filter(|lsn| lsn != "0/0"), - ); - cache.subscriptions.insert( - name.clone(), - crate::model::replication::SubscriptionState { - name, - owner: Some(row.get("owner_name")), - connection: crate::analysis::facts::ConnectionTarget::Redacted, - publications: row.get("subpublications"), - params: Some(params), - enabled: row.get("subenabled"), - slot_name: row.get("subslotname"), - generation: 0, - }, - ); - } - - // User-defined types, including ordered enum labels and domains. - let type_query = format!( - " - SELECT - n.nspname AS schema_name, - t.typname AS type_name, - t.typtype::text AS type_kind, - CASE WHEN t.typtype = 'd' - THEN pg_catalog.format_type(t.typbasetype, t.typtypmod) - ELSE NULL - END AS domain_base_type, - COALESCE( - array_agg(e.enumlabel ORDER BY e.enumsortorder) - FILTER (WHERE e.enumlabel IS NOT NULL), - ARRAY[]::text[] - ) AS enum_labels - FROM pg_type t - JOIN pg_namespace n ON n.oid = t.typnamespace - LEFT JOIN pg_enum e ON e.enumtypid = t.oid - WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') - AND t.typtype IN ('e', 'd') - {schema_filter} - GROUP BY n.nspname, t.typname, t.typtype, t.typbasetype, t.typtypmod; - " - ); + push_param( + "origin", + row.try_get("origin") + .with_context(|| format!("subscription '{name}' origin"))?, + ); + push_param( + "skip_lsn", + row.try_get::<_, Option>("skip_lsn") + .with_context(|| format!("subscription '{name}' skip LSN"))? + .filter(|lsn| lsn != "0/0"), + ); + Ok(( + name.clone(), + crate::model::replication::SubscriptionState { + name, + owner: Some(row.try_get("owner_name").context("subscription owner")?), + connection: crate::analysis::facts::ConnectionTarget::Redacted, + publications: row + .try_get("subpublications") + .context("subscription publication names")?, + params: Some(params), + enabled: row + .try_get("subenabled") + .context("subscription enabled state")?, + slot_name: row + .try_get("subslotname") + .context("subscription slot name")?, + generation: 0, + }, + )) + }) + .collect() +} - for row in client.query(&type_query, &[&schema_values])? { - let schema_name: String = row.get("schema_name"); - let type_name: String = row.get("type_name"); - let type_kind: String = row.get("type_kind"); - let domain_base_type: Option = row.get("domain_base_type"); - let enum_labels: Vec = row.get("enum_labels"); - let kind = match type_kind.as_str() { - "e" => crate::model::types::TypeKind::Enum { - variants: enum_labels, - }, - "d" => crate::model::types::TypeKind::Domain { - base_type: domain_base_type.unwrap_or_default(), - base_type_id: None, - }, - _ => continue, - }; - let id = ObjectId::new(&schema_name, &type_name); - cache.types.insert( - id.clone(), - crate::model::types::TypeState { - id, - generation: 0, - kind, - }, - ); - } +fn populate_cache_from_client( + client: &mut impl GenericClient, + schemas: Option<&[String]>, +) -> Result { + let mut cache = DbCache::new(); + let schema_values = schemas.map(|items| items.to_vec()); + let provenance = load_provenance(client, schemas)?; + cache.pg_version_num = Some(provenance.pg_version_num); + cache.metadata = provenance.metadata; + cache.search_path = provenance.search_path; - // Catalog dependencies. - let depend_query = r#" - SELECT - d.classid, d.objid, d.objsubid, - d.refclassid, d.refobjid, d.refobjsubid, - d.deptype::text, - COALESCE(n1.nspname, n1p.nspname, n1t.nspname) AS obj_schema, - COALESCE(c1.relname, p1.proname, t1.typname) AS obj_name, - COALESCE(n2.nspname, n2p.nspname, n2t.nspname) AS ref_schema, - COALESCE(c2.relname, p2.proname, t2.typname) AS ref_name - FROM pg_depend d - LEFT JOIN pg_class c1 ON c1.oid = d.objid AND d.classid = 'pg_class'::regclass - LEFT JOIN pg_namespace n1 ON n1.oid = c1.relnamespace - LEFT JOIN pg_proc p1 ON p1.oid = d.objid AND d.classid = 'pg_proc'::regclass - LEFT JOIN pg_namespace n1p ON n1p.oid = p1.pronamespace - LEFT JOIN pg_type t1 ON t1.oid = d.objid AND d.classid = 'pg_type'::regclass - LEFT JOIN pg_namespace n1t ON n1t.oid = t1.typnamespace - LEFT JOIN pg_class c2 ON c2.oid = d.refobjid AND d.refclassid = 'pg_class'::regclass - LEFT JOIN pg_namespace n2 ON n2.oid = c2.relnamespace - LEFT JOIN pg_proc p2 ON p2.oid = d.refobjid AND d.refclassid = 'pg_proc'::regclass - LEFT JOIN pg_namespace n2p ON n2p.oid = p2.pronamespace - LEFT JOIN pg_type t2 ON t2.oid = d.refobjid AND d.refclassid = 'pg_type'::regclass - LEFT JOIN pg_namespace n2t ON n2t.oid = t2.typnamespace - WHERE d.deptype IN ('n', 'a', 'i') - AND COALESCE(n1.nspname, n1p.nspname, n1t.nspname) IS NOT NULL - AND COALESCE(n1.nspname, n1p.nspname, n1t.nspname) - NOT IN ('pg_catalog', 'information_schema') - AND ( - $1::text[] IS NULL - OR COALESCE(n1.nspname, n1p.nspname, n1t.nspname) = ANY($1) - ) + let schema_filter = "AND ($1::text[] IS NULL OR n.nspname = ANY($1))"; + let schema_filter_with_fk = r#" + AND ( + $1::text[] IS NULL + OR n.nspname = ANY($1) + OR c.oid IN ( + SELECT conrelid FROM pg_constraint cst + JOIN pg_class c2 ON c2.oid = cst.confrelid + JOIN pg_namespace n2 ON n2.oid = c2.relnamespace + WHERE n2.nspname = ANY($1) + ) + OR c.oid IN ( + SELECT confrelid FROM pg_constraint cst + JOIN pg_class c2 ON c2.oid = cst.conrelid + JOIN pg_namespace n2 ON n2.oid = c2.relnamespace + WHERE n2.nspname = ANY($1) + ) + ) + "#; + let schema_filter_n1_or_n2 = + "AND ($1::text[] IS NULL OR n1.nspname = ANY($1) OR n2.nspname = ANY($1))"; + let schema_filter_nt = r#" + AND ( + $1::text[] IS NULL + OR n_t.nspname = ANY($1) + OR t.oid IN ( + SELECT conrelid FROM pg_constraint cst + JOIN pg_class c2 ON c2.oid = cst.confrelid + JOIN pg_namespace n2 ON n2.oid = c2.relnamespace + WHERE n2.nspname = ANY($1) + ) + OR t.oid IN ( + SELECT confrelid FROM pg_constraint cst + JOIN pg_class c2 ON c2.oid = cst.conrelid + JOIN pg_namespace n2 ON n2.oid = c2.relnamespace + WHERE n2.nspname = ANY($1) + ) + ) "#; - for row in client.query(depend_query, &[&schema_values])? { - let classid: u32 = row.get(0); - let objid: u32 = row.get(1); - let objsubid: i32 = row.get(2); - let refclassid: u32 = row.get(3); - let refobjid: u32 = row.get(4); - let refobjsubid: i32 = row.get(5); - let deptype: String = row.get(6); - let obj_schema: Option = row.get(7); - let obj_name: Option = row.get(8); - let ref_schema: Option = row.get(9); - let ref_name: Option = row.get(10); - - cache.dependencies.push(crate::db::cache::DependencyCache { - classid, - objid, - objsubid, - refclassid, - refobjid, - refobjsubid, - deptype, - obj_schema, - obj_name, - ref_schema, - ref_name, - }); - } + // Schemas are an authoritative catalog only for the requested sync scope. + // FK-only external schemas pulled in below deliberately do not enter it. + cache.schemas = load_schemas(client, &schema_values, schema_filter)?; + // A scoped request can name schemas that do not exist yet. PostgreSQL's + // effective search path skips those entries, so do not let them become + // inferred-present namespaces when the cache is hydrated. + cache + .search_path + .retain(|schema| cache.schemas.contains_key(schema)); - // View dependencies are owned by pg_rewrite entries, so the generic pg_depend - // query above cannot recover the dependent view's schema-qualified identity. - let view_depend_query = r#" - SELECT DISTINCT - 'pg_class'::regclass::oid AS classid, - vc.oid AS objid, - 0 AS objsubid, - 'pg_class'::regclass::oid AS refclassid, - tc.oid AS refobjid, - 0 AS refobjsubid, - vn.nspname AS obj_schema, - vc.relname AS obj_name, - tn.nspname AS ref_schema, - tc.relname AS ref_name - FROM pg_rewrite rw - JOIN pg_class vc ON vc.oid = rw.ev_class - JOIN pg_namespace vn ON vn.oid = vc.relnamespace - JOIN pg_depend d ON d.objid = rw.oid - JOIN pg_class tc ON tc.oid = d.refobjid - JOIN pg_namespace tn ON tn.oid = tc.relnamespace - WHERE vc.relkind IN ('v', 'm') - AND d.deptype = 'n' - -- PostgreSQL 14/15 expose an internal rewrite-rule self-edge. It is - -- not a dependency of the view definition and must not enter the - -- modeled dependency graph. - AND tc.oid <> vc.oid - AND ( - $1::text[] IS NULL - OR (vn.nspname = ANY($1) AND tn.nspname = ANY($1)) - ) - "#; + cache.sequences = load_sequences(client, &schema_values)?; - for row in client.query(view_depend_query, &[&schema_values])? { - cache.dependencies.push(crate::db::cache::DependencyCache { - classid: row.get(0), - objid: row.get(1), - objsubid: row.get(2), - refclassid: row.get(3), - refobjid: row.get(4), - refobjsubid: row.get(5), - deptype: "view".to_string(), - obj_schema: Some(row.get(6)), - obj_name: Some(row.get(7)), - ref_schema: Some(row.get(8)), - ref_name: Some(row.get(9)), - }); + cache.relations = + load_relations_and_columns(client, schemas, &schema_values, schema_filter_with_fk)?; + + let (relation_decorations, relation_grants) = + load_relation_decorations(client, &schema_values, schema_filter_with_fk)?; + for decoration in relation_decorations { + let relation = cache + .relations + .get_mut(&decoration.relation_id) + .with_context(|| { + format!( + "relation decoration references omitted relation '{}'", + decoration.relation_id + ) + })?; + relation.triggers.extend(decoration.triggers); + relation.policies.extend(decoration.policies); } + for grant in relation_grants { + let relation = cache + .relations + .get_mut(&grant.relation_id) + .with_context(|| { + format!( + "relation privilege references omitted relation '{}'", + grant.relation_id + ) + })?; + relation + .privileges + .grant(grant.grantee, [grant.privilege].into_iter().collect()); + } + + cache.triggers = load_triggers(client, &schema_values, schema_filter_with_fk)?; + + cache.constraints = load_constraints(client, &schema_values, schema_filter_with_fk)?; + + cache.foreign_keys = + load_foreign_keys(client, schemas, &schema_values, schema_filter_n1_or_n2)?; + + cache.indexes = load_indexes(client, &schema_values, schema_filter_nt)?; + + cache.functions = load_routines(client, &schema_values, schema_filter)?; + + cache.publications = load_publications(client, cache.pg_version_num.unwrap_or_default())?; + + cache.subscriptions = load_subscriptions(client, cache.pg_version_num.unwrap_or_default())?; + + cache.types = load_types(client, &schema_values, schema_filter)?; + + // 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 V6. + cache.dependencies = load_view_dependencies(client, &schema_values)?; // Role identity and membership are required to distinguish a valid // `SET ROLE` from a migration that PostgreSQL would reject. pg_roles does // not expose password hashes or other credentials. - for row in client.query( - "SELECT rolname, rolcanlogin, rolsuper FROM pg_roles ORDER BY rolname;", - &[], - )? { - let name: String = row.get(0); - let id = ObjectId::new("", &name); - cache.roles.insert( - id.clone(), - crate::model::role::RoleState { - id, - can_login: row.get(1), - is_superuser: row.get(2), - member_of: Vec::new(), - can_set_role_to: Vec::new(), - granted_privileges: Vec::new(), - }, + cache.roles = load_roles(client, cache.pg_version_num.unwrap_or_default())?; + + cache + .validate_semantics() + .map_err(anyhow::Error::msg) + .context("PostgreSQL catalogs produced a semantically invalid Cache V6 baseline")?; + Ok(cache) +} + +#[cfg(test)] +mod catalog_conversion_tests { + use super::*; + + #[test] + fn known_catalog_codes_convert_without_fallbacks() { + assert!(matches!( + sequence_kind_from_pg(Some("i"), false).unwrap(), + crate::model::sequence::SequenceKind::Identity + )); + assert!(matches!( + sequence_kind_from_pg(Some("a"), true).unwrap(), + crate::model::sequence::SequenceKind::SerialLike + )); + assert!(matches!( + relation_kind_from_pg(b'm').unwrap(), + RelationKind::MaterializedView + )); + assert!(matches!( + persistence_from_pg(b'u').unwrap(), + Persistence::Unlogged + )); + assert_eq!( + partition_strategy_from_pg(Some("h")).unwrap(), + Some("HASH".to_string()) ); + assert!(matches!( + routine_volatility_from_pg("i").unwrap(), + crate::model::function::Volatility::Immutable + )); + assert!(matches!( + routine_kind_from_pg("a").unwrap(), + crate::model::function::RoutineKind::Aggregate + )); + assert_eq!(subscription_streaming_from_pg("p").unwrap(), "parallel"); + assert_eq!(subscription_two_phase_from_pg("e").unwrap(), "true"); } - let membership_query = if cache.pg_version_num.unwrap_or_default() >= 160_000 { - "SELECT member.rolname, parent.rolname, 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;" - } else { - "SELECT member.rolname, parent.rolname, 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;" - }; - for row in client.query(membership_query, &[])? { - let member = ObjectId::new("", row.get::<_, String>(0)); - let parent = ObjectId::new("", row.get::<_, String>(1)); - let set_option: bool = row.get(2); - if let Some(role) = cache.roles.get_mut(&member) { - role.member_of.push(parent.clone()); - if set_option { - role.can_set_role_to.push(parent); - } + #[test] + fn unknown_catalog_codes_are_actionable_errors() { + for error in [ + sequence_kind_from_pg(Some("x"), false).unwrap_err(), + relation_kind_from_pg(b'x').unwrap_err(), + persistence_from_pg(b'x').unwrap_err(), + partition_strategy_from_pg(Some("x")).unwrap_err(), + routine_volatility_from_pg("x").unwrap_err(), + routine_kind_from_pg("x").unwrap_err(), + subscription_streaming_from_pg("x").unwrap_err(), + subscription_two_phase_from_pg("x").unwrap_err(), + ] { + assert!(!error.to_string().is_empty()); } } - Ok(cache) + #[test] + fn connection_timeout_default_preserves_an_explicit_value() { + let mut defaulted = PostgresConfig::new(); + apply_connection_safety_defaults(&mut defaulted); + assert_eq!( + defaulted.get_connect_timeout(), + Some(&DEFAULT_CONNECT_TIMEOUT) + ); + + let explicit = Duration::from_secs(3); + let mut configured = PostgresConfig::new(); + configured.connect_timeout(explicit); + apply_connection_safety_defaults(&mut configured); + assert_eq!(configured.get_connect_timeout(), Some(&explicit)); + } } #[cfg(test)] @@ -1558,18 +2021,8 @@ mod atomic_write_tests { use std::fs; use std::io::Read; - #[test] - fn production_cache_writer_atomically_replaces_and_decodes() { - let temp_dir = tempfile::tempdir().unwrap(); - let cache_path = temp_dir.path().join("baseline.cache"); - fs::write(&cache_path, b"old-cache").unwrap(); - - let mut cache = DbCache::new(); - cache.pg_version_num = Some(180002); - write_cache(&cache_path, cache, false).unwrap(); - - let encoded = fs::read(&cache_path).unwrap(); - assert_ne!(encoded, b"old-cache"); + fn decode_written_cache(path: &Path) -> DbCache { + let encoded = fs::read(path).unwrap(); let reader = std::io::Cursor::new(encoded); let mut decoder = zstd::stream::Decoder::new(reader).unwrap(); let mut payload = Vec::new(); @@ -1581,7 +2034,53 @@ mod atomic_write_tests { let versioned: DbCacheVersioned = bincode::serde::decode_from_slice(payload, config) .unwrap() .0; - assert_eq!(versioned.into_cache().unwrap().pg_version_num, Some(180002)); + versioned.into_cache().unwrap() + } + + #[test] + fn production_cache_writer_atomically_replaces_and_decodes() { + let temp_dir = tempfile::tempdir().unwrap(); + let cache_path = temp_dir.path().join("baseline.cache"); + fs::write(&cache_path, b"old-cache").unwrap(); + + let mut cache = DbCache::new(); + cache.pg_version_num = Some(180002); + write_cache(&cache_path, cache, false).unwrap(); + + assert_eq!( + decode_written_cache(&cache_path).pg_version_num, + Some(180002) + ); + assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1); + } + + #[test] + fn concurrent_cache_writers_leave_one_complete_decodable_payload() { + let temp_dir = tempfile::tempdir().unwrap(); + let cache_path = temp_dir.path().join("baseline.cache"); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(3)); + let mut writers = Vec::new(); + for version in [170_007, 180_002] { + let cache_path = cache_path.clone(); + let barrier = barrier.clone(); + writers.push(std::thread::spawn(move || { + let mut cache = DbCache::new(); + cache.pg_version_num = Some(version); + barrier.wait(); + write_cache(&cache_path, cache, false) + })); + } + barrier.wait(); + let results = writers + .into_iter() + .map(|writer| writer.join().unwrap()) + .collect::>(); + + assert!(results.iter().any(Result::is_ok)); + assert!(matches!( + decode_written_cache(&cache_path).pg_version_num, + Some(170_007 | 180_002) + )); assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1); } diff --git a/src/sync_tests.rs b/src/sync_tests.rs index 7a9b484..92f16af 100644 --- a/src/sync_tests.rs +++ b/src/sync_tests.rs @@ -92,6 +92,21 @@ mod tests { assert!(!database_config_is_local(&remote)); } + #[test] + fn remote_database_rejection_does_not_echo_credentials() { + let tmp = NamedTempFile::new().unwrap(); + let secret = "phase5-password-must-not-leak"; + let database_url = + format!("postgres://migration_user:{secret}@db.internal.example/safe_migrate"); + let _database_url = EnvironmentValueGuard::set("DATABASE_URL", &database_url); + + let error = sync_cache(tmp.path(), None, false).unwrap_err(); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains("Remote DATABASE_URL connections are not supported")); + assert!(!diagnostic.contains(secret)); + assert!(!diagnostic.contains(&database_url)); + } + #[test] fn test_sync_requires_postgresql_14_or_newer() { let error = ensure_supported_postgres_version(130_012).unwrap_err(); diff --git a/tests/architectural_gaps.rs b/tests/architectural_gaps.rs index 2177a72..11e6987 100644 --- a/tests/architectural_gaps.rs +++ b/tests/architectural_gaps.rs @@ -12,36 +12,29 @@ mod architectural_gap_tests { #[test] fn test_fk_parent_table_lock_escalation() { let engine = setup_engine(); - let mut cache = safe_migrate::db::cache::DbCache::new(); - - // Parent is huge (causes Tier 1 lock if evaluated correctly) - cache.insert_baseline( - object_id("public", "parent_tbl"), - safe_migrate::model::relation::RelationState::new( - object_id("public", "parent_tbl"), - ObjectId::new("public", "postgres"), - 0, - Some(500_000), - RelationKind::Table, - Persistence::Permanent, - 0, - ), - ); - // Child is tiny - cache.insert_baseline( - object_id("public", "child_tbl"), - safe_migrate::model::relation::RelationState::new( - object_id("public", "child_tbl"), - ObjectId::new("public", "postgres"), - 0, - Some(10), - RelationKind::Table, - Persistence::Permanent, - 0, - ), - ); - - let mut state = safe_migrate::analysis::state::AnalysisState::new(cache); + let mut state = setup_state(); + // Use valid PostgreSQL topology so the FK mutation is applied and the + // lock rule can classify the larger parent table. + engine + .analyze( + "CREATE TABLE parent_tbl(id int PRIMARY KEY); CREATE TABLE child_tbl(p_id int);", + &mut state, + ) + .unwrap(); + if let Some(RelationOverlay::Present(parent)) = state + .local + .relations + .get_mut(&object_id("public", "parent_tbl")) + { + parent.estimated_rows = Some(500_000); + } + if let Some(RelationOverlay::Present(child)) = state + .local + .relations + .get_mut(&object_id("public", "child_tbl")) + { + child.estimated_rows = Some(10); + } let violations = engine.analyze("ALTER TABLE child_tbl ADD CONSTRAINT fk FOREIGN KEY (p_id) REFERENCES parent_tbl(id);", &mut state).unwrap(); let is_tier_1 = violations @@ -89,7 +82,7 @@ mod architectural_gap_tests { .analyze( " BEGIN; - CREATE TABLE a(id int); + CREATE TABLE a(id int PRIMARY KEY); SAVEPOINT s; CREATE TABLE b(id int); ROLLBACK TO s; @@ -120,7 +113,7 @@ mod architectural_gap_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -137,7 +130,7 @@ mod architectural_gap_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -233,13 +226,13 @@ mod architectural_gap_tests { ) .unwrap(); - assert!(state.local.graph.edges.iter().any(|e| matches!( + assert!(state.local.graph.edges().iter().any(|e| matches!( e.kind, safe_migrate::analysis::graph::DependencyKind::ViewDependency { .. } ) && e.dependent == object_id("public", "v") && e.referenced == object_id("public", "base_table"))); - assert!(!state.local.graph.edges.iter().any(|e| matches!( + assert!(!state.local.graph.edges().iter().any(|e| matches!( e.kind, safe_migrate::analysis::graph::DependencyKind::ViewDependency { .. } ) && e.dependent @@ -258,7 +251,7 @@ mod architectural_gap_tests { .analyze("CREATE VIEW v AS SELECT * FROM app_sessions;", &mut state) .unwrap(); - assert!(state.local.graph.edges.iter().any(|e| matches!( + assert!(state.local.graph.edges().iter().any(|e| matches!( e.kind, safe_migrate::analysis::graph::DependencyKind::ViewDependency { .. } ) && e.dependent @@ -271,14 +264,17 @@ mod architectural_gap_tests { let engine = setup_engine(); let mut state = setup_state(); engine - .analyze("CREATE TABLE sessions(id int);", &mut state) + .analyze( + "CREATE SCHEMA app; CREATE TABLE app.sessions(id int);", + &mut state, + ) .unwrap(); engine .analyze("CREATE VIEW v AS SELECT * FROM app.sessions;", &mut state) .unwrap(); assert!( - state.local.graph.edges.iter().any(|e| matches!( + state.local.graph.edges().iter().any(|e| matches!( e.kind, safe_migrate::analysis::graph::DependencyKind::ViewDependency { .. } ) && e.dependent == object_id("public", "v") @@ -287,7 +283,7 @@ mod architectural_gap_tests { ); assert!( - !state.local.graph.edges.iter().any(|e| matches!( + !state.local.graph.edges().iter().any(|e| matches!( e.kind, safe_migrate::analysis::graph::DependencyKind::ViewDependency { .. } ) && e.dependent == object_id("public", "v") @@ -307,7 +303,7 @@ mod architectural_gap_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -337,7 +333,7 @@ mod architectural_gap_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -416,7 +412,7 @@ mod architectural_gap_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -471,7 +467,7 @@ mod architectural_gap_tests { engine .analyze( " - CREATE TABLE a(id int); + CREATE TABLE a(id int PRIMARY KEY); CREATE VIEW v AS SELECT * FROM a; ALTER TABLE a RENAME TO b; DROP TABLE b CASCADE; @@ -628,7 +624,7 @@ mod architectural_gap_tests { engine .analyze( " - CREATE TABLE a(id int); + CREATE TABLE a(id int PRIMARY KEY); CREATE TABLE b(a_id int); ALTER TABLE b ADD CONSTRAINT fk FOREIGN KEY (a_id) REFERENCES a(id); ALTER TABLE a RENAME TO a2; @@ -688,7 +684,7 @@ mod architectural_gap_tests { engine .analyze( " - CREATE TABLE a(id int); + CREATE TABLE a(id int PRIMARY KEY); CREATE TABLE b(a_id int); ALTER TABLE b ADD CONSTRAINT fk FOREIGN KEY (a_id) REFERENCES a(id); ", diff --git a/tests/bug_fixes.rs b/tests/bug_fixes.rs index c37e2a2..df13d96 100644 --- a/tests/bug_fixes.rs +++ b/tests/bug_fixes.rs @@ -535,7 +535,7 @@ mod phase10_bug_fixes_and_sorting_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -548,7 +548,7 @@ mod phase10_bug_fixes_and_sorting_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -568,7 +568,7 @@ mod phase10_bug_fixes_and_sorting_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -581,7 +581,7 @@ mod phase10_bug_fixes_and_sorting_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -940,7 +940,7 @@ mod phase10_bug_fixes_and_sorting_tests { // Bug 17 — SetType/SetDefault on nonexistent column // ───────────────────────────────────────────── #[test] - fn test_bug017_set_type_on_nonexistent_column_taints_confidence() { + fn test_bug017_set_type_on_nonexistent_column_is_a_conflict() { let engine = setup_engine(); let mut state = setup_state(); @@ -948,22 +948,21 @@ mod phase10_bug_fixes_and_sorting_tests { .analyze("CREATE TABLE t(id int);", &mut state) .unwrap(); - let _v = engine + let violations = engine .analyze( "ALTER TABLE t ALTER COLUMN nonexistent_col SET DATA TYPE text;", &mut state, ) .unwrap(); - assert_eq!( - state.local.confidence, - safe_migrate::analysis::state::Confidence::Tainted, - "Confidence should be Tainted when SET TYPE on a nonexistent column" - ); + assert!(violations.iter().any(|violation| { + violation.rule_id == "chain-conflict" && violation.reason.contains("nonexistent_col") + })); + assert_eq!(state.local.confidence, Confidence::Exact); } #[test] - fn test_bug017_set_default_on_nonexistent_column_taints_confidence() { + fn test_bug017_set_default_on_nonexistent_column_is_a_conflict() { let engine = setup_engine(); let mut state = setup_state(); @@ -971,18 +970,17 @@ mod phase10_bug_fixes_and_sorting_tests { .analyze("CREATE TABLE t(id int);", &mut state) .unwrap(); - let _v = engine + let violations = engine .analyze( "ALTER TABLE t ALTER COLUMN nonexistent_col SET DEFAULT 42;", &mut state, ) .unwrap(); - assert_eq!( - state.local.confidence, - safe_migrate::analysis::state::Confidence::Tainted, - "Confidence should be Tainted when SET DEFAULT on a nonexistent column" - ); + assert!(violations.iter().any(|violation| { + violation.rule_id == "chain-conflict" && violation.reason.contains("nonexistent_col") + })); + assert_eq!(state.local.confidence, Confidence::Exact); } #[test] @@ -1050,6 +1048,7 @@ mod phase10_bug_fixes_and_sorting_tests { assert!(matrix.has_privilege(&role, Privilege::Truncate)); assert!(matrix.has_privilege(&role, Privilege::References)); assert!(matrix.has_privilege(&role, Privilege::Trigger)); + assert!(matrix.has_privilege(&role, Privilege::Maintain)); // 3. has_privilege for All itself should also work assert!(matrix.has_privilege(&role, Privilege::All)); @@ -1061,6 +1060,37 @@ mod phase10_bug_fixes_and_sorting_tests { assert!(!matrix.has_privilege(&role, Privilege::Select)); assert!(!matrix.has_privilege(&role, Privilege::All)); } + + #[test] + fn postgres17_all_grant_tracks_maintain_without_leaking_to_older_versions() { + use safe_migrate::model::relation::{Privilege, RelationOverlay}; + + let engine = setup_engine(); + for (version, expected) in [(170_000, true), (160_000, false)] { + let mut cache = cache_with_table("public", "t_large", None); + cache.pg_version_num = Some(version); + let mut state = AnalysisState::new(cache); + engine + .analyze("GRANT ALL ON TABLE t_large TO app_user;", &mut state) + .expect("GRANT ALL should analyze"); + + let Some(RelationOverlay::Present(relation)) = + state.local.relations.get(&object_id("public", "t_large")) + else { + panic!("baseline relation should remain present"); + }; + let grantee = object_id("", "app_user"); + assert_eq!( + relation + .privileges + .grants + .get(&grantee) + .is_some_and(|privileges| privileges.contains(&Privilege::Maintain)), + expected, + "PG {version} GRANT ALL MAINTAIN expansion mismatch" + ); + } + } // ───────────────────────────────────────────── // Bug 14 — Directive parsing tolerates whitespace // ───────────────────────────────────────────── @@ -1273,8 +1303,7 @@ mod phase10_bug_fixes_and_sorting_tests { } #[test] - fn test_bug018_drop_schema_no_cascade_ok_when_empty() { - // 1a inverse: DROP SCHEMA without CASCADE must NOT conflict when schema is empty. + fn drop_schema_without_cascade_taints_when_emptiness_is_not_complete_evidence() { let engine = setup_engine(); let mut state = setup_state(); @@ -1284,12 +1313,12 @@ mod phase10_bug_fixes_and_sorting_tests { let violations = engine.analyze("DROP SCHEMA myschema;", &mut state).unwrap(); - let conflict = violations.iter().find(|v| v.rule_id == "chain-conflict"); assert!( - conflict.is_none(), - "Expected no chain-conflict for DROP SCHEMA on empty schema, got: {:?}", - violations + !violations.iter().any(|v| v.rule_id == "chain-conflict"), + "an apparently empty schema is uncertain rather than a conflict: {violations:?}" ); + assert!(state.local.schemas.contains_key("myschema")); + assert_eq!(state.local.confidence, Confidence::Tainted); } #[test] diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 74ad19b..11742fe 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -2,9 +2,14 @@ use std::fs; use std::io::Write; use std::time::{SystemTime, UNIX_EPOCH}; +use chacha20poly1305::{ + XChaCha20Poly1305, XNonce, + aead::{Aead, KeyInit}, +}; use safe_migrate::ast::identifiers::ObjectId; use safe_migrate::db::cache::{CACHE_V6_MAGIC, DbCache, DbCacheVersioned}; use safe_migrate::model::relation::{Persistence, RelationKind, RelationState}; +use safe_migrate::model::schema::SchemaState; fn parse_json_stdout(output: &std::process::Output) -> serde_json::Value { serde_json::from_slice(&output.stdout).expect("stdout must contain exactly one JSON document") @@ -258,6 +263,96 @@ fn test_cli_lint_invalid_cache() { cmd.assert().failure(); } +#[test] +fn test_cli_rejects_semantically_contradictory_v6_cache() { + let mut invalid = DbCache::new(); + invalid.schemas.insert( + "app".to_string(), + SchemaState { + name: "other".to_string(), + owner: ObjectId::new("", "postgres"), + generation: 0, + }, + ); + let config = bincode::config::standard().with_variable_int_encoding(); + let encoded = + bincode::serde::encode_to_vec(DbCacheVersioned::V6(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_V6_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 assert = cmd + .arg("cache") + .arg("inspect") + .arg("--cache") + .arg(cache.path()) + .assert() + .failure(); + let stderr = String::from_utf8_lossy(&assert.get_output().stderr); + assert!( + stderr.contains("schema cache key 'app' disagrees with embedded identity 'other'"), + "unexpected stderr: {stderr}" + ); +} + +#[test] +fn test_cli_rejects_authenticated_semantically_contradictory_v6_cache() { + let mut invalid = DbCache::new(); + invalid.schemas.insert( + "app".to_string(), + SchemaState { + name: "other".to_string(), + owner: ObjectId::new("", "postgres"), + generation: 0, + }, + ); + let config = bincode::config::standard().with_variable_int_encoding(); + let encoded = + bincode::serde::encode_to_vec(DbCacheVersioned::V6(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_V6_MAGIC).unwrap(); + encoder.write_all(&encoded).unwrap(); + encoder.finish().unwrap(); + + let key = [0x2au8; 32]; + let nonce_bytes = [0x17u8; 24]; + let cipher = XChaCha20Poly1305::new_from_slice(&key).unwrap(); + let nonce = XNonce::try_from(nonce_bytes.as_slice()).unwrap(); + let ciphertext = cipher.encrypt(&nonce, compressed.as_ref()).unwrap(); + let mut envelope = b"SMENC001".to_vec(); + envelope.extend_from_slice(&nonce_bytes); + envelope.extend_from_slice(&ciphertext); + + let cache = tempfile::NamedTempFile::new().unwrap(); + fs::write(cache.path(), envelope).unwrap(); + 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 assert = cmd + .env("SAFE_MIGRATE_CACHE_KEY", "2a".repeat(32)) + .arg("cache") + .arg("inspect") + .arg("--cache") + .arg(cache.path()) + .arg("--config") + .arg(config_file.path()) + .assert() + .failure(); + let stderr = String::from_utf8_lossy(&assert.get_output().stderr); + assert!( + stderr.contains("schema cache key 'app' disagrees with embedded identity 'other'"), + "unexpected stderr: {stderr}" + ); + assert!(!stderr.contains(&"2a".repeat(32))); +} + #[test] fn test_cli_rejects_cache_with_oversized_decoded_container() { let config = bincode::config::standard().with_variable_int_encoding(); @@ -293,6 +388,37 @@ fn test_cli_rejects_cache_with_oversized_decoded_container() { ); } +#[test] +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::V6(Box::default()), config).unwrap(); + + let mut compressed = Vec::new(); + let mut encoder = zstd::stream::Encoder::new(&mut compressed, 3).unwrap(); + encoder.write_all(CACHE_V6_MAGIC).unwrap(); + encoder.write_all(&encoded).unwrap(); + encoder.write_all(b"trailing-data").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 assert = cmd + .arg("cache") + .arg("inspect") + .arg("--cache") + .arg(cache.path()) + .assert() + .failure(); + let stderr = String::from_utf8_lossy(&assert.get_output().stderr); + assert!( + stderr.contains("trailing payload data"), + "unexpected stderr: {stderr}" + ); +} + #[test] fn test_cache_inspect_rejects_unsupported_legacy_cache_without_exposing_its_version() { let mut compressed = Vec::new(); diff --git a/tests/common/invariants.rs b/tests/common/invariants.rs new file mode 100644 index 0000000..334e9ea --- /dev/null +++ b/tests/common/invariants.rs @@ -0,0 +1,271 @@ +use safe_migrate::analysis::graph::DependencyKind; +use safe_migrate::analysis::state::AnalysisState; +use safe_migrate::analysis::transaction::TransactionFrameKind; +use safe_migrate::db::cache::DbCache; +use safe_migrate::model::function::FunctionOverlay; +use safe_migrate::model::relation::RelationOverlay; +use safe_migrate::model::replication::{PublicationOverlay, SubscriptionOverlay}; +use safe_migrate::model::role::RoleOverlay; +use safe_migrate::model::schema::SchemaOverlay; +use safe_migrate::model::sequence::SequenceOverlay; +use safe_migrate::model::trigger::TriggerOverlay; +use safe_migrate::model::types::TypeOverlay; +use std::collections::HashSet; + +pub fn assert_cache_invariants(cache: &DbCache) { + for (id, relation) in &cache.relations { + assert_eq!( + id, &relation.id, + "cached relation map key disagrees with state" + ); + } + for (id, ty) in &cache.types { + assert_eq!(id, &ty.id, "cached type map key disagrees with state"); + } + for (id, function) in &cache.functions { + assert_eq!( + id, &function.id, + "cached function map key disagrees with state" + ); + } + for (id, sequence) in &cache.sequences { + assert_eq!( + id, &sequence.id, + "cached sequence map key disagrees with state" + ); + if let Some((table_id, _)) = &sequence.owned_by { + assert!( + cache.relations.contains_key(table_id), + "cached owned sequence must reference a cached relation" + ); + } + } + for (id, role) in &cache.roles { + assert_eq!(id, &role.id, "cached role map key disagrees with state"); + } + for (name, schema) in &cache.schemas { + assert_eq!( + name, &schema.name, + "cached schema map key disagrees with state" + ); + } + for (name, publication) in &cache.publications { + assert_eq!( + name, &publication.name, + "cached publication map key disagrees with state" + ); + } + for (name, subscription) in &cache.subscriptions { + assert_eq!( + name, &subscription.name, + "cached subscription map key disagrees with state" + ); + } + + let mut constraint_keys = HashSet::new(); + for constraint in &cache.constraints { + assert!( + cache.relations.contains_key(&constraint.table_id), + "cached constraint must reference a cached relation" + ); + assert!( + constraint_keys.insert((constraint.table_id.clone(), constraint.name.clone())), + "cached constraints must have unique table/name identities" + ); + } + for index in &cache.indexes { + assert!( + cache.relations.contains_key(&index.table_id), + "cached index must reference a cached relation" + ); + } + for trigger in &cache.triggers { + assert!( + cache.relations.contains_key(&trigger.table_id), + "cached trigger must reference a cached relation" + ); + } + for foreign_key in &cache.foreign_keys { + assert!( + cache.relations.contains_key(&foreign_key.from_table) + && cache.relations.contains_key(&foreign_key.to_table), + "cached foreign key must reference cached relations" + ); + } +} + +pub fn assert_state_invariants(state: &AnalysisState) { + let local = &state.local; + assert!( + local.graph.indexes_are_valid(), + "dependency-graph indexes disagree with canonical edges" + ); + + for (name, schema) in &local.schemas { + if let SchemaOverlay::Present(schema) = schema { + assert_eq!(name, &schema.name, "schema map key disagrees with state"); + } + } + for (id, relation) in &local.relations { + if let RelationOverlay::Present(relation) = relation { + assert_eq!(id, &relation.id, "relation map key disagrees with state"); + } + } + for (id, ty) in &local.types { + if let TypeOverlay::Present(ty) = ty { + assert_eq!(id, &ty.id, "type map key disagrees with state"); + } + } + for (id, function) in &local.functions { + if let FunctionOverlay::Present(function) = function { + assert_eq!(id, &function.id, "function map key disagrees with state"); + } + } + for (id, sequence) in &local.sequences { + if let SequenceOverlay::Present(sequence) = sequence { + assert_eq!(id, &sequence.id, "sequence map key disagrees with state"); + } + } + for (id, role) in &local.roles { + if let RoleOverlay::Present(role) = role { + assert_eq!(id, &role.id, "role map key disagrees with state"); + } + } + for (name, publication) in &local.publications { + if let PublicationOverlay::Present(publication) = publication { + assert_eq!( + name, &publication.name, + "publication map key disagrees with state" + ); + } + } + for (name, subscription) in &local.subscriptions { + if let SubscriptionOverlay::Present(subscription) = subscription { + assert_eq!( + name, &subscription.name, + "subscription map key disagrees with state" + ); + } + } + for (id, trigger) in &local.triggers { + if let TriggerOverlay::Present(trigger) = trigger { + assert_eq!(id, &trigger.id, "trigger map key disagrees with state"); + assert!( + !matches!( + local.relations.get(&trigger.table_id), + Some(RelationOverlay::Dropped) + ), + "present trigger belongs to a dropped relation" + ); + } + } + for ((table_id, name), constraint) in &local.constraints { + assert_eq!( + table_id, &constraint.table_id, + "constraint table key disagrees with state" + ); + assert_eq!( + name, &constraint.name, + "constraint name key disagrees with state" + ); + assert!( + !matches!( + local.relations.get(table_id), + Some(RelationOverlay::Dropped) + ), + "constraint belongs to a dropped relation" + ); + } + for key in &local.pending_validation { + let constraint = local + .constraints + .get(key) + .expect("pending validation must reference a known constraint"); + assert!( + !constraint.validated, + "validated constraint cannot be pending" + ); + } + + for edge in local.graph.edges() { + match &edge.kind { + DependencyKind::ForeignKey { + constraint_name: Some(name), + .. + } => assert!( + local + .constraints + .contains_key(&(edge.dependent.clone(), name.clone())), + "foreign-key edge must have a matching constraint" + ), + DependencyKind::ViewDependency { .. } => { + let dependent_is_modeled_view = matches!( + local.relations.get(&edge.dependent), + Some(RelationOverlay::Present(relation)) + if matches!(relation.kind, safe_migrate::model::relation::RelationKind::View | safe_migrate::model::relation::RelationKind::MaterializedView) + ); + let dependent_schema_is_omitted = state + .baseline_schemas + .as_ref() + .is_some_and(|schemas| !schemas.contains(&edge.dependent.schema)); + assert!( + dependent_is_modeled_view || dependent_schema_is_omitted, + "view dependency must have a modeled view or an explicitly omitted dependent schema" + ); + assert!(!matches!( + local.relations.get(&edge.referenced), + Some(RelationOverlay::Dropped) + )); + } + DependencyKind::SequenceOwnedBy { column } => assert!(matches!( + local.sequences.get(&edge.dependent), + Some(SequenceOverlay::Present(sequence)) + if sequence.owned_by == Some((edge.referenced.clone(), column.clone())) + )), + DependencyKind::TriggerOnTable { trigger_id, .. } => assert!(matches!( + local.triggers.get(trigger_id), + Some(TriggerOverlay::Present(trigger)) if trigger.table_id == edge.referenced + )), + DependencyKind::PublicationIncludes { publication_name } => assert!(matches!( + local.publications.get(publication_name), + Some(PublicationOverlay::Present(publication)) if publication.name == *publication_name + )), + DependencyKind::ConstraintOnRelation { + constraint_name, .. + } => assert!( + local + .constraints + .contains_key(&(edge.dependent.clone(), constraint_name.clone())), + "constraint key edge must have a matching constraint" + ), + DependencyKind::IndexOnRelation { .. } + | DependencyKind::RenameTo + | DependencyKind::PartitionOf + | DependencyKind::ColumnGeneratedFrom { .. } + | DependencyKind::ForeignKey { + constraint_name: None, + .. + } => {} + } + } + + if local.transactions.is_empty() { + assert!( + !local.transaction_aborted, + "an aborted transaction must retain its root frame" + ); + } else { + assert!(matches!( + local.transactions.first().map(|frame| &frame.kind), + Some(TransactionFrameKind::Root) + )); + assert!( + local + .transactions + .iter() + .skip(1) + .all(|frame| matches!(frame.kind, TransactionFrameKind::Savepoint { .. })), + "only the first transaction frame may be the root" + ); + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index de0ea0b..5a6eab8 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,5 +1,7 @@ #![allow(dead_code)] +pub mod invariants; + use safe_migrate::ast::identifiers::ObjectId; use safe_migrate::db::cache::DbCache; use safe_migrate::engine::config::Config; diff --git a/tests/golden/representative-report.json b/tests/golden/representative-report.json new file mode 100644 index 0000000..99ec565 --- /dev/null +++ b/tests/golden/representative-report.json @@ -0,0 +1,33 @@ +{ + "schema_version": 1, + "confidence": "Exact", + "verdict": "CAUTIOUS", + "summary": { + "total": 1, + "tier1": 0, + "tier2": 1, + "tier3": 0 + }, + "violations": [ + { + "rule_id": "test-rule", + "operation_kind": { + "Other": "test" + }, + "object_kind": "Unknown", + "object_name": "test.object", + "tier": "Tier2", + "reason": "needs review", + "recipe": "test recipe", + "dedup_key": null, + "sql": null, + "fk_dependency_related": false, + "location": { + "file": "migrations/001.sql", + "line": 3, + "column": 5 + }, + "statement_index": 1 + } + ] +} diff --git a/tests/golden/representative-report.md b/tests/golden/representative-report.md new file mode 100644 index 0000000..346ea99 --- /dev/null +++ b/tests/golden/representative-report.md @@ -0,0 +1,20 @@ +# safe-migrate report + +**Verdict:** CAUTIOUS +**Confidence:** Exact + +| Severity | Findings | +| --- | ---: | +| HALT (Tier 1) | 0 | +| WARN (Tier 2) | 1 | +| SAFE (Tier 3) | 0 | + +## Findings + +### WARN — test-rule (`test-rule`) + +**Location:** `migrations/001.sql:3:5` +**Statement:** 1 +**Object:** object test.object +**Reason:** needs review +**Recommendation:** test recipe diff --git a/tests/identifier_casing.rs b/tests/identifier_casing.rs index f5fbb2f..add9829 100644 --- a/tests/identifier_casing.rs +++ b/tests/identifier_casing.rs @@ -61,7 +61,10 @@ mod identifier_casing_tests { let mut state = setup_state(); engine - .analyze("CREATE TABLE MySchema.MyTable (id int);", &mut state) + .analyze( + "CREATE SCHEMA MySchema; CREATE TABLE MySchema.MyTable (id int);", + &mut state, + ) .unwrap(); assert!(state.relation_is_present(&object_id("myschema", "mytable"))); diff --git a/tests/invariant_sequences.rs b/tests/invariant_sequences.rs index 62d98c4..59fdcd9 100644 --- a/tests/invariant_sequences.rs +++ b/tests/invariant_sequences.rs @@ -1,262 +1,11 @@ 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}; - use safe_migrate::analysis::graph::DependencyKind; use safe_migrate::analysis::state::AnalysisState; - use safe_migrate::analysis::transaction::TransactionFrameKind; - use safe_migrate::db::cache::DbCache; - use safe_migrate::model::function::FunctionOverlay; use safe_migrate::model::relation::RelationOverlay; - use safe_migrate::model::replication::{PublicationOverlay, SubscriptionOverlay}; - use safe_migrate::model::role::RoleOverlay; use safe_migrate::model::schema::SchemaOverlay; - use safe_migrate::model::sequence::SequenceOverlay; - use safe_migrate::model::trigger::TriggerOverlay; - use safe_migrate::model::types::TypeOverlay; - use std::collections::HashSet; - - fn assert_cache_invariants(cache: &DbCache) { - for (id, relation) in &cache.relations { - assert_eq!( - id, &relation.id, - "cached relation map key disagrees with state" - ); - } - for (id, ty) in &cache.types { - assert_eq!(id, &ty.id, "cached type map key disagrees with state"); - } - for (id, function) in &cache.functions { - assert_eq!( - id, &function.id, - "cached function map key disagrees with state" - ); - } - for (id, sequence) in &cache.sequences { - assert_eq!( - id, &sequence.id, - "cached sequence map key disagrees with state" - ); - if let Some((table_id, _)) = &sequence.owned_by { - assert!( - cache.relations.contains_key(table_id), - "cached owned sequence must reference a cached relation" - ); - } - } - for (id, role) in &cache.roles { - assert_eq!(id, &role.id, "cached role map key disagrees with state"); - } - for (name, schema) in &cache.schemas { - assert_eq!( - name, &schema.name, - "cached schema map key disagrees with state" - ); - } - for (name, publication) in &cache.publications { - assert_eq!( - name, &publication.name, - "cached publication map key disagrees with state" - ); - } - for (name, subscription) in &cache.subscriptions { - assert_eq!( - name, &subscription.name, - "cached subscription map key disagrees with state" - ); - } - - let mut constraint_keys = HashSet::new(); - for constraint in &cache.constraints { - assert!( - cache.relations.contains_key(&constraint.table_id), - "cached constraint must reference a cached relation" - ); - assert!( - constraint_keys.insert((constraint.table_id.clone(), constraint.name.clone())), - "cached constraints must have unique table/name identities" - ); - } - for index in &cache.indexes { - assert!( - cache.relations.contains_key(&index.table_id), - "cached index must reference a cached relation" - ); - } - for trigger in &cache.triggers { - assert!( - cache.relations.contains_key(&trigger.table_id), - "cached trigger must reference a cached relation" - ); - } - for foreign_key in &cache.foreign_keys { - assert!( - cache.relations.contains_key(&foreign_key.from_table) - && cache.relations.contains_key(&foreign_key.to_table), - "cached foreign key must reference cached relations" - ); - } - } - - fn assert_state_invariants(state: &AnalysisState) { - let local = &state.local; - - for (name, schema) in &local.schemas { - if let SchemaOverlay::Present(schema) = schema { - assert_eq!(name, &schema.name, "schema map key disagrees with state"); - } - } - for (id, relation) in &local.relations { - if let RelationOverlay::Present(relation) = relation { - assert_eq!(id, &relation.id, "relation map key disagrees with state"); - } - } - for (id, ty) in &local.types { - if let TypeOverlay::Present(ty) = ty { - assert_eq!(id, &ty.id, "type map key disagrees with state"); - } - } - for (id, function) in &local.functions { - if let FunctionOverlay::Present(function) = function { - assert_eq!(id, &function.id, "function map key disagrees with state"); - } - } - for (id, sequence) in &local.sequences { - if let SequenceOverlay::Present(sequence) = sequence { - assert_eq!(id, &sequence.id, "sequence map key disagrees with state"); - } - } - for (id, role) in &local.roles { - if let RoleOverlay::Present(role) = role { - assert_eq!(id, &role.id, "role map key disagrees with state"); - } - } - for (name, publication) in &local.publications { - if let PublicationOverlay::Present(publication) = publication { - assert_eq!( - name, &publication.name, - "publication map key disagrees with state" - ); - } - } - for (name, subscription) in &local.subscriptions { - if let SubscriptionOverlay::Present(subscription) = subscription { - assert_eq!( - name, &subscription.name, - "subscription map key disagrees with state" - ); - } - } - for (id, trigger) in &local.triggers { - if let TriggerOverlay::Present(trigger) = trigger { - assert_eq!(id, &trigger.id, "trigger map key disagrees with state"); - assert!( - !matches!( - local.relations.get(&trigger.table_id), - Some(RelationOverlay::Dropped) - ), - "present trigger belongs to a dropped relation" - ); - } - } - for ((table_id, name), constraint) in &local.constraints { - assert_eq!( - table_id, &constraint.table_id, - "constraint table key disagrees with state" - ); - assert_eq!( - name, &constraint.name, - "constraint name key disagrees with state" - ); - assert!( - !matches!( - local.relations.get(table_id), - Some(RelationOverlay::Dropped) - ), - "constraint belongs to a dropped relation" - ); - } - for key in &local.pending_validation { - let constraint = local - .constraints - .get(key) - .expect("pending validation must reference a known constraint"); - assert!( - !constraint.validated, - "validated constraint cannot be pending" - ); - } - - for edge in &local.graph.edges { - match &edge.kind { - DependencyKind::ForeignKey { - constraint_name: Some(name), - .. - } => assert!( - local - .constraints - .contains_key(&(edge.dependent.clone(), name.clone())), - "foreign-key edge must have a matching constraint" - ), - DependencyKind::ViewDependency { .. } => { - assert!(matches!( - local.relations.get(&edge.dependent), - Some(RelationOverlay::Present(relation)) - if matches!( - relation.kind, - safe_migrate::model::relation::RelationKind::View - | safe_migrate::model::relation::RelationKind::MaterializedView - ) - )); - assert!(!matches!( - local.relations.get(&edge.referenced), - Some(RelationOverlay::Dropped) - )); - } - DependencyKind::SequenceOwnedBy { column } => assert!(matches!( - local.sequences.get(&edge.dependent), - Some(SequenceOverlay::Present(sequence)) - if sequence.owned_by == Some((edge.referenced.clone(), column.clone())) - )), - DependencyKind::TriggerOnTable { trigger_id, .. } => assert!(matches!( - local.triggers.get(trigger_id), - Some(TriggerOverlay::Present(trigger)) if trigger.table_id == edge.referenced - )), - DependencyKind::PublicationIncludes { publication_name } => assert!(matches!( - local.publications.get(publication_name), - Some(PublicationOverlay::Present(publication)) if publication.name == *publication_name - )), - DependencyKind::IndexOnRelation { .. } - | DependencyKind::RenameTo - | DependencyKind::PartitionOf - | DependencyKind::ColumnGeneratedFrom { .. } - | DependencyKind::ForeignKey { - constraint_name: None, - .. - } => {} - } - } - - if local.transactions.is_empty() { - assert!( - !local.transaction_aborted, - "an aborted transaction must retain its root frame" - ); - } else { - assert!(matches!( - local.transactions.first().map(|frame| &frame.kind), - Some(TransactionFrameKind::Root) - )); - assert!( - local - .transactions - .iter() - .skip(1) - .all(|frame| matches!(frame.kind, TransactionFrameKind::Savepoint { .. })), - "only the first transaction frame may be the root" - ); - } - } fn analyze_and_validate(state: &mut AnalysisState, sql: &str) { let findings = setup_engine() @@ -322,4 +71,217 @@ mod invariant_sequences { Some(SchemaOverlay::Present(schema)) if schema.name == "app" )); } + + #[test] + fn deterministic_generated_sequences_restore_every_modeled_family() { + let mut state = setup_state(); + + for sequence in 0..16 { + let schema = format!("generated_{sequence}"); + for sql in [ + "BEGIN;".to_string(), + format!("CREATE SCHEMA {schema};"), + format!("CREATE TABLE {schema}.items (id bigint PRIMARY KEY, value text);"), + format!("CREATE INDEX items_value_idx ON {schema}.items(value);"), + format!("CREATE VIEW {schema}.item_ids AS SELECT id FROM {schema}.items;"), + format!("CREATE TYPE {schema}.item_state AS ENUM ('new', 'ready');"), + format!("CREATE SEQUENCE {schema}.item_counter;"), + format!( + "CREATE FUNCTION {schema}.item_identity(value integer) RETURNS integer LANGUAGE SQL IMMUTABLE AS $$ SELECT value $$;" + ), + format!( + "CREATE FUNCTION {schema}.item_rank() RETURNS bigint AS 'window_row_number' LANGUAGE internal WINDOW;" + ), + format!( + "CREATE PROCEDURE {schema}.refresh_items() LANGUAGE SQL AS $$ SELECT 1 $$;" + ), + format!( + "CREATE AGGREGATE {schema}.sum_items(integer) (SFUNC = int4pl, STYPE = integer, INITCOND = '0');" + ), + "SAVEPOINT generated_checkpoint;".to_string(), + format!("ALTER TABLE {schema}.items RENAME TO renamed_items;"), + format!("DROP VIEW {schema}.item_ids;"), + "ROLLBACK TO SAVEPOINT generated_checkpoint;".to_string(), + "ROLLBACK;".to_string(), + ] { + analyze_and_validate(&mut state, &sql); + if sql.starts_with("SAVEPOINT") { + assert!(state.local.schemas.contains_key(&schema)); + assert!(state.local.relations.keys().any(|id| id.schema == schema)); + assert!(state.local.types.keys().any(|id| id.schema == schema)); + assert!(state.local.sequences.keys().any(|id| id.schema == schema)); + assert!(state.local.functions.keys().any(|id| id.schema == schema)); + } + } + + assert!(state.local.transactions.is_empty()); + assert!(!state.local.transaction_aborted); + assert!(!state.local.schemas.contains_key(&schema)); + assert!(state.local.relations.keys().all(|id| id.schema != schema)); + assert!(state.local.types.keys().all(|id| id.schema != schema)); + assert!(state.local.sequences.keys().all(|id| id.schema != schema)); + assert!(state.local.functions.keys().all(|id| id.schema != schema)); + assert!(state.local.graph.edges().iter().all(|edge| { + edge.dependent.schema != schema && edge.referenced.schema != schema + })); + assert_state_invariants(&state); + } + } + + #[test] + fn guarded_absent_operations_are_idempotent_and_rejections_only_abort() { + let mut state = AnalysisState::new(cache_with_table("public", "kept", Some(10))); + let initial_generation = state.local.generation_counter; + + for _ in 0..8 { + let findings = setup_engine() + .analyze( + "DROP TABLE IF EXISTS absent_table; DROP TYPE IF EXISTS absent_type;", + &mut state, + ) + .expect("guarded absent operations should analyze"); + assert!( + !findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "guarded absence must not conflict: {findings:?}" + ); + assert!(state.relation_is_present(&object_id("public", "kept"))); + assert_state_invariants(&state); + } + assert_eq!(state.local.generation_counter, initial_generation); + + let findings = setup_engine() + .analyze( + "BEGIN; DROP TABLE absent_table; DROP TABLE kept;", + &mut state, + ) + .expect("rejected transaction sequence should analyze"); + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "missing unguarded object must conflict: {findings:?}" + ); + assert!(state.local.transaction_aborted); + assert!(state.relation_is_present(&object_id("public", "kept"))); + assert_state_invariants(&state); + + analyze_and_validate(&mut state, "ROLLBACK;"); + assert!(!state.local.transaction_aborted); + assert!(state.relation_is_present(&object_id("public", "kept"))); + } + + #[test] + fn inverse_rename_preserves_view_dependencies() { + let mut state = setup_state(); + for sql in [ + "CREATE TABLE rename_source (id bigint);", + "CREATE VIEW rename_view AS SELECT id FROM rename_source;", + ] { + analyze_and_validate(&mut state, sql); + } + + let source = object_id("public", "rename_source"); + let view = object_id("public", "rename_view"); + assert!( + state + .local + .graph + .edges() + .iter() + .any(|edge| { edge.dependent == view && edge.referenced == source }) + ); + + analyze_and_validate( + &mut state, + "ALTER TABLE rename_source RENAME TO renamed_source;", + ); + analyze_and_validate( + &mut state, + "ALTER TABLE renamed_source RENAME TO rename_source;", + ); + + assert!(state.relation_is_present(&source)); + assert!(!state.relation_is_present(&object_id("public", "renamed_source"))); + assert!( + state + .local + .graph + .edges() + .iter() + .any(|edge| { edge.dependent == view && edge.referenced == source }) + ); + assert_state_invariants(&state); + } + + #[test] + fn structured_cross_family_rollback_is_exact_and_reports_are_repeatable() { + let statements = [ + "BEGIN;", + "CREATE SCHEMA phase5;", + "SET LOCAL search_path TO phase5, public;", + "SET LOCAL lock_timeout = '750ms';", + "SET LOCAL statement_timeout = '3s';", + "CREATE ROLE phase5_owner;", + "SET LOCAL SESSION AUTHORIZATION phase5_owner;", + "CREATE TABLE phase5.parent (id integer) PARTITION BY RANGE (id);", + "CREATE TABLE phase5.child (id integer);", + "ALTER TABLE phase5.parent ATTACH PARTITION phase5.child FOR VALUES FROM (0) TO (10);", + "ALTER TABLE phase5.parent DETACH PARTITION phase5.child;", + "CREATE FUNCTION phase5.identity(value integer) RETURNS integer LANGUAGE SQL IMMUTABLE AS $$ SELECT value $$;", + "CREATE FUNCTION phase5.identity(value text) RETURNS text LANGUAGE SQL IMMUTABLE AS $$ SELECT value $$;", + "CREATE PUBLICATION phase5_changes FOR TABLE phase5.parent;", + "CREATE SUBSCRIPTION phase5_sub CONNECTION 'host=publisher.invalid' PUBLICATION phase5_changes WITH (connect=false);", + "SAVEPOINT phase5_checkpoint;", + "ALTER PUBLICATION phase5_changes RENAME TO phase5_renamed_changes;", + "ALTER SUBSCRIPTION phase5_sub RENAME TO phase5_renamed_sub;", + "ROLLBACK TO SAVEPOINT phase5_checkpoint;", + "ROLLBACK;", + ]; + + let run = || { + let mut state = setup_state(); + let mut reports = Vec::new(); + for sql in statements { + let findings = setup_engine() + .analyze(sql, &mut state) + .expect("structure-aware statement should analyze"); + assert_state_invariants(&state); + reports.push( + serde_json::to_string(&safe_migrate::Reporter::json_report( + &findings, + &state.local.confidence, + )) + .expect("report should serialize"), + ); + } + + assert!(state.local.transactions.is_empty()); + assert!(!state.local.transaction_aborted); + assert_eq!(state.local.search_path, ["public"]); + assert!(!state.local.schemas.contains_key("phase5")); + assert!(state.local.relations.keys().all(|id| id.schema != "phase5")); + assert!(state.local.functions.keys().all(|id| id.schema != "phase5")); + assert!(!state.local.publications.contains_key("phase5_changes")); + assert!(!state.local.subscriptions.contains_key("phase5_sub")); + assert!( + !state + .local + .roles + .contains_key(&object_id("", "phase5_owner")) + ); + assert!(state.local.graph.edges().iter().all(|edge| { + edge.dependent.schema != "phase5" && edge.referenced.schema != "phase5" + })); + assert_state_invariants(&state); + reports + }; + + assert_eq!( + run(), + run(), + "repeated analysis must produce identical reports" + ); + } } diff --git a/tests/live_differential_harness.rs b/tests/live_differential_harness.rs index db88de2..51ebcac 100644 --- a/tests/live_differential_harness.rs +++ b/tests/live_differential_harness.rs @@ -1308,6 +1308,11 @@ fn snapshot_live_state( populate_cache(client, Some(schemas))? }; let mut state = NormalizedState::default(); + // `format_type` is search_path-sensitive for user-defined types. Resolve + // each cached column to its catalog identity before projecting it so a + // 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); if scope.contains(&ComparisonScope::Schemas) { for (name, schema) in &cache.schemas { @@ -1405,20 +1410,33 @@ fn snapshot_live_state( } if scope.contains(&ComparisonScope::Relations) || scope.contains(&ComparisonScope::Columns) { - for (id, relation) in cache.relations { + for (id, relation) in &cache.relations { let mut normalized = NormalizedRelation { - kind: normalize_relation_kind(relation.kind), - owner: relation.owner.name, - partition_strategy: relation.partition_type, + kind: normalize_relation_kind(relation.kind.clone()), + owner: relation.owner.name.clone(), + partition_strategy: relation.partition_type.clone(), columns: BTreeMap::new(), }; if scope.contains(&ComparisonScope::Columns) { - for column in relation.columns { + let resolved_relation = resolved_cache_state.local.relations.get(id); + for column in &relation.columns { + let type_id = resolved_relation.and_then(|overlay| match overlay { + RelationOverlay::Present(resolved) => resolved + .columns + .iter() + .find(|resolved_column| resolved_column.name == column.name) + .and_then(|resolved_column| resolved_column.type_id.as_ref()), + RelationOverlay::Dropped => None, + }); normalized.columns.insert( - column.name, + column.name.clone(), NormalizedColumn { - data_type: normalize_data_type( - &column.data_type.unwrap_or_else(|| "".to_string()), + data_type: normalize_data_type_with_identity( + &column + .data_type + .clone() + .unwrap_or_else(|| "".to_string()), + type_id, ), is_nullable: column.is_nullable, has_default: column.default.is_some() @@ -1620,7 +1638,7 @@ fn snapshot_simulator_state(state: &AnalysisState, scope: &[ComparisonScope]) -> } if scope.contains(&ComparisonScope::Triggers) { - for edge in &state.local.graph.edges { + for edge in state.local.graph.edges() { let DependencyKind::TriggerOnTable { trigger_id, function_id, @@ -1661,11 +1679,12 @@ fn snapshot_simulator_state(state: &AnalysisState, scope: &[ComparisonScope]) -> normalized.columns.insert( column.name.clone(), NormalizedColumn { - data_type: normalize_data_type( + data_type: normalize_data_type_with_identity( &column .data_type .clone() .unwrap_or_else(|| "".to_string()), + column.type_id.as_ref(), ), is_nullable: column.is_nullable, has_default: column.default.is_some() @@ -1680,7 +1699,7 @@ fn snapshot_simulator_state(state: &AnalysisState, scope: &[ComparisonScope]) -> } } - for edge in &state.local.graph.edges { + for edge in state.local.graph.edges() { match &edge.kind { DependencyKind::IndexOnRelation { .. } if scope.contains(&ComparisonScope::Indexes) => { projection.indexes.insert(NormalizedIndex { @@ -2255,6 +2274,7 @@ fn normalize_privilege(privilege: Privilege) -> String { Privilege::References => "references", Privilege::Trigger => "trigger", Privilege::All => "all", + Privilege::Maintain => "maintain", } .to_string() } @@ -2292,6 +2312,33 @@ fn normalize_data_type(data_type: &str) -> String { } } +fn normalize_data_type_with_identity( + data_type: &str, + type_id: Option<&safe_migrate::ast::identifiers::ObjectId>, +) -> String { + let normalized = normalize_data_type(data_type); + let Some(type_id) = type_id else { + return normalized; + }; + + // Keep type modifiers and array dimensions from the display string while + // replacing the search_path-dependent base name with its stable identity. + let suffix = normalized + .find(|character| ['(', '['].contains(&character)) + .map(|index| &normalized[index..]) + .unwrap_or(""); + format!("{}.{}{}", type_id.schema, type_id.name, suffix) +} + +#[test] +fn normalized_type_identity_preserves_modifiers_and_arrays() { + let type_id = safe_migrate::ast::identifiers::ObjectId::new("public", "amount"); + assert_eq!( + normalize_data_type_with_identity("numeric(10,2)[]", Some(&type_id)), + "public.amount(10,2)[]" + ); +} + fn qualified_name(schema: &str, name: &str) -> String { format!("{schema}.{name}") } diff --git a/tests/performance_scenarios.rs b/tests/performance_scenarios.rs index fc1569a..9ac6550 100644 --- a/tests/performance_scenarios.rs +++ b/tests/performance_scenarios.rs @@ -1,11 +1,57 @@ mod common; +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +struct CountingAllocator; + +static ALLOCATION_COUNT: AtomicUsize = AtomicUsize::new(0); +static ALLOCATED_BYTES: AtomicUsize = AtomicUsize::new(0); + +unsafe impl GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let pointer = unsafe { System.alloc(layout) }; + if !pointer.is_null() { + ALLOCATION_COUNT.fetch_add(1, Ordering::Relaxed); + ALLOCATED_BYTES.fetch_add(layout.size(), Ordering::Relaxed); + } + pointer + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + let pointer = unsafe { System.alloc_zeroed(layout) }; + if !pointer.is_null() { + ALLOCATION_COUNT.fetch_add(1, Ordering::Relaxed); + ALLOCATED_BYTES.fetch_add(layout.size(), Ordering::Relaxed); + } + pointer + } + + unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) { + unsafe { System.dealloc(pointer, layout) }; + } + + unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + let pointer = unsafe { System.realloc(pointer, layout, new_size) }; + if !pointer.is_null() { + ALLOCATION_COUNT.fetch_add(1, Ordering::Relaxed); + ALLOCATED_BYTES.fetch_add(new_size, Ordering::Relaxed); + } + pointer + } +} + +#[global_allocator] +static GLOBAL_ALLOCATOR: CountingAllocator = CountingAllocator; + mod performance_scenarios { + use super::{ALLOCATED_BYTES, ALLOCATION_COUNT}; use crate::common::{object_id, setup_engine, setup_state}; use safe_migrate::db::cache::{DbCache, DbCacheVersioned}; use safe_migrate::db::cache_file::{CACHE_KEY_ENV, protect_cache_bytes, unprotect_cache_bytes}; use safe_migrate::model::relation::{Persistence, RelationKind, RelationState}; use std::io::Cursor; + use std::sync::atomic::Ordering; use std::time::Instant; const LARGE_BASELINE_RELATIONS: usize = 1_000; @@ -43,6 +89,94 @@ mod performance_scenarios { cache } + fn allocation_snapshot() -> (usize, usize) { + ( + ALLOCATION_COUNT.load(Ordering::Relaxed), + ALLOCATED_BYTES.load(Ordering::Relaxed), + ) + } + + fn allocation_delta(before: (usize, usize)) -> (usize, usize) { + let after = allocation_snapshot(); + (after.0 - before.0, after.1 - before.1) + } + + #[test] + #[ignore = "manual allocation scenario; run alone with --ignored --nocapture"] + fn large_state_checkpoint_and_prestate_capture() { + let state = safe_migrate::AnalysisState::with_baseline(large_baseline(), true); + + let started = Instant::now(); + let before = allocation_snapshot(); + let checkpoint = std::hint::black_box(state.clone()); + let checkpoint_allocations = allocation_delta(before); + let checkpoint_elapsed = started.elapsed(); + assert_eq!(checkpoint.local.relations.len(), LARGE_BASELINE_RELATIONS); + + let started = Instant::now(); + let before = allocation_snapshot(); + let pre_state = std::hint::black_box(state.capture_pre_state()); + let pre_state_allocations = allocation_delta(before); + let pre_state_elapsed = started.elapsed(); + assert_eq!(pre_state.relations.len(), LARGE_BASELINE_RELATIONS); + + eprintln!( + "scenario=large_state_checkpoint relations={LARGE_BASELINE_RELATIONS} allocations={} allocated_bytes={} elapsed_us={}", + checkpoint_allocations.0, + checkpoint_allocations.1, + checkpoint_elapsed.as_micros() + ); + eprintln!( + "scenario=large_prestate_capture relations={LARGE_BASELINE_RELATIONS} allocations={} allocated_bytes={} elapsed_us={}", + pre_state_allocations.0, + pre_state_allocations.1, + pre_state_elapsed.as_micros() + ); + } + + #[test] + #[ignore = "manual allocation scenario; run alone with --ignored --nocapture"] + fn large_baseline_short_chain_allocations() { + let engine = setup_engine(); + let mut state = safe_migrate::AnalysisState::with_baseline(large_baseline(), true); + let files = (0..50) + .map(|index| { + ( + format!("V{index:04}__alter.sql"), + format!("ALTER TABLE perf_baseline_{index} ADD COLUMN measured_value integer;"), + ) + }) + .collect::>(); + + let started = Instant::now(); + let before = allocation_snapshot(); + let findings = engine + .analyze_chain(&files, &mut state) + .expect("large-baseline allocation chain should analyze"); + let allocations = allocation_delta(before); + let elapsed = started.elapsed(); + + assert!( + findings + .iter() + .all(|finding| finding.rule_id != "chain-conflict"), + "unexpected state conflict: {findings:?}" + ); + let relation = state + .get_relation(&object_id("public", "perf_baseline_49")) + .expect("last baseline relation should remain present"); + let safe_migrate::model::relation::RelationOverlay::Present(relation) = relation else { + panic!("last baseline relation was dropped"); + }; + assert!(relation.has_column("measured_value")); + eprintln!( + "scenario=large_baseline_short_chain statements=50 relations={LARGE_BASELINE_RELATIONS} allocations={} allocated_bytes={} elapsed_ms={}", + allocations.0, + allocations.1, + elapsed.as_millis() + ); + } + #[test] #[ignore = "manual performance scenario; run with --ignored --nocapture"] fn ordered_thousand_statement_chain() { @@ -169,6 +303,63 @@ mod performance_scenarios { ); } + #[test] + #[ignore = "manual graph-index scenario; run alone with --ignored --nocapture"] + fn large_dependency_graph_lookup_index() { + use safe_migrate::analysis::graph::{DependencyEdge, DependencyGraph, DependencyKind}; + + const EDGES: usize = 10_000; + const TARGETS: usize = 100; + const ROUNDS: usize = 10; + + let mut graph = DependencyGraph::new(); + for index in 0..EDGES { + graph.add_edge(DependencyEdge::new( + object_id("public", &format!("perf_view_{index}")), + object_id("public", &format!("perf_target_{}", index % TARGETS)), + DependencyKind::ViewDependency { view_generation: 1 }, + )); + } + let targets = (0..TARGETS) + .map(|index| object_id("public", &format!("perf_target_{index}"))) + .collect::>(); + + let indexed_started = Instant::now(); + let mut indexed_count = 0; + for _ in 0..ROUNDS { + for target in &targets { + indexed_count += graph.cascade_edges(target).len(); + } + } + let indexed_elapsed = indexed_started.elapsed(); + + let scan_started = Instant::now(); + let mut scan_count = 0; + for _ in 0..ROUNDS { + for target in &targets { + scan_count += graph + .edges() + .iter() + .filter(|edge| { + matches!(edge.kind, DependencyKind::ViewDependency { .. }) + && edge.referenced == *target + }) + .count(); + } + } + let scan_elapsed = scan_started.elapsed(); + + assert_eq!(indexed_count, EDGES * ROUNDS); + assert_eq!(scan_count, indexed_count); + assert!(graph.indexes_are_valid()); + eprintln!( + "scenario=large_dependency_graph_lookup_index edges={EDGES} lookups={} indexed_us={} canonical_scan_us={}", + TARGETS * ROUNDS, + indexed_elapsed.as_micros(), + scan_elapsed.as_micros() + ); + } + #[test] #[ignore = "manual performance scenario; run with --ignored --nocapture"] fn location_rich_reports_with_many_findings() { diff --git a/tests/resolver_namespaces.rs b/tests/resolver_namespaces.rs new file mode 100644 index 0000000..0cd25b2 --- /dev/null +++ b/tests/resolver_namespaces.rs @@ -0,0 +1,247 @@ +mod common; + +#[cfg(test)] +mod resolver_namespace_tests { + use super::common::{object_id, setup_engine, setup_state}; + use safe_migrate::model::function::FunctionOverlay; + use safe_migrate::model::relation::RelationOverlay; + + fn assert_no_conflict(findings: &[safe_migrate::report::violations::Violation]) { + assert!( + findings + .iter() + .all(|finding| finding.rule_id != "chain-conflict"), + "unexpected resolver conflict: {findings:?}" + ); + } + + #[test] + fn dropped_relation_tombstone_does_not_shadow_a_later_present_relation() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE SCHEMA lookup_a; + CREATE SCHEMA lookup_b; + CREATE TABLE lookup_a.shared_name (id integer); + CREATE TABLE lookup_b.shared_name (id integer); + DROP TABLE lookup_a.shared_name; + SET search_path TO lookup_a, lookup_b;", + &mut state, + ) + .unwrap(); + + let findings = engine + .analyze( + "ALTER TABLE shared_name ADD COLUMN resolved integer;", + &mut state, + ) + .unwrap(); + + assert_no_conflict(&findings); + let Some(RelationOverlay::Present(relation)) = + state.get_relation(&object_id("lookup_b", "shared_name")) + else { + panic!("lookup_b.shared_name should remain present"); + }; + assert!(relation.has_column("resolved")); + } + + #[test] + fn a_type_does_not_shadow_a_relation_in_a_later_schema() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE SCHEMA lookup_a; + CREATE SCHEMA lookup_b; + CREATE TYPE lookup_a.shared_name AS ENUM ('lookup_a'); + CREATE TABLE lookup_b.shared_name (id integer); + SET search_path TO lookup_a, lookup_b;", + &mut state, + ) + .unwrap(); + + let findings = engine + .analyze( + "ALTER TABLE shared_name ADD COLUMN resolved integer;", + &mut state, + ) + .unwrap(); + + assert_no_conflict(&findings); + let Some(RelationOverlay::Present(relation)) = + state.get_relation(&object_id("lookup_b", "shared_name")) + else { + panic!("lookup_b.shared_name should remain present"); + }; + assert!(relation.has_column("resolved")); + } + + #[test] + fn a_relation_does_not_shadow_a_type_in_a_later_schema() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE SCHEMA lookup_a; + CREATE SCHEMA lookup_b; + CREATE TABLE lookup_a.shared_name (id integer); + CREATE TYPE lookup_b.shared_name AS ENUM ('old'); + SET search_path TO lookup_a, lookup_b;", + &mut state, + ) + .unwrap(); + + let findings = engine + .analyze( + "ALTER TYPE shared_name RENAME VALUE 'old' TO 'new';", + &mut state, + ) + .unwrap(); + + assert_no_conflict(&findings); + } + + #[test] + fn an_unrelated_overload_does_not_shadow_an_exact_routine_signature() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE SCHEMA lookup_a; + CREATE SCHEMA lookup_b; + CREATE FUNCTION lookup_a.work(value text) RETURNS integer + LANGUAGE sql IMMUTABLE AS 'SELECT 1'; + CREATE FUNCTION lookup_b.work(value integer) RETURNS integer + LANGUAGE sql VOLATILE AS 'SELECT value'; + SET search_path TO lookup_a, lookup_b;", + &mut state, + ) + .unwrap(); + + let findings = engine + .analyze("ALTER FUNCTION work(integer) IMMUTABLE;", &mut state) + .unwrap(); + + assert_no_conflict(&findings); + assert!(matches!( + state + .local + .functions + .get(&object_id("lookup_b", "work(integer)")), + Some(FunctionOverlay::Present(_)) + )); + } + + #[test] + fn dropped_routine_tombstone_does_not_shadow_a_later_exact_signature() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE SCHEMA lookup_a; + CREATE SCHEMA lookup_b; + CREATE FUNCTION lookup_a.work(value integer) RETURNS integer + LANGUAGE sql VOLATILE AS 'SELECT value'; + CREATE FUNCTION lookup_b.work(value integer) RETURNS integer + LANGUAGE sql VOLATILE AS 'SELECT value'; + DROP FUNCTION lookup_a.work(integer); + SET search_path TO lookup_a, lookup_b;", + &mut state, + ) + .unwrap(); + + let findings = engine + .analyze("ALTER FUNCTION work(integer) IMMUTABLE;", &mut state) + .unwrap(); + + assert_no_conflict(&findings); + } + + #[test] + fn sequence_in_an_earlier_schema_shadows_a_later_table() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE SCHEMA lookup_a; + CREATE SCHEMA lookup_b; + CREATE SEQUENCE lookup_a.shared_name; + CREATE TABLE lookup_b.shared_name (id integer); + SET search_path TO lookup_a, lookup_b;", + &mut state, + ) + .unwrap(); + + let findings = engine + .analyze( + "ALTER TABLE shared_name ADD COLUMN wrong integer;", + &mut state, + ) + .unwrap(); + + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "the shared relation namespace must select lookup_a.shared_name: {findings:?}" + ); + } + + #[test] + fn quoted_relation_lookup_preserves_case_and_search_path_order() { + let engine = setup_engine(); + let mut state = setup_state(); + let findings = engine + .analyze( + "CREATE SCHEMA lookup_a; + CREATE SCHEMA lookup_b; + CREATE TABLE lookup_a.\"SharedName\" (id integer); + CREATE TABLE lookup_b.sharedname (id integer); + SET search_path TO lookup_a, lookup_b; + ALTER TABLE \"SharedName\" ADD COLUMN resolved integer;", + &mut state, + ) + .unwrap(); + + assert_no_conflict(&findings); + let Some(RelationOverlay::Present(relation)) = + state.get_relation(&object_id("lookup_a", "SharedName")) + else { + panic!("quoted lookup_a.SharedName should remain present"); + }; + assert!(relation.has_column("resolved")); + } + + #[test] + fn postgresql_identifier_truncation_creates_a_real_namespace_collision() { + let engine = setup_engine(); + let mut state = setup_state(); + let findings = engine + .analyze( + "CREATE TABLE aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaax (id integer); + CREATE TABLE aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaay (id integer);", + &mut state, + ) + .unwrap(); + + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "names equal after PostgreSQL's 63-byte truncation must conflict: {findings:?}" + ); + assert!(state.relation_is_present(&object_id( + "public", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ))); + assert!( + !state + .local + .relations + .keys() + .any(|id| id.name.ends_with('x') || id.name.ends_with('y')) + ); + } +} diff --git a/tests/rule_evaluation.rs b/tests/rule_evaluation.rs index c80e219..99612c3 100644 --- a/tests/rule_evaluation.rs +++ b/tests/rule_evaluation.rs @@ -504,6 +504,27 @@ mod rule_evaluation_tests { assert!(v.iter().any(|v| v.rule_id == "overbroad-grant")); } + #[test] + fn grant_option_warns_even_when_state_matrix_skips_unmodeled_grant_chain() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze("CREATE TABLE grant_option_target(id int);", &mut state) + .unwrap(); + + let findings = engine + .analyze( + "GRANT SELECT ON grant_option_target TO app_user WITH GRANT OPTION;", + &mut state, + ) + .unwrap(); + + assert!(findings.iter().any(|finding| { + finding.rule_id == "overbroad-grant" && finding.reason.contains("WITH GRANT OPTION") + })); + } + #[test] fn grant_all_owner_exemption_requires_every_grantee_to_own_every_table() { let engine = setup_engine(); diff --git a/tests/state_machine_guards.rs b/tests/state_machine_guards.rs index d0db02e..37e7f29 100644 --- a/tests/state_machine_guards.rs +++ b/tests/state_machine_guards.rs @@ -138,12 +138,25 @@ mod state_machine_guards_tests { } #[test] - fn unknown_view_in_multi_drop_does_not_preserve_known_targets() { + fn unknown_scoped_target_in_multi_drop_preserves_known_targets() { let engine = setup_engine(); let mut cache = safe_migrate::db::cache::DbCache::new(); cache.metadata.schemas = Some(vec!["app".to_string()]); + let table_id = object_id("app", "known_table"); let view_id = object_id("app", "known_view"); let materialized_view_id = object_id("app", "known_materialized_view"); + cache.insert_baseline( + table_id.clone(), + RelationState::new( + table_id.clone(), + object_id("", "postgres"), + 0, + None, + RelationKind::Table, + Persistence::Permanent, + 0, + ), + ); cache.insert_baseline( view_id.clone(), RelationState::new( @@ -170,6 +183,12 @@ mod state_machine_guards_tests { ); let mut state = AnalysisState::new(cache); + engine + .analyze( + "DROP TABLE IF EXISTS app.known_table, tenant.unknown_table;", + &mut state, + ) + .unwrap(); engine .analyze("DROP VIEW app.known_view, tenant.unknown_view;", &mut state) .unwrap(); @@ -180,14 +199,99 @@ mod state_machine_guards_tests { ) .unwrap(); - assert!(!state.relation_is_present(&view_id)); - assert!(!state.relation_is_present(&materialized_view_id)); + assert!(state.relation_is_present(&table_id)); + assert!(state.relation_is_present(&view_id)); + assert!(state.relation_is_present(&materialized_view_id)); assert_eq!( state.local.confidence, safe_migrate::analysis::state::Confidence::Tainted ); } + #[test] + fn alter_view_rename_column_taints_instead_of_becoming_an_exact_noop() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze( + "CREATE TABLE source (id int); CREATE VIEW v AS SELECT id FROM source;", + &mut state, + ) + .expect("view setup should analyze"); + engine + .analyze("ALTER VIEW v RENAME COLUMN id TO renamed_id;", &mut state) + .expect("typed but unsupported view alteration should analyze"); + + assert_eq!( + state.local.confidence, + safe_migrate::analysis::state::Confidence::Tainted + ); + } + + #[test] + fn all_tables_in_schema_grants_are_tainted_when_relation_scope_is_incomplete() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze( + "CREATE SCHEMA app; CREATE TABLE app.entries (id int); GRANT SELECT ON ALL TABLES IN SCHEMA app TO reader;", + &mut state, + ) + .expect("schema-wide grant should analyze"); + + assert_eq!( + state.local.confidence, + safe_migrate::analysis::state::Confidence::Tainted + ); + } + + #[test] + fn schema_cascade_skips_already_dropped_sequences_without_panicking() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze( + "CREATE SCHEMA app; CREATE SEQUENCE app.counter; DROP SEQUENCE app.counter; DROP SCHEMA app CASCADE;", + &mut state, + ) + .expect("schema cascade after sequence drop should analyze"); + + assert!(matches!( + state.local.schemas.get("app"), + Some(safe_migrate::model::schema::SchemaOverlay::Dropped) + )); + } + + #[test] + fn conflicting_cascade_does_not_report_dependencies_that_were_not_dropped() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE TABLE parent (id int PRIMARY KEY); CREATE TABLE child (id int REFERENCES parent(id));", + &mut state, + ) + .expect("dependency setup should analyze"); + + let findings = engine + .analyze("DROP TABLE parent, missing CASCADE;", &mut state) + .expect("conflicting cascade should analyze"); + + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict") + ); + assert!( + findings + .iter() + .all(|finding| finding.rule_id != "destructive-cascade") + ); + } + #[test] fn missing_unguarded_drop_aborts_following_transaction_statements() { let engine = setup_engine(); @@ -243,7 +347,7 @@ mod state_machine_guards_tests { let edge_count = state .local .graph - .edges + .edges() .iter() .filter(|e| { matches!( @@ -260,7 +364,7 @@ mod state_machine_guards_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -280,7 +384,7 @@ mod state_machine_guards_tests { let before = state .local .graph - .edges + .edges() .iter() .filter(|e| { matches!( @@ -299,7 +403,7 @@ mod state_machine_guards_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, diff --git a/tests/state_mutation.rs b/tests/state_mutation.rs index 7f24c9f..a12b281 100644 --- a/tests/state_mutation.rs +++ b/tests/state_mutation.rs @@ -2,9 +2,13 @@ mod common; mod state_mutation_tests { use crate::common::*; + use safe_migrate::analysis::facts::FunctionSigFact; use safe_migrate::analysis::graph::{DependencyEdge, DependencyGraph, DependencyKind}; - use safe_migrate::analysis::state::Confidence; - use safe_migrate::ast::identifiers::ObjectId; + use safe_migrate::analysis::mutations::{ + DropAggregateMutation, DropFunctionMutation, DropProcedureMutation, Mutation, + }; + use safe_migrate::analysis::state::{Confidence, MutationResult}; + use safe_migrate::ast::identifiers::{Ident, ObjectId, QualifiedName}; use safe_migrate::db::cache::{DbCache, DependencyCache}; use safe_migrate::model::constraint::ConstraintKind; use safe_migrate::model::function::{ @@ -14,6 +18,7 @@ mod state_mutation_tests { Persistence, RelationKind, RelationOverlay, RelationState, }; use safe_migrate::model::role::RoleState; + use safe_migrate::model::schema::SchemaState; use safe_migrate::model::sequence::SequenceOverlay; use safe_migrate::model::types::{TypeKind, TypeOverlay, TypeState}; @@ -39,6 +44,263 @@ mod state_mutation_tests { } } + #[test] + fn missing_alter_table_does_not_leave_an_implicit_sequence() { + let engine = setup_engine(); + let mut state = setup_state(); + + let violations = engine + .analyze("ALTER TABLE missing ADD COLUMN id serial;", &mut state) + .unwrap(); + + assert!( + violations + .iter() + .any(|violation| violation.rule_id == "chain-conflict") + ); + assert!(state.local.sequences.is_empty()); + assert!(!state.relation_is_present(&object_id("public", "missing"))); + } + + #[test] + fn dropping_a_child_table_removes_its_outgoing_dependency_edges() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze( + "CREATE TABLE parent (id int primary key); CREATE TABLE child (id int, parent_id int REFERENCES parent(id)); CREATE INDEX child_idx ON child(id); DROP TABLE child;", + &mut state, + ) + .unwrap(); + + assert!(!state.relation_is_present(&object_id("public", "child"))); + assert!(!state.local.graph.edges().iter().any(|edge| { + edge.dependent == object_id("public", "child") + || (matches!( + edge.kind, + safe_migrate::analysis::graph::DependencyKind::IndexOnRelation { .. } + ) && edge.referenced == object_id("public", "child")) + })); + } + + #[test] + fn dropping_a_table_removes_publication_and_partition_edges() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze( + "CREATE TABLE parent (id int) PARTITION BY LIST (id); CREATE TABLE child PARTITION OF parent FOR VALUES IN (1); CREATE PUBLICATION pub FOR TABLE child; DROP TABLE parent CASCADE;", + &mut state, + ) + .unwrap(); + + assert!(state.local.graph.edges().iter().all(|edge| { + edge.dependent != object_id("public", "parent") + && edge.dependent != object_id("public", "child") + && edge.referenced != object_id("public", "parent") + && edge.referenced != object_id("public", "child") + })); + } + + #[test] + fn replacing_a_view_replaces_its_dependency_edges() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze( + "CREATE TABLE first (id int); CREATE TABLE second (id int); CREATE VIEW v AS SELECT * FROM first; CREATE OR REPLACE VIEW v AS SELECT * FROM second;", + &mut state, + ) + .unwrap(); + + let dependencies: Vec<_> = state + .local + .graph + .edges() + .iter() + .filter(|edge| { + matches!( + edge.kind, + safe_migrate::analysis::graph::DependencyKind::ViewDependency { .. } + ) && edge.dependent == object_id("public", "v") + }) + .map(|edge| edge.referenced.clone()) + .collect(); + assert_eq!(dependencies, vec![object_id("public", "second")]); + } + + #[test] + fn replacing_a_view_preserves_relation_metadata() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze( + "CREATE TABLE base (id int); CREATE VIEW v AS SELECT * FROM base; CREATE FUNCTION notify_view() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END; $$; CREATE TRIGGER v_insert INSTEAD OF INSERT ON v FOR EACH ROW EXECUTE FUNCTION notify_view(); GRANT SELECT ON v TO app_user; CREATE OR REPLACE VIEW v AS SELECT id FROM base;", + &mut state, + ) + .unwrap(); + + let Some(RelationOverlay::Present(view)) = + state.local.relations.get(&object_id("public", "v")) + else { + panic!("view should remain present"); + }; + assert!(view.triggers.contains("v_insert")); + assert!( + view.privileges + .grants + .contains_key(&ObjectId::new("", "app_user")) + ); + } + + #[test] + fn dropping_a_view_honors_restrict_and_cascade_dependencies() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze( + "CREATE TABLE source (id int); CREATE VIEW base_view AS SELECT * FROM source; CREATE VIEW dependent_view AS SELECT * FROM base_view;", + &mut state, + ) + .unwrap(); + let restricted = engine.analyze("DROP VIEW base_view;", &mut state).unwrap(); + assert!( + restricted + .iter() + .any(|violation| violation.rule_id == "chain-conflict") + ); + assert!(state.relation_is_present(&object_id("public", "base_view"))); + assert!(state.relation_is_present(&object_id("public", "dependent_view"))); + + engine + .analyze("DROP VIEW base_view CASCADE;", &mut state) + .unwrap(); + assert!(!state.relation_is_present(&object_id("public", "base_view"))); + assert!(!state.relation_is_present(&object_id("public", "dependent_view"))); + } + + #[test] + fn dependent_object_creates_and_type_alterations_require_existing_targets() { + let engine = setup_engine(); + let mut state = setup_state(); + + for sql in [ + "CREATE INDEX missing_idx ON missing(id);", + "CREATE TRIGGER missing_trigger BEFORE INSERT ON missing FOR EACH ROW EXECUTE FUNCTION missing_function();", + "REFRESH MATERIALIZED VIEW missing_view;", + "ALTER TYPE missing_type ADD VALUE 'new';", + ] { + let violations = engine.analyze(sql, &mut state).unwrap(); + assert!( + violations + .iter() + .any(|violation| violation.rule_id == "chain-conflict"), + "expected target validation for {sql}" + ); + } + assert!(state.local.graph.edges().is_empty()); + assert!(state.local.types.is_empty()); + } + + #[test] + fn sequence_ownership_requires_a_table_target() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze("CREATE VIEW occupied (id) AS SELECT 1;", &mut state) + .unwrap(); + let violations = engine + .analyze("CREATE SEQUENCE owned OWNED BY occupied.id;", &mut state) + .unwrap(); + + assert!(violations.iter().any(|violation| { + violation.rule_id == "chain-conflict" && violation.reason.contains("not a table") + })); + assert!( + !state + .local + .sequences + .contains_key(&object_id("public", "owned")) + ); + } + + #[test] + fn views_using_sequence_functions_do_not_treat_function_names_as_relations() { + let engine = setup_engine(); + let mut state = setup_state(); + + let violations = engine + .analyze( + "CREATE SEQUENCE source_seq; CREATE VIEW sequence_view AS SELECT nextval('source_seq'::regclass);", + &mut state, + ) + .unwrap(); + + assert!( + !violations + .iter() + .any(|violation| violation.rule_id == "chain-conflict"), + "sequence-backed view should be accepted: {violations:?}" + ); + } + + #[test] + fn policies_require_existing_table_targets() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze("CREATE VIEW protected_view AS SELECT 1 AS id;", &mut state) + .unwrap(); + let violations = engine + .analyze( + "CREATE POLICY protected_policy ON protected_view USING (true);", + &mut state, + ) + .unwrap(); + + assert!(violations.iter().any(|violation| { + violation.rule_id == "chain-conflict" && violation.reason.contains("is not a table") + })); + let Some(RelationOverlay::Present(view)) = state + .local + .relations + .get(&object_id("public", "protected_view")) + else { + panic!("view should remain present"); + }; + assert!(view.policies.is_empty()); + } + + #[test] + fn dropping_a_type_with_modeled_dependents_requires_cascade() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze( + "CREATE TYPE status AS ENUM ('new'); CREATE TABLE jobs (state status);", + &mut state, + ) + .unwrap(); + let violations = engine.analyze("DROP TYPE status;", &mut state).unwrap(); + assert!( + violations + .iter() + .any(|violation| violation.rule_id == "chain-conflict") + ); + assert!(matches!( + state.local.types.get(&object_id("public", "status")), + Some(TypeOverlay::Present(_)) + )); + } + #[test] fn cached_view_rewrite_self_edge_is_ignored_but_real_dependency_is_kept() { let view_id = object_id("public", "v"); @@ -80,18 +342,193 @@ mod state_mutation_tests { cache.dependencies.push(dependency(&table_id)); let state = safe_migrate::AnalysisState::new(cache); - assert!(!state.local.graph.edges.iter().any(|edge| { + assert!(!state.local.graph.edges().iter().any(|edge| { matches!(edge.kind, DependencyKind::ViewDependency { .. }) && edge.dependent == view_id && edge.referenced == view_id })); - assert!(state.local.graph.edges.iter().any(|edge| { + assert!(state.local.graph.edges().iter().any(|edge| { matches!(edge.kind, DependencyKind::ViewDependency { .. }) && edge.dependent == view_id && edge.referenced == table_id })); } + #[test] + fn scoped_view_dependencies_keep_edges_to_omitted_schemas() { + let view_id = object_id("app", "v"); + let external_table = object_id("tenant", "base"); + let mut cache = DbCache::new(); + cache.metadata.schemas = Some(vec!["app".to_string()]); + cache.insert_baseline( + view_id.clone(), + RelationState::new( + view_id.clone(), + object_id("public", "owner"), + 0, + None, + RelationKind::View, + Persistence::Permanent, + 0, + ), + ); + cache.dependencies.push(DependencyCache { + classid: 0, + objid: 0, + objsubid: 0, + refclassid: 0, + refobjid: 0, + refobjsubid: 0, + deptype: "view".to_string(), + obj_schema: Some(view_id.schema.clone()), + obj_name: Some(view_id.name.clone()), + ref_schema: Some(external_table.schema.clone()), + ref_name: Some(external_table.name.clone()), + }); + + let state = safe_migrate::AnalysisState::new(cache); + assert!(state.local.graph.edges().iter().any(|edge| { + matches!(edge.kind, DependencyKind::ViewDependency { .. }) + && edge.dependent == view_id + && edge.referenced == external_table + })); + } + + #[test] + fn scoped_view_dependencies_keep_omitted_dependents() { + let in_scope_table = object_id("app", "base"); + let omitted_view = object_id("tenant", "v"); + let mut cache = DbCache::new(); + cache.metadata.schemas = Some(vec!["app".to_string()]); + cache.insert_baseline( + in_scope_table.clone(), + RelationState::new( + in_scope_table.clone(), + object_id("public", "owner"), + 0, + None, + RelationKind::Table, + Persistence::Permanent, + 0, + ), + ); + cache.dependencies.push(DependencyCache { + classid: 0, + objid: 0, + objsubid: 0, + refclassid: 0, + refobjid: 0, + refobjsubid: 0, + deptype: "view".to_string(), + obj_schema: Some(omitted_view.schema.clone()), + obj_name: Some(omitted_view.name.clone()), + ref_schema: Some(in_scope_table.schema.clone()), + ref_name: Some(in_scope_table.name.clone()), + }); + + let state = safe_migrate::AnalysisState::new(cache); + assert!(state.local.graph.edges().iter().any(|edge| { + matches!(edge.kind, DependencyKind::ViewDependency { .. }) + && edge.dependent == omitted_view + && edge.referenced == in_scope_table + })); + } + + #[test] + fn cascades_taint_when_a_scoped_dependent_is_omitted() { + let in_scope_table = object_id("app", "base"); + let omitted_view = object_id("tenant", "v"); + let mut cache = DbCache::new(); + cache.metadata.schemas = Some(vec!["app".to_string()]); + cache.insert_baseline( + in_scope_table.clone(), + RelationState::new( + in_scope_table.clone(), + object_id("public", "owner"), + 0, + None, + RelationKind::Table, + Persistence::Permanent, + 0, + ), + ); + cache.dependencies.push(DependencyCache { + classid: 0, + objid: 0, + objsubid: 0, + refclassid: 0, + refobjid: 0, + refobjsubid: 0, + deptype: "view".to_string(), + obj_schema: Some(omitted_view.schema), + obj_name: Some(omitted_view.name), + ref_schema: Some(in_scope_table.schema.clone()), + ref_name: Some(in_scope_table.name.clone()), + }); + + let engine = setup_engine(); + let mut state = safe_migrate::AnalysisState::new(cache); + let violations = engine + .analyze("DROP TABLE app.base CASCADE;", &mut state) + .unwrap(); + + assert!( + !violations + .iter() + .any(|violation| violation.rule_id == "chain-conflict"), + "known cascade should execute: {violations:?}" + ); + assert_eq!(state.local.confidence, Confidence::Tainted); + assert!(!state.relation_is_present(&in_scope_table)); + } + + #[test] + fn non_view_catalog_dependencies_do_not_enter_the_modeled_graph() { + let view_id = object_id("public", "v"); + let table_id = object_id("public", "t"); + let mut cache = DbCache::new(); + for (id, kind) in [ + (view_id.clone(), RelationKind::View), + (table_id.clone(), RelationKind::Table), + ] { + cache.insert_baseline( + id.clone(), + RelationState::new( + id, + object_id("public", "owner"), + 0, + None, + kind, + Persistence::Permanent, + 0, + ), + ); + } + cache.dependencies.push(DependencyCache { + classid: 0, + objid: 0, + objsubid: 0, + refclassid: 0, + refobjid: 0, + refobjsubid: 0, + deptype: "n".to_string(), + obj_schema: Some(view_id.schema.clone()), + obj_name: Some(view_id.name.clone()), + ref_schema: Some(table_id.schema.clone()), + ref_name: Some(table_id.name.clone()), + }); + + let state = safe_migrate::AnalysisState::new(cache); + assert!( + !state + .local + .graph + .edges() + .iter() + .any(|edge| { edge.dependent == view_id && edge.referenced == table_id }) + ); + } + #[test] fn test_topology_drop_table() { let engine = setup_engine(); @@ -229,6 +666,73 @@ mod state_mutation_tests { ); } + #[test] + fn cascade_drop_removes_foreign_key_metadata_from_surviving_tables() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze( + "CREATE TABLE parent (id integer PRIMARY KEY); + CREATE TABLE child (parent_id integer); + ALTER TABLE child ADD CONSTRAINT child_parent_fk + FOREIGN KEY (parent_id) REFERENCES parent(id); + DROP TABLE parent CASCADE;", + &mut state, + ) + .unwrap(); + + let child = object_id("public", "child"); + assert!(state.relation_is_present(&child)); + assert!( + !state + .local + .constraints + .contains_key(&(child.clone(), "child_parent_fk".to_string())), + "the surviving table must not retain a dropped foreign key" + ); + assert!(!state.local.graph.edges().iter().any(|edge| { + matches!( + edge.kind, + DependencyKind::ForeignKey { + constraint_name: Some(ref name), + .. + } if name == "child_parent_fk" + ) + })); + } + + #[test] + fn schema_cascade_cleans_cross_schema_view_and_foreign_key_state() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze( + "CREATE SCHEMA base; + CREATE SCHEMA app; + CREATE TABLE base.parent (id integer PRIMARY KEY); + CREATE TABLE app.child (parent_id integer); + ALTER TABLE app.child ADD CONSTRAINT child_parent_fk + FOREIGN KEY (parent_id) REFERENCES base.parent(id); + CREATE VIEW app.parent_view AS SELECT * FROM base.parent; + DROP SCHEMA base CASCADE;", + &mut state, + ) + .unwrap(); + + let child = object_id("app", "child"); + let view = object_id("app", "parent_view"); + assert!(state.relation_is_present(&child)); + assert!(!state.relation_is_present(&view)); + assert!( + !state + .local + .constraints + .contains_key(&(child, "child_parent_fk".to_string())) + ); + } + #[test] fn failed_drop_table_keeps_owned_triggers_for_later_dependency_checks() { let engine = setup_engine(); @@ -271,7 +775,7 @@ mod state_mutation_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -303,12 +807,12 @@ mod state_mutation_tests { let b = object_id("public", "b"); let child = object_id("public", "new_child"); let mut graph = DependencyGraph::new(); - graph.edges.push(DependencyEdge::new( + graph.add_edge(DependencyEdge::new( a.clone(), b.clone(), DependencyKind::PartitionOf, )); - graph.edges.push(DependencyEdge::new( + graph.add_edge(DependencyEdge::new( b, a.clone(), DependencyKind::PartitionOf, @@ -317,6 +821,36 @@ mod state_mutation_tests { 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 test_topology_rename_index() { let engine = setup_engine(); @@ -333,7 +867,7 @@ mod state_mutation_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -345,7 +879,7 @@ mod state_mutation_tests { !state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -362,7 +896,7 @@ mod state_mutation_tests { engine .analyze( - "CREATE TABLE p(id int); CREATE TABLE c(p_id int); ALTER TABLE c ADD CONSTRAINT fk FOREIGN KEY (p_id) REFERENCES p(id);", + "CREATE TABLE p(id int PRIMARY KEY); CREATE TABLE c(p_id int); ALTER TABLE c ADD CONSTRAINT fk FOREIGN KEY (p_id) REFERENCES p(id);", &mut state, ) .unwrap(); @@ -371,7 +905,7 @@ mod state_mutation_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -388,7 +922,7 @@ mod state_mutation_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -415,7 +949,7 @@ mod state_mutation_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -430,7 +964,7 @@ mod state_mutation_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -457,7 +991,7 @@ mod state_mutation_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -484,7 +1018,7 @@ mod state_mutation_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -503,7 +1037,7 @@ mod state_mutation_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -514,6 +1048,120 @@ mod state_mutation_tests { ); } + #[test] + fn dropping_owned_sequence_cascade_removes_dependent_nextval_default() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze( + "CREATE TABLE t(id int, other_id int); CREATE SEQUENCE s OWNED BY t.id; CREATE SEQUENCE other OWNED BY t.other_id; ALTER TABLE t ALTER COLUMN id SET DEFAULT nextval('s'); ALTER TABLE t ALTER COLUMN other_id SET DEFAULT nextval('other');", + &mut state, + ) + .unwrap(); + + engine + .analyze("DROP SEQUENCE s CASCADE;", &mut state) + .unwrap(); + + let Some(RelationOverlay::Present(table)) = state.get_relation(&object_id("public", "t")) + else { + panic!("table should remain present"); + }; + assert_eq!( + table + .get_column("id") + .and_then(|column| column.default.as_ref()), + None + ); + assert!( + table + .get_column("other_id") + .and_then(|column| column.default.as_ref()) + .is_some() + ); + assert_eq!( + table + .get_column("id") + .and_then(|column| column.default_expr_text.as_deref()), + None + ); + } + + #[test] + fn dropping_schema_cascade_removes_cross_schema_sequence_defaults() { + let engine = setup_engine(); + let mut cache = { + let mut cache = DbCache::new(); + cache.metadata.source_lock_timeout_ms = 1_000; + cache.metadata.source_statement_timeout_ms = 10_000; + cache + }; + let name = "public"; + cache.schemas.insert( + name.to_string(), + SchemaState { + name: name.to_string(), + owner: object_id("", "postgres"), + generation: 0, + }, + ); + let mut state = safe_migrate::AnalysisState::new(cache); + + engine + .analyze( + "CREATE SCHEMA seq_schema; CREATE TABLE t(id int); CREATE SEQUENCE seq_schema.s; ALTER TABLE t ALTER COLUMN id SET DEFAULT nextval('seq_schema.s');", + &mut state, + ) + .unwrap(); + + engine + .analyze("DROP SCHEMA seq_schema CASCADE;", &mut state) + .unwrap(); + + assert!(matches!( + state.local.schemas.get("seq_schema"), + Some(safe_migrate::model::schema::SchemaOverlay::Dropped) + )); + + assert!(matches!( + state.local.sequences.get(&object_id("seq_schema", "s")), + Some(SequenceOverlay::Dropped) + )); + + let Some(RelationOverlay::Present(table)) = state.get_relation(&object_id("public", "t")) + else { + panic!("table should remain present"); + }; + assert_eq!( + table + .get_column("id") + .and_then(|column| column.default.as_ref()), + None + ); + } + + #[test] + fn create_table_as_select_taints_unknown_columns_and_skips_later_column_edits() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze("CREATE TABLE copied AS SELECT 1 AS id;", &mut state) + .unwrap(); + assert_eq!(state.local.confidence, Confidence::Tainted); + + let violations = engine + .analyze("ALTER TABLE copied DROP COLUMN id;", &mut state) + .unwrap(); + assert!( + !violations + .iter() + .any(|violation| violation.rule_id == "chain-conflict") + ); + assert!(state.relation_is_present(&object_id("public", "copied"))); + } + #[test] fn test_topology_type_and_domain() { let engine = setup_engine(); @@ -1337,11 +1985,17 @@ mod state_mutation_tests { engine .analyze( - "CREATE TABLE t(id int); CREATE POLICY p ON t FOR SELECT USING(true); CREATE TRIGGER tr BEFORE INSERT ON t EXECUTE FUNCTION f();", + "CREATE TABLE t(id int); CREATE POLICY p ON t FOR SELECT USING(true); CREATE FUNCTION f() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END; $$; CREATE TRIGGER tr BEFORE INSERT ON t EXECUTE FUNCTION f();", &mut state, ) .unwrap(); + assert_eq!( + state.local.confidence, + Confidence::Tainted, + "policy expressions are retained for rule evaluation but are not modeled in relation state" + ); + if let Some(RelationOverlay::Present(r)) = state.get_relation(&object_id("public", "t")) { assert!(r.policies.contains("p")); assert!(r.triggers.contains("tr")); @@ -1357,6 +2011,32 @@ mod state_mutation_tests { } } + #[test] + fn instead_of_trigger_on_view_is_tracked() { + let engine = setup_engine(); + let mut state = setup_state(); + + let violations = engine + .analyze( + "CREATE TABLE base(id int); CREATE VIEW v AS SELECT * FROM base; CREATE FUNCTION f() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END; $$; CREATE TRIGGER tr INSTEAD OF INSERT ON v FOR EACH ROW EXECUTE FUNCTION f();", + &mut state, + ) + .unwrap(); + + assert!( + !violations + .iter() + .any(|violation| violation.rule_id == "chain-conflict"), + "view trigger target should be valid: {violations:?}" + ); + let Some(RelationOverlay::Present(view)) = + state.local.relations.get(&object_id("public", "v")) + else { + panic!("view should remain present"); + }; + assert!(view.triggers.contains("tr")); + } + #[test] fn rename_trigger_updates_identity_and_rolls_back() { let engine = setup_engine(); @@ -1430,7 +2110,7 @@ mod state_mutation_tests { .unwrap(); assert!(state.local.publications.contains_key("pub")); - let deps = &state.local.graph.edges; + let deps = &state.local.graph.edges(); assert!( deps.iter() .any(|d| matches!(&d.kind, DependencyKind::PublicationIncludes { publication_name } if publication_name == "pub") && d.dependent == object_id("public", "t1")) @@ -1640,7 +2320,7 @@ mod state_mutation_tests { ) .unwrap(); - assert!(state.local.graph.edges.iter().any(|edge| { + assert!(state.local.graph.edges().iter().any(|edge| { edge.dependent == object_id("public", "indexed_table_id_idx") && edge.referenced == object_id("public", "indexed_table") && matches!( @@ -1774,7 +2454,7 @@ mod state_mutation_tests { state .local .graph - .edges + .edges() .iter() .filter(|e| matches!( e.kind, @@ -1802,7 +2482,7 @@ mod state_mutation_tests { ) .unwrap(); - assert!(!state.local.graph.edges.iter().any(|edge| { + assert!(!state.local.graph.edges().iter().any(|edge| { matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) && edge.referenced == object_id("public", "mv") })); @@ -1821,7 +2501,7 @@ mod state_mutation_tests { } #[test] - fn creating_a_new_function_is_exact_when_v6_proves_the_routine_name_is_free() { + fn creating_a_new_function_taints_unmodeled_body_state() { let engine = setup_engine(); let mut state = setup_state(); @@ -1832,7 +2512,10 @@ mod state_mutation_tests { ) .unwrap(); - assert_eq!(state.local.confidence, Confidence::Exact); + // FunctionState tracks identity and selected options, but not the + // SQL body/dependency graph represented by `AS`; retaining Exact here + // would overstate what later dependency checks can prove. + assert_eq!(state.local.confidence, Confidence::Tainted); assert!(matches!( state.local.functions.get(&object_id("public", "work()")), Some(FunctionOverlay::Present(function)) @@ -1902,7 +2585,11 @@ mod state_mutation_tests { .get(&object_id("public", "combined(integer)")), Some(FunctionOverlay::Dropped) )); - assert_eq!(state.local.confidence, Confidence::Exact); + assert_eq!( + state.local.confidence, + Confidence::Tainted, + "aggregate implementation details are intentionally not modeled" + ); engine .analyze( @@ -2072,6 +2759,120 @@ mod state_mutation_tests { } } + #[test] + fn routine_drop_lookup_outcomes_preserve_guarded_and_unknown_semantics() { + fn signature(schema: &str) -> FunctionSigFact { + FunctionSigFact { + name: QualifiedName::new( + Some(Ident::new(schema, false)), + Ident::new("work", false), + ), + params: vec!["integer".into()], + } + } + + fn routine(kind: RoutineKind) -> FunctionState { + let id = object_id("public", "work(integer)"); + FunctionState { + id, + routine_kind: kind, + arg_types: vec!["integer".into()], + arg_type_ids: Vec::new(), + return_type: "void".into(), + return_type_id: None, + volatility: Volatility::Volatile, + language: "sql".into(), + security: SecurityMode::Invoker, + } + } + + let function_drop = |schema: &str, if_exists| { + Mutation::DropFunction(DropFunctionMutation { + signatures: vec![signature(schema)], + if_exists, + cascade: false, + }) + }; + let procedure_drop = |schema: &str, if_exists| { + Mutation::DropProcedure(DropProcedureMutation { + signatures: vec![signature(schema)], + if_exists, + cascade: false, + }) + }; + let aggregate_drop = |schema: &str, if_exists| { + Mutation::DropAggregate(DropAggregateMutation { + signatures: vec![signature(schema)], + if_exists, + cascade: false, + }) + }; + + for (kind, drop) in [ + (RoutineKind::Procedure, function_drop("public", true)), + (RoutineKind::Function, procedure_drop("public", true)), + ] { + let mut cache = DbCache::new(); + cache + .functions + .insert(object_id("public", "work(integer)"), routine(kind)); + let mut state = safe_migrate::AnalysisState::new(cache); + assert!(matches!( + state.apply(&drop, None), + MutationResult::Conflict { .. } + )); + assert_eq!(state.local.confidence, Confidence::Exact); + } + + let mut wrong_kind_cache = DbCache::new(); + wrong_kind_cache.functions.insert( + object_id("public", "work(integer)"), + routine(RoutineKind::Function), + ); + let mut wrong_kind_state = safe_migrate::AnalysisState::new(wrong_kind_cache); + assert!(matches!( + wrong_kind_state.apply(&aggregate_drop("public", true), None), + MutationResult::Conflict { .. } + )); + assert_eq!(wrong_kind_state.local.confidence, Confidence::Exact); + + for drop in [ + function_drop("public", true), + procedure_drop("public", true), + aggregate_drop("public", true), + ] { + let mut state = setup_state(); + assert_eq!(state.apply(&drop, None), MutationResult::Skipped); + assert_eq!(state.local.confidence, Confidence::Exact); + } + + for drop in [ + function_drop("tenant", false), + procedure_drop("tenant", false), + aggregate_drop("tenant", false), + ] { + let mut cache = DbCache::new(); + cache.metadata.schemas = Some(vec!["public".into()]); + let mut state = safe_migrate::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::AnalysisState::new(cache); + assert_eq!( + state.apply(&function_drop("tenant", true), None), + MutationResult::Skipped + ); + assert_eq!(state.local.confidence, Confidence::Tainted); + assert_eq!( + state.apply(&aggregate_drop("tenant", true), None), + MutationResult::Skipped + ); + assert_eq!(state.local.confidence, Confidence::Tainted); + } + #[test] fn procedure_kind_and_lifecycle_are_enforced_within_the_chain() { let engine = setup_engine(); @@ -2159,7 +2960,11 @@ mod state_mutation_tests { && violation.reason.contains("missing_pub") && violation.reason.contains("does not exist") })); - assert_eq!(state.local.confidence, Confidence::Exact); + assert_eq!( + state.local.confidence, + Confidence::Tainted, + "FOR ALL TABLES publication state depends on catalog-wide inheritance knowledge" + ); } #[test] @@ -2280,7 +3085,7 @@ mod state_mutation_tests { panic!("expected explicit publication scope"); }; assert_eq!(objects.len(), 2); - assert!(state.local.graph.edges.iter().any(|edge| { + assert!(state.local.graph.edges().iter().any(|edge| { matches!( &edge.kind, DependencyKind::PublicationIncludes { publication_name } @@ -2863,6 +3668,7 @@ mod state_mutation_tests { .expect("foreign key should be recorded"); assert_eq!(constraint.kind, ConstraintKind::ForeignKey); assert!(!constraint.validated); + assert!(state.local.pending_validation.contains(&key)); engine .analyze( @@ -2871,6 +3677,7 @@ mod state_mutation_tests { ) .unwrap(); assert!(state.local.constraints[&key].validated); + assert!(!state.local.pending_validation.contains(&key)); } #[test] @@ -2932,6 +3739,55 @@ mod state_mutation_tests { assert!(unique.validated); } + #[test] + fn create_table_records_inline_foreign_key_check_and_exclusion_constraints() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE TABLE parent (id integer PRIMARY KEY); + CREATE TABLE reservations ( + id integer, + parent_id integer, + period int4range, + CONSTRAINT reservations_parent_fk + FOREIGN KEY (parent_id) REFERENCES parent(id), + CONSTRAINT reservations_id_check CHECK (id > 0), + CONSTRAINT reservations_period_excl + EXCLUDE USING gist (period WITH &&) + );", + &mut state, + ) + .unwrap(); + + let table = object_id("public", "reservations"); + for (name, kind) in [ + ("reservations_parent_fk", ConstraintKind::ForeignKey), + ("reservations_id_check", ConstraintKind::Check), + ("reservations_period_excl", ConstraintKind::Exclusion), + ] { + assert_eq!( + state + .local + .constraints + .get(&(table.clone(), name.to_string())) + .map(|constraint| constraint.kind), + Some(kind), + "missing inline constraint {name}" + ); + } + assert!(state.local.graph.edges().iter().any(|edge| { + edge.dependent == table + && matches!( + edge.kind, + DependencyKind::ForeignKey { + constraint_name: Some(ref name), + .. + } if name == "reservations_parent_fk" + ) + })); + } + #[test] fn create_table_preserves_explicit_constraint_names_and_avoids_generated_collisions() { let engine = setup_engine(); diff --git a/tests/transaction_lifecycle.rs b/tests/transaction_lifecycle.rs index 8cd032c..5362947 100644 --- a/tests/transaction_lifecycle.rs +++ b/tests/transaction_lifecycle.rs @@ -24,7 +24,7 @@ mod transaction_lifecycle_tests { let violations = engine .analyze( - "BEGIN; COMMIT AND CHAIN; CREATE INDEX CONCURRENTLY idx ON users (id); ROLLBACK;", + "CREATE TABLE users (id int); BEGIN; COMMIT AND CHAIN; CREATE INDEX CONCURRENTLY idx ON users (id); ROLLBACK;", &mut state, ) .unwrap(); @@ -44,7 +44,7 @@ mod transaction_lifecycle_tests { let violations = engine .analyze( - "BEGIN; ROLLBACK AND CHAIN; CREATE INDEX CONCURRENTLY idx ON users (id); ROLLBACK;", + "CREATE TABLE users (id int); BEGIN; ROLLBACK AND CHAIN; CREATE INDEX CONCURRENTLY idx ON users (id); ROLLBACK;", &mut state, ) .unwrap(); @@ -70,6 +70,50 @@ mod transaction_lifecycle_tests { assert!(!state.relation_is_present(&object_id("public", "t"))); } + #[test] + fn statement_journal_restores_replication_state_after_late_conflict() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE TABLE journal_table(id integer); + CREATE PUBLICATION journal_publication FOR TABLE journal_table;", + &mut state, + ) + .unwrap(); + + engine.analyze("BEGIN;", &mut state).unwrap(); + let publication_before = state.local.publications.get("journal_publication").cloned(); + let graph_before = state.local.graph.edges().to_vec(); + let generation_before = state.local.generation_counter; + let confidence_before = state.local.confidence.clone(); + + let findings = engine + .analyze( + "ALTER PUBLICATION journal_publication ADD TABLE journal_table;", + &mut state, + ) + .unwrap(); + + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict") + ); + assert_eq!( + state.local.publications.get("journal_publication").cloned(), + publication_before + ); + assert_eq!(state.local.graph.edges(), graph_before); + assert_eq!(state.local.generation_counter, generation_before); + assert_eq!(state.local.confidence, confidence_before); + assert!(state.local.transaction_aborted); + + engine.analyze("ROLLBACK;", &mut state).unwrap(); + assert!(state.local.transactions.is_empty()); + assert!(!state.local.transaction_aborted); + } + #[test] fn missing_savepoint_aborts_the_transaction_and_skips_later_statements() { let engine = setup_engine(); @@ -110,7 +154,7 @@ mod transaction_lifecycle_tests { let violations = engine .analyze( - "BEGIN; SAVEPOINT MixedCase; ROLLBACK TO SAVEPOINT mixedcase; CREATE INDEX CONCURRENTLY idx ON users (id); ROLLBACK;", + "CREATE TABLE users (id int); BEGIN; SAVEPOINT MixedCase; ROLLBACK TO SAVEPOINT mixedcase; CREATE INDEX CONCURRENTLY idx ON users (id); ROLLBACK;", &mut state, ) .unwrap(); @@ -541,8 +585,7 @@ mod transaction_lifecycle_tests { state .local .graph - .edges - .push(safe_migrate::analysis::graph::DependencyEdge { + .add_edge(safe_migrate::analysis::graph::DependencyEdge { dependent: v1_id.clone(), referenced: t1_id.clone(), kind: safe_migrate::analysis::graph::DependencyKind::ViewDependency { @@ -550,7 +593,7 @@ mod transaction_lifecycle_tests { }, }); - assert_eq!(state.local.graph.edges[0].referenced, t1_id); + assert_eq!(state.local.graph.edges()[0].referenced, t1_id); engine .analyze("BEGIN; ALTER TABLE t1 RENAME TO t2; ROLLBACK;", &mut state) @@ -558,6 +601,6 @@ mod transaction_lifecycle_tests { assert!(state.relation_is_present(&t1_id)); assert!(!state.relation_is_present(&object_id("public", "t2"))); - assert_eq!(state.local.graph.edges[0].referenced, t1_id); + assert_eq!(state.local.graph.edges()[0].referenced, t1_id); } } diff --git a/tests/v045_state.rs b/tests/v045_state.rs index 6949dad..c330a16 100644 --- a/tests/v045_state.rs +++ b/tests/v045_state.rs @@ -75,7 +75,7 @@ fn cache_v5_hydrates_schema_sequence_and_ownership_edge() { state.local.sequences.get(&sequence_id), Some(SequenceOverlay::Present(sequence)) if sequence.kind == SequenceKind::SerialLike )); - assert!(state.local.graph.edges.iter().any(|edge| { + assert!(state.local.graph.edges().iter().any(|edge| { edge.dependent == sequence_id && edge.referenced == table_id && matches!(