Metadata-Version: 2.5
Name: dbt-plan
Version: 0.16.0
Summary: Static analysis tool that warns about risky DDL changes before dbt run
Project-URL: Homepage, https://github.com/PresentJay/dbt-plan
Project-URL: Documentation, https://presentjay.github.io/dbt-plan
Project-URL: Repository, https://github.com/PresentJay/dbt-plan
Project-URL: Issues, https://github.com/PresentJay/dbt-plan/issues
Author: PresentJay
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: bigquery,ci,dbt,ddl,impact-analysis,redshift,snowflake
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Database
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Python: >=3.10
Requires-Dist: sqlglot>=28.0.0
Provides-Extra: dbt
Requires-Dist: dbt-core>=1.7.0; extra == 'dbt'
Requires-Dist: dbt-duckdb>=1.7.0; extra == 'dbt'
Provides-Extra: dev
Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
Requires-Dist: pytest>=8.0.0; extra == 'dev'
Requires-Dist: ruff>=0.8.0; extra == 'dev'
Provides-Extra: mcp
Requires-Dist: mcp>=2.0.0; extra == 'mcp'
Provides-Extra: test
Requires-Dist: pytest-cov>=6.0.0; extra == 'test'
Requires-Dist: pytest>=8.0.0; extra == 'test'
Description-Content-Type: text/markdown

# dbt-plan

Static analysis tool that warns about risky DDL changes before `dbt run`.

Like `terraform plan` for dbt, and used the same way: you run it **before** the thing
that changes your warehouse, not only in CI afterwards.

Analyzes compiled SQL files from warehouses including Snowflake, BigQuery,
Redshift, Postgres and DuckDB through one code path. SQL dialect support varies;
see [analysis limits](docs/analysis-limits.md).

## What It Looks Like

```
$ dbt-plan check

dbt-plan -- 4 model(s) changed
  dialect: snowflake (default; adapter: unknown)
  baseline: unknown revision, unknown snapshot time


DESTRUCTIVE  int_order_enriched (incremental, sync_all_columns)
  ADD COLUMN  billing_method
  ADD COLUMN  shipping_city
  DROP COLUMN  billing_info
  DROP COLUMN  shipping_info
  Downstream: dim_customers, fct_daily_sales (2 model(s))
  >> BROKEN_REF  fct_daily_sales: reads dropped column(s): shipping_info

SAFE  dim_customers (table)
  CREATE OR REPLACE TABLE

SAFE  dim_publishers (table)
  CREATE OR REPLACE TABLE

SAFE  fct_daily_sales (incremental, append_new_columns)
  ADD COLUMN  total_sales

dbt-plan: 4 checked, 3 safe, 0 warning, 1 destructive, 1 cascade risk(s)
```

## What It Does

dbt-plan analyzes compiled SQL diffs to catch dangerous schema changes at PR time:

- **Column changes**: detects ADD/DROP COLUMN from SQL diff
- **Risk assessment**: judges safety based on materialization x on_schema_change rules
- **Cascade analysis**: finds downstream models broken by a dropped column — the ones that
  name it, resolved against the project's own schema rather than matched as text; the ones
  that select `*` and lose it without their own file changing; and the tests whose fixtures
  pin it down. Names the exposures whose owners need telling
- **Contracts**: reports a change an enforced contract will reject, in either direction
- **Config changes**: detects materialization or on_schema_change policy changes
- **Type changes**: compares explicit `CAST` types between revisions
- **`SELECT *` resolution**: reads the columns from the CTEs of the same statement, and follows a `ref()` into the referenced model's compiled SQL

The analysis reads files, compares them, and warns; it does not connect to a warehouse or simulate `dbt run`. The optional `dbt-plan run` workflow invokes your compile command, which can require credentials and execute macros.

## Quick Start

```bash
pip install dbt-plan
dbt-plan run               # compile baseline → compile current → check
```

`dbt-plan run` does the whole thing in one command, and needs whatever credentials your
`dbt compile` normally needs.

### The loop it is built for

Once you have a baseline, the inner loop is a single sub-second command. Edit a model or a
macro, recompile, and see what `dbt run` would do — *before* running it:

```bash
dbt-plan snapshot          # once, on the revision you are changing from
                           # ... edit models, edit macros ...
dbt compile && dbt-plan check
```

`dbt-plan check` reads local compiled artifacts. Its latency is separate from
`dbt compile`, which can depend on your adapter, macros, and warehouse.
See the [reproducible CLI benchmark](docs/performance.md) for measured workloads,
raw samples, and commands to measure your machine.

### Working with a coding agent

An agent editing models cannot eyeball a diff and hesitate. Give it the check and the
reasons behind it:

```bash
dbt-plan agent-setup       # writes dbt-plan guidance into your AGENTS.md
dbt-plan check --format json
```

The guidance leads with what an agent most often gets wrong: adding a model to
`ignore_models`, or downgrading `on_schema_change` from `sync_all_columns` to `ignore`,
silences a real finding without making the change safe.

Or give it the check as an MCP tool:

```bash
pip install 'dbt-plan[mcp]'
dbt-plan-mcp                # stdio MCP server exposing `plan` and `snapshot`
```

`plan` returns the verdict, the per-model operations, and — separately — a `refusals`
list naming everything dbt-plan declined to judge. That separation is the point: a person
reading "safe" may still glance at the diff, an agent reading it proceeds, so a
non-empty `refusals` must never be collapsed into the verdict.

Both tools accept `target_dir` when dbt writes artifacts outside the default `target/`,
for example `plan(project_dir=".", target_dir="build")`.

The server is a separate package from the analysis core. The core is offline and
synchronous by design and `tests/test_invariants.py` fails the build on an `asyncio` or
network import anywhere inside it; an MCP server is both, so keeping them apart is what
keeps that guarantee provable.

Registry entry — the line below is how the MCP registry verifies that whoever publishes
the entry also owns this PyPI package, so it has to stay in the README that ships:

```
mcp-name: io.github.PresentJay/dbt-plan
```

### More commands

```bash
dbt-plan init              # Generate .dbt-plan.yml config + update .gitignore
dbt-plan stats             # Analyze project readiness
dbt-plan ci-setup          # Generate GitHub Actions workflow
dbt-plan check --format github   # GitHub markdown output
dbt-plan check --format json     # JSON for CI pipelines
dbt-plan run --against main           # compare with where this branch left main
dbt-plan check --select fct_orders    # one model
dbt-plan check --select fct_orders+   # it and everything downstream
```


`--select` accepts model names and optional upstream/downstream `+` operators;
comma-separated terms form a union. In the next minor release, unsupported or
unknown selections fail with exit 3. Use explicit version names such as
`fct_orders_v2`. See the [selection contract and examples](docs/selection.md).

## Scope

dbt-plan is a **static analysis warning tool**, not a runtime simulator.

| In scope | Out of scope |
|----------|-------------|
| Column ADD/DROP detection from compiled SQL | `dbt run` simulation |
| materialization × on_schema_change risk rules | Warehouse connection |
| Cascade: broken refs, build failures, inherited column loss | `seed` / `source` change detection |
| Config change detection (materialization, osc) | `pre_hook` / `post_hook` DDL analysis |
| Unit test fixtures and exposure owners downstream | `seed` / `source` fixtures dbt-plan cannot read |
| Enforced-contract violations: names, and types by family | Contract types compared more finely than family |
| Explicit `CAST` type changes | Type changes on uncast columns |
| `SELECT *` resolved through CTEs and `ref()` | `SELECT *` over a source or a raw table |
| CI exit codes + structured output | `full_refresh` mode judgment |

**Design principle**: false warnings are OK, false safe is never OK.

## When to use it

dbt-plan answers a narrower question than the warehouse-connected tools (Recce,
SQLMesh, data-diff) and costs nothing to run, so it works as the cheap gate in
front of them — and on the Fusion engine, which compiles without a warehouse
connection, that includes fork pull requests where they cannot run at all.
See [use cases](docs/use-cases.md) for the comparison, real timings, and what it
gets wrong.

## Deliberately Not Planned

Ideas that look useful but contradict what this tool is:

| Idea | Why not |
|------|---------|
| INFORMATION_SCHEMA query | Requires a warehouse connection. dbt-plan reads files and nothing else, which is what lets it run wherever its input exists — including a fork's pull request, once the project compiles on Fusion. |
| Type changes on columns with no explicit `CAST` | The type is whatever the warehouse assigned, so seeing a change would mean asking it. Columns that *are* cast explicitly on both sides are compared — see below. |

## DDL Prediction Rules

| Materialization | on_schema_change | Predicted DDL | Safety |
|-----------------|------------------|---------------|--------|
| table | any | `CREATE OR REPLACE TABLE` | SAFE |
| view | any | `CREATE OR REPLACE VIEW` | SAFE |
| ephemeral | any | (no physical object) | SAFE |
| snapshot | any | `REVIEW REQUIRED` | WARNING |
| incremental | ignore | no schema DDL; existing targets may fail on column changes | WARNING on changed/unknown columns |
| incremental | fail | build failure | WARNING |
| incremental | append_new_columns | `ADD COLUMN` only | SAFE |
| incremental | sync_all_columns | `ADD + DROP COLUMN` | DESTRUCTIVE if columns removed |
| any | (model removed) | `MODEL REMOVED` | DESTRUCTIVE |
| any | (unknown osc) | `UNKNOWN on_schema_change` | WARNING |
| materialized_view / custom | (not set by you) | `UNKNOWN materialization` | WARNING |
| materialized_view / custom | (you set one) | follows the incremental rules | per osc |
| any | (`contract: {enforced: true}`) | `CONTRACT VIOLATION` | WARNING |

Under a contract, a column's declared `data_type` is compared with its explicit `CAST`,
by family -- text against number against date/time against boolean. `varchar` and `text`
are the same family and not a finding; `varchar` and `integer` are a build failure.
Comparing more finely means a per-adapter type table, and a wrong answer about a type is
worse than no answer.

"Not set by you" means the author wrote no `on_schema_change`, in the model or in
`dbt_project.yml`. dbt resolves one for every model regardless, so the resolved value
asserts nothing; an explicit setting is a claim about how that materialization behaves
and is honoured. dbt-plan reads `unrendered_config` to tell them apart.

An enforced contract inverts the rules above: dbt requires every column to be declared,
so a column added to the SQL fails the build just as a removed one does. Names only —
dbt compares its declared `data_type` against the warehouse, which dbt-plan does not read.

## CI Integration (GitHub Actions)

```yaml
name: dbt-plan
on:
  pull_request:

jobs:
  plan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    env:
      # Whatever your profiles.yml reads. `dbt compile` connects; dbt-plan does not.
      UV_PROJECT_ENVIRONMENT: ${{ runner.temp }}/project-venv
      SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
      SNOWFLAKE_USER: ${{ secrets.SNOWFLAKE_USER }}
      SNOWFLAKE_PRIVATE_KEY: ${{ secrets.SNOWFLAKE_PRIVATE_KEY }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0          # the base revision has to be in the clone
          persist-credentials: false
      - uses: actions/setup-python@v5
        with: { python-version: '3.12' }
      - run: pip install uv
      - run: uv sync --locked

      - uses: PresentJay/dbt-plan@v1
        with:
          compile-command: uv run --no-sync dbt compile
```

This example assumes a checked-in `uv.lock` with your dbt adapter included. The
virtual environment lives outside the checkout so changing revisions cannot replace
it. With pip/requirements.txt, install your adapter before the Action and use the
default `dbt compile` command. Run `dbt deps` first if your project uses dbt packages.

Keep the `pull_request` trigger. Never switch it to `pull_request_target` — `dbt compile`
runs Jinja and macros written in the pull request, so that would hand your warehouse
credentials to code from any fork.

| Input | Default | |
|---|---|---|
| `compile-command` | `dbt compile` | Runs twice, once per revision. |
| `base-ref` | the PR base | The revision to compare against. |
| `project-dir` | `.` | dbt project directory. |
| `target-dir` | `target` | dbt artifact directory relative to `project-dir`; set this when dbt writes to a custom path such as `build`. |
| `dialect` | empty | Preserve CLI configuration and manifest adapter detection, then fall back to `snowflake`. Set only to override the project. |
| `version` | latest | Pin a dbt-plan release. |
| `fail-on` | `destructive` | Or `warning`, or `never`. |
| `summary` | `true` | Write the report to the job step summary. |

Outputs `verdict` (`safe` / `destructive` / `warning`), `exit-code`, and `report`
(path to the JSON report), so a later step can comment on the PR or open a ticket.

For a workflow you own outright rather than a wrapped action, `dbt-plan ci-setup`
generates one with credential wiring and least-privilege notes inline. The next-minor
generator supports `pyproject.toml`/`uv.lock` and `requirements.txt`, installs dbt and
dbt-plan in one environment, and separates summary rendering from its gate. The default
`FAIL_ON: destructive` allows warnings but always blocks execution errors. Regenerate
existing workflows to adopt it; see [docs/ci-integration.md](docs/ci-integration.md).

## Exit codes

The next minor release separates execution failures from review findings (a breaking
change from 0.15.x). This contract is implemented on main and is not yet released.

| Code | Meaning |
|---|---|
| `0` | No blocking findings under the configured policy. |
| `1` | Destructive findings. |
| `2` | Review required: SQL uncertainty or potential build/test failures. |
| `3` | Execution failed: invalid input, missing artifacts, compile/recovery failure, or an internal error. No completed verdict. |

`warning_exit_code` still controls review findings; its default remains `2`.
Setting it to `0` does not suppress execution errors. Code `3` is reserved and cannot
be used for warnings. The Action fails on execution errors even with `fail-on: never`.
See [the migration guide](docs/exit-codes.md) before upgrading a CI script or pinned Action.

## How It Works

```mermaid
flowchart TD
    A[dbt-plan snapshot] --> B[Save compiled SQL + manifest.json]

    C[dbt-plan check] --> D[diff_compiled_dirs]
    D --> E[base compiled SQL]
    D --> F[current compiled SQL]
    E --> G[extract_columns]
    F --> H[extract_columns]
    G --> I[base columns]
    H --> J[current columns]
    I --> K[column diff]
    J --> K
    K --> L[predict_ddl + manifest config]
    L --> M{Safety?}
    M -->|SAFE| N[exit 0]
    M -->|WARNING| O[exit 2]
    M -->|DESTRUCTIVE| P[exit 1 — block merge]
    L --> Q[find_downstream]
    Q --> R[format_text / format_github]
```

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, TDD workflow, and coding rules.

**First contribution?** [Choose a small, ready task](docs/first-contribution.md):
SQL fixtures, documentation or a sample-script fix, with exact expected results
and no warehouse credentials required.

**CI feedback for contributors:** PRs to this repository get one automatically
updated status comment with the tested commit, approval waits, and links to failed
jobs. Comment `/ci` to refresh it, or `/ci retry` to retry a transient failure.
External contributions still need a maintainer's execution approval; retrying does
not bypass it. See [commands and limits](CONTRIBUTING.md#checking-ci-on-your-pull-request).
This helper is for contributions to dbt-plan itself, separate from the GitHub Action
you install in your own dbt project.

### Architecture

```
src/dbt_plan/
├── columns.py      # SQLGlot column extraction (multi-dialect)
├── config.py       # .dbt-plan.yml + env var configuration
├── predictor.py    # DDL risk assessment rules + cascade analysis
├── manifest.py     # manifest.json parsing + downstream BFS
├── diff.py         # compiled SQL directory comparison
├── formatter.py    # text / GitHub markdown / JSON output
└── cli.py          # CLI: snapshot, check, init, stats, run, ci-setup
```

### How to Contribute

**Where to start:** the [open issues](https://github.com/PresentJay/dbt-plan/issues),
particularly those labelled [good first issue](https://github.com/PresentJay/dbt-plan/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
Each starter issue has an agreed scope, expected results and validation commands.
Check its assignee and recent comments, then confirm ownership before starting.

**Design decisions:** See [docs/design-notes.md](docs/design-notes.md).

## Supported

- dbt-core 1.7+, and the dbt Fusion engine (verified against `2.0.0-preview.218`)
- Snowflake, BigQuery, Redshift, Postgres, DuckDB and other mapped adapters: dialect inferred from the manifest. Unmapped adapters fall back to Snowflake; see [analysis limits](docs/analysis-limits.md).
- Python 3.10+
- CTE, UNION ALL, QUALIFY, window functions, VARIANT access

## License

Apache-2.0

For adapter dialect fallback, dbt Mesh consumers, and stars over sources or seeds,
see [analysis limits](docs/analysis-limits.md). For CLI `compile_command` argv
semantics, the Action's shell command input, and `dbt deps` setup, see
[compile commands](docs/ci-integration.md#컴파일-명령과-dbt-패키지).
