Skip to main content

dbt-cst

A lossless dbt model parser written in Rust with Python bindings. It parses a model (Jinja templated SQL) into a concrete syntax tree (CST) that keeps every comment, blank line, indentation and Jinja tag, so a file can be read, modified and written back out with its formatting intact:

from dbt_cst import parse

doc = parse(source)
assert str(doc) == source  # byte for byte, for every model in albert-dbt-analytics-transforms

The tree groups the SQL into queries, clauses, select columns and CTEs, and the Jinja into blocks, without interpreting either: a column is the text between two commas, a WHERE clause is the text between its keyword and the next clause keyword. That is enough to add and remove columns in a model someone has customized by hand, which rewriting the file from a template cannot do. The Python package is an abi3 wheel built against Python 3.9, so one wheel works on Python 3.9 and 3.11.

Python usage

from dbt_cst import parse, DbtSyntaxError

doc = parse(path.read_text())

    doc.config["materialized"]                  # "view"
    doc.config["materialized"] = "table"        # quoted like the call's other strings
    doc.config["tags"] = ["pii"]                # added in the call's layout: one per line, trailing comma
    doc.config.set_raw("schema", 'var("schema")')
    del doc.config["alias"]

    # Example: Modifying multiple config values
    doc.config["materialized"] = "table"
    doc.config["schema"] = "analytics"
    doc.config["tags"] = ["pii", "finance"]
    doc.config["enabled"] = False

    # Example: Using raw expressions for dynamic values
    doc.config.set_raw("alias", 'var("model_alias")')
    doc.config.set_raw("schema", 'var("schema")')

    # Example: Removing config values
    if "description" in doc.config:
        del doc.config["description"]

    doc.sources                                 # [("trading", "apex_apexentry")]
    doc.refs                                    # [("users",), ("package", "model")]

query = doc.query                           # the model's own SELECT, after any CTEs
select = query.select
select.names                                # ["account_id", "activity_id", "percent"]
"account_id" in select                      # True, compared ignoring case

select.add("user_id")                       # appended in the select's comma style
select.add("amount / 100 AS dollars", index=select.index("account_id") + 1)
select.remove("activity_id")                # with its comma and the comments above it
select.find("percent").text = '"percent" AS pct'

renamed = query.cte("renamed").query.select # CTE bodies are queries too
for query in doc.queries():                 # every query, subqueries included
    ...

path.write_text(str(doc))

API

parse(source) -> Document raises DbtSyntaxError (a ValueError) with line, column and offset attributes. Only a Jinja tag that is never closed is an error. SQL that does not fit the expected shape still parses: it ends up as unstructured tokens and still round trips.

Object Members
Document config, query, queries(), sources, refs, str()
Config c[key], get(key, default), c[key] = value, del c[key], raw(key), set_raw(key, src), keys(), in, len, iter
Query select (the first), selects (one per UNION branch), ctes, cte(name), clauses
Cte name, query
Clause name (FROM, GROUP BY, UNION ALL, ...), str()
Select columns, names, find(name), index(name), add(text, index=None), remove(name_or_column), in, len, iter
Column text (settable), expr, alias, name, is_templated, select, detach()

Config values are Jinja literals: strings, numbers, booleans, None, lists and dicts. Reading an argument whose value is an expression such as var("x") raises ValueError; use raw() for its source text.

Column.name is the alias, or the last part of a plain column reference (t.id gives id), unquoted. It is None for an unaliased expression or a bare Jinja tag such as {{ dbt_utils.star(...) }}.

Handles are live: a Column from select.columns stays attached to the document, and two handles to the same node compare equal.

Building new models

Model, Source and Ref build a model from Python, for writing a new file or parsing into a Document to edit further:

from dbt_cst import Model, Source

model = Model(
    "id",
    '"name"',
    from_=Source("banking", "banking_account"),
    where="NOT __deleted\nOR __deleted IS NULL",
    config={"materialized": "view", "schema": "banking"},
    comma="leading",                     # or "trailing", the default
)
path.write_text(str(model))
doc = model.to_document()

The config call gets one argument per line with a trailing comma, its values rendered as Config.__setitem__ renders them. SELECT gets one column per line, and FROM and WHERE each start a line. Columns are SQL source, so quote identifiers that need it. A column that parses as more than one select item raises ValueError.

How it parses

  1. Lexing (src/lexer.rs) splits the file into tokens covering every byte. Jinja tags end where Jinja says they do, skipping string literals and nested brackets, so {{ {'a': 1} }} and {{ "}}" }} are one tag each. A tag inside a SQL string or comment is absorbed into that token. {% raw %} sections are one token.
  2. Grouping (src/parser.rs) pairs (/), [/] and Jinja block tags with one stack. Any {% name %} whose {% endname %} appears in the file is opened tentatively, so tags from Jinja, dbt (materialization, test, snapshot) and packages all work without a list of names. A tag that never closes, like a one line {% set %}, or one whose end would cross a parenthesis, is put back as a plain token. elif and else split blocks into branches.
  3. Structure: a top level SELECT or WITH starts a query, which is split into clauses at top level clause keywords. SELECT items are split at top level commas, and WITH clauses into CTEs. "Top level" means outside any bracket or Jinja block, so commas in function calls and keywords in subqueries never split anything. A Jinja block that supplies its own commas, like {% for c in cols %}{{ c }},{% endfor %}, separates the columns around it.

The lexer is written with nom, like looker-cst's parser. The one hand written scan is the end of a {{ }} or {% %} tag, which tracks bracket depth and skips string literals the way Jinja's own lexer does.

Rust usage

let doc = dbt_cst::parse(&source)?;
let query = doc.query().unwrap();
let select = dbt_cst::query::clauses_named(&query.read(), "SELECT").remove(0);
dbt_cst::edit::insert_item(&mut select.write(), usize::MAX, "user_id")?;
assert!(doc.to_string().contains("user_id"));

cargo run --example dump -- path/to/model.sql prints the tree.

Development

cargo test                           # Rust tests, cloning the corpus into target/corpus on first run
uv run pytest                        # builds the extension with maturin, then runs the Python tests
DBT_EXTRA_REPOS=../albert-dbt-analytics-transforms cargo test --release --test corpus

The corpus is the 14 public dbt projects listed in tests/corpus_repos.txt (dbt-utils, dbt-expectations, automate-dv, the Fivetran packages and others, about 1,700 .sql files). Both test suites shallow clone them into target/corpus on first use, or into DBT_CORPUS when set. A failed clone skips those files unless REQUIRE_DBT_CORPUS is set, as in CI. DBT_EXTRA_REPOS adds more checkouts, separated by :.

  • tests/corpus.rs round trips every file, adds and removes a column in every SELECT, removes each column in turn, and adds and removes a config argument.
  • tests/python/test_corpus.py does the same read, modify and write cycle through the Python API, one test per file.
  • tests/fixtures/ holds awkward inputs (CRLF, unicode, unterminated quotes, unbalanced brackets, unpaired Jinja blocks) that both suites round trip, and edge_cases.sql, a customized model whose structure both suites check.
  • tests/python/test_albert_models.py checks every model in a sibling albert-dbt-analytics-transforms checkout (or ALBERT_DBT_REPO) against the field list DBTGenerator reads today.

Metadata

Release files for dbt-cst 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for dbt-cst 0.2.0
File Size Uploaded
dbt_cst-0.2.0.tar.gz 61.8 kB Details

Built distributions (wheels)

Table of built distributions (wheels) for dbt-cst 0.2.0
File
dbt_cst-0.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.9 abi3 Linux glibc 2.17+ x86-64 Details
dbt_cst-0.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl CPython 3.9 abi3 Linux glibc 2.17+ ARM64 Details
dbt_cst-0.2.0-cp39-abi3-macosx_11_0_arm64.whl CPython 3.9 abi3 macOS 11.0+ ARM64 Details
dbt_cst-0.2.0-cp39-abi3-macosx_10_12_x86_64.whl CPython 3.9 abi3 macOS 10.12+ x86-64 Details

Total release size: 1.7 MB

Release files / dbt_cst-0.2.0.tar.gz

Download URL dbt_cst-0.2.0.tar.gz
Size 61.8 kB
Tags Source
SHA-256 checksum
How to use checksums
5c29b521ede3b6f4bd086ab1332e739ce30c3ba0495847b16047091f5430d5ec
BLAKE2b-256 checksum
How to use checksums
5d0865c6464ad26b42f8228c12fe65b4de9935cedf4c2769abed67083dc80c5f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / dbt_cst-0.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL dbt_cst-0.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 413.3 kB
Tags CPython 3.9 Linux glibc 2.17+ x86-64 abi3
SHA-256 checksum
How to use checksums
96ed47281d594db40c57a649320084d0a37d246d19e3c63ac588af6181213378
BLAKE2b-256 checksum
How to use checksums
63dd8dfd69d4120de4c6fa82bae41578692f82e7796cc20ee970bba9d2cc26ef
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / dbt_cst-0.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl

Download URL dbt_cst-0.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Size 398.1 kB
Tags CPython 3.9 Linux glibc 2.17+ ARM64 abi3
SHA-256 checksum
How to use checksums
dd2fd2ffcb9d3ca77cdbe80f4c3ffa9f041eaf3544ec34549cb8c045164408d3
BLAKE2b-256 checksum
How to use checksums
24251955a9e4c8223ac9bbef215052847fa3b43a5c128351c40775d20575823c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / dbt_cst-0.2.0-cp39-abi3-macosx_11_0_arm64.whl

Download URL dbt_cst-0.2.0-cp39-abi3-macosx_11_0_arm64.whl
Size 422.5 kB
Tags CPython 3.9 abi3 macOS 11.0+ ARM64
SHA-256 checksum
How to use checksums
e98165992451c151381425e86f941b0598ec958ce92f75cfbd45ee951b50f5ee
BLAKE2b-256 checksum
How to use checksums
10a06afbf8669494c3845afaa205c1aba21f85e41596ef5d27d0d9e7539ab080
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / dbt_cst-0.2.0-cp39-abi3-macosx_10_12_x86_64.whl

Download URL dbt_cst-0.2.0-cp39-abi3-macosx_10_12_x86_64.whl
Size 426.4 kB
Tags CPython 3.9 abi3 macOS 10.12+ x86-64
SHA-256 checksum
How to use checksums
69ee826cdb4830ba9becea2dc89840728a4cdcdfb997fc6abc6157ebed44f010
BLAKE2b-256 checksum
How to use checksums
98d9c441b7ef911640c6e139c448e5f1f5f3b004317781ad353795abc3292031
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release history Release notifications | RSS feed

This release

0.2.0 This release

5 release files

0.1.0

5 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page