DbtProject reads a dbt project: model SQL, schema.yml,
the semantic manifest, and the compiled manifest.json when there is one. �3 covers what dex reads out of it and what it
writes back.
This document is about the seam underneath that: what a second format has to
satisfy, and what dex promises to do with it.
Why you would write your own
One reason, and it is a real one: your models are not a dbt project. A host that builds its transformation graph in something else (an orchestrator’s asset graph, SQLMesh) still has everything dex needs to be useful. It knows which tables it builds, at what grain, and how they relate. What it does not have is adbt_project.yml, and generating a fake one to satisfy dex means maintaining a
translation nobody reads and dex cannot check.
The seam exists so that translation can be code you own instead.
A semantic layer that owns its own definitions is not one of these, and
writing it as a project format is the wrong shape. It builds nothing, so it has
no model graph to fingerprint and no relation it could claim to own. It belongs
on the semantic axis instead, as a semantic source with its own contracts. That
distinction is spelled out under
the two protocols beside the tiers further down, and in full
in �0.
The three tiers
The contract is three nested protocols inadapters/project.py. Implement the one
that matches what your format can actually answer, and stop there: a narrower
implementation is complete rather than partial.
tier_of(project) reports the highest tier an object satisfies, checked with
isinstance rather than declared, so a format cannot claim a tier it has not
implemented.
Two protocols sit beside the tiers rather than in them.
PlacingProject is one, and reaching reconcile’s write path means implementing it
as well as tier 3. Its three methods are load(), edit_path() and
editing_surface(), and they are described under
Where an edit lands below.
SemanticCatalogProject is the other, one method, semantic_catalog(), and it is
what explore semantic list --local reads. It is described under
Reading the semantic layer twice.
It is here because a dbt project happens to hold a semantic layer, not because
a semantic layer is a project. A repository can have a layer and no
transformation project at all, and one that does is configured on the semantic
axis (semantic.vendor) and built through
exmergo_dex_core.semantic_source, which checks that what it built can answer a
catalog and checks nothing else. Nothing on this page applies to it: it owns no
model graph, no compilation, no targets, and no write surface into dbt’s files,
and it satisfies none of the tiers below. See
the semantic layer reference for that axis and its own
conformance contracts.
Both are beside rather than on a tier because these protocols are
runtime_checkable: a method added to a tier would demote every format that has not
implemented it yet, so tier_of would start answering 2 where it answered 3 and the
write path would close for exactly the implementers who were already passing. A
capability a format may legitimately decline belongs in its own protocol, where
declining it is an answer rather than a regression.
That is also why load() is there rather than on tier 3, which is where you would
look for it first. Nothing outside the placement path calls it: both callers reach it
only for a format that places, so asking it of tier 3 would demote formats that never
needed it in order to state a requirement that is not theirs.
Tier 1 is where the value is concentrated, and it is easy to underestimate.
Declared keys and declared joins reach dex through definitions() and through no
other channel: not through the layers, not through a content hash. A format that
implements only tier 1 has already delivered the part of a project that changes what
explore concludes.
Tier 3 is the one to decline on purpose. The clearest case is a project reduced
from a running graph, where the reduction is not the source of truth: the code that
produced the graph is, so writing into the reduction would edit an artifact that is
regenerated on the next run. Declining the tier is the honest answer, and it is why
the tiers exist rather than a writeback: no flag. A flag is a claim the engine has
to trust, while a tier is checkable.
Ask which artifact the edit lands in, not where the project came from. Those two
questions have different answers more often than the graph example suggests. A format
can reduce a graph for its model list and still read its declared keys, joins and
semantics from hand-authored files that nothing regenerates, which is a common shape:
an asset graph carries neither column names nor join keys, so a format over one has
to get them from somewhere, and that somewhere is usually a file a person wrote. Those
files are a real source of truth and they are the shape reconcile already proposes
edits to. A format holding one may reach tier 3 for that channel while still refusing
to author a model, and deciding from “we are graph-derived” alone would decline a tier
it could honestly serve.
That distinction has teeth, and it is what maintain reconcile reads. A format that
does not implement EditableProject gets every finding back as an advisory
proposal, with no edits and no stored plan, and a warning naming the format and the
tier it declined. The findings themselves are still surfaced: declining the write
tier removes dex’s authority to author an edit, not your need to see the drift.
Declining the tier and declining one kind are different statements and they no longer
produce the same outcome. A format that implements tier 3 and answers None for one
kind has refused that artifact, not the write path, and reconcile still writes to the
kinds it placed.
That used to hold by accident. Reconcile’s two mechanical write paths gated on the
models/staging/stg_<table>.* scaffold convention and failed closed, so a generated
tree was safe as long as its own directory naming happened not to collide, and a
format whose layers used that vocabulary would have been written into. The guarantee
now rests on what your format declared rather than on what it happened to be called:
the tier is asked first, and the paths themselves come from the format rather than
from that convention.
If you do implement tier 3, two parameters carry the whole safety story.
project_dir is where the edits go, and it comes from the caller rather than from
however your project was built. dex is applying a stored plan, and a plan records the
directory it was pinned against relative to the repository root, which is what keeps
it valid when the repository moves. A project built from engine configuration need
not point at the same place. Resolving the directory yourself means writing into
whichever project the engine happened to be configured for while hash-checking
against that project’s files, and the disagreement is silent. If your format is not
keyed by a directory, ignore the parameter, exactly as you ignore repo_root on
ProjectContext.
confirmed is the human-edit conflict handshake, and it is most of the reason this
tier is separable at all. Re-hash every target against the content the edit was
planned against. A target whose hash moved is a conflict, because a human edited it
while the plan sat in review. With confirmed=False a conflict refuses the whole
apply and writes nothing, and the divergence comes back as diffs for someone to read.
With confirmed=True that someone has looked and said to go ahead. A write path that
cannot receive confirmed silently overwrites the work, which is the failure this
seam exists to prevent, so EditableProjectContract asserts the behavior rather than
trusting it: no check on the shape of your class can see it.
What you return has to say what happened. transform apply reads written to
decide whether the plan is now applied and conflicts to decide whether to show a
human the divergence and ask. A result answering neither fails in both directions at
once: a plan recorded as applied that wrote nothing, or a conflict that never reaches
the person it was raised for. Return dbt_project.ApplyResult, or anything exposing
those two.
Reading the semantic layer twice, for two different questions
Beside tier 2 sits one optional protocol,SemanticCatalogProject, and the reason
it is a second channel rather than a third member of the tier is worth stating
before you implement either.
semantic_layer() is a fingerprint. Its job is to make a change detectable, so
it reduces the layer to a content hash per definition plus the physical column
behind each field, and it hashes what the author wrote rather than what a compiler
produced, which is what keeps a stored baseline stable across a tool upgrade. It
deliberately throws away everything a reader wants: element types, the project’s own
labels and descriptions, a measure’s aggregation, a metric’s composition, and the
token a query actually groups by.
semantic_catalog() is the read view explore semantic list returns, and every
field it carries is a field the fingerprint is right to drop. Widening the
fingerprint to serve it would push presentation metadata into a persisted baseline
and cost it the stability it exists for, so the two are separate reductions of one
layer and your format performs both.
Four things to get right, all of them things dex itself got wrong first:
An entity is not one record. EntityInfo.roles carries one entry per
(entity, semantic model) declaration, with that model’s own type, expr and
description. An entity is primary in the one model that keys it and foreign in
every model that joins to it, the join key differs per model for the same entity, and
each declaration is where a project documents that model’s join. The single
top-level type is derived, primary wherever any declaration is primary. Returning
one record per entity means picking a value, and whichever you pick is iteration
order rather than a fact about the layer.
DimensionInfo.name is a query token, not a display name. A caller pastes it
into --group-by. If your layer requires a qualified path, return the path;
definition and semantic_model are where the declaration behind it goes.
SemanticCatalogContract asserts that every dimension a metric claims to be
groupable by appears as a dimension row, because the two have to be one vocabulary
or neither can be acted on.
Resolve the layer to the warehouse, and never invent the resolution.
SemanticModelInfo.relation is the physical relation the model sits on, and it is
the only place a relation appears in the catalog: a dimension, an entity
declaration and a measure each carry column and each already names its
semantic_model, so an element’s address is its column plus its model’s relation.
That is what connects your layer to the objects explore map and
explore profile describe, and what lets a caller answer “which table is behind
this metric”. Leave column unset wherever the reference is a computed expression
rather than a plain column, because the PII gate resolves a dimension to a column
and reads that column’s evidence: a guessed column makes it screen the wrong one
and report the verdict as authoritative. A backend that structurally cannot know
the relation declares that gap instead, which is a different statement from a
format that could resolve it and did not.
Say what one dimension row is. dimension_scope is declarations (one row per
declared dimension) or queryable_paths (one row per groupable token, so a dimension
reached through a join appears once per path). Both are honest and they produce very
different counts for one layer, which is unreadable to a caller who is not told
which they hold.
physical_columns maps every dimension and entity token, bare and qualified, to the
(relation, column) behind it. It is never serialized: the PII request-gate reads it
to resolve a token to a profiled column, which is why the resolution belongs to the
format rather than to a query backend. Leave a token out where the reference is a
computed expression, on the same principle as the None columns on
SemanticModelDef: a guessed column makes the gate over-claim.
Raise ProjectError where the layer cannot be read yet, as opposed to being
empty. An uncompiled project and a project that declares no metrics are different
answers and only one is fixed by running a command, and the caller turns the raise
into a refusal naming that command. Declining the protocol outright is also a
complete answer: a format with no semantic layer implements nothing here, and
explore semantic list --local refuses by name rather than returning an empty
catalog that reads as a layer with nothing in it.
Where an edit lands, and what you own
Tier 3 says your format can receive an edit. It does not say where the edit goes, and reconcile decides that before it consults you.PlacingProject is where you
answer, and reaching the write path means implementing all three of its methods.
AttributeError from inside a command the tier check already let through.
load() reads the keyspace, once per command, and returns a ProjectView:
files is keyed exactly the way edit_path keys and editing_surface prefixes,
which is the whole point of the type. root is what the plan records as the directory
it was pinned against, relative to the repository root where that subtraction is
possible and verbatim where it is not, so a format keyed by something other than a
directory returns whatever identifies its root and the plan carries that. The hash need
only be consistent with what your own writer re-checks: it is your keyspace, and dex
compares your value against your value.
ProjectView and SourceFileView are declared protocols and neither is
runtime_checkable. Nothing calls isinstance against them, because an isinstance
check on a data protocol only asks whether the attribute names exist, which reads as a
type check and is not one. PlacingProjectContract makes that check instead, with a
message naming the missing member and what it costs.
edit_path(kind, model) says where an edit of that kind for that table lives. Two
things about it are easy to get wrong. model is the warehouse table the finding is
about, not a model name in your vocabulary: the stg_ prefix is dbt’s own scaffold
convention and dex does not apply it here. And the return is a key into whatever your
load() returns rather than a filesystem path, because your format already owns that
keyspace.
Answering None for a kind is a complete answer, not a degenerate one. The kinds
are not equally receivable and you are expected to differ across them. SCHEMA_YML
is a mutation of a file you already have. MODEL_SQL carries dbt SQL that dex
generates alongside the path, so placement alone cannot open that channel: a format
that places a staging model elsewhere gets that half as advice rather than having
dbt written into its tree. One None and one path is the shape this exists for, and
the two halves are answered separately.
Declining MODEL_SQL costs you the model and nothing else. Schema drift on a table
you place a SCHEMA_YML for still arrives as a mechanical edit to that declaration,
because a column appearing or disappearing upstream is a fact about the declaration
rather than about dbt SQL. The proposal says in its own words that dex authored
nothing for the model, so a reader can tell a half you declined from a half dex
dropped. What lands in the declaration is described under
What reconcile writes into a declaration.
The kind list grows over time (MACRO_SQL, SNAPSHOT_SQL, SEED_CSV, TEST_SQL
and ANALYSIS_SQL are all dbt-shaped artifacts a non-dbt format may have no
equivalent of), and None stays the right answer for every one your format does
not place. The shipped dbt format
answers None for them too: reconcile proposes staging models and their
schema.yml and nothing else, so a path for the rest would be invented rather
than known.
editing_surface() declares the region those paths must stay inside, and it is a
different question with a different caller. transform plan validates edits an agent
authored, where there is no (kind, model) pair to ask edit_path about and no
prior answer to compare against, only a path and the question of whether it is inside
what you admit to owning. Prefixes are matched by path segment, so declarations
admits declarations/orders.yml and does not admit declarations_backup/orders.yml.
Absolute paths and paths climbing out through .. are refused ahead of this and are
not yours to permit. An empty list is coherent: it refuses every edit rather than
admitting all of them, which is the same statement declining tier 3 makes.
This is containment, which is a safety property and stays mandatory. What the seam
moved is who declares the surface, not whether there is one.
Honor it in your writer too, and expect dex to re-check it. Containment is
checked when a plan is stored, and re-checked against this declaration before dex
hands a stored plan to write_edits, for the same reason the hashes are re-checked:
a plan is a stored artifact that sits through a human review, so what it was
validated against then is not what it is being written into. That refusal is a hard
one, and confirmed is not a way past it. Nobody accepts a write outside the surface
the format itself declared. Your own writer still has to check, because write_edits
is a public method of your format and dex is not its only caller, and the conformance
suite asserts the case a prefix comparison gets wrong.
Declare what your writer accepts, not less. The shipped dbt format lists every
one of its authored path families and the four root manifests it authors by
name, because a
declaration narrower than the writer is not a modest one: it refuses the project
config, the profiles and the package manifests at apply, every one of which is a path
dex authors through a plan.
One thing dex still spells its own way. Every edit finds your model inside the
placed file by the file’s own name (declarations/orders.yml means a model named
orders). If you pack several models into one file, no entry matches and you get a
warning instead of an edit, which is a refusal to guess rather than a wrong write.
That edit is checked against your declarations before it is proposed. If
definitions() reports a composite grain covering the column, no column-level
unique is proposed on it and the warning names the combination, because the edit
would assert something your project explicitly does not claim: dbt runs a
column-level unique and a unique_combination_of_columns independently, so the
new test fails every build from then on and can only go green by changing the
declared grain, while a format that resolves the two as dbt’s semantics imply
discards it and the plan applies having changed nothing.
This is the practical reason declared_composite_keys is worth populating properly,
and why it is a separate field from declared_keys rather than several entries in
it. It is not decoration on a diagram. maintain grain re-verifies the combinations
that arrive there, so a format that leaves the field empty loses grain verification
on exactly the tables whose grain is composite, and gets offered unique tests on
their member columns. Splitting one grain across several entries is worse than
leaving it out, because each entry then claims a column is unique on its own, which
is a stronger claim than the one you made and the one that gets acted on. The
conformance suite checks both shapes.
What reconcile writes into a declaration
Your file is one a person wrote and nothing regenerates, so dex splices it: the bytes that declare the drifted column change and every other byte stays where it was. Comments, descriptions, key order and formatting survive, and the diff a reviewer reads is the size of the change rather than the size of the file.
A test added to a column that already has a list joins that list under the key it
already uses, so an entry never gains both
tests and data_tests, which dbt
refuses to parse. A column with no list gets one under the spelling most of the
file already uses, and under data_tests, dbt’s current key, when the file has no
precedent.
A retype is the one dex declines, and the reason is worth stating. Nothing dex
authors declares a type, and the type it holds is the connector’s own spelling rather
than a canonical one: Snowflake reports NUMBER(38,0) and NUMBER(10,2) both as
FIXED, BigQuery reports a repeated record as ARRAY<STRUCT>, ClickHouse carries
nullability inside the type as Nullable(Int64). A type written from that would be
one warehouse’s word for the column rather than the column’s, so the change is
surfaced with both spellings named and the edit is yours to make.
Two consequences of that last point are worth knowing before you read a drift report:
- On ClickHouse, nullability is the type constructor, so a column that starts
or stops accepting nulls is reported as
column_retypedand never asnullability_changed. It therefore arrives as advice where every other connector produces an edit. - On Snowflake, a precision or scale change produces no schema finding at all, because both spellings render identically.
columns: block it
never had. A tests list stays flow if it was flow and block if it was block, the
file’s own choice between tests and data_tests is kept, and removing the last
test removes the key rather than leaving an empty list. A test written as a mapping
is counted and never rewritten, so a configured accepted_values survives an edit to
the plain names beside it. A file dex cannot span safely, one indented with tabs, one
holding several YAML documents, or one using anchors and aliases, is declined by name
rather than spliced at an offset nobody chose. Every result is re-parsed and compared
against what the edit intended before it can reach the plan store.
A table that drifted on two axes arrives as one edit. Schema drift and a lost
unique key both land in the same declaration, and two edits on one path would pin the
same content hash, so the second would overwrite the first and the change a reviewer
approved would not be the change that got written. Everything folds into one edit per
path, and an edit that would have reproduced the file you already have is dropped
rather than offered.
The one rule that is not visible in the signatures
definitions() must not raise. Not on a project that is absent, not on an
ambiguous choice between two, not on a source that will not parse.
Exploration runs against warehouses with no project at all, so absence is an
ordinary state rather than an error, and a format that raises turns a normal
condition into an outage in the middle of a command that had nothing to do with the
project. The empty result is the correct answer; a note saying why is how the
operator tells “nothing declared” apart from “your source has a typo in it”.
This is the assertion a second implementation is most likely to get wrong, and a
contract that only checked shapes would not catch it. It is also not hypothetical:
the shipped dbt format violated it until recently, because a dbt_project.yml that
is not valid YAML surfaced as yaml.YAMLError while only DbtProjectError was
caught. The conformance suite below is what found that.
Tier 2 is different, deliberately. transform_layer() and semantic_layer()
presume a project that loads, because maintain already treats an unreadable
project as a handled state and carries a note saying so. Returning empty layers
instead would be worse than raising: an empty layer compared against an empty layer
reads as “no drift” rather than “this could not be checked”.
Writing one
ProjectDefinitions is the engine’s existing model, not a new neutral one. That is
deliberate: it is already what definitions() returns and already what every
consumer reads, so a second format reuses the vocabulary rather than introducing a
parallel one that has to be mapped at every call site.
Physical relations go in two maps. model_relations holds what the project
builds, keyed by the name a finding should carry, one entry per relation; maintain verify checks every key there as a built model, so a relation the project only
reads belongs in source_relations instead. The dbt format keys a versioned model
as <name>.v<N>, one entry per version, and a source as source_name.table.
Where your format’s shape and this model’s shape disagree, say so in notes rather
than inventing a value. A fabricated field is indistinguishable from a measured one
by the time a finding is shown to a human.
That rule holds at tier 2 too, and two things make it possible to follow there.
The three path fields are optional. SourceTable, SemanticModelDef and MetricDef
each carry a path, and each accepts None. They are provenance and nothing else: the
file an analyst would open, carried on the dangling_source and definition_changed
findings, never opened by dex. A format whose sources are declared in configuration, or
whose metrics are objects in a graph, leaves them unset, and the finding omits the key
rather than reporting a null. Both findings still identify their subject by name, so
nothing is lost but a shortcut. Supplying a plausible-looking path instead would attach
a file that is not there to a high-severity finding, which is the same failure the
None on SemanticModelDef’s column mappings exists to avoid.
TransformLayer and SemanticLayer carry notes. Same meaning as on
ProjectDefinitions: what your format could not supply. A layer that is faithful but
narrower than a dbt project’s has somewhere in the return value to say so, and the
maintain commands fold those notes into their warnings, at detection time as well as
when the baseline is pinned. Worth using for anything a reader would otherwise
misread: a file_count of zero beside a dozen models is a shape nobody can interpret
without one line of explanation.
Notes are informational, deliberately. No detector reads them, they take no part in any
comparison (so a changed note is never drift), and dex will not branch on one. Anything
dex has to decide from belongs in a tier, which is checkable, rather than in prose the
engine would have to trust. That is the same reason the tiers exist instead of a
writeback flag.
Proving it works
MaintainProjectContract instead if you reach tier 2, EditableProjectContract
if you reach tier 3, mix DeclaringProjectContract and SemanticProjectContract
beside it for the content your format declares, mix PlacingProjectContract and
SemanticCatalogContract beside it for each protocol you implement beside the
tiers, and mix
ProjectFactoryContract in front of it if dex will build your format from a name
rather than be handed an instance. Construction is a separate contract, so a format
that passes the behavioral suite can still be unreachable from configuration; “the
suite is green” should mean correct and constructable.
Tier 2’s assertions are thin, because the layers’ contents are your format’s business.
The one worth knowing about is that your layers survive a JSON round trip inside a
Snapshot, since that is what reaching tier 2 buys: a store serializes the baseline
and a later command loads it back to diff against. The check is deliberately a real
serialization rather than a copy, because that is where a value your format chose can be
accepted in Python and rejected on the way back, and the failure would otherwise surface
on the run after the one that caused it.
Two hooks are worth overriding beyond make_project:
-
make_unreadable_project()returns a project whose source your format cannot parse. It defaults toNone, which skips the never-raises assertions with a message saying so. That is a compromise, because a format reduced from an in-memory object may genuinely have no unparseable state. If yours does have one, override it: those are the assertions worth the most. -
DeclaringProjectContract, mixed in beside your tier contract, checks that a declared key and a declared join actually arrive. It is separate because the two contracts answer different questions: whether dex can safely call your format, and whether reading it was worth doing. It carries two further hooks that default to skipping, and both are worth supplying if your format can reach the state:a_project_declaring_a_composite_key()returns(project, model, columns).declared_composite_keysis a separate field fromdeclared_keysand nothing else in the suite reaches it. Declare more than two columns: a format that handles a composite grain by special-casing the pair passes a two-column fixture and fails a four-column one, so a pair cannot tell you what you came to find out. A truncated composite key is the expensive failure here, because it does not read as a missing declaration. It reads as a narrower grain that is simply wrong.a_project_declaring_a_join_with_differently_named_sides()returns the same shape asa_project_declaring_a_join, withcolumn != to_column(the contract checks that and refuses a mirrored fixture). If both ends of your join fixture are spelled the same, an implementation that reads the source column and copies it onto the target satisfies the plain join assertion exactly, and the defect ships. A key whose two ends are named differently is the ordinary case, and a format that mirrors joins on a column the target may not have, surfacing as a wrong answer rather than as a failure.
-
SemanticProjectContract, mixed in besideMaintainProjectContractif your format declares semantics at all. Its one hook,a_project_declaring_a_semantic_model(), returns(project, name, dimensions, measures)where the two mappings arefield name -> the warehouse column behind it. It asserts tier 2 first, against the project the hook returns.semantic_layeris a tier-2 member, so a format mixing this in besideExploreProjectContractwould otherwise fail withAttributeError: no attribute 'semantic_layer', which names the missing attribute rather than the tier it belongs to. This is the gap most worth closing, because the tier contract cannot see it.MaintainProjectContractchecks that an empty project yields an empty semantic layer and never looks at a populated one, so a format that reads every field name and drops the column behind each passes the tier suite completely.SemanticModelDefkeys every field to a column and the drift detector skips any field whose column isNone, correctly, since it cannot resolve what it was not given. A layer mapped entirely toNonetherefore validates, serializes, and compares clean forever: the check does not fail, it never runs, and a dropped warehouse column that should raisedangling_referenceat high severity raises nothing. Map a field toNonewhen you genuinely have no bare column for it, and include one such field if you can produce one, becausecategorical_dimensionstakesstrrather thanstr | Noneand a field that is categorical and unresolved has to be left out of that mapping rather than given an invented column.
an_edit_against_a_changed_target() returns a project, a directory, an edit set
pinned to content that has since moved, and a callable reading what the target holds
now. It raises rather than skipping because the excuse that justifies skipping
make_unreadable_project does not apply here. A format may genuinely have no
unparseable state, but a format that reached tier 3 writes into a source of truth a
human can also edit, so the case where the human got there first exists for all of
them.
Four assertions are built from it. An unconfirmed write must leave the target alone
and a confirmed one must go through, and those two only mean something as a pair,
since a write_edits that never writes satisfies the first on its own. That pair
rules out a writer that never writes; it does not rule out a writer that writes too
much, which is what the other two are for:
- A conflict refuses the apply, not the conflicting edit within it. The edit set is the unit. A writer that lands the clean edits and holds back the conflicting one leaves the project matching neither the proposal nor what the human had, while the apply reports itself refused, so nothing records which half arrived. A single-edit set cannot see this, which is why the assertion stages two.
- A create pinned to no prior content is refused when the target now exists.
old_content_hash=Noneis a claim that the file was absent at plan time. If one is there now, the claim is false and honoring it costs the human who created it during review the whole file rather than the lines that diverged. A writer readingNoneas “nothing to compare, go ahead” passes every other assertion, because in the staged conflict the pinned hash is a real one.
a_clean_edit(project) returns
an edit that is not in conflict, pinned truthfully against the project the conflict
hook just staged, and a callable reading its target. Without it the all-or-nothing
assertion can only ask what write_edits reported, so a writer that lands half an
edit set and reports nothing written still passes; with it, the target is read
directly and the claim is checked against the project. Returning None falls back to
an edit derived from the staged conflict’s own path, which is inside your surface by
construction and needs nothing from you.
PlacingProjectContract, mixed in beside it, has one hook that is likewise not
optional. placeable_model() returns a warehouse table your format would place an
edit for, spelled as the warehouse spells it rather than as your format names the
model derived from it, because that is how reconcile’s findings arrive and it is the
thing edit_path is most often gotten wrong on. The assertions are cheap and mostly
about your three answers agreeing: at least one kind places somewhere, every path
edit_path returns is inside editing_surface(), and the surface itself stays
within the project. That middle one is a reason the contract exists, since a
placement outside your own declared surface builds a proposal the plan store then
refuses, and from the outside that reads as dex declining rather than as the format
contradicting itself.
Two more come from load() being part of the same keyspace. The view has to carry
root and files, and the entry behind a path your own hook staged an edit against
has to carry content and sha256, each of which fails silently in its own way
when it is absent. And write_edits has to refuse a path outside the surface you
declared: what is asserted is the case a prefix comparison gets wrong, a sibling that
merely starts with the same characters, since a format matching by string prefix
admits the whole neighborhood of every region it owns while passing everything else.
Refusing by raising and refusing by writing nothing both count.
Placement presupposes tier 3, and the contract checks that first, the way
SemanticProjectContract checks tier 2. Mix it in beside EditableProjectContract:
the assertions here write through your format, and placing an edit a format cannot
receive describes a path that stops halfway.
The suite needs only pytest: no dialect engine, no connector. A packaging test keeps
it that way.
Formats contributed here run the same suite: see
packages/dex-core/tests/adapters/test_project_parity.py, which is deliberately the
same few lines a third party writes.
Constructing one
The tiers describe what dex may ask of a project it already holds. Getting it to hold one is a separate contract, and it is separate on purpose: keeping it off the tiers is what preserves the property that makes this seam cheap, that a class with the right methods is a project, with no base class and no registration step. There are two ways in, and a host normally needs only one. A host that runs dex in its own process hands over an instance..dex/config.yml, which is the only door open
to a host that reaches dex as a subprocess:
format is an open registry rather than a closed set:
A format published as its own package registers itself:
format: acme resolves. A shipped name always wins over a
registration, so installing a package can never silently redirect which models an
existing repo is reasoned about, and nothing in the output would have said so.
--project-format overrides the configured name for one run. It leaves options
behind when it names a different format, because options are not namespaced by
format and one format’s coordinates are not another’s.
What a factory is
Anything callable that takes aProjectContext and returns a project: a function, a
class whose __init__ takes one, or a classmethod like DbtProject.from_context.
ProjectContext is a set of nullable slots, and the point of the shape is that a
format ignores the ones it does not have.
repo_root, the directory dex was pointed at, orNonewhen there is no repository in the picture.project_dir, where within that repository the project was pinned, relative torepo_root.Nonewhen nothing pinned one.connector, the name of the warehouse dex resolved for this run, orNonewhen nothing named one. A name, never a live adapter and never a credential, so reading it opens no connection and costs nothing. It is here because a format may have to read an authored expression or relation name the way the active warehouse would, and identifier arity, quoting, and unquoted-case folding are the connector’s rules: a format that guesses them links a declaration to the wrong column or to none. dbt reads its own target and ignores this.options, your format’s own coordinates, passed through verbatim. dex does not interpret them, so the keys are yours to define and yours to validate.
ProjectContext, because .dex/config.yml is
committed. Read the credential from the environment at construction, or skip this
contract and hand the engine a project you built yourself.
Two alternatives that were rejected
Recorded so they are not re-argued. A constructor argument alone. It is the smaller change and it is what storage shipped first, and storage had to add the other half afterwards for exactly the reason that applies here: a host talking to dex as a subprocess has no object to pass, so the seam would have been unreachable for the deployment shape that asked for it. Keying the context on a directory.project_dir alone reads naturally against
the only format that exists, and that is what makes it the wrong choice: a project
reduced from a running graph has no directory, a hosted one has service coordinates,
and a contract shaped around dbt would have left both unbuildable. The nullable
slots plus verbatim options are what let a format that is keyed by nothing at all
be named the same way.
Declaring load() on tier 3. It is where a reader looks for it, and it is the
protocol the method’s callers are typed against, so it was the obvious home. It is
the wrong one twice over. These protocols are runtime_checkable, so a method added
to tier 3 demotes every format that has not implemented it, which is the argument
that put PlacingProject beside the tiers in the first place; and the requirement is
not tier 3’s to state, because nothing outside the placement path calls load(). A
format that receives edits and does not place them never needed a view, and would
have been demoted to say otherwise. Beside the two methods that share its keyspace,
it demotes only formats that were already going to fail, and it fails them into the
advisory degradation the gate was written to give.
Source: references/project.md (edit on main). This page is generated; change the source, not the site.