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
- 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. - 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.elifandelsesplit blocks into branches. - Structure: a top level
SELECTorWITHstarts 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.rsround 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.pydoes 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, andedge_cases.sql, a customized model whose structure both suites check.tests/python/test_albert_models.pychecks every model in a siblingalbert-dbt-analytics-transformscheckout (orALBERT_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)
| File | Size | Uploaded | |
|---|---|---|---|
| dbt_cst-0.2.0.tar.gz | 61.8 kB | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| 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}
|